Browse Source

add basic image library support (#1608)

* add basic image library support

* add image page

* update changelog
pull/1609/head
Jason Dove 2 years ago committed by GitHub
parent
commit
f8c4f44216
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  3. 5
      ErsatzTV.Application/MediaCards/ImageCardResultsViewModel.cs
  4. 21
      ErsatzTV.Application/MediaCards/ImageCardViewModel.cs
  5. 65
      ErsatzTV.Application/MediaCards/Mapper.cs
  6. 5
      ErsatzTV.Application/MediaCollections/Commands/AddImageToCollection.cs
  7. 76
      ErsatzTV.Application/MediaCollections/Commands/AddImageToCollectionHandler.cs
  8. 3
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  9. 3
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  10. 2
      ErsatzTV.Application/Playouts/Mapper.cs
  11. 4
      ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs
  12. 2
      ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs
  13. 3
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  14. 6
      ErsatzTV.Application/Search/Queries/QuerySearchIndexImages.cs
  15. 29
      ErsatzTV.Application/Search/Queries/QuerySearchIndexImagesHandler.cs
  16. 3
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  17. 19
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  18. 3
      ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
  19. 9
      ErsatzTV.Core/Domain/MediaItem/Image.cs
  20. 8
      ErsatzTV.Core/Domain/Metadata/ImageMetadata.cs
  21. 1
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  22. 6
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  23. 1
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  24. 7
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  25. 13
      ErsatzTV.Core/Interfaces/Repositories/IImageRepository.cs
  26. 1
      ErsatzTV.Core/Interfaces/Streaming/IExternalJsonPlayoutItemProvider.cs
  27. 57
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  28. 17
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs
  29. 9
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  30. 5
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  31. 5105
      ErsatzTV.Infrastructure.MySql/Migrations/20240211153028_Add_Image_ImageMetadata.Designer.cs
  32. 329
      ErsatzTV.Infrastructure.MySql/Migrations/20240211153028_Add_Image_ImageMetadata.cs
  33. 5105
      ErsatzTV.Infrastructure.MySql/Migrations/20240211153125_Add_LocalLibrary_Images.Designer.cs
  34. 26
      ErsatzTV.Infrastructure.MySql/Migrations/20240211153125_Add_LocalLibrary_Images.cs
  35. 174
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  36. 4929
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240211120605_Add_LocalLibrary_Images.Designer.cs
  37. 26
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240211120605_Add_LocalLibrary_Images.cs
  38. 5103
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240211135444_Add_Image_ImageMetadata.Designer.cs
  39. 324
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240211135444_Add_Image_ImageMetadata.cs
  40. 174
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  41. 22
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/ImageConfiguration.cs
  42. 41
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ImageMetadataConfiguration.cs
  43. 164
      ErsatzTV.Infrastructure/Data/Repositories/ImageRepository.cs
  44. 29
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  45. 16
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  46. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  47. 1
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  48. 7
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  49. 66
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  50. 12
      ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs
  51. 11
      ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  52. 15
      ErsatzTV.Scanner/Core/Interfaces/Metadata/IImageFolderScanner.cs
  53. 4
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  54. 256
      ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs
  55. 143
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  56. 8
      ErsatzTV.Scanner/Core/Metadata/SongFolderScanner.cs
  57. 2
      ErsatzTV.Scanner/Program.cs
  58. 45
      ErsatzTV/Pages/CollectionItems.razor
  59. 155
      ErsatzTV/Pages/ImageList.razor
  60. 2
      ErsatzTV/Pages/LocalLibraryEditor.razor
  61. 9
      ErsatzTV/Pages/MultiSelectBase.cs
  62. 9
      ErsatzTV/Pages/OtherVideoList.razor
  63. 55
      ErsatzTV/Pages/Search.razor
  64. 17
      ErsatzTV/Pages/Trash.razor
  65. 1
      ErsatzTV/Shared/MainLayout.razor
  66. 1
      ErsatzTV/Startup.cs

15
CHANGELOG.md

@ -16,11 +16,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -16,11 +16,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `sub_language` and `sub_language_tag` fields to search index
- Add `/iptv` request logging in its own log category at debug level
- Add channel guide (XMLTV) template system
- Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, `_episode.sbntxt`, `_musicVideo.sbntxt`, `_song.sbntxt`, or `_otherVideo.sbntxt` which are located in the config subfolder `templates/channel-guide`
- Copy the file, remove the leading underscore from the name, and only make edits to the copied file
- The default templates will be extracted and overwritten every time ErsatzTV is started
- The templates use [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
- The templates contain comments describing which fields are available for use in the templates
- Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, `_episode.sbntxt`, `_musicVideo.sbntxt`, `_song.sbntxt`, or `_otherVideo.sbntxt` which are located in the config subfolder `templates/channel-guide`
- Copy the file, remove the leading underscore from the name, and only make edits to the copied file
- The default templates will be extracted and overwritten every time ErsatzTV is started
- The templates use [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
- The templates contain comments describing which fields are available for use in the templates
- Add *experimental* and *incomplete* `Images` library kind
- Image libraries have fallback metadata added like Other Video libraries (every folder is a tag)
- Image library items currently *all* have a duration of 15 seconds
- Future updates will allow custom/distinct durations
### Fixed
- Fix antiforgery error caused by reusing existing browser tabs across docker container restarts
@ -29,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -29,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- A deep scan can be used to fix all movies, otherwise any future updates made to JF movies will correctly sync to ETV
- Automatically generate JWT tokens to allow channel previews of protected streams
- Fix bug applying music video fallback metadata
- Fix playback of media items with no audio streams
### Changed
- Log search index updates under scanner category at debug level, to indicate a potential cause for the UI being out of date

3
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

@ -9,7 +9,8 @@ public record CollectionCardResultsViewModel( @@ -9,7 +9,8 @@ public record CollectionCardResultsViewModel(
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards,
List<SongCardViewModel> SongCards)
List<SongCardViewModel> SongCards,
List<ImageCardViewModel> ImageCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}

5
ErsatzTV.Application/MediaCards/ImageCardResultsViewModel.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core.Search;
namespace ErsatzTV.Application.MediaCards;
public record ImageCardResultsViewModel(int Count, List<ImageCardViewModel> Cards, SearchPageMap PageMap);

21
ErsatzTV.Application/MediaCards/ImageCardViewModel.cs

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

65
ErsatzTV.Application/MediaCards/Mapper.cs

@ -137,6 +137,15 @@ internal static class Mapper @@ -137,6 +137,15 @@ internal static class Mapper
songMetadata.Song.State);
}
internal static ImageCardViewModel ProjectToViewModel(ImageMetadata imageMetadata) =>
new(
imageMetadata.ImageId,
imageMetadata.Title,
imageMetadata.OriginalTitle,
imageMetadata.SortTitle,
string.Empty, // TODO: thumbnail?
imageMetadata.Image.State);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
@ -152,30 +161,38 @@ internal static class Mapper @@ -152,30 +161,38 @@ internal static class Mapper
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
collection.MediaItems.OfType<Show>()
.Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(),
// collection view doesn't use local paths
collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false, string.Empty))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
// collection view doesn't use local paths
collection.MediaItems.OfType<MusicVideo>()
.Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty))
.ToList(),
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 };
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
collection.MediaItems.OfType<Show>()
.Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(),
// collection view doesn't use local paths
collection.MediaItems.OfType<Episode>()
.Map(
e => ProjectToViewModel(
e.EpisodeMetadata.Head(),
maybeJellyfin,
maybeEmby,
false,
string.Empty))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
// collection view doesn't use local paths
collection.MediaItems.OfType<MusicVideo>()
.Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty))
.ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Image>().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList())
{ UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(
Actor actor,

5
ErsatzTV.Application/MediaCollections/Commands/AddImageToCollection.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record AddImageToCollection(int CollectionId, int ImageId) : IRequest<Either<BaseError, Unit>>;

76
ErsatzTV.Application/MediaCollections/Commands/AddImageToCollectionHandler.cs

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddImageToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddImageToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddImageRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddImageRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Image);
if (await dbContext.SaveChangesAsync() > 0)
{
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
AddImageToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateImage(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddImageToCollection 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, Image>> ValidateImage(
TvContext dbContext,
AddImageToCollection request) =>
dbContext.Images
.SelectOneAsync(m => m.Id, e => e.Id == request.ImageId)
.Map(o => o.ToValidation<BaseError>("Image does not exist"));
private sealed record Parameters(Collection Collection, Image Image);
}

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

@ -12,4 +12,5 @@ public record AddItemsToCollection @@ -12,4 +12,5 @@ public record AddItemsToCollection
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds) : IRequest<Either<BaseError, Unit>>;
List<int> SongIds,
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>;

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

@ -39,7 +39,7 @@ public class AddItemsToCollectionHandler : @@ -39,7 +39,7 @@ public class AddItemsToCollectionHandler :
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyAddItemsRequest(dbContext, c, request));
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request));
}
private async Task<Unit> ApplyAddItemsRequest(
@ -55,6 +55,7 @@ public class AddItemsToCollectionHandler : @@ -55,6 +55,7 @@ public class AddItemsToCollectionHandler :
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();

2
ErsatzTV.Application/Playouts/Mapper.cs

@ -75,6 +75,8 @@ internal static class Mapper @@ -75,6 +75,8 @@ internal static class Mapper
? t
: $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown song]");
case Image i:
return i.ImageMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown image]");
default:
return string.Empty;
}

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

@ -57,6 +57,10 @@ public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayout @@ -57,6 +57,10 @@ public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayout
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now)
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)

2
ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs

@ -79,6 +79,8 @@ public class PreviewBlockPlayoutHandler( @@ -79,6 +79,8 @@ public class PreviewBlockPlayoutHandler(
.Include(mi => (mi as OtherVideo).MediaVersions)
.Include(mi => (mi as Song).SongMetadata)
.Include(mi => (mi as Song).MediaVersions)
.Include(mi => (mi as Image).ImageMetadata)
.Include(mi => (mi as Image).MediaVersions)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == playoutItem.MediaItemId);
foreach (MediaItem mediaItem in maybeMediaItem)

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

@ -26,7 +26,8 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA @@ -26,7 +26,8 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA
await GetIds(LuceneSearchIndex.ArtistType, request.Query),
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query),
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query),
await GetIds(LuceneSearchIndex.SongType, request.Query));
await GetIds(LuceneSearchIndex.SongType, request.Query),
await GetIds(LuceneSearchIndex.ImageType, request.Query));
private async Task<List<int>> GetIds(string type, string query) =>
(await _searchIndex.Search(_client, $"type:{type} AND ({query})", 0, 0)).Items.Map(i => i.Id).ToList();

6
ErsatzTV.Application/Search/Queries/QuerySearchIndexImages.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Application.MediaCards;
namespace ErsatzTV.Application.Search;
public record QuerySearchIndexImages
(string Query, int PageNumber, int PageSize) : IRequest<ImageCardResultsViewModel>;

29
ErsatzTV.Application/Search/Queries/QuerySearchIndexImagesHandler.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexImagesHandler(IClient client, ISearchIndex searchIndex, IImageRepository imageRepository)
: IRequestHandler<QuerySearchIndexImages, ImageCardResultsViewModel>
{
public async Task<ImageCardResultsViewModel> Handle(
QuerySearchIndexImages request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<ImageCardViewModel> items = await imageRepository
.GetImagesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new ImageCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}

3
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

@ -8,4 +8,5 @@ public record SearchResultAllItemsViewModel( @@ -8,4 +8,5 @@ public record SearchResultAllItemsViewModel(
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds);
List<int> SongIds,
List<int> ImageIds);

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

@ -139,6 +139,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -139,6 +139,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.Watermark)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
@ -146,11 +154,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -146,11 +154,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
maybePlayoutItem = await _externalJsonPlayoutItemProvider.CheckForExternalJson(
channel,
now,
ffmpegPath,
ffprobePath);
maybePlayoutItem = await _externalJsonPlayoutItemProvider.CheckForExternalJson(channel, now, ffprobePath);
}
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
@ -202,6 +206,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -202,6 +206,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
cancellationToken);
}
if (playoutItemWithPath.PlayoutItem.MediaItem is Image)
{
audioPath = string.Empty;
}
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));

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

@ -6,5 +6,6 @@ public enum LibraryMediaKind @@ -6,5 +6,6 @@ public enum LibraryMediaKind
Shows = 2,
MusicVideos = 3,
OtherVideos = 4,
Songs = 5
Songs = 5,
Images = 6
}

9
ErsatzTV.Core/Domain/MediaItem/Image.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class Image : MediaItem
{
public static readonly int DefaultSeconds = 15;
public List<ImageMetadata> ImageMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}

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

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public class ImageMetadata : Metadata
{
public int? DurationSeconds { get; set; }
public int ImageId { get; set; }
public Image Image { get; set; }
}

1
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -31,6 +31,7 @@ public static class MediaItemExtensions @@ -31,6 +31,7 @@ public static class MediaItemExtensions
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
Song s => s.MediaVersions.Head(),
Image i => i.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

6
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -199,6 +199,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -199,6 +199,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState);
});
// when no audio streams are available, use null audio source
if (!audioVersion.MediaVersion.Streams.Any(s => s.MediaStreamKind is MediaStreamKind.Audio))
{
audioInputFile = new NullAudioInputFile(audioState);
}
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
switch (channel.StreamingMode)
{

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

@ -12,4 +12,5 @@ public interface IFallbackMetadataProvider @@ -12,4 +12,5 @@ public interface IFallbackMetadataProvider
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
Option<SongMetadata> GetFallbackMetadata(Song song);
Option<ImageMetadata> GetFallbackMetadata(Image image);
}

7
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -1,13 +1,12 @@ @@ -1,13 +1,12 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface ILocalStatisticsProvider
{
Task<Either<BaseError, MediaVersion>> GetStatistics(string ffmpegPath, string ffprobePath, string path);
Task<Either<BaseError, MediaVersion>> GetStatistics(string ffprobePath, string path);
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
Either<BaseError, List<SongTag>> GetSongTags(string ffprobePath, MediaItem mediaItem);
Either<BaseError, List<SongTag>> GetSongTags(MediaItem mediaItem);
}

13
ErsatzTV.Core/Interfaces/Repositories/IImageRepository.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IImageRepository
{
Task<Either<BaseError, MediaItemScanResult<Image>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindImagePaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddTag(ImageMetadata metadata, Tag tag);
Task<List<ImageMetadata>> GetImagesForCards(List<int> ids);
}

1
ErsatzTV.Core/Interfaces/Streaming/IExternalJsonPlayoutItemProvider.cs

@ -7,6 +7,5 @@ public interface IExternalJsonPlayoutItemProvider @@ -7,6 +7,5 @@ public interface IExternalJsonPlayoutItemProvider
Task<Either<BaseError, PlayoutItemWithPath>> CheckForExternalJson(
Channel channel,
DateTimeOffset now,
string ffmpegPath,
string ffprobePath);
}

