mirror of https://github.com/ErsatzTV/ErsatzTV.git
66 changed files with 22706 additions and 83 deletions
@ -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); |
||||
@ -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; } |
||||
} |
||||
@ -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>>; |
||||
@ -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); |
||||
} |
||||
@ -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>; |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
@ -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,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); |
||||
} |
||||
|
||||
@ -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); |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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()); |
||||
} |
||||
} |
||||
} |
||||
@ -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)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue