Browse Source

add songs libraries (#490)

* first pass at adding song libraries

* start handling optional video

* fix song playback

* fix song transitions

* add songs page to UI
pull/491/head
Jason Dove 4 years ago committed by GitHub
parent
commit
852728c816
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  3. 11
      ErsatzTV.Application/MediaCards/Mapper.cs
  4. 5
      ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
  5. 11
      ErsatzTV.Application/MediaCards/SongCardResultsViewModel.cs
  6. 17
      ErsatzTV.Application/MediaCards/SongCardViewModel.cs
  7. 3
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  8. 1
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  9. 8
      ErsatzTV.Application/MediaCollections/Commands/AddSongToCollection.cs
  10. 80
      ErsatzTV.Application/MediaCollections/Commands/AddSongToCollectionHandler.cs
  11. 10
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  12. 5
      ErsatzTV.Application/Playouts/Mapper.cs
  13. 6
      ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs
  14. 3
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  15. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSongs.cs
  16. 44
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSongsHandler.cs
  17. 3
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  18. 65
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  19. 18
      ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs
  20. 2
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  21. 3
      ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
  22. 1
      ErsatzTV.Core/Domain/MediaItem/MediaStream.cs
  23. 10
      ErsatzTV.Core/Domain/MediaItem/Song.cs
  24. 8
      ErsatzTV.Core/Domain/Metadata/SongMetadata.cs
  25. 19
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  26. 20
      ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs
  27. 24
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  28. 73
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  29. 121
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  30. 7
      ErsatzTV.Core/FFmpeg/WatermarkOptions.cs
  31. 1
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  32. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  33. 15
      ErsatzTV.Core/Interfaces/Metadata/ISongFolderScanner.cs
  34. 17
      ErsatzTV.Core/Interfaces/Repositories/ISongRepository.cs
  35. 2
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  36. 50
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  37. 15
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  38. 47
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  39. 23
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  40. 195
      ErsatzTV.Core/Metadata/SongFolderScanner.cs
  41. 8
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  42. 21
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  43. 23
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/SongConfiguration.cs
  44. 22
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/SongMetadataConfiguration.cs
  45. 5
      ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs
  46. 37
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  47. 8
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  48. 146
      ErsatzTV.Infrastructure/Data/Repositories/SongRepository.cs
  49. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  50. 3671
      ErsatzTV.Infrastructure/Migrations/20211122235925_Add_LocalLibrary_Songs.Designer.cs
  51. 23
      ErsatzTV.Infrastructure/Migrations/20211122235925_Add_LocalLibrary_Songs.cs
  52. 3826
      ErsatzTV.Infrastructure/Migrations/20211123000053_Add_Songs_SongMetadata.Designer.cs
  53. 285
      ErsatzTV.Infrastructure/Migrations/20211123000053_Add_Songs_SongMetadata.cs
  54. 3829
      ErsatzTV.Infrastructure/Migrations/20211123021230_Add_StreamAttachedPic.Designer.cs
  55. 26
      ErsatzTV.Infrastructure/Migrations/20211123021230_Add_StreamAttachedPic.cs
  56. 158
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  57. 50
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  58. 43
      ErsatzTV/Pages/CollectionItems.razor
  59. 9
      ErsatzTV/Pages/MultiSelectBase.cs
  60. 48
      ErsatzTV/Pages/Search.razor
  61. 158
      ErsatzTV/Pages/SongList.razor
  62. 1
      ErsatzTV/Shared/MainLayout.razor
  63. 2
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
### 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`
- Channels that play songs *require* fallback filler to provide looping video to pair with the songs
## [0.2.5-alpha] - 2021-11-21
### Fixed
- Include other video title in channel guide (xmltv)

3
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

@ -10,7 +10,8 @@ namespace ErsatzTV.Application.MediaCards @@ -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; }
}

11
ErsatzTV.Application/MediaCards/Mapper.cs

@ -110,6 +110,13 @@ namespace ErsatzTV.Application.MediaCards @@ -110,6 +110,13 @@ namespace ErsatzTV.Application.MediaCards
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata) =>
new(
songMetadata.SongId,
songMetadata.Title,
songMetadata.OriginalTitle,
songMetadata.SortTitle);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
@ -141,7 +148,9 @@ namespace ErsatzTV.Application.MediaCards @@ -141,7 +148,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(

5
ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs

@ -30,7 +30,7 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -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 @@ -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));

11
ErsatzTV.Application/MediaCards/SongCardResultsViewModel.cs

@ -0,0 +1,11 @@ @@ -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);
}

17
ErsatzTV.Application/MediaCards/SongCardViewModel.cs

@ -0,0 +1,17 @@ @@ -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; }
}
}

3
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs

@ -13,5 +13,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -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>>;
}

1
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs

@ -57,6 +57,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -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();

8
ErsatzTV.Application/MediaCollections/Commands/AddSongToCollection.cs

@ -0,0 +1,8 @@ @@ -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>>;
}

80
ErsatzTV.Application/MediaCollections/Commands/AddSongToCollectionHandler.cs

@ -0,0 +1,80 @@ @@ -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);
}
}

10
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -27,6 +27,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -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 @@ -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 @@ -46,6 +48,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
@ -117,6 +120,13 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -117,6 +120,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin,
progressMax);
break;
case LibraryMediaKind.Songs:
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;

5
ErsatzTV.Application/Playouts/Mapper.cs

@ -48,6 +48,11 @@ namespace ErsatzTV.Application.Playouts @@ -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;
}

6
ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs

@ -24,7 +24,7 @@ namespace ErsatzTV.Application.Playouts.Queries @@ -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 @@ -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)

3
ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs

@ -26,7 +26,8 @@ namespace ErsatzTV.Application.Search.Queries @@ -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)

8
ErsatzTV.Application/Search/Queries/QuerySearchIndexSongs.cs

@ -0,0 +1,8 @@ @@ -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>;
}

44
ErsatzTV.Application/Search/Queries/QuerySearchIndexSongsHandler.cs

@ -0,0 +1,44 @@ @@ -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);
}
}
}

3
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Search @@ -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);
}

65
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core; @@ -8,6 +8,7 @@ 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.Jellyfin;
@ -94,6 +95,12 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -94,6 +95,12 @@ 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(ov => ov.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(ov => ov.Streams)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath);
@ -106,14 +113,34 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -106,14 +113,34 @@ namespace ErsatzTV.Application.Streaming.Queries
return await maybePlayoutItem.Match(
async playoutItemWithPath =>
{
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
string videoPath = playoutItemWithPath.Path;
MediaVersion videoVersion = version;
string audioPath = playoutItemWithPath.Path;
MediaVersion audioVersion = version;
if (playoutItemWithPath.PlayoutItem.MediaItem is Song)
{
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))
};
// find filler to loop as video for song
Either<BaseError, PlayoutItemWithPath> fallbackFiller =
await CheckForFallbackFiller(dbContext, channel, now);
// fail if we can't find filler
if (fallbackFiller.IsLeft)
{
return Left<BaseError, PlayoutItemProcessModel>(
BaseError.New("Unable to locate fallback filler for song"));
}
foreach (PlayoutItemWithPath filler in fallbackFiller.RightToSeq())
{
videoPath = filler.Path;
videoVersion = filler.PlayoutItem.MediaItem.GetHeadVersion();
}
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
@ -129,8 +156,10 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -129,8 +156,10 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath,
saveReports,
channel,
version,
playoutItemWithPath.Path,
videoVersion,
audioVersion,
videoPath,
audioPath,
playoutItemWithPath.PlayoutItem.StartOffset,
playoutItemWithPath.PlayoutItem.FinishOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
@ -271,14 +300,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -271,14 +300,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 +353,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -331,14 +353,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;

18
ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs

@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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(0, 0, 0, 1);
result.IsNone.Should().BeTrue();
}
@ -31,7 +31,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -72,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -278,7 +278,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -278,7 +278,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -349,7 +349,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -349,7 +349,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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -420,7 +420,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -420,7 +420,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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -542,7 +542,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -542,7 +542,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(0, 0, 0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(

2
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -184,6 +184,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -184,6 +184,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode = StreamingMode.TransportStream
},
v,
v,
file,
file,
now,
now + TimeSpan.FromSeconds(5),

3
ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
Movies = 1,
Shows = 2,
MusicVideos = 3,
OtherVideos = 4
OtherVideos = 4,
Songs = 5
}
}

1
ErsatzTV.Core/Domain/MediaItem/MediaStream.cs

@ -12,6 +12,7 @@ @@ -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; }

10
ErsatzTV.Core/Domain/MediaItem/Song.cs

@ -0,0 +1,10 @@ @@ -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; }
}
}

8
ErsatzTV.Core/Domain/Metadata/SongMetadata.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class SongMetadata : Metadata
{
public int SongId { get; set; }
public Song Song { get; set; }
}
}

19
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -0,0 +1,19 @@ @@ -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))
};
}
}

20
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -60,15 +60,23 @@ namespace ErsatzTV.Core.FFmpeg @@ -60,15 +60,23 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithInputCodec(string codec)
public FFmpegComplexFilterBuilder WithInputCodec(Option<string> maybeCodec)
{
foreach (string codec in maybeCodec)
{
_inputCodec = codec;
}
return this;
}
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
{
foreach (string pixelFormat in maybePixelFormat)
{
_pixelFormat = pixelFormat;
}
return this;
}
@ -85,12 +93,12 @@ namespace ErsatzTV.Core.FFmpeg @@ -85,12 +93,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
public Option<FFmpegComplexFilter> Build(int videoInput, int videoStreamIndex, int audioInput, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
var videoLabel = $"0:{videoStreamIndex}";
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
var videoLabel = $"{videoInput}:{videoStreamIndex}";
string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
@ -306,7 +314,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -306,7 +314,7 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append("[vt];");
}
var watermarkLabel = "[1:v]";
var watermarkLabel = $"[{audioInput+1}:v]";
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
{
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");

24
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -45,8 +45,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -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 @@ -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 @@ -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,17 +98,20 @@ namespace ErsatzTV.Core.FFmpeg @@ -98,17 +98,20 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoTrackTimeScale = 90000;
}
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
{
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoDecoder =
(result.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat) switch
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
{
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le") =>
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
) =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
@ -125,6 +128,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -125,6 +128,7 @@ namespace ErsatzTV.Core.FFmpeg
{
result.VideoCodec = "copy";
}
}
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
{
@ -150,7 +154,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -150,7 +154,7 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioCodec = "copy";
}
if (version.VideoScanKind == VideoScanKind.Interlaced)
if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
{
result.Deinterlace = true;
}

73
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -71,7 +71,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -71,7 +71,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 +84,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -84,7 +84,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",
@ -187,15 +187,15 @@ namespace ErsatzTV.Core.FFmpeg @@ -187,15 +187,15 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithWatermark(
Option<ChannelWatermark> watermark,
Option<string> maybePath,
IDisplaySize resolution,
bool isAnimated)
public FFmpegProcessBuilder WithWatermarks(
List<WatermarkOptions> watermarkOptions,
IDisplaySize resolution)
{
foreach (string path in maybePath)
foreach (WatermarkOptions options in watermarkOptions)
{
if (isAnimated)
foreach (string path in options.ImagePath)
{
if (options.IsAnimated)
{
_arguments.Add("-ignore_loop");
_arguments.Add("0");
@ -204,14 +204,34 @@ namespace ErsatzTV.Core.FFmpeg @@ -204,14 +204,34 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("-i");
_arguments.Add(path);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(options.Watermark, resolution);
}
// TODO: when image path is null?
}
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
{
WithInfiniteLoop();
}
if (!string.IsNullOrWhiteSpace(decoder))
{
_arguments.Add("-c:v");
@ -223,7 +243,16 @@ namespace ErsatzTV.Core.FFmpeg @@ -223,7 +243,16 @@ 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;
}
@ -477,6 +506,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -477,6 +506,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithFilterComplex(
MediaStream videoStream,
Option<MediaStream> maybeAudioStream,
string videoPath,
string audioPath,
string videoCodec)
{
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
@ -484,10 +515,22 @@ namespace ErsatzTV.Core.FFmpeg @@ -484,10 +515,22 @@ 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 != videoPath)
{
audioIndex = 1;
}
var videoLabel = $"{videoIndex}:{videoStreamIndex}";
var audioLabel = $"{audioIndex}:{maybeIndex.Match(i => i.ToString(), () => "a")}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(
videoIndex,
videoStreamIndex,
audioIndex,
maybeIndex);
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
maybeFilter.IfSome(
filter =>
{

121
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
@ -35,8 +36,10 @@ namespace ErsatzTV.Core.FFmpeg @@ -35,8 +36,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 +51,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -48,13 +51,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,24 +65,28 @@ namespace ErsatzTV.Core.FFmpeg @@ -62,24 +65,28 @@ 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));
List<WatermarkOptions> watermarkOptions =
await GetAllWatermarkOptions(channel, globalWatermark, videoStream);
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)
.WithWatermarks(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
@ -96,7 +103,12 @@ namespace ErsatzTV.Core.FFmpeg @@ -96,7 +103,12 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
},
() =>
{
@ -105,18 +117,33 @@ namespace ErsatzTV.Core.FFmpeg @@ -105,18 +117,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 +155,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -128,7 +155,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:
@ -199,7 +226,35 @@ namespace ErsatzTV.Core.FFmpeg @@ -199,7 +226,35 @@ namespace ErsatzTV.Core.FFmpeg
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<List<WatermarkOptions>> GetAllWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
Option<MediaStream> maybeVideoStream)
{
var result = new List<WatermarkOptions>();
foreach (WatermarkOptions options in Optional(await GetWatermarkOptions(channel, globalWatermark)))
{
result.Add(options);
}
foreach (MediaStream videoStream in maybeVideoStream.Where(s => s.AttachedPic))
{
// TODO: use attached pic as watermark
// var options = new WatermarkOptions(
// new ChannelWatermark
// {
// },
// None,
// false);
//
// result.Add(options);
}
return result;
}
private async Task<WatermarkOptions> GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark)
{
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
@ -214,13 +269,18 @@ namespace ErsatzTV.Core.FFmpeg @@ -214,13 +269,18 @@ namespace ErsatzTV.Core.FFmpeg
channel.Watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(channel.Watermark, customPath);
return new WatermarkOptions(channel.Watermark, customPath, 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(
channel.Watermark,
maybeChannelPath,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@ -236,22 +296,25 @@ namespace ErsatzTV.Core.FFmpeg @@ -236,22 +296,25 @@ namespace ErsatzTV.Core.FFmpeg
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(watermark, customPath);
return new WatermarkOptions(watermark, customPath, 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(
watermark,
maybeChannelPath,
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, false);
}
private record WatermarkOptions(Option<ChannelWatermark> Watermark, Option<string> ImagePath);
}
}

7
ErsatzTV.Core/FFmpeg/WatermarkOptions.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.FFmpeg
{
public record WatermarkOptions(Option<ChannelWatermark> Watermark, Option<string> ImagePath, bool IsAnimated);
}

1
ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs

@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -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);
}
}

1
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -17,6 +17,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -17,6 +17,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
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);
}
}

15
ErsatzTV.Core/Interfaces/Metadata/ISongFolderScanner.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
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,
decimal progressMin,
decimal progressMax);
}
}