57
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -154,6 +154,25 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -154,6 +154,25 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
return GetSongMetadata(path, metadata);
}
public Option<ImageMetadata> GetFallbackMetadata(Image image)
{
string path = image.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileNameWithoutExtension(path);
var metadata = new ImageMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
Image = image,
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = []
};
return GetImageMetadata(path, metadata);
}
[GeneratedRegex(@"s(?:eason)?\s?(\d+)(?![e\d])", RegexOptions.IgnoreCase)]
private static partial Regex SeasonNumber();
@ -308,6 +327,44 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -308,6 +327,44 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
return None;
}
}
private Option<ImageMetadata> GetImageMetadata(string path, ImageMetadata metadata)
{
try
{
string folder = Path.GetDirectoryName(path);
if (folder == null)
{
return None;
}
string libraryPath = metadata.Image.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 = [];
metadata.Actors = [];
metadata.Genres = [];
metadata.Tags = tags;
metadata.Studios = [];
metadata.DateUpdated = DateTime.UtcNow;
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
return metadata;
}
catch (Exception ex)
{
_client.Notify(ex);
return None;
}
}
private Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
{

17
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs

@ -75,6 +75,10 @@ public class BlockPlayoutBuilder( @@ -75,6 +75,10 @@ public class BlockPlayoutBuilder(
}
DateTimeOffset currentTime = start;
if (updatedEffectiveBlocks.Count > 0)
{
currentTime = updatedEffectiveBlocks.Min(eb => eb.Start);
}
foreach (EffectiveBlock effectiveBlock in updatedEffectiveBlocks)
{
@ -128,6 +132,8 @@ public class BlockPlayoutBuilder( @@ -128,6 +132,8 @@ public class BlockPlayoutBuilder(
historyKey,
collectionMediaItems);
bool pastTime = false;
foreach (MediaItem mediaItem in enumerator.Current)
{
Logger.LogDebug(
@ -171,6 +177,7 @@ public class BlockPlayoutBuilder( @@ -171,6 +177,7 @@ public class BlockPlayoutBuilder(
effectiveBlock.Block.Name,
blockFinish);
pastTime = true;
break;
}
@ -194,6 +201,11 @@ public class BlockPlayoutBuilder( @@ -194,6 +201,11 @@ public class BlockPlayoutBuilder(
currentTime += itemDuration;
enumerator.MoveNext();
}
if (pastTime)
{
break;
}
}
}
@ -337,6 +349,11 @@ public class BlockPlayoutBuilder( @@ -337,6 +349,11 @@ public class BlockPlayoutBuilder(
private static TimeSpan DurationForMediaItem(MediaItem mediaItem)
{
if (mediaItem is Image)
{
return TimeSpan.FromSeconds(Domain.Image.DefaultSeconds);
}
MediaVersion version = mediaItem.GetHeadVersion();
return version.Duration;
}

9
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -300,8 +300,8 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -300,8 +300,8 @@ public class PlayoutBuilder : IPlayoutBuilder
return None;
}
playout.Items ??= new List<PlayoutItem>();
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();
playout.Items ??= [];
playout.ProgramScheduleAnchors ??= [];
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
@ -791,6 +791,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -791,6 +791,7 @@ public class PlayoutBuilder : IPlayoutBuilder
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
Image => false,
_ => true
};
@ -1097,6 +1098,10 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1097,6 +1098,10 @@ public class PlayoutBuilder : IPlayoutBuilder
return s.SongMetadata.HeadOrNone().Match(
sm => sm.Title ?? string.Empty,
() => "[unknown song]");
case Image i:
return i.ImageMetadata.HeadOrNone().Match(
sm => sm.Title ?? string.Empty,
() => "[unknown song]");
default:
return string.Empty;
}

5
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -179,6 +179,11 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe @@ -179,6 +179,11 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
{
if (mediaItem is Image)
{
return TimeSpan.FromSeconds(Domain.Image.DefaultSeconds);
}
MediaVersion version = mediaItem.GetHeadVersion();
return version.Duration;
}

5105
ErsatzTV.Infrastructure.MySql/Migrations/20240211153028_Add_Image_ImageMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

329
ErsatzTV.Infrastructure.MySql/Migrations/20240211153028_Add_Image_ImageMetadata.cs

@ -0,0 +1,329 @@ @@ -0,0 +1,329 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_Image_ImageMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Tag",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Subtitle",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Studio",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "MetadataGuid",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageId",
table: "MediaVersion",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Genre",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Artwork",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Actor",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "Image",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Image", x => x.Id);
table.ForeignKey(
name: "FK_Image_MediaItem_Id",
column: x => x.Id,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "ImageMetadata",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DurationSeconds = table.Column<int>(type: "int", nullable: true),
ImageId = table.Column<int>(type: "int", nullable: false),
MetadataKind = table.Column<int>(type: "int", nullable: false),
Title = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
OriginalTitle = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SortTitle = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Year = table.Column<int>(type: "int", nullable: true),
ReleaseDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
DateAdded = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DateUpdated = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ImageMetadata", x => x.Id);
table.ForeignKey(
name: "FK_ImageMetadata_Image_ImageId",
column: x => x.ImageId,
principalTable: "Image",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Tag_ImageMetadataId",
table: "Tag",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Subtitle_ImageMetadataId",
table: "Subtitle",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Studio_ImageMetadataId",
table: "Studio",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_ImageMetadataId",
table: "MetadataGuid",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MediaVersion_ImageId",
table: "MediaVersion",
column: "ImageId");
migrationBuilder.CreateIndex(
name: "IX_Genre_ImageMetadataId",
table: "Genre",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Artwork_ImageMetadataId",
table: "Artwork",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Actor_ImageMetadataId",
table: "Actor",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_ImageMetadata_ImageId",
table: "ImageMetadata",
column: "ImageId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_ImageMetadata_ImageMetadataId",
table: "Actor",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Artwork_ImageMetadata_ImageMetadataId",
table: "Artwork",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_ImageMetadata_ImageMetadataId",
table: "Genre",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MediaVersion_Image_ImageId",
table: "MediaVersion",
column: "ImageId",
principalTable: "Image",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_ImageMetadata_ImageMetadataId",
table: "MetadataGuid",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Studio_ImageMetadata_ImageMetadataId",
table: "Studio",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Subtitle_ImageMetadata_ImageMetadataId",
table: "Subtitle",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tag_ImageMetadata_ImageMetadataId",
table: "Tag",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_ImageMetadata_ImageMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Artwork_ImageMetadata_ImageMetadataId",
table: "Artwork");
migrationBuilder.DropForeignKey(
name: "FK_Genre_ImageMetadata_ImageMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MediaVersion_Image_ImageId",
table: "MediaVersion");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_ImageMetadata_ImageMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_ImageMetadata_ImageMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Subtitle_ImageMetadata_ImageMetadataId",
table: "Subtitle");
migrationBuilder.DropForeignKey(
name: "FK_Tag_ImageMetadata_ImageMetadataId",
table: "Tag");
migrationBuilder.DropTable(
name: "ImageMetadata");
migrationBuilder.DropTable(
name: "Image");
migrationBuilder.DropIndex(
name: "IX_Tag_ImageMetadataId",
table: "Tag");
migrationBuilder.DropIndex(
name: "IX_Subtitle_ImageMetadataId",
table: "Subtitle");
migrationBuilder.DropIndex(
name: "IX_Studio_ImageMetadataId",
table: "Studio");
migrationBuilder.DropIndex(
name: "IX_MetadataGuid_ImageMetadataId",
table: "MetadataGuid");
migrationBuilder.DropIndex(
name: "IX_MediaVersion_ImageId",
table: "MediaVersion");
migrationBuilder.DropIndex(
name: "IX_Genre_ImageMetadataId",
table: "Genre");
migrationBuilder.DropIndex(
name: "IX_Artwork_ImageMetadataId",
table: "Artwork");
migrationBuilder.DropIndex(
name: "IX_Actor_ImageMetadataId",
table: "Actor");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Tag");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Subtitle");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Studio");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "MetadataGuid");
migrationBuilder.DropColumn(
name: "ImageId",
table: "MediaVersion");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Genre");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Artwork");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Actor");
}
}
}

5105
ErsatzTV.Infrastructure.MySql/Migrations/20240211153125_Add_LocalLibrary_Images.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure.MySql/Migrations/20240211153125_Add_LocalLibrary_Images.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_LocalLibrary_Images : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local images library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Images', 6, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1) AS A");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_id())");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

