Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc4c7a284 | ||
|
|
a6b15f68c9 | ||
|
|
0edfb71f8d | ||
|
|
21b90a1b6c | ||
|
|
1582f5dd15 | ||
|
|
fd3b72525d | ||
|
|
55d1871d94 | ||
|
|
a90eb2d4de | ||
|
|
ed3f1b1dad | ||
|
|
8e08ff059f | ||
|
|
fb8c3a0453 | ||
|
|
e45fb67769 | ||
|
|
3a40d6ce77 | ||
|
|
ac048b72ae | ||
|
|
852728c816 | ||
|
|
096f2d42e8 | ||
|
|
1b29e252ff | ||
|
|
a4dc9bfb31 | ||
|
|
184c21a91b | ||
|
|
6ea3191cf8 | ||
|
|
d487bbca08 |
@@ -18,8 +18,11 @@ jobs:
|
||||
kind: windows
|
||||
target: win-x64
|
||||
- os: macos-latest
|
||||
kind: maxOS
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Get the sources
|
||||
|
||||
+30
-1
@@ -5,6 +5,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.0-alpha] - 2021-11-25
|
||||
### Fixed
|
||||
- Properly fix database incompatibility introduced with `v0.2.4-alpha` and partially fixed with `v0.2.5-alpha`
|
||||
- The proper fix requires rebuilding all playouts, which will happen on startup after upgrading
|
||||
- Fix local library locking/progress display when adding paths
|
||||
- Fix grouping duration items in EPG when custom title is configured
|
||||
|
||||
### Added
|
||||
- Add *experimental* `Songs` local libraries
|
||||
- Like `Other Videos`, `Songs` require no metadata or particular folder layout, and will have tags added for each containing folder
|
||||
- For Example, a song at `rock/band/1990 - Album/01 whatever.flac` will have the tags `rock`, `band` and `1990 - Album`, and the title `01 whatever`
|
||||
- Songs will also have basic metadata read from embedded tags (album, artist, title)
|
||||
- Video will be automatically generated for songs using metadata and cover art or watermarks if available
|
||||
- Add support for `.webm` video files
|
||||
|
||||
## [0.2.5-alpha] - 2021-11-21
|
||||
### Fixed
|
||||
- Include other video title in channel guide (xmltv)
|
||||
- Fix bug introduced with 0.2.4-alpha that caused some playouts to build from year 0
|
||||
- Use less memory matching Trakt list items
|
||||
|
||||
### Added
|
||||
- Build osx-arm64 packages on release
|
||||
|
||||
### Changed
|
||||
- No longer warn about local Plex guids; they aren't used for Trakt matching and can be ignored
|
||||
|
||||
## [0.2.4-alpha] - 2021-11-13
|
||||
### Changed
|
||||
- Upgrade to dotnet 6
|
||||
@@ -790,7 +817,9 @@ 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.2.4-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...HEAD
|
||||
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
|
||||
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
|
||||
[0.2.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
|
||||
[0.2.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.2-alpha...v0.2.3-alpha
|
||||
[0.2.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...v0.2.2-alpha
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
@@ -37,7 +36,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
CreateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
@@ -53,7 +53,6 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
(LocalLibrary existing, LocalLibrary incoming) = parameters;
|
||||
existing.Name = incoming.Name;
|
||||
|
||||
// toAdd
|
||||
var toAdd = incoming.Paths
|
||||
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
|
||||
.ToList();
|
||||
@@ -77,7 +76,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
|
||||
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.MediaCards
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards,
|
||||
List<ArtistCardViewModel> ArtistCards,
|
||||
List<MusicVideoCardViewModel> MusicVideoCards,
|
||||
List<OtherVideoCardViewModel> OtherVideoCards)
|
||||
List<OtherVideoCardViewModel> OtherVideoCards,
|
||||
List<SongCardViewModel> SongCards)
|
||||
{
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ namespace ErsatzTV.Application.MediaCards
|
||||
otherVideoMetadata.OriginalTitle,
|
||||
otherVideoMetadata.SortTitle);
|
||||
|
||||
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
|
||||
{
|
||||
string album = string.IsNullOrWhiteSpace(songMetadata.Album) ? "" : $" - {songMetadata.Album}";
|
||||
return new SongCardViewModel(
|
||||
songMetadata.SongId,
|
||||
songMetadata.Title,
|
||||
songMetadata.Artist + album,
|
||||
songMetadata.SortTitle);
|
||||
}
|
||||
|
||||
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
|
||||
new(
|
||||
artistMetadata.ArtistId,
|
||||
@@ -141,7 +151,9 @@ namespace ErsatzTV.Application.MediaCards
|
||||
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head()))
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
|
||||
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
|
||||
|
||||
internal static ActorCardViewModel ProjectToViewModel(
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
GetCollectionCards 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());
|
||||
@@ -83,6 +83,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record SongCardResultsViewModel(
|
||||
int Count,
|
||||
List<SongCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record SongCardViewModel
|
||||
(
|
||||
int SongId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle) : MediaCardViewModel(
|
||||
SongId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
null)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.Append(request.SongIds)
|
||||
.ToList();
|
||||
|
||||
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record AddSongToCollection
|
||||
(int CollectionId, int SongId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class AddSongToCollectionHandler :
|
||||
MediatR.IRequestHandler<AddSongToCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public AddSongToCollectionHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddSongToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddSongRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
parameters.Collection.MediaItems.Add(parameters.Song);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
// rebuild all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository
|
||||
.PlayoutIdsUsingCollection(parameters.Collection.Id))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddSongToCollection request) =>
|
||||
(await CollectionMustExist(dbContext, request), await ValidateSong(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
AddSongToCollection request) =>
|
||||
dbContext.Collections
|
||||
.Include(c => c.MediaItems)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, Song>> ValidateSong(
|
||||
TvContext dbContext,
|
||||
AddSongToCollection request) =>
|
||||
dbContext.Songs
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.SongId)
|
||||
.Map(o => o.ToValidation<BaseError>("Song does not exist"));
|
||||
|
||||
private record Parameters(Collection Collection, Song Song);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Match(
|
||||
|
||||
@@ -208,6 +208,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -220,6 +221,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -241,6 +243,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -253,6 +256,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -274,6 +278,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -286,6 +291,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(sm => sm.Season.SeasonNumber == item.Season)
|
||||
.FirstOrDefaultAsync()
|
||||
@@ -308,6 +314,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -320,6 +327,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(em => em.Episode.Season.SeasonNumber == item.Season)
|
||||
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private readonly IMovieFolderScanner _movieFolderScanner;
|
||||
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
|
||||
private readonly ISongFolderScanner _songFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
|
||||
public ScanLocalLibraryHandler(
|
||||
@@ -36,6 +37,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IOtherVideoFolderScanner otherVideoFolderScanner,
|
||||
ISongFolderScanner songFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
@@ -46,6 +48,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_otherVideoFolderScanner = otherVideoFolderScanner;
|
||||
_songFolderScanner = songFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
@@ -67,7 +70,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters)
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, int libraryRefreshInterval) = parameters;
|
||||
(LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
|
||||
int libraryRefreshInterval) = parameters;
|
||||
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
@@ -117,6 +121,14 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.Songs:
|
||||
await _songFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
}
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
@@ -149,11 +161,12 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
|
||||
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateLibraryRefreshInterval())
|
||||
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateFFmpegPath(), await ValidateLibraryRefreshInterval())
|
||||
.Apply(
|
||||
(library, ffprobePath, libraryRefreshInterval) => new RequestParameters(
|
||||
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
|
||||
library,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval));
|
||||
|
||||
@@ -170,6 +183,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffmpegPath =>
|
||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
@@ -178,6 +198,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private record RequestParameters(
|
||||
LocalLibrary LocalLibrary,
|
||||
string FFprobePath,
|
||||
string FFmpegPath,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ namespace ErsatzTV.Application.Playouts
|
||||
.Map(ovm => ovm.Title ?? string.Empty)
|
||||
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
|
||||
.IfNone("[unknown video]");
|
||||
case Song s:
|
||||
return s.SongMetadata.HeadOrNone()
|
||||
.Map(sm => sm.Title ?? string.Empty)
|
||||
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
|
||||
.IfNone("[unknown song]");
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
GetFuturePlayoutItemsById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
DateTime now = DateTimeOffset.Now.UtcDateTime;
|
||||
|
||||
@@ -57,6 +57,10 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Filter(i => i.PlayoutId == request.PlayoutId)
|
||||
.Filter(i => i.Finish >= now)
|
||||
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)
|
||||
|
||||
@@ -26,7 +26,8 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
await GetIds(SearchIndex.EpisodeType, request.Query),
|
||||
await GetIds(SearchIndex.ArtistType, request.Query),
|
||||
await GetIds(SearchIndex.MusicVideoType, request.Query),
|
||||
await GetIds(SearchIndex.OtherVideoType, request.Query));
|
||||
await GetIds(SearchIndex.OtherVideoType, request.Query),
|
||||
await GetIds(SearchIndex.SongType, request.Query));
|
||||
|
||||
private Task<List<int>> GetIds(string type, string query) =>
|
||||
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexSongs
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<SongCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public class
|
||||
QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs,
|
||||
SongCardResultsViewModel>
|
||||
{
|
||||
private readonly ISongRepository _songRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexSongsHandler(ISearchIndex searchIndex, ISongRepository songRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_songRepository = songRepository;
|
||||
}
|
||||
|
||||
public async Task<SongCardResultsViewModel> Handle(
|
||||
QuerySearchIndexSongs request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<SongCardViewModel> items = await _songRepository
|
||||
.GetSongsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new SongCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Search
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds);
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -15,12 +15,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
|
||||
{
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public GetConcatProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
|
||||
+158
-32
@@ -1,15 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
@@ -31,15 +36,17 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
@@ -47,7 +54,9 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
IImageCache imageCache,
|
||||
ITempFilePool tempFilePool)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
@@ -59,6 +68,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_imageCache = imageCache;
|
||||
_tempFilePool = tempFilePool;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
@@ -94,6 +105,15 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(ov => ov.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.ForChannelAndTime(channel.Id, now)
|
||||
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
|
||||
.BindT(ValidatePlayoutItemPath);
|
||||
@@ -106,18 +126,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
return await maybePlayoutItem.Match(
|
||||
async playoutItemWithPath =>
|
||||
{
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
|
||||
};
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
string videoPath = playoutItemWithPath.Path;
|
||||
MediaVersion videoVersion = version;
|
||||
|
||||
string audioPath = playoutItemWithPath.Path;
|
||||
MediaVersion audioVersion = version;
|
||||
|
||||
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
|
||||
@@ -125,12 +140,137 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
watermarkId => dbContext.ChannelWatermarks
|
||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
||||
|
||||
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
|
||||
{
|
||||
Option<string> drawtextFile = None;
|
||||
|
||||
videoVersion = new FallbackMediaVersion
|
||||
{
|
||||
Id = -1,
|
||||
Chapters = new List<MediaChapter>(),
|
||||
Width = 192,
|
||||
Height = 108,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
string[] backgrounds =
|
||||
{
|
||||
"background_blank.png",
|
||||
"background_e.png",
|
||||
"background_t.png",
|
||||
"background_v.png"
|
||||
};
|
||||
|
||||
var random = new Random();
|
||||
|
||||
// use random ETV color by default
|
||||
string artworkPath = Path.Combine(
|
||||
FileSystemLayout.ResourcesCacheFolder,
|
||||
backgrounds[random.Next() % backgrounds.Length]);
|
||||
|
||||
// use thumbnail (cover art) if present
|
||||
foreach (SongMetadata metadata in song.SongMetadata)
|
||||
{
|
||||
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.DrawText);
|
||||
drawtextFile = fileName;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Artist))
|
||||
{
|
||||
sb.AppendLine(metadata.Artist);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
sb.AppendLine($"\"{metadata.Title}\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Album))
|
||||
{
|
||||
sb.AppendLine(metadata.Album);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(fileName, sb.ToString());
|
||||
|
||||
foreach (Artwork artwork in Optional(
|
||||
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
|
||||
{
|
||||
string customPath = _imageCache.GetPathForImage(
|
||||
artwork.Path,
|
||||
ArtworkKind.Thumbnail,
|
||||
Option<int>.None);
|
||||
|
||||
artworkPath = customPath;
|
||||
|
||||
// signal that we want to use cover art as watermark
|
||||
videoVersion = new CoverArtMediaVersion
|
||||
{
|
||||
Chapters = new List<MediaChapter>(),
|
||||
// always stretch cover art
|
||||
Width = 192,
|
||||
Height = 108,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
videoPath = artworkPath;
|
||||
|
||||
videoVersion.MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = videoPath }
|
||||
};
|
||||
|
||||
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
|
||||
ffmpegPath,
|
||||
drawtextFile,
|
||||
channel,
|
||||
maybeGlobalWatermark,
|
||||
videoVersion,
|
||||
videoPath);
|
||||
|
||||
foreach (string si in maybeSongImage.RightToSeq())
|
||||
{
|
||||
videoPath = si;
|
||||
videoVersion = new BackgroundImageMediaVersion
|
||||
{
|
||||
Chapters = new List<MediaChapter>(),
|
||||
// song image has been pre-generated with correct size
|
||||
Height = channel.FFmpegProfile.Resolution.Height,
|
||||
Width = channel.FFmpegProfile.Resolution.Width,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
|
||||
},
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = si }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
Process process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
videoVersion,
|
||||
audioVersion,
|
||||
videoPath,
|
||||
audioPath,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
playoutItemWithPath.PlayoutItem.FinishOffset,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
@@ -271,14 +411,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.MapT(pi => pi.StartOffset - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
|
||||
MediaVersion version = item switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(item))
|
||||
};
|
||||
MediaVersion version = item.GetHeadVersion();
|
||||
|
||||
version.MediaFiles = await dbContext.MediaFiles
|
||||
.AsNoTracking()
|
||||
@@ -331,14 +464,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
|
||||
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
|
||||
{
|
||||
MediaVersion version = playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
|
||||
};
|
||||
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var builder = new FFmpegComplexFilterBuilder();
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsNone.Should().BeTrue();
|
||||
}
|
||||
@@ -31,7 +31,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -72,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
.WithAlignedAudio(duration)
|
||||
.WithDeinterlace(true);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -274,11 +274,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
}),
|
||||
new Resolution { Width = 1920, Height = 1080 })
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
None)
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -349,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -420,7 +421,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -542,7 +543,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -26,13 +26,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -49,14 +49,14 @@ live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldLimitSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -70,13 +70,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -90,14 +90,14 @@ live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -111,7 +111,7 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
|
||||
start,
|
||||
@@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -140,14 +140,14 @@ live001138.ts
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -161,13 +161,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine);
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -200,13 +200,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine);
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string str)
|
||||
{
|
||||
return str
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace("\r", "\n")
|
||||
.Replace("\n", Environment.NewLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<IImageCache>().Object,
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new Mock<ILogger<FFmpegProcessService>>().Object);
|
||||
|
||||
MediaVersion v = new MediaVersion();
|
||||
@@ -184,6 +185,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
v,
|
||||
v,
|
||||
file,
|
||||
file,
|
||||
now,
|
||||
now + TimeSpan.FromSeconds(5),
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||
|
||||
var input = new LocalStatisticsProvider.FFprobe(
|
||||
new LocalStatisticsProvider.FFprobeFormat("123.45"),
|
||||
new LocalStatisticsProvider.FFprobeFormat("123.45", null),
|
||||
new List<LocalStatisticsProvider.FFprobeStream>(),
|
||||
new List<LocalStatisticsProvider.FFprobeChapter>());
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ 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;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -601,6 +603,8 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
new Mock<IMediator>().Object,
|
||||
null,
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,72 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fill_Exact_Duration_CustomTitle()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemDuration
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CustomTitle = "Custom Title"
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
StartState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop);
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
|
||||
|
||||
enumerator.State.Index.Should().Be(1);
|
||||
|
||||
playoutItems.Count.Should().Be(3);
|
||||
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[0].CustomTitle.Should().Be("Custom Title");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[1].CustomTitle.Should().Be("Custom Title");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
playoutItems[2].CustomTitle.Should().Be("Custom Title");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Have_Gap_Duration_Tail_Mode_None()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Movies = 1,
|
||||
Shows = 2,
|
||||
MusicVideos = 3,
|
||||
OtherVideos = 4
|
||||
OtherVideos = 4,
|
||||
Songs = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class BackgroundImageMediaVersion : MediaVersion
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class CoverArtMediaVersion : MediaVersion
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class FallbackMediaVersion : MediaVersion
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
public string Title { get; set; }
|
||||
public bool Default { get; set; }
|
||||
public bool Forced { get; set; }
|
||||
public bool AttachedPic { get; set; }
|
||||
public string PixelFormat { get; set; }
|
||||
public int BitsPerRawSample { get; set; }
|
||||
public int MediaVersionId { get; set; }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Song : MediaItem
|
||||
{
|
||||
public List<SongMetadata> SongMetadata { get; set; }
|
||||
public List<MediaVersion> MediaVersions { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Fallback = 0,
|
||||
Sidecar = 1,
|
||||
External = 2
|
||||
External = 2,
|
||||
Embedded = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class SongMetadata : Metadata
|
||||
{
|
||||
public string Album { get; set; }
|
||||
public string Artist { get; set; }
|
||||
public string Date { get; set; }
|
||||
public string Track { get; set; }
|
||||
public int SongId { get; set; }
|
||||
public Song Song { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Extensions
|
||||
{
|
||||
public static class MediaItemExtensions
|
||||
{
|
||||
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
Song s => s.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -21,8 +23,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private IDisplaySize _resolution;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private Option<int> _watermarkIndex;
|
||||
private string _pixelFormat;
|
||||
private string _videoEncoder;
|
||||
private Option<string> _drawtext;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
@@ -60,22 +64,66 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputCodec(string codec)
|
||||
public FFmpegComplexFilterBuilder WithInputCodec(Option<string> maybeCodec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
foreach (string codec in maybeCodec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
|
||||
{
|
||||
_pixelFormat = pixelFormat;
|
||||
foreach (string pixelFormat in maybePixelFormat)
|
||||
{
|
||||
_pixelFormat = pixelFormat;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
|
||||
public FFmpegComplexFilterBuilder WithWatermark(
|
||||
Option<ChannelWatermark> watermark,
|
||||
IDisplaySize resolution,
|
||||
Option<int> watermarkIndex)
|
||||
{
|
||||
_watermark = watermark;
|
||||
_resolution = resolution;
|
||||
_watermarkIndex = watermarkIndex;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithDrawtextFile(
|
||||
MediaVersion videoVersion,
|
||||
Option<string> drawtextFile)
|
||||
{
|
||||
foreach (string file in drawtextFile)
|
||||
{
|
||||
string effectiveFile = file;
|
||||
|
||||
if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion)
|
||||
{
|
||||
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "OPTIKabel-Heavy.otf");
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
fontPath = fontPath
|
||||
.Replace(@"\", @"/\")
|
||||
.Replace(@":/", @"\\:/");
|
||||
|
||||
effectiveFile = effectiveFile
|
||||
.Replace(@"\", @"/\")
|
||||
.Replace(@":/", @"\\:/");
|
||||
}
|
||||
|
||||
// TODO: calculate by percent
|
||||
_drawtext =
|
||||
$"drawtext=fontfile={fontPath}:textfile={effectiveFile}:x=50:y=H-175:fontsize=36:fontcolor=white";
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -85,19 +133,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
|
||||
public Option<FFmpegComplexFilter> Build(bool videoOnly, int videoInput, int videoStreamIndex, int audioInput, Option<int> audioStreamIndex, bool isSong)
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
|
||||
var videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
|
||||
string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
bool isHardwareDecode = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4",
|
||||
HardwareAccelerationKind.Nvenc => true,
|
||||
HardwareAccelerationKind.Qsv => true,
|
||||
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
|
||||
HardwareAccelerationKind.Nvenc => !isSong,
|
||||
HardwareAccelerationKind.Qsv => !isSong,
|
||||
_ => false
|
||||
};
|
||||
|
||||
@@ -105,7 +153,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
var videoFilterQueue = new List<string>();
|
||||
string watermarkPreprocess = string.Empty;
|
||||
string watermarkOverlay = string.Empty;
|
||||
|
||||
|
||||
if (_normalizeLoudness)
|
||||
{
|
||||
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
|
||||
@@ -120,9 +168,37 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
|
||||
(_deinterlace || _scaleToSize.IsSome);
|
||||
if (usesHardwareFilters)
|
||||
|
||||
if (isSong)
|
||||
{
|
||||
videoFilterQueue.Add("hwupload");
|
||||
switch (acceleration)
|
||||
{
|
||||
case HardwareAccelerationKind.Qsv:
|
||||
videoFilterQueue.Add("format=nv12");
|
||||
break;
|
||||
case HardwareAccelerationKind.Vaapi:
|
||||
videoFilterQueue.Add("format=nv12|vaapi");
|
||||
break;
|
||||
default:
|
||||
videoFilterQueue.Add("format=yuv420p");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (usesHardwareFilters || isSong, acceleration)
|
||||
{
|
||||
case (true, HardwareAccelerationKind.Nvenc):
|
||||
videoFilterQueue.Add("hwupload_cuda");
|
||||
break;
|
||||
case (true, HardwareAccelerationKind.Qsv):
|
||||
videoFilterQueue.Add("hwupload=extra_hw_frames=64");
|
||||
break;
|
||||
case (true, HardwareAccelerationKind.Vaapi):
|
||||
videoFilterQueue.Add("hwupload");
|
||||
break;
|
||||
case (true, _) when usesHardwareFilters:
|
||||
videoFilterQueue.Add("hwupload");
|
||||
break;
|
||||
}
|
||||
|
||||
if (_deinterlace)
|
||||
@@ -165,6 +241,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ when videoOnly => $"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
@@ -187,6 +264,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
"format=p010le,format=nv12",
|
||||
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
|
||||
_ when isSong => "format=yuv420p",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
videoFilterQueue.Add(format);
|
||||
@@ -197,6 +276,16 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
|
||||
if (videoOnly)
|
||||
{
|
||||
videoFilterQueue.Add("boxblur=40[b];[b]split[b1][b2];[b1]format=rgba,geq=r=0:g=0:b=0:a=120*(Y/H)[fg];[b2][fg]overlay=format=auto");
|
||||
}
|
||||
|
||||
if (isSong)
|
||||
{
|
||||
videoFilterQueue.Add("fps=30");
|
||||
}
|
||||
|
||||
foreach (ChannelWatermark watermark in _watermark)
|
||||
{
|
||||
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
|
||||
@@ -242,6 +331,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
|
||||
|
||||
foreach (string drawtext in _drawtext)
|
||||
{
|
||||
videoFilterQueue.Add(drawtext);
|
||||
}
|
||||
|
||||
string outputPixelFormat = null;
|
||||
|
||||
@@ -306,7 +400,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
complexFilter.Append("[vt];");
|
||||
}
|
||||
|
||||
var watermarkLabel = "[1:v]";
|
||||
var watermarkLabel = $"[{audioInput+1}:v]";
|
||||
foreach (int index in _watermarkIndex)
|
||||
{
|
||||
watermarkLabel = $"[{audioInput+1}:{index}]";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
||||
{
|
||||
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
|
||||
@@ -320,10 +419,21 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
complexFilter.Append(",hwupload");
|
||||
switch (isSong, acceleration)
|
||||
{
|
||||
case (true, HardwareAccelerationKind.Nvenc):
|
||||
complexFilter.Append(",hwupload_cuda");
|
||||
break;
|
||||
case (_, HardwareAccelerationKind.Qsv):
|
||||
complexFilter.Append(",format=yuv420p,hwupload=extra_hw_frames=64");
|
||||
break;
|
||||
default:
|
||||
complexFilter.Append(",hwupload");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
videoLabel = "[v]";
|
||||
complexFilter.Append(videoLabel);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public FFmpegPlaybackSettings CalculateSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
MediaVersion version,
|
||||
MediaStream videoStream,
|
||||
MediaVersion videoVersion,
|
||||
Option<MediaStream> videoStream,
|
||||
Option<MediaStream> audioStream,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now,
|
||||
@@ -76,10 +76,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
case StreamingMode.TransportStream:
|
||||
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
|
||||
|
||||
if (NeedToScale(ffmpegProfile, version))
|
||||
if (NeedToScale(ffmpegProfile, videoVersion))
|
||||
{
|
||||
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, version);
|
||||
if (!scaledSize.IsSameSizeAs(version))
|
||||
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion);
|
||||
if (!scaledSize.IsSameSizeAs(videoVersion))
|
||||
{
|
||||
int fixedHeight = scaledSize.Height + scaledSize.Height % 2;
|
||||
int fixedWidth = scaledSize.Width + scaledSize.Width % 2;
|
||||
@@ -87,7 +87,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
|
||||
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(videoVersion);
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
{
|
||||
result.PadToDesiredResolution = true;
|
||||
@@ -98,32 +98,36 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
}
|
||||
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
|
||||
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
|
||||
result.VideoDecoder =
|
||||
(result.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat) switch
|
||||
{
|
||||
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le") =>
|
||||
"h264",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
|
||||
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VideoCodec = "copy";
|
||||
result.VideoDecoder =
|
||||
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
|
||||
{
|
||||
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
|
||||
) =>
|
||||
"h264",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
|
||||
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
|
||||
@@ -150,7 +154,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.AudioCodec = "copy";
|
||||
}
|
||||
|
||||
if (version.VideoScanKind == VideoScanKind.Interlaced)
|
||||
if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
|
||||
{
|
||||
result.Deinterlace = true;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private string _vaapiDevice;
|
||||
private HardwareAccelerationKind _hwAccel;
|
||||
private string _outputPixelFormat;
|
||||
private bool _noAutoScale;
|
||||
private Option<int> _outputFramerate;
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
|
||||
{
|
||||
@@ -71,7 +73,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, string pixelFormat, string encoder)
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, Option<string> pixelFormat, string encoder)
|
||||
{
|
||||
_hwAccel = hwAccel;
|
||||
|
||||
@@ -84,7 +86,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
|
||||
break;
|
||||
case HardwareAccelerationKind.Nvenc:
|
||||
string outputFormat = (encoder, pixelFormat) switch
|
||||
string outputFormat = (encoder, pixelFormat.IfNone("")) switch
|
||||
{
|
||||
("hevc_nvenc", "yuv420p10le") => "p010le",
|
||||
("h264_nvenc", "yuv420p10le") => "p010le",
|
||||
@@ -147,6 +149,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
_arguments.Add("-stream_loop");
|
||||
_arguments.Add("-1");
|
||||
|
||||
if (_hwAccel is HardwareAccelerationKind.Qsv or HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
@@ -188,30 +195,66 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithWatermark(
|
||||
Option<ChannelWatermark> watermark,
|
||||
Option<string> maybePath,
|
||||
IDisplaySize resolution,
|
||||
bool isAnimated)
|
||||
Option<WatermarkOptions> watermarkOptions,
|
||||
IDisplaySize resolution)
|
||||
{
|
||||
foreach (string path in maybePath)
|
||||
foreach (WatermarkOptions options in watermarkOptions)
|
||||
{
|
||||
if (isAnimated)
|
||||
foreach (string path in options.ImagePath)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
if (options.IsAnimated)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
|
||||
options.Watermark,
|
||||
resolution,
|
||||
options.ImageStreamIndex);
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDrawtextFile(
|
||||
MediaVersion videoVersion,
|
||||
Option<string> drawtextFile)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithDrawtextFile(
|
||||
videoVersion,
|
||||
drawtextFile);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInputCodec(string input, string decoder, string codec, string pixelFormat)
|
||||
public FFmpegProcessBuilder WithInputCodec(
|
||||
Option<TimeSpan> maybeStart,
|
||||
bool loop,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
string decoder,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat)
|
||||
{
|
||||
if (audioPath == videoPath)
|
||||
{
|
||||
WithSeek(maybeStart);
|
||||
WithInfiniteLoop(loop);
|
||||
}
|
||||
else
|
||||
{
|
||||
_noAutoScale = true;
|
||||
_outputFramerate = 30;
|
||||
|
||||
_arguments.Add("-loop");
|
||||
_arguments.Add("1");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decoder))
|
||||
{
|
||||
_arguments.Add("-c:v");
|
||||
@@ -223,7 +266,34 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add($"{input}");
|
||||
_arguments.Add(videoPath);
|
||||
|
||||
if (audioPath != videoPath)
|
||||
{
|
||||
WithSeek(maybeStart);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(audioPath);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithSongInput(
|
||||
string videoPath,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
_outputFramerate = 30;
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(videoPath);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -327,7 +397,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add($"{format}");
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
|
||||
{
|
||||
const int SEGMENT_SECONDS = 4;
|
||||
@@ -430,6 +500,18 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
});
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
|
||||
if (_noAutoScale)
|
||||
{
|
||||
_arguments.Add("-noautoscale");
|
||||
}
|
||||
|
||||
foreach (int framerate in _outputFramerate)
|
||||
{
|
||||
_arguments.Add("-r");
|
||||
_arguments.Add(framerate.ToString());
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -473,10 +555,23 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithOutputFormat(string format, string output)
|
||||
{
|
||||
_arguments.Add("-f");
|
||||
_arguments.Add(format);
|
||||
|
||||
_arguments.Add("-y");
|
||||
_arguments.Add(output);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex(
|
||||
MediaStream videoStream,
|
||||
Option<MediaStream> maybeAudioStream,
|
||||
string videoPath,
|
||||
Option<string> audioPath,
|
||||
string videoCodec)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
|
||||
@@ -484,10 +579,33 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
int videoStreamIndex = videoStream.Index;
|
||||
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
|
||||
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}";
|
||||
var videoIndex = 0;
|
||||
var audioIndex = 0;
|
||||
if (audioPath.IsNone)
|
||||
{
|
||||
// no audio index, so use same as video
|
||||
audioIndex = 0;
|
||||
}
|
||||
else if (audioPath.IfNone("NotARealPath") != videoPath)
|
||||
{
|
||||
audioIndex = 1;
|
||||
if (_hwAccel == HardwareAccelerationKind.None)
|
||||
{
|
||||
_outputPixelFormat = "yuv420p";
|
||||
}
|
||||
}
|
||||
|
||||
var videoLabel = $"{videoIndex}:{videoStreamIndex}";
|
||||
var audioLabel = $"{audioIndex}:{maybeIndex.Match(i => i.ToString(), () => "a")}";
|
||||
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(
|
||||
audioPath.IsNone,
|
||||
videoIndex,
|
||||
videoStreamIndex,
|
||||
audioIndex,
|
||||
maybeIndex,
|
||||
audioPath.IsSome && videoPath != audioPath.IfNone("NotARealPath"));
|
||||
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
|
||||
maybeFilter.IfSome(
|
||||
filter =>
|
||||
{
|
||||
@@ -505,8 +623,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(videoLabel);
|
||||
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(audioLabel);
|
||||
foreach (string _ in audioPath)
|
||||
{
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(audioLabel);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegProcessService
|
||||
public class FFmpegProcessService : IFFmpegProcessService
|
||||
{
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
private readonly ILogger<FFmpegProcessService> _logger;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
|
||||
@@ -23,11 +24,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
IImageCache imageCache,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<FFmpegProcessService> logger)
|
||||
{
|
||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_imageCache = imageCache;
|
||||
_tempFilePool = tempFilePool;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -35,8 +38,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
MediaVersion version,
|
||||
string path,
|
||||
MediaVersion videoVersion,
|
||||
MediaVersion audioVersion,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset finish,
|
||||
DateTimeOffset now,
|
||||
@@ -48,13 +53,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
version,
|
||||
videoVersion,
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
start,
|
||||
@@ -62,26 +67,30 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
inPoint,
|
||||
outPoint);
|
||||
|
||||
(Option<ChannelWatermark> maybeWatermark, Option<string> maybeWatermarkPath) =
|
||||
GetWatermarkOptions(channel, globalWatermark);
|
||||
|
||||
bool isAnimated = await maybeWatermarkPath.Match(
|
||||
p => _imageCache.IsAnimated(p),
|
||||
() => Task.FromResult(false));
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithVaapiDriver(vaapiDriver, vaapiDevice)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration, videoStream.PixelFormat, playbackSettings.VideoCodec)
|
||||
.WithHardwareAcceleration(
|
||||
playbackSettings.HardwareAcceleration,
|
||||
videoStream.PixelFormat,
|
||||
playbackSettings.VideoCodec)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInfiniteLoop(fillerKind == FillerKind.Fallback)
|
||||
.WithInputCodec(path, playbackSettings.VideoDecoder, videoStream.Codec, videoStream.PixelFormat)
|
||||
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
|
||||
.WithInputCodec(
|
||||
playbackSettings.StreamSeek,
|
||||
fillerKind == FillerKind.Fallback,
|
||||
videoPath,
|
||||
audioPath,
|
||||
playbackSettings.VideoDecoder,
|
||||
videoStream.Codec,
|
||||
videoStream.PixelFormat)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
|
||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||
|
||||
playbackSettings.ScaledSize.Match(
|
||||
@@ -96,7 +105,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
@@ -105,18 +119,33 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,7 +157,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, version)
|
||||
return builder.WithHls(channel.Number, videoVersion)
|
||||
.WithRealtimeOutput(hlsRealtime)
|
||||
.Build();
|
||||
default:
|
||||
@@ -196,14 +225,130 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.Build();
|
||||
}
|
||||
|
||||
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
|
||||
{
|
||||
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithInput(inputFile)
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
Option<string> drawtextFile,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
MediaVersion videoVersion,
|
||||
string videoPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string outputFile = _tempFilePool.GetNextTempFile(TempFileCategory.SongBackground);
|
||||
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
|
||||
FFmpegPlaybackSettings scalePlaybackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
channel.FFmpegProfile,
|
||||
videoVersion,
|
||||
videoStream,
|
||||
None,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
.WithDrawtextFile(videoVersion, drawtextFile);
|
||||
|
||||
foreach (IDisplaySize scaledSize in scalePlaybackSettings.ScaledSize)
|
||||
{
|
||||
builder = builder.WithScaling(scaledSize);
|
||||
|
||||
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
|
||||
{
|
||||
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
|
||||
}
|
||||
}
|
||||
|
||||
using Process process = builder
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
None,
|
||||
videoPath,
|
||||
None,
|
||||
playbackSettings.VideoCodec)
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.Build();
|
||||
|
||||
_logger.LogInformation(
|
||||
"ffmpeg song arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error generating song image");
|
||||
return Left(BaseError.New(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
|
||||
displaySize.Width != target.Width || displaySize.Height != target.Height;
|
||||
|
||||
private WatermarkOptions GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark)
|
||||
private async Task<WatermarkOptions> GetWatermarkOptions(
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
MediaVersion videoVersion)
|
||||
{
|
||||
if (videoVersion is BackgroundImageMediaVersion)
|
||||
{
|
||||
return new WatermarkOptions(None, None, None, false);
|
||||
}
|
||||
|
||||
Option<ChannelWatermark> watermarkOverride = videoVersion is FallbackMediaVersion or CoverArtMediaVersion
|
||||
? new ChannelWatermark
|
||||
{
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
HorizontalMarginPercent = 3,
|
||||
VerticalMarginPercent = 5,
|
||||
Location = ChannelWatermarkLocation.BottomRight,
|
||||
Size = ChannelWatermarkSize.Scaled,
|
||||
WidthPercent = 25,
|
||||
Opacity = 100
|
||||
}
|
||||
: None;
|
||||
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
|
||||
channel.FFmpegProfile.NormalizeVideo)
|
||||
{
|
||||
if (videoVersion is CoverArtMediaVersion)
|
||||
{
|
||||
return new WatermarkOptions(
|
||||
watermarkOverride,
|
||||
videoVersion.MediaFiles.Head().Path,
|
||||
0,
|
||||
false);
|
||||
}
|
||||
|
||||
// check for channel watermark
|
||||
if (channel.Watermark != null)
|
||||
{
|
||||
@@ -214,13 +359,23 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
channel.Watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
Option<int>.None);
|
||||
return new WatermarkOptions(channel.Watermark, customPath);
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(channel.Watermark),
|
||||
customPath,
|
||||
None,
|
||||
await _imageCache.IsAnimated(customPath));
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
return new WatermarkOptions(channel.Watermark, maybeChannelPath);
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(channel.Watermark),
|
||||
maybeChannelPath,
|
||||
None,
|
||||
await maybeChannelPath.Match(
|
||||
p => _imageCache.IsAnimated(p),
|
||||
() => Task.FromResult(false)));
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -236,22 +391,30 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
Option<int>.None);
|
||||
return new WatermarkOptions(watermark, customPath);
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(watermark),
|
||||
customPath,
|
||||
None,
|
||||
await _imageCache.IsAnimated(customPath));
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
return new WatermarkOptions(watermark, maybeChannelPath);
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(watermark),
|
||||
maybeChannelPath,
|
||||
None,
|
||||
await maybeChannelPath.Match(
|
||||
p => _imageCache.IsAnimated(p),
|
||||
() => Task.FromResult(false)));
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new WatermarkOptions(None, None);
|
||||
return new WatermarkOptions(None, None, None, false);
|
||||
}
|
||||
|
||||
private record WatermarkOptions(Option<ChannelWatermark> Watermark, Option<string> ImagePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public enum TempFileCategory
|
||||
{
|
||||
DrawText = 0,
|
||||
SongBackground = 1,
|
||||
CoverArt = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class TempFilePool : ITempFilePool
|
||||
{
|
||||
private const int ItemLimit = 10;
|
||||
private readonly Dictionary<TempFileCategory, int> _state = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public string GetNextTempFile(TempFileCategory category)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = 0;
|
||||
|
||||
if (_state.TryGetValue(category, out int current))
|
||||
{
|
||||
index = (current + 1) % ItemLimit;
|
||||
}
|
||||
|
||||
_state[category] = index;
|
||||
|
||||
return GetFileName(category, index);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFileName(TempFileCategory category, int index)
|
||||
{
|
||||
return Path.Combine(FileSystemLayout.TempFilePoolFolder, $"{category}_{index}".ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public record WatermarkOptions(
|
||||
Option<ChannelWatermark> Watermark,
|
||||
Option<string> ImagePath,
|
||||
Option<int> ImageStreamIndex,
|
||||
bool IsAnimated);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Core
|
||||
|
||||
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
|
||||
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
|
||||
public static readonly string TempFilePoolFolder = Path.Combine(AppDataFolder, "temp-pool");
|
||||
|
||||
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegProcessService
|
||||
{
|
||||
Task<Process> ForPlayoutItem(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
MediaVersion videoVersion,
|
||||
MediaVersion audioVersion,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset finish,
|
||||
DateTimeOffset now,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
bool hlsRealtime,
|
||||
FillerKind fillerKind,
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint);
|
||||
|
||||
Process ForError(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
Option<TimeSpan> duration,
|
||||
string errorMessage,
|
||||
bool hlsRealtime);
|
||||
|
||||
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
|
||||
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
|
||||
|
||||
Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
Option<string> drawtextFile,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
MediaVersion videoVersion,
|
||||
string videoPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface ITempFilePool
|
||||
{
|
||||
string GetNextTempFile(TempFileCategory category);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
MovieMetadata GetFallbackMetadata(Movie movie);
|
||||
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
|
||||
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
|
||||
Option<SongMetadata> GetFallbackMetadata(Song song);
|
||||
string GetSortTitle(string title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
||||
Task<bool> RefreshTagMetadata(Song song, string ffprobePath);
|
||||
Task<bool> RefreshFallbackMetadata(Movie movie);
|
||||
Task<bool> RefreshFallbackMetadata(Episode episode);
|
||||
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
|
||||
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
|
||||
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
|
||||
Task<bool> RefreshFallbackMetadata(Song song);
|
||||
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -8,5 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
|
||||
|
||||
Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ISongFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
string ffmpegPath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface ISongRepository
|
||||
{
|
||||
Task<Either<BaseError, MediaItemScanResult<Song>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindSongPaths(LibraryPath libraryPath);
|
||||
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<bool> AddGenre(SongMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(SongMetadata metadata, Tag tag);
|
||||
Task<List<SongMetadata>> GetSongsForCards(List<int> ids);
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,10 @@ namespace ErsatzTV.Core.Iptv
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Title ?? string.Empty)
|
||||
.IfNone("[unknown song]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
return GetOtherVideoMetadata(path, metadata);
|
||||
}
|
||||
|
||||
public Option<SongMetadata> GetFallbackMetadata(Song song)
|
||||
{
|
||||
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileNameWithoutExtension(path);
|
||||
var metadata = new SongMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
Song = song
|
||||
};
|
||||
|
||||
return GetSongMetadata(path, metadata);
|
||||
}
|
||||
|
||||
public string GetSortTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -266,6 +280,43 @@ namespace ErsatzTV.Core.Metadata
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
string folder = Path.GetDirectoryName(path);
|
||||
if (folder == null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
string libraryPath = metadata.Song.LibraryPath.Path;
|
||||
string parent = Optional(Directory.GetParent(libraryPath)).Match(
|
||||
di => di.FullName,
|
||||
() => libraryPath);
|
||||
|
||||
string diff = Path.GetRelativePath(parent, folder);
|
||||
|
||||
var tags = diff.Split(Path.DirectorySeparatorChar)
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = new List<Artwork>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -17,7 +21,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
public static readonly List<string> VideoFileExtensions = new()
|
||||
{
|
||||
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
|
||||
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"
|
||||
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts", ".webm"
|
||||
};
|
||||
|
||||
public static readonly List<string> AudioFileExtensions = new()
|
||||
{
|
||||
".aac", ".alac", ".flac", ".mp3", ".m4a", ".wav", ".wma"
|
||||
};
|
||||
|
||||
public static readonly List<string> ImageFileExtensions = new()
|
||||
@@ -41,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList();
|
||||
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
@@ -52,12 +63,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_imageCache = imageCache;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_tempFilePool = tempFilePool;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -68,14 +83,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
MediaVersion version = mediaItem.Item switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
MediaVersion version = mediaItem.Item.GetHeadVersion();
|
||||
|
||||
string path = version.MediaFiles.Head().Path;
|
||||
|
||||
@@ -108,7 +116,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind, Option<string> ffmpegPath)
|
||||
{
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
@@ -125,6 +133,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
|
||||
// if ffmpeg path is passed, we want to convert to png
|
||||
foreach (string path in ffmpegPath)
|
||||
{
|
||||
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
|
||||
using Process process = _ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
artworkFile = tempName;
|
||||
}
|
||||
|
||||
Either<BaseError, string> maybeCacheName =
|
||||
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -23,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IEpisodeNfoReader _episodeNfoReader;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||
@@ -31,6 +33,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ISongRepository _songRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public LocalMetadataProvider(
|
||||
@@ -40,9 +43,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ISongRepository songRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IEpisodeNfoReader episodeNfoReader,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
{
|
||||
_metadataRepository = metadataRepository;
|
||||
@@ -51,9 +56,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
_songRepository = songRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_episodeNfoReader = episodeNfoReader;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -130,6 +137,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
() => RefreshFallbackMetadata(musicVideo)));
|
||||
|
||||
public Task<bool> RefreshTagMetadata(Song song, string ffprobePath) =>
|
||||
LoadSongMetadata(song, ffprobePath).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(song, metadata),
|
||||
() => RefreshFallbackMetadata(song)));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
|
||||
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
|
||||
|
||||
@@ -144,6 +157,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata => ApplyMetadataUpdate(otherVideo, metadata),
|
||||
() => Task.FromResult(false));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Song song) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(song).Match(
|
||||
metadata => ApplyMetadataUpdate(song, metadata),
|
||||
() => Task.FromResult(false));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
@@ -182,6 +200,95 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<SongMetadata>> LoadSongMetadata(Song song, string ffprobePath)
|
||||
{
|
||||
string path = song.GetHeadVersion().MediaFiles.Head().Path;
|
||||
|
||||
try
|
||||
{
|
||||
Either<BaseError, Dictionary<string, string>> maybeTags =
|
||||
await _localStatisticsProvider.GetFormatTags(ffprobePath, song);
|
||||
|
||||
return maybeTags.Match(
|
||||
tags =>
|
||||
{
|
||||
Option<SongMetadata> maybeFallbackMetadata =
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(song);
|
||||
|
||||
var result = new SongMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Embedded,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(path),
|
||||
|
||||
Artwork = new List<Artwork>(),
|
||||
Actors = new List<Actor>(),
|
||||
Genres = new List<Genre>(),
|
||||
Studios = new List<Studio>(),
|
||||
Tags = new List<Tag>()
|
||||
};
|
||||
|
||||
// TODO: check for cover artwork, and use for watermark if not embedded
|
||||
// maybe add album as entity rather than string?
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Album, out string album))
|
||||
{
|
||||
result.Album = album;
|
||||
}
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Artist, out string artist))
|
||||
{
|
||||
result.Artist = artist;
|
||||
}
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Date, out string date))
|
||||
{
|
||||
result.Date = date;
|
||||
}
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Genre, out string genre))
|
||||
{
|
||||
// TODO: split genres? or is this only ever one?
|
||||
result.Genres.Add(new Genre { Name = genre });
|
||||
}
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Title, out string title))
|
||||
{
|
||||
result.Title = title;
|
||||
result.OriginalTitle = title;
|
||||
}
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Track, out string track))
|
||||
{
|
||||
result.Track = track;
|
||||
}
|
||||
|
||||
foreach (SongMetadata fallbackMetadata in maybeFallbackMetadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result.Title))
|
||||
{
|
||||
result.Title = fallbackMetadata.Title;
|
||||
result.OriginalTitle = fallbackMetadata.OriginalTitle;
|
||||
}
|
||||
|
||||
// preserve folder tagging - maybe someone uses this
|
||||
foreach (Tag tag in fallbackMetadata.Tags)
|
||||
{
|
||||
result.Tags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
_ => Option<SongMetadata>.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(ex, "Failed to read embedded song metadata from {Path}", path);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
|
||||
{
|
||||
var updated = false;
|
||||
@@ -661,6 +768,49 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private Task<bool> ApplyMetadataUpdate(Song song, SongMetadata metadata) =>
|
||||
Optional(song.SongMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.Title = metadata.Title;
|
||||
existing.Artist = metadata.Artist;
|
||||
existing.Album = metadata.Album;
|
||||
existing.Date = metadata.Date;
|
||||
existing.Track = metadata.Track;
|
||||
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
_songRepository.AddGenre,
|
||||
_songRepository.AddTag,
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false));
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.SongId = song.Id;
|
||||
song.SongMetadata = new List<SongMetadata> { metadata };
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
|
||||
{
|
||||
@@ -931,7 +1081,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
if (existing is not MusicVideoMetadata)
|
||||
if (existing is not MusicVideoMetadata and not SongMetadata)
|
||||
{
|
||||
foreach (Actor actor in existing.Actors
|
||||
.Filter(a => incoming.Actors.All(a2 => a2.Name != a.Name))
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
@@ -34,15 +35,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
string filePath = mediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
MusicVideo mv => mv.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
OtherVideo ov => ov.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -76,16 +69,63 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
|
||||
return maybeProbe.Match(
|
||||
ffprobe =>
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.album))
|
||||
{
|
||||
result.Add(MetadataFormatTag.Album, ffprobe.format.tags.album);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.artist))
|
||||
{
|
||||
result.Add(MetadataFormatTag.Artist, ffprobe.format.tags.artist);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.date))
|
||||
{
|
||||
result.Add(MetadataFormatTag.Date, ffprobe.format.tags.date);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.genre))
|
||||
{
|
||||
result.Add(MetadataFormatTag.Genre, ffprobe.format.tags.genre);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.title))
|
||||
{
|
||||
result.Add(MetadataFormatTag.Title, ffprobe.format.tags.title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.track))
|
||||
{
|
||||
result.Add(MetadataFormatTag.Track, ffprobe.format.tags.track);
|
||||
}
|
||||
|
||||
return Right<BaseError, Dictionary<string, string>>(result);
|
||||
},
|
||||
Left<BaseError, Dictionary<string, string>>);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get format tags for media item {Id}", mediaItem.Id);
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyVersionUpdate(MediaItem mediaItem, MediaVersion version, string filePath)
|
||||
{
|
||||
MediaVersion mediaItemVersion = mediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
MediaVersion mediaItemVersion = mediaItem.GetHeadVersion();
|
||||
|
||||
bool durationChange = mediaItemVersion.Duration != version.Duration;
|
||||
|
||||
@@ -221,6 +261,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
stream.Default = videoStream.disposition.@default == 1;
|
||||
stream.Forced = videoStream.disposition.forced == 1;
|
||||
stream.AttachedPic = videoStream.disposition.attached_pic == 1;
|
||||
}
|
||||
|
||||
version.Streams.Add(stream);
|
||||
@@ -290,7 +331,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
last.EndTime = version.Duration;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return version;
|
||||
},
|
||||
_ => new MediaVersion
|
||||
@@ -312,12 +353,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
// ReSharper disable InconsistentNaming
|
||||
public record FFprobe(FFprobeFormat format, List<FFprobeStream> streams, List<FFprobeChapter> chapters);
|
||||
|
||||
public record FFprobeFormat(string duration);
|
||||
public record FFprobeFormat(string duration, FFprobeFormatTags tags);
|
||||
|
||||
public record FFprobeDisposition(int @default, int forced);
|
||||
public record FFprobeDisposition(int @default, int forced, int attached_pic);
|
||||
|
||||
public record FFprobeTags(string language, string title);
|
||||
|
||||
public record FFprobeFormatTags(
|
||||
string title,
|
||||
string artist,
|
||||
string album,
|
||||
string track,
|
||||
string genre,
|
||||
string date);
|
||||
|
||||
public record FFprobeStream(
|
||||
int index,
|
||||
string codec_name,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public static class MetadataFormatTag
|
||||
{
|
||||
public static readonly string Album = "album";
|
||||
public static readonly string Artist = "artist";
|
||||
public static readonly string Date = "date";
|
||||
public static readonly string Genre = "genre";
|
||||
public static readonly string Title = "title";
|
||||
public static readonly string Track = "track";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -40,8 +41,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
ISearchRepository searchRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediator mediator,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||
: base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_movieRepository = movieRepository;
|
||||
@@ -229,7 +239,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -41,11 +42,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediator mediator,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<MusicVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -217,7 +222,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async artworkFile =>
|
||||
{
|
||||
ArtistMetadata metadata = artist.ArtistMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -380,7 +385,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async thumbnailFile =>
|
||||
{
|
||||
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail);
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -39,11 +40,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
ISearchRepository searchRepository,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<OtherVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ISongRepository _songRepository;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<SongFolderScanner> _logger;
|
||||
|
||||
public SongFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ISongRepository songRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<SongFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_songRepository = songRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
string ffmpegPath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
string songFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(songFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == songFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
songFolder);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffprobePath))
|
||||
.BindT(video => UpdateMetadata(video, ffprobePath))
|
||||
.BindT(video => UpdateThumbnail(video, ffprobePath, ffmpegPath));
|
||||
|
||||
await maybeSong.Match(
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in await _songRepository.FindSongPaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Removing missing song at {Path}", path);
|
||||
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(songIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(songIds);
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(
|
||||
MediaItemScanResult<Song> result, string ffprobePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Song song = result.Item;
|
||||
string path = song.GetHeadVersion().MediaFiles.Head().Path;
|
||||
|
||||
bool shouldUpdate = Optional(song.SongMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated != _localFileSystem.GetLastWriteTime(path),
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
song.SongMetadata ??= new List<SongMetadata>();
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshTagMetadata(song, ffprobePath))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
|
||||
MediaItemScanResult<Song> result,
|
||||
string ffprobePath,
|
||||
string ffmpegPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Song song = result.Item;
|
||||
await LocateThumbnail(song).Match(
|
||||
async thumbnailFile =>
|
||||
{
|
||||
SongMetadata metadata = song.SongMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath);
|
||||
},
|
||||
() => Task.CompletedTask); // TODO: check for embedded artwork
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private Option<string> LocateThumbnail(Song song)
|
||||
{
|
||||
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
Option<DirectoryInfo> parent = Optional(Directory.GetParent(path));
|
||||
|
||||
return parent.Map(
|
||||
di =>
|
||||
{
|
||||
string coverPath = Path.Combine(di.FullName, "cover.jpg");
|
||||
return ImageFileExtensions
|
||||
.Map(ext => Path.ChangeExtension(coverPath, ext))
|
||||
.Filter(f => _localFileSystem.FileExists(f))
|
||||
.HeadOrNone();
|
||||
}).Flatten();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -40,11 +41,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
ISearchRepository searchRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediator mediator,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -362,7 +367,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async artworkFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -381,7 +386,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async posterFile =>
|
||||
{
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster);
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None);
|
||||
});
|
||||
|
||||
return season;
|
||||
@@ -401,7 +406,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
|
||||
{
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -267,6 +267,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
_ => true
|
||||
};
|
||||
|
||||
@@ -374,7 +376,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
CollectionEnumeratorState state = maybeAnchor.Match(
|
||||
anchor => anchor.EnumeratorState,
|
||||
anchor => anchor.EnumeratorState ??
|
||||
(anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }),
|
||||
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
|
||||
|
||||
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
|
||||
@@ -435,7 +438,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey)
|
||||
{
|
||||
var result = new List<CollectionWithItems>();
|
||||
List<CollectionWithItems> result;
|
||||
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
@@ -474,6 +477,10 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return ov.OtherVideoMetadata.HeadOrNone().Match(
|
||||
ovm => ovm.Title ?? string.Empty,
|
||||
() => "[unknown video]");
|
||||
case Song s:
|
||||
return s.SongMetadata.HeadOrNone().Match(
|
||||
sm => sm.Title ?? string.Empty,
|
||||
() => "[unknown song]");
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -161,29 +162,13 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
||||
{
|
||||
MediaVersion version = mediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo mv => mv.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
return version.Duration;
|
||||
}
|
||||
|
||||
protected static List<MediaChapter> ChaptersForMediaItem(MediaItem mediaItem)
|
||||
{
|
||||
MediaVersion version = mediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo mv => mv.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
return version.Chapters;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
GuideGroup = nextState.NextGuideGroup,
|
||||
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.Tail
|
||||
: FillerKind.None
|
||||
: FillerKind.None,
|
||||
CustomTitle = scheduleItem.CustomTitle
|
||||
};
|
||||
|
||||
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
|
||||
@@ -99,7 +100,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
nextState = nextState with
|
||||
{
|
||||
CurrentTime = itemEndTimeWithFiller,
|
||||
NextGuideGroup = nextState.IncrementGuideGroup
|
||||
|
||||
// only bump guide group if we don't have a custom title
|
||||
NextGuideGroup = string.IsNullOrWhiteSpace(scheduleItem.CustomTitle)
|
||||
? nextState.IncrementGuideGroup
|
||||
: nextState.NextGuideGroup
|
||||
};
|
||||
|
||||
contentEnumerator.MoveNext();
|
||||
@@ -133,7 +138,10 @@ namespace ErsatzTV.Core.Scheduling
|
||||
};
|
||||
}
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup };
|
||||
if (playoutItems.Select(pi => pi.GuideGroup).Distinct().Count() != 1)
|
||||
{
|
||||
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup };
|
||||
}
|
||||
|
||||
foreach (DateTimeOffset nextItemStart in durationUntil)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class SongConfiguration : IEntityTypeConfiguration<Song>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Song> builder)
|
||||
{
|
||||
builder.ToTable("Song");
|
||||
|
||||
builder.HasMany(m => m.SongMetadata)
|
||||
.WithOne(m => m.Song)
|
||||
.HasForeignKey(m => m.SongId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(m => m.MediaVersions)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class SongMetadataConfiguration : IEntityTypeConfiguration<SongMetadata>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SongMetadata> builder)
|
||||
{
|
||||
builder.ToTable("SongMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
.HasForeignKey(pi => pi.PlayoutId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.OwnsOne(p => p.Anchor);
|
||||
builder.Navigation(p => p.Anchor).IsRequired();
|
||||
builder.OwnsOne(p => p.Anchor)
|
||||
.ToTable("PlayoutAnchor");
|
||||
|
||||
builder.HasMany(p => p.ProgramScheduleAnchors)
|
||||
.WithOne(a => a.Playout)
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
builder.ToTable("PlayoutProgramScheduleAnchor");
|
||||
|
||||
builder.OwnsOne(a => a.EnumeratorState);
|
||||
builder.Navigation(a => a.EnumeratorState).IsRequired();
|
||||
builder.OwnsOne(a => a.EnumeratorState).ToTable("CollectionEnumeratorState");
|
||||
|
||||
builder.HasOne(i => i.Collection)
|
||||
.WithMany()
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
|
||||
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
|
||||
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
|
||||
AND A.ChannelId IS NULL
|
||||
AND A.SongMetadataId IS NULL AND A.ChannelId IS NULL
|
||||
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
|
||||
.Map(result => result.ToList());
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Collections
|
||||
.Include(c => c.CollectionItems)
|
||||
.OrderBy(c => c.Id)
|
||||
@@ -44,7 +44,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<MediaItem>> GetItems(int collectionId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -55,13 +55,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetSongItems(dbContext, collectionId));
|
||||
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
public async Task<List<MediaItem>> GetMultiCollectionItems(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -81,6 +82,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetSongItems(dbContext, collectionId));
|
||||
}
|
||||
|
||||
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
|
||||
@@ -94,7 +96,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<MediaItem>> GetSmartCollectionItems(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -145,6 +147,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
|
||||
|
||||
var songIds = searchResults.Items
|
||||
.Filter(i => i.Type == SearchIndex.SongType)
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
result.AddRange(await GetSongItems(dbContext, songIds));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -152,7 +160,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<CollectionWithItems>();
|
||||
|
||||
@@ -441,6 +449,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Filter(m => otherVideoIds.Contains(m.Id))
|
||||
.ToListAsync();
|
||||
|
||||
private async Task<List<Song>> GetSongItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT s.Id FROM CollectionItem ci
|
||||
INNER JOIN Song s ON s.Id = ci.MediaItemId
|
||||
WHERE ci.CollectionId = @CollectionId",
|
||||
new { CollectionId = collectionId });
|
||||
|
||||
return await GetSongItems(dbContext, ids);
|
||||
}
|
||||
|
||||
private static Task<List<Song>> GetSongItems(TvContext dbContext, IEnumerable<int> songIds) =>
|
||||
dbContext.Songs
|
||||
.Include(m => m.SongMetadata)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Chapters)
|
||||
.Filter(m => songIds.Contains(m.Id))
|
||||
.ToListAsync();
|
||||
|
||||
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
|
||||
@@ -248,6 +248,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
SongMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<Option<MediaItem>> GetItemToIndex(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Include(mi => mi.LibraryPath)
|
||||
@@ -101,6 +101,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Song).SongMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(mi => mi.Id)
|
||||
@@ -139,7 +143,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class SongRepository : ISongRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SongRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbConnection = dbConnection;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<Song>>> GetOrAdd(
|
||||
LibraryPath libraryPath,
|
||||
string path)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<Song> maybeExisting = await dbContext.Songs
|
||||
.AsNoTracking()
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(s => s.SongMetadata)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(s => s.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(s => s.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
mediaItem =>
|
||||
Right<BaseError, MediaItemScanResult<Song>>(
|
||||
new MediaItemScanResult<Song>(mediaItem) { IsAdded = false }).AsTask(),
|
||||
async () => await AddSong(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindSongPaths(LibraryPath libraryPath) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT MF.Path
|
||||
FROM MediaFile MF
|
||||
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
|
||||
INNER JOIN Song O on MV.SongId = O.Id
|
||||
INNER JOIN MediaItem MI on O.Id = MI.Id
|
||||
WHERE MI.LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPath.Id });
|
||||
|
||||
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT O.Id
|
||||
FROM Song O
|
||||
INNER JOIN MediaItem MI on O.Id = MI.Id
|
||||
INNER JOIN MediaVersion MV on O.Id = MV.SongId
|
||||
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
||||
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
foreach (int songId in ids)
|
||||
{
|
||||
Song song = await dbContext.Songs.FindAsync(songId);
|
||||
if (song != null)
|
||||
{
|
||||
dbContext.Songs.Remove(song);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public Task<bool> AddGenre(SongMetadata metadata, Genre genre) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Genre (Name, SongMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddTag(SongMetadata metadata, Tag tag) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Tag (Name, SongMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public async Task<List<SongMetadata>> GetSongsForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.SongMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(ovm => ids.Contains(ovm.SongId))
|
||||
.Include(ovm => ovm.Song)
|
||||
.Include(ovm => ovm.Artwork)
|
||||
.OrderBy(ovm => ovm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Song>>> AddSong(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
await dbContext.Songs.AddAsync(song);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(song).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(song.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<Song>(song) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
|
||||
public DbSet<OtherVideo> OtherVideos { get; set; }
|
||||
public DbSet<OtherVideoMetadata> OtherVideoMetadata { get; set; }
|
||||
public DbSet<Song> Songs { get; set; }
|
||||
public DbSet<SongMetadata> SongMetadata { get; set; }
|
||||
public DbSet<Show> Shows { get; set; }
|
||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||
public DbSet<Season> Seasons { get; set; }
|
||||
|
||||
+3671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Move_ToOwnedTables : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Playout_ProgramScheduleItem_Anchor_NextScheduleItemId",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Playout_Anchor_NextScheduleItemId",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnumeratorState_Index",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnumeratorState_Seed",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_DurationFinish",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InDurationFiller",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_MultipleRemaining",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_NextGuideGroup",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_NextScheduleItemId",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_NextStart",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CollectionEnumeratorState",
|
||||
columns: table => new
|
||||
{
|
||||
PlayoutProgramScheduleAnchorId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Seed = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Index = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CollectionEnumeratorState", x => x.PlayoutProgramScheduleAnchorId);
|
||||
table.ForeignKey(
|
||||
name: "FK_CollectionEnumeratorState_PlayoutProgramScheduleAnchor_PlayoutProgramScheduleAnchorId",
|
||||
column: x => x.PlayoutProgramScheduleAnchorId,
|
||||
principalTable: "PlayoutProgramScheduleAnchor",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlayoutAnchor",
|
||||
columns: table => new
|
||||
{
|
||||
PlayoutId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NextScheduleItemId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NextStart = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
MultipleRemaining = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
DurationFinish = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
InFlood = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
InDurationFiller = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
NextGuideGroup = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlayoutAnchor", x => x.PlayoutId);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayoutAnchor_Playout_PlayoutId",
|
||||
column: x => x.PlayoutId,
|
||||
principalTable: "Playout",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayoutAnchor_ProgramScheduleItem_NextScheduleItemId",
|
||||
column: x => x.NextScheduleItemId,
|
||||
principalTable: "ProgramScheduleItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayoutAnchor_NextScheduleItemId",
|
||||
table: "PlayoutAnchor",
|
||||
column: "NextScheduleItemId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CollectionEnumeratorState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlayoutAnchor");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EnumeratorState_Index",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EnumeratorState_Seed",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "Anchor_DurationFinish",
|
||||
table: "Playout",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InDurationFiller",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Anchor_MultipleRemaining",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Anchor_NextGuideGroup",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Anchor_NextScheduleItemId",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "Anchor_NextStart",
|
||||
table: "Playout",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Playout_Anchor_NextScheduleItemId",
|
||||
table: "Playout",
|
||||
column: "Anchor_NextScheduleItemId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Playout_ProgramScheduleItem_Anchor_NextScheduleItemId",
|
||||
table: "Playout",
|
||||
column: "Anchor_NextScheduleItemId",
|
||||
principalTable: "ProgramScheduleItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_LocalLibrary_Songs : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// create local songs library
|
||||
migrationBuilder.Sql(
|
||||
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
|
||||
SELECT 'Songs', 5, Id FROM
|
||||
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1)");
|
||||
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+3826
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_Songs_SongMetadata : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Tag",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Studio",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongId",
|
||||
table: "MediaVersion",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Genre",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Artwork",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Actor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Song",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Song", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Song_MediaItem_Id",
|
||||
column: x => x.Id,
|
||||
principalTable: "MediaItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SongMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SongId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MetadataKind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", nullable: true),
|
||||
OriginalTitle = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SortTitle = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Year = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SongMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SongMetadata_Song_SongId",
|
||||
column: x => x.SongId,
|
||||
principalTable: "Song",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tag_SongMetadataId",
|
||||
table: "Tag",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Studio_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MetadataGuid_SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaVersion_SongId",
|
||||
table: "MediaVersion",
|
||||
column: "SongId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Genre_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Artwork_SongMetadataId",
|
||||
table: "Artwork",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Actor_SongMetadataId",
|
||||
table: "Actor",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SongMetadata_SongId",
|
||||
table: "SongMetadata",
|
||||
column: "SongId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Artwork_SongMetadata_SongMetadataId",
|
||||
table: "Artwork",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MediaVersion_Song_SongId",
|
||||
table: "MediaVersion",
|
||||
column: "SongId",
|
||||
principalTable: "Song",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_SongMetadata_SongMetadataId",
|
||||
table: "Tag",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Artwork_SongMetadata_SongMetadataId",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MediaVersion_Song_SongId",
|
||||
table: "MediaVersion");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_SongMetadata_SongMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Song");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tag_SongMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Studio_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MetadataGuid_SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaVersion_SongId",
|
||||
table: "MediaVersion");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Genre_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Artwork_SongMetadataId",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Actor_SongMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongId",
|
||||
table: "MediaVersion");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Actor");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3829
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_StreamAttachedPic : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AttachedPic",
|
||||
table: "MediaStream",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AttachedPic",
|
||||
table: "MediaStream");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3843
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_SongAlbum : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Album",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Artist",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Date",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Track",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Album",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Artist",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Date",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Track",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3843
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_SongMetadata : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE SongMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
|
||||
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -845,7 +845,14 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
return $"tmdb://{strip2}";
|
||||
}
|
||||
|
||||
_logger.LogWarning("Unsupported guid format from Plex; ignoring: {Guid}", guid);
|
||||
if (guid.StartsWith("local://"))
|
||||
{
|
||||
_logger.LogDebug("Ignoring local Plex guid: {Guid}", guid);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unsupported guid format from Plex; ignoring: {Guid}", guid);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
private const string TraktListField = "trakt_list";
|
||||
private const string AlbumField = "album";
|
||||
private const string MinutesField = "minutes";
|
||||
private const string ArtistField = "artist";
|
||||
|
||||
public const string MovieType = "movie";
|
||||
public const string ShowType = "show";
|
||||
@@ -62,6 +63,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
public const string MusicVideoType = "music_video";
|
||||
public const string EpisodeType = "episode";
|
||||
public const string OtherVideoType = "other_video";
|
||||
public const string SongType = "song";
|
||||
private readonly List<CultureInfo> _cultureInfos;
|
||||
|
||||
private readonly ILogger<SearchIndex> _logger;
|
||||
@@ -126,6 +128,9 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
case OtherVideo otherVideo:
|
||||
await UpdateOtherVideo(searchRepository, otherVideo);
|
||||
break;
|
||||
case Song song:
|
||||
await UpdateSong(searchRepository, song);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +231,9 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
case OtherVideo otherVideo:
|
||||
await UpdateOtherVideo(searchRepository, otherVideo);
|
||||
break;
|
||||
case Song song:
|
||||
await UpdateSong(searchRepository, song);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -815,6 +823,59 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateSong(ISearchRepository searchRepository, Song song)
|
||||
{
|
||||
Option<SongMetadata> maybeMetadata = song.SongMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
{
|
||||
SongMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = new Document
|
||||
{
|
||||
new StringField(IdField, song.Id.ToString(), Field.Store.YES),
|
||||
new StringField(TypeField, SongType, Field.Store.YES),
|
||||
new TextField(TitleField, metadata.Title, Field.Store.NO),
|
||||
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
|
||||
new TextField(LibraryNameField, song.LibraryPath.Library.Name, Field.Store.NO),
|
||||
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),
|
||||
};
|
||||
|
||||
await AddLanguages(searchRepository, doc, song.MediaVersions);
|
||||
|
||||
foreach (MediaVersion version in song.MediaVersions.HeadOrNone())
|
||||
{
|
||||
doc.Add(new Int32Field(MinutesField, (int)Math.Ceiling(version.Duration.TotalMinutes), Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Album))
|
||||
{
|
||||
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Artist))
|
||||
{
|
||||
doc.Add(new TextField(ArtistField, metadata.Artist, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags)
|
||||
{
|
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
_writer.UpdateDocument(new Term(IdField, song.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metadata.Song = null;
|
||||
_logger.LogWarning(ex, "Error indexing song with metadata {@Metadata}", metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SearchItem ProjectToSearchItem(Document doc) => new(
|
||||
doc.Get(TypeField),
|
||||
|
||||
@@ -51,8 +51,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\background.png" />
|
||||
<EmbeddedResource Include="Resources\background_blank.png" />
|
||||
<EmbeddedResource Include="Resources\background_e.png" />
|
||||
<EmbeddedResource Include="Resources\background_t.png" />
|
||||
<EmbeddedResource Include="Resources\background_v.png" />
|
||||
<EmbeddedResource Include="Resources\ErsatzTV.png" />
|
||||
<EmbeddedResource Include="Resources\Roboto-Regular.ttf" />
|
||||
<EmbeddedResource Include="Resources\OPTIKabel-Heavy.otf" />
|
||||
<EmbeddedResource Include="Resources\ISO-639-2_utf-8.txt" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#other_videos")">@_data.OtherVideoCards.Count Other Videos</MudLink>
|
||||
}
|
||||
|
||||
@if (_data.SongCards.Any())
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#songs")">@_data.SongCards.Count Songs</MudLink>
|
||||
}
|
||||
@if (SupportsCustomOrdering())
|
||||
{
|
||||
<div style="margin-left: auto">
|
||||
@@ -248,6 +253,30 @@
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.SongCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
|
||||
Songs
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (SongCardViewModel card in _data.SongCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveSongFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
@@ -307,6 +336,7 @@
|
||||
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
|
||||
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
|
||||
.Append(_data.OtherVideoCards.OrderBy(ov => ov.SortTitle))
|
||||
.Append(_data.SongCards.OrderBy(s => s.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -404,6 +434,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveSongFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is SongCardViewModel song)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = new List<int> { song.SongId }
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("song", $"{song.Title}", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveItemsWithConfirmation(
|
||||
string entityType,
|
||||
string entityName,
|
||||
|
||||
@@ -99,7 +99,8 @@ namespace ErsatzTV.Pages
|
||||
_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<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
|
||||
_selectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList());
|
||||
|
||||
protected async Task AddItemsToCollection(
|
||||
List<int> movieIds,
|
||||
@@ -109,10 +110,11 @@ namespace ErsatzTV.Pages
|
||||
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;
|
||||
musicVideoIds.Count + otherVideoIds.Count + songIds.Count;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
|
||||
@@ -130,7 +132,8 @@ namespace ErsatzTV.Pages
|
||||
episodeIds,
|
||||
artistIds,
|
||||
musicVideoIds,
|
||||
otherVideoIds);
|
||||
otherVideoIds,
|
||||
songIds);
|
||||
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request);
|
||||
addResult.Match(
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
{
|
||||
<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>
|
||||
}
|
||||
<div style="margin-left: auto">
|
||||
<MudTooltip Text="Add All To Collection">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
@@ -282,6 +287,34 @@
|
||||
}
|
||||
</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"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
@@ -292,6 +325,7 @@
|
||||
private TelevisionEpisodeCardResultsViewModel _episodes;
|
||||
private MusicVideoCardResultsViewModel _musicVideos;
|
||||
private OtherVideoCardResultsViewModel _otherVideos;
|
||||
private SongCardResultsViewModel _songs;
|
||||
private ArtistCardResultsViewModel _artists;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -305,6 +339,7 @@
|
||||
_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));
|
||||
}
|
||||
}
|
||||
@@ -320,6 +355,7 @@
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -532,6 +568,17 @@
|
||||
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 async Task AddAllToCollection(MouseEventArgs _)
|
||||
{
|
||||
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query));
|
||||
@@ -543,6 +590,7 @@
|
||||
results.ArtistIds,
|
||||
results.MusicVideoIds,
|
||||
results.OtherVideoIds,
|
||||
results.SongIds,
|
||||
"search results");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
@page "/music/songs"
|
||||
@page "/music/songs/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<SongList>
|
||||
@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.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
|
||||
<div style="max-width: 300px; width: 33%;">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
<FragmentLetterAnchor TCard="SongCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Link=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
|
||||
BaseUri="/music/songs"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private SongCardResultsViewModel _data;
|
||||
private string _query;
|
||||
|
||||
protected override Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
protected override async Task RefreshData()
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:song" : $"type:song AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexSongs(searchQuery, PageNumber, PageSize));
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"/music/songs/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"/music/songs/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is SongCardViewModel song)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "song" }, { "EntityName", song.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
|
||||
{
|
||||
var request = new AddSongToCollection(collection.Id, song.SongId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding song to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding song to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {song.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user