17
ErsatzTV.Core/Interfaces/Repositories/ISongRepository.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
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> AddTag(SongMetadata metadata, Tag tag);
Task<List<SongMetadata>> GetSongsForCards(List<int> ids);
}
}

2
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -344,6 +344,8 @@ namespace ErsatzTV.Core.Iptv @@ -344,6 +344,8 @@ namespace ErsatzTV.Core.Iptv
.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]"
};
}

50
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -104,6 +104,19 @@ namespace ErsatzTV.Core.Metadata @@ -104,6 +104,19 @@ 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))
@ -267,6 +280,43 @@ namespace ErsatzTV.Core.Metadata @@ -267,6 +280,43 @@ namespace ErsatzTV.Core.Metadata
}
}
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)
{
try

15
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -4,6 +4,7 @@ using System.IO; @@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -20,6 +21,11 @@ namespace ErsatzTV.Core.Metadata @@ -20,6 +21,11 @@ namespace ErsatzTV.Core.Metadata
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"
};
public static readonly List<string> AudioFileExtensions = new()
{
".aac", ".alac", ".flac", ".mp3", ".m4a", ".wav", ".wma"
};
public static readonly List<string> ImageFileExtensions = new()
{
"jpg", "jpeg", "png", "gif", "tbn"
@ -68,14 +74,7 @@ namespace ErsatzTV.Core.Metadata @@ -68,14 +74,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;

47
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -31,6 +31,7 @@ namespace ErsatzTV.Core.Metadata @@ -31,6 +31,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,6 +41,7 @@ namespace ErsatzTV.Core.Metadata @@ -40,6 +41,7 @@ namespace ErsatzTV.Core.Metadata
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IOtherVideoRepository otherVideoRepository,
ISongRepository songRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader,
@ -51,6 +53,7 @@ namespace ErsatzTV.Core.Metadata @@ -51,6 +53,7 @@ namespace ErsatzTV.Core.Metadata
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_otherVideoRepository = otherVideoRepository;
_songRepository = songRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader;
@ -144,6 +147,11 @@ namespace ErsatzTV.Core.Metadata @@ -144,6 +147,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),
@ -662,6 +670,45 @@ namespace ErsatzTV.Core.Metadata @@ -662,6 +670,45 @@ 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;
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,
(_, _) => Task.FromResult(false),
_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)
{
try

23
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -5,6 +5,7 @@ using System.Globalization; @@ -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 @@ -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)
@ -78,14 +71,7 @@ namespace ErsatzTV.Core.Metadata @@ -78,14 +71,7 @@ namespace ErsatzTV.Core.Metadata
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 +207,7 @@ namespace ErsatzTV.Core.Metadata @@ -221,6 +207,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);
@ -314,7 +301,7 @@ namespace ErsatzTV.Core.Metadata @@ -314,7 +301,7 @@ namespace ErsatzTV.Core.Metadata
public record FFprobeFormat(string duration);
public record FFprobeDisposition(int @default, int forced);
public record FFprobeDisposition(int @default, int forced, int attached_pic);
public record FFprobeTags(string language, string title);

195
ErsatzTV.Core/Metadata/SongFolderScanner.cs

@ -0,0 +1,195 @@ @@ -0,0 +1,195 @@
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.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,
ILogger<SongFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
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,
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(UpdateMetadata);
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)
{
try
{
Song song = result.Item;
if (!Optional(song.SongMetadata).Flatten().Any())
{
song.SongMetadata ??= new List<SongMetadata>();
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(song))
{
result.IsUpdated = true;
}
}
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}
}

8
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -267,6 +267,8 @@ namespace ErsatzTV.Core.Scheduling @@ -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
};
@ -436,7 +438,7 @@ namespace ErsatzTV.Core.Scheduling @@ -436,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)
{
@ -475,6 +477,10 @@ namespace ErsatzTV.Core.Scheduling @@ -475,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;
}