174
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -34,6 +34,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -34,6 +34,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("int");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<int?>("MovieMetadataId")
.HasColumnType("int");
@ -70,6 +73,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -70,6 +73,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -167,6 +172,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -167,6 +172,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("int");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<int?>("MovieMetadataId")
.HasColumnType("int");
@ -199,6 +207,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -199,6 +207,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -678,6 +688,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -678,6 +688,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("int");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<int?>("MovieMetadataId")
.HasColumnType("int");
@ -705,6 +718,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -705,6 +718,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -720,6 +735,49 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -720,6 +735,49 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Genre");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DateUpdated")
.HasColumnType("datetime(6)");
b.Property<int?>("DurationSeconds")
.HasColumnType("int");
b.Property<int>("ImageId")
.HasColumnType("int");
b.Property<int>("MetadataKind")
.HasColumnType("int");
b.Property<string>("OriginalTitle")
.HasColumnType("longtext");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("datetime(6)");
b.Property<string>("SortTitle")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<int?>("Year")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ImageId");
b.ToTable("ImageMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinCollection", b =>
{
b.Property<int>("Id")
@ -1058,6 +1116,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1058,6 +1116,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("Height")
.HasColumnType("int");
b.Property<int?>("ImageId")
.HasColumnType("int");
b.Property<int?>("MovieId")
.HasColumnType("int");
@ -1089,6 +1150,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1089,6 +1150,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeId");
b.HasIndex("ImageId");
b.HasIndex("MovieId");
b.HasIndex("MusicVideoId");
@ -1115,6 +1178,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1115,6 +1178,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Guid")
.HasColumnType("longtext");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<int?>("MovieMetadataId")
.HasColumnType("int");
@ -1139,6 +1205,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1139,6 +1205,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2254,6 +2322,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2254,6 +2322,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("int");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<int?>("MovieMetadataId")
.HasColumnType("int");
@ -2281,6 +2352,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2281,6 +2352,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2342,6 +2415,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2342,6 +2415,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<bool>("Forced")
.HasColumnType("tinyint(1)");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<bool>("IsExtracted")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
@ -2391,6 +2467,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2391,6 +2467,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2421,6 +2499,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2421,6 +2499,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("ExternalCollectionId")
.HasColumnType("longtext");
b.Property<int?>("ImageMetadataId")
.HasColumnType("int");
b.Property<int?>("MovieMetadataId")
.HasColumnType("int");
@ -2448,6 +2529,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2448,6 +2529,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2704,6 +2787,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2704,6 +2787,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Episode", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Image", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Image", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3036,6 +3126,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3036,6 +3126,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Actors")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Actors")
.HasForeignKey("MovieMetadataId")
@ -3097,6 +3192,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3097,6 +3192,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Artwork")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Artwork")
.HasForeignKey("MovieMetadataId")
@ -3282,6 +3382,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3282,6 +3382,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Genres")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Genres")
.HasForeignKey("MovieMetadataId")
@ -3313,6 +3418,17 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3313,6 +3418,17 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Image", "Image")
.WithMany("ImageMetadata")
.HasForeignKey("ImageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Image");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.JellyfinMediaSource", "JellyfinMediaSource")
@ -3419,6 +3535,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3419,6 +3535,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Image", null)
.WithMany("MediaVersions")
.HasForeignKey("ImageId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Movie", null)
.WithMany("MediaVersions")
.HasForeignKey("MovieId")
@ -3452,6 +3573,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3452,6 +3573,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Guids")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Guids")
.HasForeignKey("MovieMetadataId")
@ -4052,6 +4178,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4052,6 +4178,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Studios")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Studios")
.HasForeignKey("MovieMetadataId")
@ -4103,6 +4234,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4103,6 +4234,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("MovieMetadataId")
@ -4146,6 +4282,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4146,6 +4282,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Tags")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Tags")
.HasForeignKey("MovieMetadataId")
@ -4311,6 +4452,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4311,6 +4452,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Season");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Image", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Image", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -4617,6 +4767,23 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4617,6 +4767,23 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
{
b.Navigation("Paths");
@ -4860,6 +5027,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4860,6 +5027,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("MediaVersions");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Image", b =>
{
b.Navigation("ImageMetadata");
b.Navigation("MediaVersions");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
{
b.Navigation("MediaVersions");

4929
ErsatzTV.Infrastructure.Sqlite/Migrations/20240211120605_Add_LocalLibrary_Images.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure.Sqlite/Migrations/20240211120605_Add_LocalLibrary_Images.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_LocalLibrary_Images : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local images library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Images', 6, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1)");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

5103
ErsatzTV.Infrastructure.Sqlite/Migrations/20240211135444_Add_Image_ImageMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

324
ErsatzTV.Infrastructure.Sqlite/Migrations/20240211135444_Add_Image_ImageMetadata.cs

@ -0,0 +1,324 @@ @@ -0,0 +1,324 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_Image_ImageMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Tag",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Subtitle",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Studio",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "MetadataGuid",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageId",
table: "MediaVersion",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Genre",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Artwork",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageMetadataId",
table: "Actor",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "Image",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_Image", x => x.Id);
table.ForeignKey(
name: "FK_Image_MediaItem_Id",
column: x => x.Id,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ImageMetadata",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DurationSeconds = table.Column<int>(type: "INTEGER", nullable: true),
ImageId = 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_ImageMetadata", x => x.Id);
table.ForeignKey(
name: "FK_ImageMetadata_Image_ImageId",
column: x => x.ImageId,
principalTable: "Image",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tag_ImageMetadataId",
table: "Tag",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Subtitle_ImageMetadataId",
table: "Subtitle",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Studio_ImageMetadataId",
table: "Studio",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_ImageMetadataId",
table: "MetadataGuid",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MediaVersion_ImageId",
table: "MediaVersion",
column: "ImageId");
migrationBuilder.CreateIndex(
name: "IX_Genre_ImageMetadataId",
table: "Genre",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Artwork_ImageMetadataId",
table: "Artwork",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Actor_ImageMetadataId",
table: "Actor",
column: "ImageMetadataId");
migrationBuilder.CreateIndex(
name: "IX_ImageMetadata_ImageId",
table: "ImageMetadata",
column: "ImageId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_ImageMetadata_ImageMetadataId",
table: "Actor",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Artwork_ImageMetadata_ImageMetadataId",
table: "Artwork",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_ImageMetadata_ImageMetadataId",
table: "Genre",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MediaVersion_Image_ImageId",
table: "MediaVersion",
column: "ImageId",
principalTable: "Image",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_ImageMetadata_ImageMetadataId",
table: "MetadataGuid",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Studio_ImageMetadata_ImageMetadataId",
table: "Studio",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Subtitle_ImageMetadata_ImageMetadataId",
table: "Subtitle",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tag_ImageMetadata_ImageMetadataId",
table: "Tag",
column: "ImageMetadataId",
principalTable: "ImageMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_ImageMetadata_ImageMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Artwork_ImageMetadata_ImageMetadataId",
table: "Artwork");
migrationBuilder.DropForeignKey(
name: "FK_Genre_ImageMetadata_ImageMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MediaVersion_Image_ImageId",
table: "MediaVersion");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_ImageMetadata_ImageMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_ImageMetadata_ImageMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Subtitle_ImageMetadata_ImageMetadataId",
table: "Subtitle");
migrationBuilder.DropForeignKey(
name: "FK_Tag_ImageMetadata_ImageMetadataId",
table: "Tag");
migrationBuilder.DropTable(
name: "ImageMetadata");
migrationBuilder.DropTable(
name: "Image");
migrationBuilder.DropIndex(
name: "IX_Tag_ImageMetadataId",
table: "Tag");
migrationBuilder.DropIndex(
name: "IX_Subtitle_ImageMetadataId",
table: "Subtitle");
migrationBuilder.DropIndex(
name: "IX_Studio_ImageMetadataId",
table: "Studio");
migrationBuilder.DropIndex(
name: "IX_MetadataGuid_ImageMetadataId",
table: "MetadataGuid");
migrationBuilder.DropIndex(
name: "IX_MediaVersion_ImageId",
table: "MediaVersion");
migrationBuilder.DropIndex(
name: "IX_Genre_ImageMetadataId",
table: "Genre");
migrationBuilder.DropIndex(
name: "IX_Artwork_ImageMetadataId",
table: "Artwork");
migrationBuilder.DropIndex(
name: "IX_Actor_ImageMetadataId",
table: "Actor");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Tag");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Subtitle");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Studio");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "MetadataGuid");
migrationBuilder.DropColumn(
name: "ImageId",
table: "MediaVersion");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Genre");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Artwork");
migrationBuilder.DropColumn(
name: "ImageMetadataId",
table: "Actor");
}
}
}