21
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -3,6 +3,7 @@ using System.Collections.Generic; @@ -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 @@ -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;
}

23
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/SongConfiguration.cs

@ -0,0 +1,23 @@ @@ -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);
}
}
}

22
ErsatzTV.Infrastructure/Data/Configurations/Metadata/SongMetadataConfiguration.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
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.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

5
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs

@ -85,6 +85,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -85,6 +85,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.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();
}

37
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -34,7 +34,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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>(

8
ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs

@ -29,7 +29,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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 @@ -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 @@ -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);
}
}

146
ErsatzTV.Infrastructure/Data/Repositories/SongRepository.cs

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
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(ov => ov.SongMetadata)
.ThenInclude(ovm => ovm.Artwork)
.Include(ov => ov.SongMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.Streams)
.Include(ov => ov.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.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> 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);
}
}
}
}

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -43,6 +43,8 @@ namespace ErsatzTV.Infrastructure.Data @@ -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
ErsatzTV.Infrastructure/Migrations/20211122235925_Add_LocalLibrary_Songs.Designer.cs generated

File diff suppressed because it is too large Load Diff

23
ErsatzTV.Infrastructure/Migrations/20211122235925_Add_LocalLibrary_Songs.cs

@ -0,0 +1,23 @@ @@ -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
ErsatzTV.Infrastructure/Migrations/20211123000053_Add_Songs_SongMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

285
ErsatzTV.Infrastructure/Migrations/20211123000053_Add_Songs_SongMetadata.cs

@ -0,0 +1,285 @@ @@ -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
ErsatzTV.Infrastructure/Migrations/20211123021230_Add_StreamAttachedPic.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure/Migrations/20211123021230_Add_StreamAttachedPic.cs

@ -0,0 +1,26 @@ @@ -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");
}
}
}

158
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -56,6 +56,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -56,6 +56,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
@ -75,6 +78,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -75,6 +78,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("Actor", (string)null);
});
@ -169,6 +174,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -169,6 +174,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
@ -187,6 +195,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -187,6 +195,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("Artwork", (string)null);
});
@ -602,6 +612,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -602,6 +612,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
@ -618,6 +631,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -618,6 +631,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("Genre");
});
@ -840,6 +855,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -840,6 +855,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AttachedPic")
.HasColumnType("INTEGER");
b.Property<int>("BitsPerRawSample")
.HasColumnType("INTEGER");
@ -925,6 +943,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -925,6 +943,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b.Property<int?>("SongId")
.HasColumnType("INTEGER");
b.Property<int>("VideoScanKind")
.HasColumnType("INTEGER");
@ -941,6 +962,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -941,6 +962,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("OtherVideoId");
b.HasIndex("SongId");
b.ToTable("MediaVersion", (string)null);
});
@ -974,6 +997,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -974,6 +997,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
@ -990,6 +1016,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -990,6 +1016,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("MetadataGuid", (string)null);
});
@ -1603,6 +1631,46 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1603,6 +1631,46 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("SmartCollection", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SongMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<int>("SongId")
.HasColumnType("INTEGER");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SongId");
b.ToTable("SongMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b =>
{
b.Property<int>("Id")
@ -1633,6 +1701,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1633,6 +1701,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
@ -1649,6 +1720,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1649,6 +1720,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("Studio", (string)null);
});
@ -1701,6 +1774,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1701,6 +1774,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
@ -1717,6 +1793,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1717,6 +1793,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("Tag");
});
@ -2052,6 +2130,13 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2052,6 +2130,13 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Show", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Song", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Song", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyEpisode", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Episode");
@ -2234,6 +2319,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2234,6 +2319,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Actors")
.HasForeignKey("SongMetadataId");
b.Navigation("Artwork");
});
@ -2289,6 +2378,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2289,6 +2378,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Artwork")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Artwork")
.HasForeignKey("SongMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
@ -2456,6 +2550,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2456,6 +2550,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Genres")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Genres")
.HasForeignKey("SongMetadataId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b =>
@ -2578,6 +2676,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2578,6 +2676,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("MediaVersions")
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Song", null)
.WithMany("MediaVersions")
.HasForeignKey("SongId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
@ -2613,6 +2716,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2613,6 +2716,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Guids")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Guids")
.HasForeignKey("SongMetadataId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b =>
@ -2963,6 +3070,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2963,6 +3070,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Show");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SongMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Song", "Song")
.WithMany("SongMetadata")
.HasForeignKey("SongId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Song");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
@ -2995,6 +3113,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2995,6 +3113,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Studios")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Studios")
.HasForeignKey("SongMetadataId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Style", b =>
@ -3038,6 +3160,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3038,6 +3160,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Tags")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Tags")
.HasForeignKey("SongMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b =>
@ -3286,6 +3413,15 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3286,6 +3413,15 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Song", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Song", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyEpisode", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Episode", null)
@ -3584,6 +3720,21 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3584,6 +3720,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MultiCollectionSmartItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SongMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
{
b.Navigation("Items");
@ -3663,6 +3814,13 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3663,6 +3814,13 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("ShowMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Song", b =>
{
b.Navigation("MediaVersions");
b.Navigation("SongMetadata");
});
#pragma warning restore 612, 618
}
}