174
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -32,6 +32,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -32,6 +32,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -68,6 +71,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -68,6 +71,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -165,6 +170,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -165,6 +170,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -197,6 +205,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -197,6 +205,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -676,6 +686,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -676,6 +686,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -703,6 +716,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -703,6 +716,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -718,6 +733,49 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -718,6 +733,49 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Genre");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int?>("DurationSeconds")
.HasColumnType("INTEGER");
b.Property<int>("ImageId")
.HasColumnType("INTEGER");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ImageId");
b.ToTable("ImageMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinCollection", b =>
{
b.Property<int>("Id")
@ -1056,6 +1114,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1056,6 +1114,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<int?>("ImageId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieId")
.HasColumnType("INTEGER");
@ -1087,6 +1148,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1087,6 +1148,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeId");
b.HasIndex("ImageId");
b.HasIndex("MovieId");
b.HasIndex("MusicVideoId");
@ -1113,6 +1176,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1113,6 +1176,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Guid")
.HasColumnType("TEXT");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -1137,6 +1203,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1137,6 +1203,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2252,6 +2320,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2252,6 +2320,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -2279,6 +2350,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2279,6 +2350,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2340,6 +2413,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2340,6 +2413,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<bool>("Forced")
.HasColumnType("INTEGER");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<bool>("IsExtracted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@ -2389,6 +2465,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2389,6 +2465,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2419,6 +2497,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2419,6 +2497,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("ExternalCollectionId")
.HasColumnType("TEXT");
b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -2446,6 +2527,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2446,6 +2527,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EpisodeMetadataId");
b.HasIndex("ImageMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
@ -2702,6 +2785,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2702,6 +2785,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Episode", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Image", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Image", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3034,6 +3124,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3034,6 +3124,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Actors")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Actors")
.HasForeignKey("MovieMetadataId")
@ -3095,6 +3190,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3095,6 +3190,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Artwork")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Artwork")
.HasForeignKey("MovieMetadataId")
@ -3280,6 +3380,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3280,6 +3380,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Genres")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Genres")
.HasForeignKey("MovieMetadataId")
@ -3311,6 +3416,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3311,6 +3416,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Image", "Image")
.WithMany("ImageMetadata")
.HasForeignKey("ImageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Image");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.JellyfinMediaSource", "JellyfinMediaSource")
@ -3417,6 +3533,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3417,6 +3533,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Image", null)
.WithMany("MediaVersions")
.HasForeignKey("ImageId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Movie", null)
.WithMany("MediaVersions")
.HasForeignKey("MovieId")
@ -3450,6 +3571,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3450,6 +3571,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Guids")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Guids")
.HasForeignKey("MovieMetadataId")
@ -4050,6 +4176,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4050,6 +4176,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Studios")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Studios")
.HasForeignKey("MovieMetadataId")
@ -4101,6 +4232,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4101,6 +4232,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("MovieMetadataId")
@ -4144,6 +4280,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4144,6 +4280,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ImageMetadata", null)
.WithMany("Tags")
.HasForeignKey("ImageMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Tags")
.HasForeignKey("MovieMetadataId")
@ -4309,6 +4450,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4309,6 +4450,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Season");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Image", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Image", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -4615,6 +4765,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4615,6 +4765,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
{
b.Navigation("Paths");
@ -4858,6 +5025,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4858,6 +5025,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("MediaVersions");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Image", b =>
{
b.Navigation("ImageMetadata");
b.Navigation("MediaVersions");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
{
b.Navigation("MediaVersions");

22
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/ImageConfiguration.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 ImageConfiguration : IEntityTypeConfiguration<Image>
{
public void Configure(EntityTypeBuilder<Image> builder)
{
builder.ToTable("Image");
builder.HasMany(i => i.ImageMetadata)
.WithOne(m => m.Image)
.HasForeignKey(m => m.ImageId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(i => i.MediaVersions)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

41
ErsatzTV.Infrastructure/Data/Configurations/Metadata/ImageMetadataConfiguration.cs

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class ImageMetadataConfiguration : IEntityTypeConfiguration<ImageMetadata>
{
public void Configure(EntityTypeBuilder<ImageMetadata> builder)
{
builder.ToTable("ImageMetadata");
builder.HasMany(sm => sm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

164
ErsatzTV.Infrastructure/Data/Repositories/ImageRepository.cs

@ -0,0 +1,164 @@ @@ -0,0 +1,164 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class ImageRepository : IImageRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<ImageRepository> _logger;
public ImageRepository(IDbContextFactory<TvContext> dbContextFactory, ILogger<ImageRepository> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Either<BaseError, MediaItemScanResult<Image>>> GetOrAdd(
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Image> maybeExisting = await dbContext.Images
.AsNoTracking()
.Include(i => i.ImageMetadata)
.ThenInclude(ovm => ovm.Genres)
.Include(i => i.ImageMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(i => i.ImageMetadata)
.ThenInclude(ovm => ovm.Studios)
.Include(i => i.ImageMetadata)
.ThenInclude(ovm => ovm.Guids)
.Include(i => i.ImageMetadata)
.ThenInclude(ovm => ovm.Actors)
.Include(i => i.ImageMetadata)
.ThenInclude(ovm => ovm.Actors)
.ThenInclude(a => a.Artwork)
.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<Image>>(
new MediaItemScanResult<Image>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddImage(dbContext, libraryPath.Id, path));
}
public async Task<IEnumerable<string>> FindImagePaths(LibraryPath libraryPath)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN Image O on MV.ImageId = 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)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT O.Id
FROM Image O
INNER JOIN MediaItem MI on O.Id = MI.Id
INNER JOIN MediaVersion MV on O.Id = MV.ImageId
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());
foreach (int otherVideoId in ids)
{
Image otherVideo = await dbContext.Images.FindAsync(otherVideoId);
if (otherVideo != null)
{
dbContext.Images.Remove(otherVideo);
}
}
await dbContext.SaveChangesAsync();
return ids;
}
public async Task<bool> AddTag(ImageMetadata metadata, Tag tag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, ImageMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<List<ImageMetadata>> GetImagesForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ImageMetadata
.AsNoTracking()
.Filter(im => ids.Contains(im.ImageId))
.Include(im => im.Image)
.Include(im => im.Artwork)
.Include(im => im.Image)
.ThenInclude(s => s.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(im => im.SortTitle)
.ToListAsync();
}
private async Task<Either<BaseError, MediaItemScanResult<Image>>> AddImage(
TvContext dbContext,
int libraryPathId,
string path)
{
try
{
if (await MediaItemRepository.MediaFileAlreadyExists(path, libraryPathId, dbContext, _logger))
{
return new MediaFileAlreadyExists();
}
var otherVideo = new Image
{
LibraryPathId = libraryPathId,
MediaVersions =
[
new MediaVersion
{
MediaFiles = [new MediaFile { Path = path }],
Streams = []
}
],
TraktListItems = new List<TraktListItem>
{
Capacity = 0
}
};
await dbContext.Images.AddAsync(otherVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(otherVideo).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(otherVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<Image>(otherVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}

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

@ -52,6 +52,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -52,6 +52,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetMusicVideoItems(dbContext, id));
result.AddRange(await GetOtherVideoItems(dbContext, id));
result.AddRange(await GetSongItems(dbContext, id));
result.AddRange(await GetImageItems(dbContext, id));
return result.Distinct().ToList();
}
@ -79,6 +80,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -79,6 +80,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
result.AddRange(await GetSongItems(dbContext, collectionId));
result.AddRange(await GetImageItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
@ -150,6 +152,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -150,6 +152,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.Map(i => i.Id)
.ToList();
result.AddRange(await GetSongItems(dbContext, songIds));
var imageIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.ImageType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetImageItems(dbContext, imageIds));
}
return result.DistinctBy(x => x.Id).ToList();
@ -590,6 +598,27 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -590,6 +598,27 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.Filter(m => songIds.Contains(m.Id))
.ToListAsync();
private static async Task<List<Image>> GetImageItems(TvContext dbContext, int collectionId)
{
IEnumerable<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT i.Id FROM CollectionItem ci
INNER JOIN Image i ON i.Id = ci.MediaItemId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await GetImageItems(dbContext, ids);
}
private static Task<List<Image>> GetImageItems(TvContext dbContext, IEnumerable<int> songIds) =>
dbContext.Images
.Include(m => m.ImageMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => songIds.Contains(m.Id))
.ToListAsync();
private static async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
{
IEnumerable<int> ids = await dbContext.Connection.QueryAsync<int>(

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

@ -140,6 +140,14 @@ public class SearchRepository : ISearchRepository @@ -140,6 +140,14 @@ public class SearchRepository : ISearchRepository
.ThenInclude(mm => mm.Guids)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Image).ImageMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Image).ImageMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Image).ImageMetadata)
.ThenInclude(mm => mm.Guids)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
@ -355,6 +363,14 @@ public class SearchRepository : ISearchRepository @@ -355,6 +363,14 @@ public class SearchRepository : ISearchRepository
.ThenInclude(mm => mm.Guids)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Image).ImageMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Image).ImageMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Image).ImageMetadata)
.ThenInclude(mm => mm.Guids)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.AsAsyncEnumerable();

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -53,6 +53,8 @@ public class TvContext : DbContext @@ -53,6 +53,8 @@ public class TvContext : DbContext
public DbSet<OtherVideoMetadata> OtherVideoMetadata { get; set; }
public DbSet<Song> Songs { get; set; }
public DbSet<SongMetadata> SongMetadata { get; set; }
public DbSet<Image> Images { get; set; }
public DbSet<ImageMetadata> ImageMetadata { get; set; }
public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; }

1
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Metadata; @@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Image=SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Images;

7
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -11,7 +11,6 @@ using ErsatzTV.Core.Domain; @@ -11,7 +11,6 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using Lucene.Net.Util.Fst;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using File = TagLib.File;
@ -37,7 +36,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -37,7 +36,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
_logger = logger;
}
public async Task<Either<BaseError, MediaVersion>> GetStatistics(string ffmpegPath, string ffprobePath, string path)
public async Task<Either<BaseError, MediaVersion>> GetStatistics(string ffprobePath, string path)
{
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, path);
return maybeProbe.Match(
@ -63,7 +62,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -63,7 +62,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
}
}
public Either<BaseError, List<SongTag>> GetSongTags(string ffprobePath, MediaItem mediaItem)
public Either<BaseError, List<SongTag>> GetSongTags(MediaItem mediaItem)
{
try
{
@ -128,7 +127,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -128,7 +127,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
async ffprobe =>
{
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
if (version.Duration.TotalSeconds < 1)
if (mediaItem is not Image && version.Duration.TotalSeconds < 1)
{
await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
}

66
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -85,6 +85,7 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -85,6 +85,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
public const string EpisodeType = "episode";
public const string OtherVideoType = "other_video";
public const string SongType = "song";
public const string ImageType = "image";
private readonly string _cleanShutdownPath;
private readonly List<CultureInfo> _cultureInfos;
@ -178,6 +179,9 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -178,6 +179,9 @@ public sealed class LuceneSearchIndex : ISearchIndex
case Song song:
await UpdateSong(searchRepository, song);
break;
case Image image:
await UpdateImage(searchRepository, image);
break;
}
}
@ -340,6 +344,9 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -340,6 +344,9 @@ public sealed class LuceneSearchIndex : ISearchIndex
case Song song:
await UpdateSong(searchRepository, song);
break;
case Image image:
await UpdateImage(searchRepository, image);
break;
}
}
@ -1269,6 +1276,64 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -1269,6 +1276,64 @@ public sealed class LuceneSearchIndex : ISearchIndex
}
}
}
private async Task UpdateImage(ISearchRepository searchRepository, Image image)
{
Option<ImageMetadata> maybeMetadata = image.ImageMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
ImageMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, image.Id.ToString(CultureInfo.InvariantCulture), Field.Store.YES),
new StringField(TypeField, ImageType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, image.LibraryPath.Library.Name, Field.Store.NO),
new StringField(
LibraryIdField,
image.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, image.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, image.MediaVersions);
AddStatistics(doc, image.MediaVersions);
doc.Add(
new StringField(
AddedDateField,
metadata.DateAdded.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
Field.Store.NO));
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
AddMetadataGuids(metadata, doc);
_writer.UpdateDocument(new Term(IdField, image.Id.ToString(CultureInfo.InvariantCulture)), doc);
}
catch (Exception ex)
{
metadata.Image = null;
_logger.LogWarning(ex, "Error indexing image with metadata {@Metadata}", metadata);
}
}
}
private static SearchItem ProjectToSearchItem(Document doc) => new(
doc.Get(TypeField, CultureInfo.InvariantCulture),
@ -1365,6 +1430,7 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -1365,6 +1430,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
OtherVideoMetadata ovm => $"{OtherVideoTitle(ovm).Replace(' ', '_')}_{ovm.Year}_{ovm.OtherVideo.State}"
.ToLowerInvariant(),
SongMetadata sm => $"{Title(sm)}_{sm.Year}_{sm.Song.State}".ToLowerInvariant(),
ImageMetadata im => $"{Title(im)}_{im.Year}_{im.Image.State}".ToLowerInvariant(),
MovieMetadata mm => $"{Title(mm)}_{mm.Year}_{mm.Movie.State}".ToLowerInvariant(),
ArtistMetadata am => $"{Title(am)}_{am.Year}_{am.Artist.State}".ToLowerInvariant(),
MusicVideoMetadata mvm => $"{Title(mvm)}_{mvm.Year}_{mvm.MusicVideo.State}".ToLowerInvariant(),

12
ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs

@ -47,7 +47,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -47,7 +47,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
public async Task<Either<BaseError, PlayoutItemWithPath>> CheckForExternalJson(
Channel channel,
DateTimeOffset now,
string ffmpegPath,
string ffprobePath)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -64,7 +63,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -64,7 +63,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
// json file must exist
if (_localFileSystem.FileExists(playout.ExternalJsonFile))
{
return await GetExternalJsonPlayoutItem(dbContext, playout, now, ffmpegPath, ffprobePath);
return await GetExternalJsonPlayoutItem(dbContext, playout, now, ffprobePath);
}
_logger.LogWarning(
@ -82,7 +81,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -82,7 +81,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
TvContext dbContext,
Playout playout,
DateTimeOffset now,
string ffmpegPath,
string ffprobePath)
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
@ -107,7 +105,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -107,7 +105,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
if (nextStart > now)
{
//_logger.LogDebug("should play program {@Program}", program);
return await BuildPlayoutItem(dbContext, startTime, program, ffmpegPath, ffprobePath);
return await BuildPlayoutItem(dbContext, startTime, program, ffprobePath);
}
startTime = nextStart;
@ -121,7 +119,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -121,7 +119,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
TvContext dbContext,
DateTimeOffset startTime,
ExternalJsonProgram program,
string ffmpegPath,
string ffprobePath)
{
// find any library path from the appropriate plex server
@ -139,7 +136,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -139,7 +136,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
if (_localFileSystem.FileExists(localPath))
{
return await StreamLocally(startTime, program, ffmpegPath, ffprobePath, localPath);
return await StreamLocally(startTime, program, ffprobePath, localPath);
}
return await StreamRemotely(dbContext, startTime, program);
@ -151,13 +148,12 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -151,13 +148,12 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
private async Task<Either<BaseError, PlayoutItemWithPath>> StreamLocally(
DateTimeOffset startTime,
ExternalJsonProgram program,
string ffmpegPath,
string ffprobePath,
string localPath)
{
// ffprobe on demand
Either<BaseError, MediaVersion> maybeMediaVersion =
await _localStatisticsProvider.GetStatistics(ffmpegPath, ffprobePath, localPath);
await _localStatisticsProvider.GetStatistics(ffprobePath, localPath);
foreach (MediaVersion mediaVersion in maybeMediaVersion.RightToSeq())
{

11
ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -19,6 +19,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -19,6 +19,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ISongFolderScanner _songFolderScanner;
private readonly IImageFolderScanner _imageFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@ -29,6 +30,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -29,6 +30,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner,
ISongFolderScanner songFolderScanner,
IImageFolderScanner imageFolderScanner,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
{
@ -39,6 +41,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -39,6 +41,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
_musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner;
_imageFolderScanner = imageFolderScanner;
_mediator = mediator;
_logger = logger;
}
@ -113,6 +116,14 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -113,6 +116,14 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.Images =>
await _imageFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
_ => Unit.Default
};

15
ErsatzTV.Scanner/Core/Interfaces/Metadata/IImageFolderScanner.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface IImageFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken);
}