50
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -62,6 +62,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -62,6 +62,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 +127,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -126,6 +127,9 @@ namespace ErsatzTV.Infrastructure.Search
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
case Song song:
await UpdateSong(searchRepository, song);
break;
}
}
@ -226,6 +230,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -226,6 +230,9 @@ namespace ErsatzTV.Infrastructure.Search
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
case Song song:
await UpdateSong(searchRepository, song);
break;
}
}
}
@ -816,6 +823,49 @@ namespace ErsatzTV.Infrastructure.Search @@ -816,6 +823,49 @@ 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));
}
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),
Convert.ToInt32(doc.Get(IdField)));

43
ErsatzTV/Pages/CollectionItems.razor

@ -63,6 +63,11 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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,

9
ErsatzTV/Pages/MultiSelectBase.cs

@ -99,7 +99,8 @@ namespace ErsatzTV.Pages @@ -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 @@ -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 @@ -130,7 +132,8 @@ namespace ErsatzTV.Pages
episodeIds,
artistIds,
musicVideoIds,
otherVideoIds);
otherVideoIds,
songIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(

48
ErsatzTV/Pages/Search.razor

@ -68,6 +68,11 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -543,6 +590,7 @@
results.ArtistIds,
results.MusicVideoIds,
results.OtherVideoIds,
results.SongIds,
"search results");
}

158
ErsatzTV/Pages/SongList.razor

@ -0,0 +1,158 @@ @@ -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));
}
}
}
}

1
ErsatzTV/Shared/MainLayout.razor

@ -54,6 +54,7 @@ @@ -54,6 +54,7 @@
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
<MudNavLink Href="/media/other/videos">Other Videos</MudNavLink>
<MudNavLink Href="/music/songs">Songs</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="/media/collections">Collections</MudNavLink>

2
ErsatzTV/Startup.cs

@ -272,6 +272,7 @@ namespace ErsatzTV @@ -272,6 +272,7 @@ namespace ErsatzTV
services.AddScoped<IArtistRepository, ArtistRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ISongRepository, SongRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IArtworkRepository, ArtworkRepository>();
@ -286,6 +287,7 @@ namespace ErsatzTV @@ -286,6 +287,7 @@ namespace ErsatzTV
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IMusicVideoFolderScanner, MusicVideoFolderScanner>();
services.AddScoped<IOtherVideoFolderScanner, OtherVideoFolderScanner>();
services.AddScoped<ISongFolderScanner, SongFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();

Loading…
Cancel
Save