4
ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -12,12 +12,14 @@ public interface ILocalMetadataProvider @@ -12,12 +12,14 @@ public interface ILocalMetadataProvider
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
Task<bool> RefreshTagMetadata(Song song, string ffprobePath);
Task<bool> RefreshTagMetadata(Song song);
Task<bool> RefreshTagMetadata(Image image);
Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
Task<bool> RefreshFallbackMetadata(Song song);
Task<bool> RefreshFallbackMetadata(Image image);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}

256
ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs

@ -0,0 +1,256 @@ @@ -0,0 +1,256 @@
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Metadata;
public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
{
private readonly IClient _client;
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<ImageFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IImageRepository _imageRepository;
public ImageFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
IMediator mediator,
IImageRepository imageRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegPngService ffmpegPngService,
ITempFilePool tempFilePool,
IClient client,
ILogger<ImageFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegPngService,
tempFilePool,
client,
logger)
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_mediator = mediator;
_imageRepository = imageRepository;
_libraryRepository = libraryRepository;
_client = client;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
decimal progressSpread = progressMax - progressMin;
var foldersCompleted = 0;
var allFolders = new System.Collections.Generic.HashSet<string>();
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path) && allFolders.Add(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
progressMin + percentCompletion * progressSpread,
Array.Empty<int>(),
Array.Empty<int>()),
cancellationToken);
string imageFolder = folderQueue.Dequeue();
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(imageFolder).ToList();
var allFiles = filesForEtag
.Filter(f => ImageFileExtensions.Contains(Path.GetExtension(f).Replace(".", string.Empty)))
.Filter(f => !Path.GetFileName(f).StartsWith("._", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(imageFolder)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
string etag = FolderEtag.Calculate(imageFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == imageFolder)
.HeadOrNone();
// skip folder if etag matches
if (allFiles.Count == 0 ||
await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
etag)
{
continue;
}
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
imageFolder);
var hasErrors = false;
foreach (string file in allFiles.OrderBy(identity))
{
Either<BaseError, MediaItemScanResult<Image>> maybeVideo = await _imageRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
//.BindT(video => UpdateThumbnail(video, cancellationToken))
//.BindT(UpdateSubtitles)
.BindT(FlagNormal);
foreach (BaseError error in maybeVideo.LeftToSeq())
{
_logger.LogWarning("Error processing image at {Path}: {Error}", file, error.Value);
hasErrors = true;
}
foreach (MediaItemScanResult<Image> result in maybeVideo.RightToSeq())
{
if (result.IsAdded || result.IsUpdated)
{
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
null,
[result.Item.Id],
Array.Empty<int>()),
cancellationToken);
}
}
}
// only do this once per folder and only if all files processed successfully
if (!hasErrors)
{
await _libraryRepository.SetEtag(libraryPath, knownFolder, imageFolder, etag);
}
}
foreach (string path in await _imageRepository.FindImagePaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing image at {Path}", path);
List<int> imageIds = await FlagFileNotFound(libraryPath, path);
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
null,
imageIds.ToArray(),
Array.Empty<int>()),
cancellationToken);
}
else if (Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> imageIds = await _imageRepository.DeleteByPath(libraryPath, path);
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
null,
Array.Empty<int>(),
imageIds.ToArray()),
cancellationToken);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
}
private async Task<Either<BaseError, MediaItemScanResult<Image>>> UpdateMetadata(
MediaItemScanResult<Image> result)
{
try
{
Image image = result.Item;
string path = image.GetHeadVersion().MediaFiles.Head().Path;
bool shouldUpdate = Optional(image.ImageMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated != _localFileSystem.GetLastWriteTime(path),
true);
if (shouldUpdate)
{
image.ImageMetadata ??= [];
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
if (await _localMetadataProvider.RefreshTagMetadata(image))
{
result.IsUpdated = true;
}
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
}

143
ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -34,6 +34,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -34,6 +34,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly IShowNfoReader _showNfoReader;
private readonly ISongRepository _songRepository;
private readonly IImageRepository _imageRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(
@ -44,6 +45,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -44,6 +45,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IMusicVideoRepository musicVideoRepository,
IOtherVideoRepository otherVideoRepository,
ISongRepository songRepository,
IImageRepository imageRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IMovieNfoReader movieNfoReader,
@ -63,6 +65,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -63,6 +65,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_musicVideoRepository = musicVideoRepository;
_otherVideoRepository = otherVideoRepository;
_songRepository = songRepository;
_imageRepository = imageRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_movieNfoReader = movieNfoReader;
@ -196,9 +199,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -196,9 +199,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await RefreshFallbackMetadata(otherVideo);
}
public async Task<bool> RefreshTagMetadata(Song song, string ffprobePath)
public async Task<bool> RefreshTagMetadata(Song song)
{
Option<SongMetadata> maybeMetadata = LoadSongMetadata(song, ffprobePath);
Option<SongMetadata> maybeMetadata = LoadSongMetadata(song);
foreach (SongMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(song, metadata);
@ -207,6 +210,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -207,6 +210,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await RefreshFallbackMetadata(song);
}
public async Task<bool> RefreshTagMetadata(Image image)
{
Option<ImageMetadata> maybeMetadata = LoadImageMetadata(image);
foreach (ImageMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(image, metadata);
}
return await RefreshFallbackMetadata(image);
}
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@ -238,6 +252,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -238,6 +252,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return false;
}
public async Task<bool> RefreshFallbackMetadata(Image image)
{
Option<ImageMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(image);
foreach (ImageMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(image, metadata);
}
return false;
}
public async Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo);
@ -296,13 +321,13 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -296,13 +321,13 @@ public class LocalMetadataProvider : ILocalMetadataProvider
}
}
private Option<SongMetadata> LoadSongMetadata(Song song, string ffprobePath)
private Option<SongMetadata> LoadSongMetadata(Song song)
{
string path = song.GetHeadVersion().MediaFiles.Head().Path;
try
{
var maybeTags = _localStatisticsProvider.GetSongTags(ffprobePath, song);
Either<BaseError, List<SongTag>> maybeTags = _localStatisticsProvider.GetSongTags(song);
foreach (List<SongTag> tags in maybeTags.RightToSeq())
{
@ -380,6 +405,73 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -380,6 +405,73 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return None;
}
}
private Option<ImageMetadata> LoadImageMetadata(Image image)
{
string path = image.GetHeadVersion().MediaFiles.Head().Path;
try
{
Either<BaseError, List<SongTag>> maybeTags = _localStatisticsProvider.GetSongTags(image);
foreach (List<SongTag> tags in maybeTags.RightToSeq())
{
Option<ImageMetadata> maybeFallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadata(image);
var result = new ImageMetadata
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
Artwork = [],
Actors = [],
Genres = [],
Studios = [],
Tags = []
};
foreach (SongTag tag in tags)
{
switch (tag.Tag)
{
case MetadataSongTag.Genre:
result.Genres.Add(new Genre { Name = tag.Value });
break;
case MetadataSongTag.Title:
result.Title = tag.Value;
break;
}
}
foreach (ImageMetadata fallbackMetadata in maybeFallbackMetadata)
{
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging
foreach (Tag tag in fallbackMetadata.Tags)
{
result.Tags.Add(tag);
}
}
return result;
}
return Option<ImageMetadata>.None;
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read embedded song metadata from {Path}", path);
_client.Notify(ex);
return None;
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{
@ -1012,6 +1104,49 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1012,6 +1104,49 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await _metadataRepository.Add(metadata);
}
private async Task<bool> ApplyMetadataUpdate(Image image, ImageMetadata metadata)
{
Option<ImageMetadata> maybeMetadata = Optional(image.ImageMetadata).Flatten().HeadOrNone();
foreach (ImageMetadata existing in maybeMetadata)
{
existing.DurationSeconds = metadata.DurationSeconds;
existing.Title = metadata.Title;
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
_imageRepository.AddTag,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false));
return await _metadataRepository.Update(existing) || updated;
}
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.ImageId = image.Id;
image.ImageMetadata = [metadata];
return await _metadataRepository.Add(metadata);
}
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{

8
ErsatzTV.Scanner/Core/Metadata/SongFolderScanner.cs

@ -144,7 +144,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -144,7 +144,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(UpdateMetadata)
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
.BindT(FlagNormal);
@ -217,9 +217,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -217,9 +217,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
}
}
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(
MediaItemScanResult<Song> result,
string ffprobePath)
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(MediaItemScanResult<Song> result)
{
try
{
@ -236,7 +234,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -236,7 +234,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
song.SongMetadata ??= new List<SongMetadata>();
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
if (await _localMetadataProvider.RefreshTagMetadata(song, ffprobePath))
if (await _localMetadataProvider.RefreshTagMetadata(song))
{
result.IsUpdated = true;
}

2
ErsatzTV.Scanner/Program.cs

@ -172,6 +172,7 @@ public class Program @@ -172,6 +172,7 @@ public class Program
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ISongRepository, SongRepository>();
services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<ICachingSearchRepository, CachingSearchRepository>();
@ -186,6 +187,7 @@ public class Program @@ -186,6 +187,7 @@ public class Program
services.AddScoped<IMusicVideoFolderScanner, MusicVideoFolderScanner>();
services.AddScoped<IOtherVideoFolderScanner, OtherVideoFolderScanner>();
services.AddScoped<ISongFolderScanner, SongFolderScanner>();
services.AddScoped<IImageFolderScanner, ImageFolderScanner>();
services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>();
services.AddScoped<IMovieNfoReader, MovieNfoReader>();
services.AddScoped<IArtistNfoReader, ArtistNfoReader>();

45
ErsatzTV/Pages/CollectionItems.razor

@ -66,6 +66,12 @@ @@ -66,6 +66,12 @@
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#songs")">@_data.SongCards.Count Songs</MudLink>
}
@if (_data?.ImageCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink>
}
@if (SupportsCustomOrdering())
{
<div style="margin-left: auto">
@ -275,6 +281,30 @@ @@ -275,6 +281,30 @@
}
</MudContainer>
}
@if (_data?.ImageCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Images
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ImageCardViewModel card in _data.ImageCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveImageFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@ -452,6 +482,19 @@ @@ -452,6 +482,19 @@
await RemoveItemsWithConfirmation("song", $"{song.Title}", request);
}
}
private async Task RemoveImageFromCollection(MediaCardViewModel vm)
{
if (vm is ImageCardViewModel image)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [image.ImageId]
};
await RemoveItemsWithConfirmation("image", $"{image.Title}", request);
}
}
private async Task RemoveItemsWithConfirmation(
string entityType,
@ -461,7 +504,7 @@ @@ -461,7 +504,7 @@
var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<RemoveFromCollectionDialog>("Remove From Collection", parameters, options);
IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>("Remove From Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{

155
ErsatzTV/Pages/ImageList.razor

@ -0,0 +1,155 @@ @@ -0,0 +1,155 @@
@page "/media/images"
@page "/media/images/page/{PageNumber:int}"
@using ErsatzTV.Extensions
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@inherits MultiSelectBase<ImageList>
@inject NavigationManager NavigationManager
<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="ImageCardViewModel" 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 is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/images"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private ImageCardResultsViewModel _data = new(0, new List<ImageCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:image" : $"type:image AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexImages(searchQuery, PageNumber, PageSize), CancellationToken);
}
private void PrevPage()
{
var uri = $"media/images/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 = $"media/images/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 ImageCardViewModel image)
{
var parameters = new DialogParameters { { "EntityType", "image" }, { "EntityName", image.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.Canceled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddImageToCollection(collection.Id, image.ImageId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding image to collection: {error.Value}");
Logger.LogError("Unexpected error adding image to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

2
ErsatzTV/Pages/LocalLibraryEditor.razor

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Disabled="IsEdit" Label="MediaKind" @bind-Value="_model.MediaKind" For="@(() => _model.MediaKind)">
<MudSelect Disabled="IsEdit" Label="Media Kind" @bind-Value="_model.MediaKind" For="@(() => _model.MediaKind)">
@foreach (LibraryMediaKind mediaKind in Enum.GetValues<LibraryMediaKind>())
{
<MudSelectItem Value="@mediaKind">@mediaKind</MudSelectItem>

9
ErsatzTV/Pages/MultiSelectBase.cs

@ -18,7 +18,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase @@ -18,7 +18,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase
public MultiSelectBase()
{
_recentlySelected = None;
SelectedItems = new System.Collections.Generic.HashSet<MediaCardViewModel>();
SelectedItems = [];
}
[Inject]
@ -89,7 +89,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase @@ -89,7 +89,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList());
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
@ -100,6 +101,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase @@ -100,6 +101,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase
List<int> musicVideoIds,
List<int> otherVideoIds,
List<int> songIds,
List<int> imageIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
@ -123,7 +125,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase @@ -123,7 +125,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase
artistIds,
musicVideoIds,
otherVideoIds,
songIds);
songIds,
imageIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(

9
ErsatzTV/Pages/OtherVideoList.razor

@ -5,8 +5,7 @@ @@ -5,8 +5,7 @@
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@inherits MultiSelectBase<OtherVideoList>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
@inject NavigationManager NavigationManager
<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">
@ -86,7 +85,7 @@ @@ -86,7 +85,7 @@
PageNumber = 1;
}
_query = _navigationManager.Uri.GetSearchQuery();
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData();
}
@ -105,7 +104,7 @@ @@ -105,7 +104,7 @@
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
NavigationManager.NavigateTo(uri);
}
private void NextPage()
@ -116,7 +115,7 @@ @@ -116,7 +115,7 @@
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)

55
ErsatzTV/Pages/Search.razor

@ -72,6 +72,11 @@ @@ -72,6 +72,11 @@
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#songs")" Style="margin-bottom: auto; margin-top: auto">@_songs.Count Songs</MudLink>
}
if (_images?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink>
}
<div style="margin-left: auto">
<MudTooltip Text="Add All To Collection">
<MudButton Variant="Variant.Filled"
@ -314,6 +319,34 @@ @@ -314,6 +319,34 @@
}
</MudContainer>
}
@if (_images?.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", "images" } })">
Images
</MudText>
@if (_images.Count > 50)
{
<MudLink Href="@GetImagesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ImageCardViewModel card in _images.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 {
@ -325,6 +358,7 @@ @@ -325,6 +358,7 @@
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images;
private ArtistCardResultsViewModel _artists;
private PersistingComponentStateSubscription _persistingSubscription;
@ -398,6 +432,15 @@ @@ -398,6 +432,15 @@
_songs = restoredSongs;
}
if (!ApplicationState.TryTakeFromJson("_images", out ImageCardResultsViewModel restoredImages))
{
_images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), CancellationToken);
}
else
{
_images = restoredImages;
}
if (!ApplicationState.TryTakeFromJson("_artists", out ArtistCardResultsViewModel restoredArtists))
{
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken);
@ -685,6 +728,17 @@ @@ -685,6 +728,17 @@
return uri;
}
private string GetImagesLink()
{
var uri = "media/images/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), CancellationToken);
@ -697,6 +751,7 @@ @@ -697,6 +751,7 @@
results.MusicVideoIds,
results.OtherVideoIds,
results.SongIds,
results.ImageIds,
"search results");
}

17
ErsatzTV/Pages/Trash.razor

@ -5,7 +5,6 @@ @@ -5,7 +5,6 @@
@using ErsatzTV.Application.Maintenance
@inherits MultiSelectBase<Search>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@ -70,6 +69,11 @@ @@ -70,6 +69,11 @@
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#songs")" Style="margin-bottom: auto; margin-top: auto">@_songs.Count Songs</MudLink>
}
if (_images?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink>
}
if (IsNotEmpty)
{
<div style="margin-left: auto">
@ -328,6 +332,7 @@ @@ -328,6 +332,7 @@
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images;
private ArtistCardResultsViewModel _artists;
protected override Task OnInitializedAsync() => RefreshData();
@ -344,6 +349,7 @@ @@ -344,6 +349,7 @@
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50), CancellationToken);
_otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50), CancellationToken);
_songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50), CancellationToken);
_images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), CancellationToken);
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken);
}
}
@ -465,7 +471,8 @@ @@ -465,7 +471,8 @@
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList());
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList());
private async Task DeleteItemsFromDatabase(
List<int> movieIds,
@ -476,16 +483,17 @@ @@ -476,16 +483,17 @@
List<int> musicVideoIds,
List<int> otherVideoIds,
List<int> songIds,
List<int> imageIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count + otherVideoIds.Count + songIds.Count;
musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
IDialogReference dialog = await Dialog.ShowAsync<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
@ -497,6 +505,7 @@ @@ -497,6 +505,7 @@
.Append(musicVideoIds)
.Append(otherVideoIds)
.Append(songIds)
.Append(imageIds)
.ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);

1
ErsatzTV/Shared/MainLayout.razor

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

1
ErsatzTV/Startup.cs

@ -608,6 +608,7 @@ public class Startup @@ -608,6 +608,7 @@ public class Startup
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ISongRepository, SongRepository>();
services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IArtworkRepository, ArtworkRepository>();

Loading…
Cancel
Save