add configurable image duration (#1617)
This commit is contained in:
+3
-2
@@ -23,8 +23,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- The templates contain comments describing which fields are available for use in the templates
|
- The templates contain comments describing which fields are available for use in the templates
|
||||||
- Add *experimental* and *incomplete* `Images` library kind
|
- Add *experimental* and *incomplete* `Images` library kind
|
||||||
- Image libraries have fallback metadata added like Other Video libraries (every folder is a tag)
|
- 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
|
- Image library items currently default to a duration of 15 seconds
|
||||||
- Future updates will allow custom/distinct durations
|
- The `Media` > `Images` page can be used to configure image durations at a folder level
|
||||||
|
- Child folders with unset durations will inherit the closest ancestor's duration
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fix antiforgery error caused by reusing existing browser tabs across docker container restarts
|
- Fix antiforgery error caused by reusing existing browser tabs across docker container restarts
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Images;
|
||||||
|
|
||||||
|
public record UpdateImageFolderDuration(int LibraryFolderId, int? ImageFolderDuration) : IRequest;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Images;
|
||||||
|
|
||||||
|
public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||||
|
: IRequestHandler<UpdateImageFolderDuration>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateImageFolderDuration request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
// delete entry if null
|
||||||
|
if (request.ImageFolderDuration is null)
|
||||||
|
{
|
||||||
|
await dbContext.ImageFolderDurations
|
||||||
|
.Filter(ifd => ifd.LibraryFolderId == request.LibraryFolderId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
// upsert if non-null
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Option<ImageFolderDuration> maybeExisting = await dbContext.ImageFolderDurations
|
||||||
|
.SelectOneAsync(ifd => ifd.LibraryFolderId, ifd => ifd.LibraryFolderId == request.LibraryFolderId);
|
||||||
|
|
||||||
|
if (maybeExisting.IsNone)
|
||||||
|
{
|
||||||
|
var entry = new ImageFolderDuration
|
||||||
|
{
|
||||||
|
LibraryFolderId = request.LibraryFolderId
|
||||||
|
};
|
||||||
|
|
||||||
|
maybeExisting = entry;
|
||||||
|
|
||||||
|
await dbContext.ImageFolderDurations.AddAsync(entry, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (ImageFolderDuration existing in maybeExisting)
|
||||||
|
{
|
||||||
|
existing.DurationSeconds = request.ImageFolderDuration.Value;
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update all images (bfs) starting at this folder
|
||||||
|
Option<LibraryFolder> maybeFolder = await dbContext.LibraryFolders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(lf => lf.ImageFolderDuration)
|
||||||
|
.SelectOneAsync(lf => lf.Id, lf => lf.Id == request.LibraryFolderId);
|
||||||
|
|
||||||
|
var queue = new Queue<FolderWithParentDuration>();
|
||||||
|
foreach (LibraryFolder libraryFolder in maybeFolder)
|
||||||
|
{
|
||||||
|
LibraryFolder currentFolder = libraryFolder;
|
||||||
|
|
||||||
|
// walk up to get duration, if needed
|
||||||
|
int? durationSeconds = currentFolder.ImageFolderDuration?.DurationSeconds;
|
||||||
|
while (durationSeconds is null && currentFolder?.ParentId is not null)
|
||||||
|
{
|
||||||
|
Option<LibraryFolder> maybeParent = await dbContext.LibraryFolders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(lf => lf.ImageFolderDuration)
|
||||||
|
.SelectOneAsync(lf => lf.Id, lf => lf.Id == currentFolder.ParentId);
|
||||||
|
|
||||||
|
if (maybeParent.IsNone)
|
||||||
|
{
|
||||||
|
currentFolder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryFolder parent in maybeParent)
|
||||||
|
{
|
||||||
|
currentFolder = parent;
|
||||||
|
durationSeconds = currentFolder.ImageFolderDuration?.DurationSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.Enqueue(new FolderWithParentDuration(libraryFolder, durationSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
(LibraryFolder currentFolder, int? parentDuration) = queue.Dequeue();
|
||||||
|
int? effectiveDuration = currentFolder.ImageFolderDuration?.DurationSeconds ?? parentDuration;
|
||||||
|
|
||||||
|
// Serilog.Log.Logger.Information(
|
||||||
|
// "Updating folder {Id} with parent duration {ParentDuration}, effective duration {EffectiveDuration}",
|
||||||
|
// currentFolder.Id,
|
||||||
|
// parentDuration,
|
||||||
|
// effectiveDuration);
|
||||||
|
|
||||||
|
// update all images in this folder
|
||||||
|
await dbContext.ImageMetadata
|
||||||
|
.Filter(
|
||||||
|
im => im.Image.MediaVersions.Any(
|
||||||
|
mv => mv.MediaFiles.Any(mf => mf.LibraryFolderId == currentFolder.Id)))
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(im => im.DurationSeconds, effectiveDuration),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
List<LibraryFolder> children = await dbContext.LibraryFolders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Filter(lf => lf.ParentId == currentFolder.Id)
|
||||||
|
.Include(lf => lf.ImageFolderDuration)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// queue all children
|
||||||
|
foreach (LibraryFolder child in children)
|
||||||
|
{
|
||||||
|
queue.Enqueue(new FolderWithParentDuration(child, effectiveDuration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FolderWithParentDuration(LibraryFolder LibraryFolder, int? ParentDuration);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ErsatzTV.Application.Images;
|
||||||
|
|
||||||
|
public record ImageFolderViewModel(
|
||||||
|
int LibraryFolderId,
|
||||||
|
string Name,
|
||||||
|
string FullPath,
|
||||||
|
int SubfolderCount,
|
||||||
|
int ImageCount,
|
||||||
|
Option<int> DurationSeconds);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Images;
|
||||||
|
|
||||||
|
public static class Mapper
|
||||||
|
{
|
||||||
|
public static ImageFolderViewModel ProjectToViewModel(
|
||||||
|
LibraryFolder libraryFolder,
|
||||||
|
int childCount,
|
||||||
|
int imageCount) =>
|
||||||
|
new(
|
||||||
|
libraryFolder.Id,
|
||||||
|
new DirectoryInfo(libraryFolder.Path).Name,
|
||||||
|
libraryFolder.Path,
|
||||||
|
childCount,
|
||||||
|
imageCount,
|
||||||
|
libraryFolder.ImageFolderDuration?.DurationSeconds ?? Option<int>.None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Images;
|
||||||
|
|
||||||
|
public record GetImageFolders(Option<int> LibraryFolderId) : IRequest<List<ImageFolderViewModel>>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Images;
|
||||||
|
|
||||||
|
public class GetImageFoldersHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||||
|
: IRequestHandler<GetImageFolders, List<ImageFolderViewModel>>
|
||||||
|
{
|
||||||
|
public async Task<List<ImageFolderViewModel>> Handle(GetImageFolders request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
// default to returning top-level folders
|
||||||
|
int? parentId = null;
|
||||||
|
|
||||||
|
// if a specific folder is requested, return its children
|
||||||
|
foreach (int libraryFolderId in request.LibraryFolderId)
|
||||||
|
{
|
||||||
|
parentId = libraryFolderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LibraryFolder> folders = await dbContext.LibraryFolders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(lf => lf.ImageFolderDuration)
|
||||||
|
.Filter(lf => lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images)
|
||||||
|
.Filter(lf => lf.ParentId == parentId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<ImageFolderViewModel>();
|
||||||
|
|
||||||
|
foreach (LibraryFolder folder in folders)
|
||||||
|
{
|
||||||
|
// count direct children of this folder
|
||||||
|
int childCount = await dbContext.LibraryFolders
|
||||||
|
.AsNoTracking()
|
||||||
|
.CountAsync(lf => lf.ParentId == folder.Id, cancellationToken);
|
||||||
|
|
||||||
|
// count all child images (any level)
|
||||||
|
int imageCount = await dbContext.MediaFiles
|
||||||
|
.AsNoTracking()
|
||||||
|
.CountAsync(mf => mf.Path.StartsWith(folder.Path), cancellationToken);
|
||||||
|
|
||||||
|
result.Add(Mapper.ProjectToViewModel(folder, childCount, imageCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,6 @@ public class LibraryFolder
|
|||||||
public LibraryFolder Parent { get; set; }
|
public LibraryFolder Parent { get; set; }
|
||||||
public ICollection<LibraryFolder> Children { get; set; }
|
public ICollection<LibraryFolder> Children { get; set; }
|
||||||
public ICollection<MediaFile> MediaFiles { get; set; }
|
public ICollection<MediaFile> MediaFiles { get; set; }
|
||||||
|
public ImageFolderDuration ImageFolderDuration { get; set; }
|
||||||
public string Etag { get; set; }
|
public string Etag { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
public class ImageFolderDuration
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int LibraryFolderId { get; set; }
|
||||||
|
public LibraryFolder LibraryFolder { get; set; }
|
||||||
|
public int DurationSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -349,9 +349,9 @@ public class BlockPlayoutBuilder(
|
|||||||
|
|
||||||
private static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
private static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
||||||
{
|
{
|
||||||
if (mediaItem is Image)
|
if (mediaItem is Image image)
|
||||||
{
|
{
|
||||||
return TimeSpan.FromSeconds(Domain.Image.DefaultSeconds);
|
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaVersion version = mediaItem.GetHeadVersion();
|
MediaVersion version = mediaItem.GetHeadVersion();
|
||||||
|
|||||||
@@ -179,9 +179,9 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
|||||||
|
|
||||||
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
||||||
{
|
{
|
||||||
if (mediaItem is Image)
|
if (mediaItem is Image image)
|
||||||
{
|
{
|
||||||
return TimeSpan.FromSeconds(Domain.Image.DefaultSeconds);
|
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaVersion version = mediaItem.GetHeadVersion();
|
MediaVersion version = mediaItem.GetHeadVersion();
|
||||||
|
|||||||
Generated
+5166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_ImageFolderDuration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ImageFolderDuration",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
LibraryFolderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
DurationSeconds = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ImageFolderDuration", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ImageFolderDuration_LibraryFolder_LibraryFolderId",
|
||||||
|
column: x => x.LibraryFolderId,
|
||||||
|
principalTable: "LibraryFolder",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ImageFolderDuration_LibraryFolderId",
|
||||||
|
table: "ImageFolderDuration",
|
||||||
|
column: "LibraryFolderId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ImageFolderDuration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -733,6 +733,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
|||||||
b.ToTable("Genre");
|
b.ToTable("Genre");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("DurationSeconds")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("LibraryFolderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryFolderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ImageFolderDuration", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3426,6 +3446,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ErsatzTV.Core.Domain.LibraryFolder", "LibraryFolder")
|
||||||
|
.WithOne("ImageFolderDuration")
|
||||||
|
.HasForeignKey("ErsatzTV.Core.Domain.ImageFolderDuration", "LibraryFolderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("LibraryFolder");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ErsatzTV.Core.Domain.Image", "Image")
|
b.HasOne("ErsatzTV.Core.Domain.Image", "Image")
|
||||||
@@ -4815,6 +4845,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Children");
|
b.Navigation("Children");
|
||||||
|
|
||||||
|
b.Navigation("ImageFolderDuration");
|
||||||
|
|
||||||
b.Navigation("MediaFiles");
|
b.Navigation("MediaFiles");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,11 @@ public class LibraryFolderConfiguration : IEntityTypeConfiguration<LibraryFolder
|
|||||||
.HasForeignKey(f => f.ParentId)
|
.HasForeignKey(f => f.ParentId)
|
||||||
.IsRequired(false)
|
.IsRequired(false)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.ImageFolderDuration)
|
||||||
|
.WithOne(ifd => ifd.LibraryFolder)
|
||||||
|
.HasForeignKey<ImageFolderDuration>(ifd => ifd.LibraryFolderId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||||
|
|
||||||
|
public class ImageFolderDurationConfiguration : IEntityTypeConfiguration<ImageFolderDuration>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ImageFolderDuration> builder) => builder.ToTable("ImageFolderDuration");
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public class LibraryRepository : ILibraryRepository
|
|||||||
return await dbContext.Libraries
|
return await dbContext.Libraries
|
||||||
.Include(l => l.Paths)
|
.Include(l => l.Paths)
|
||||||
.ThenInclude(p => p.LibraryFolders)
|
.ThenInclude(p => p.LibraryFolders)
|
||||||
|
.ThenInclude(lf => lf.ImageFolderDuration)
|
||||||
.OrderBy(l => l.Id)
|
.OrderBy(l => l.Id)
|
||||||
.SingleOrDefaultAsync(l => l.Id == libraryId)
|
.SingleOrDefaultAsync(l => l.Id == libraryId)
|
||||||
.Map(Optional);
|
.Map(Optional);
|
||||||
@@ -185,7 +186,7 @@ public class LibraryRepository : ILibraryRepository
|
|||||||
await dbContext.LibraryFolders.AddAsync(knownFolder);
|
await dbContext.LibraryFolders.AddAsync(knownFolder);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return knownFolder;
|
return knownFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class MediaItemRepository : IMediaItemRepository
|
|||||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||||
@"SELECT M.Id
|
@"SELECT M.Id
|
||||||
FROM MediaItem M
|
FROM MediaItem M
|
||||||
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, ImageId, EpisodeId)
|
||||||
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
||||||
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||||
new { LibraryPathId = libraryPath.Id, Path = path })
|
new { LibraryPathId = libraryPath.Id, Path = path })
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ public class SearchRepository : ISearchRepository
|
|||||||
.ThenInclude(mm => mm.Guids)
|
.ThenInclude(mm => mm.Guids)
|
||||||
.Include(mi => (mi as Image).MediaVersions)
|
.Include(mi => (mi as Image).MediaVersions)
|
||||||
.ThenInclude(mm => mm.Streams)
|
.ThenInclude(mm => mm.Streams)
|
||||||
|
.Include(mi => (mi as Image).MediaVersions)
|
||||||
|
.ThenInclude(mm => mm.MediaFiles)
|
||||||
.Include(mi => mi.TraktListItems)
|
.Include(mi => mi.TraktListItems)
|
||||||
.ThenInclude(tli => tli.TraktList)
|
.ThenInclude(tli => tli.TraktList)
|
||||||
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
|
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
|
||||||
@@ -371,6 +373,8 @@ public class SearchRepository : ISearchRepository
|
|||||||
.ThenInclude(mm => mm.Guids)
|
.ThenInclude(mm => mm.Guids)
|
||||||
.Include(mi => (mi as Image).MediaVersions)
|
.Include(mi => (mi as Image).MediaVersions)
|
||||||
.ThenInclude(mm => mm.Streams)
|
.ThenInclude(mm => mm.Streams)
|
||||||
|
.Include(mi => (mi as Image).MediaVersions)
|
||||||
|
.ThenInclude(mm => mm.MediaFiles)
|
||||||
.Include(mi => mi.TraktListItems)
|
.Include(mi => mi.TraktListItems)
|
||||||
.ThenInclude(tli => tli.TraktList)
|
.ThenInclude(tli => tli.TraktList)
|
||||||
.AsAsyncEnumerable();
|
.AsAsyncEnumerable();
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public class TvContext : DbContext
|
|||||||
public DbSet<SongMetadata> SongMetadata { get; set; }
|
public DbSet<SongMetadata> SongMetadata { get; set; }
|
||||||
public DbSet<Image> Images { get; set; }
|
public DbSet<Image> Images { get; set; }
|
||||||
public DbSet<ImageMetadata> ImageMetadata { get; set; }
|
public DbSet<ImageMetadata> ImageMetadata { get; set; }
|
||||||
|
public DbSet<ImageFolderDuration> ImageFolderDurations { get; set; }
|
||||||
public DbSet<Show> Shows { get; set; }
|
public DbSet<Show> Shows { get; set; }
|
||||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||||
public DbSet<Season> Seasons { get; set; }
|
public DbSet<Season> Seasons { get; set; }
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ public class ElasticSearchIndex : ISearchIndex
|
|||||||
case Song song:
|
case Song song:
|
||||||
await UpdateSong(searchRepository, song);
|
await UpdateSong(searchRepository, song);
|
||||||
break;
|
break;
|
||||||
|
case Image image:
|
||||||
|
await UpdateImage(searchRepository, image);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,6 +728,59 @@ public class ElasticSearchIndex : ISearchIndex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task UpdateImage(ISearchRepository searchRepository, Image image)
|
||||||
|
{
|
||||||
|
foreach (ImageMetadata metadata in image.ImageMetadata.HeadOrNone())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = new ElasticSearchItem
|
||||||
|
{
|
||||||
|
Id = image.Id,
|
||||||
|
Type = LuceneSearchIndex.ImageType,
|
||||||
|
Title = metadata.Title,
|
||||||
|
SortTitle = metadata.SortTitle.ToLowerInvariant(),
|
||||||
|
LibraryName = image.LibraryPath.Library.Name,
|
||||||
|
LibraryId = image.LibraryPath.Library.Id,
|
||||||
|
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
|
||||||
|
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
|
||||||
|
State = image.State.ToString(),
|
||||||
|
MetadataKind = metadata.MetadataKind.ToString(),
|
||||||
|
Language = await GetLanguages(searchRepository, image.MediaVersions),
|
||||||
|
LanguageTag = GetLanguageTags(image.MediaVersions),
|
||||||
|
SubLanguage = await GetSubLanguages(searchRepository, image.MediaVersions),
|
||||||
|
SubLanguageTag = GetSubLanguageTags(image.MediaVersions),
|
||||||
|
AddedDate = GetAddedDate(metadata.DateAdded),
|
||||||
|
Genre = metadata.Genres.Map(g => g.Name).ToList(),
|
||||||
|
Tag = metadata.Tags.Map(t => t.Name).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
IEnumerable<int> libraryFolderIds = image.MediaVersions
|
||||||
|
.SelectMany(mv => mv.MediaFiles)
|
||||||
|
.SelectMany(mf => Optional(mf.LibraryFolderId));
|
||||||
|
|
||||||
|
foreach (int libraryFolderId in libraryFolderIds)
|
||||||
|
{
|
||||||
|
doc.LibraryFolderId = libraryFolderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddStatistics(doc, image.MediaVersions);
|
||||||
|
|
||||||
|
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
|
||||||
|
{
|
||||||
|
doc.AdditionalProperties.Add(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _client.IndexAsync(doc, IndexName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
metadata.Image = null;
|
||||||
|
_logger.LogWarning(ex, "Error indexing image with metadata {@Metadata}", metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetReleaseDate(DateTime? metadataReleaseDate) =>
|
private static string GetReleaseDate(DateTime? metadataReleaseDate) =>
|
||||||
metadataReleaseDate?.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
metadataReleaseDate?.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
|||||||
internal const string PlotField = "plot";
|
internal const string PlotField = "plot";
|
||||||
internal const string LibraryNameField = "library_name";
|
internal const string LibraryNameField = "library_name";
|
||||||
internal const string LibraryIdField = "library_id";
|
internal const string LibraryIdField = "library_id";
|
||||||
|
internal const string LibraryFolderIdField = "library_folder_id";
|
||||||
internal const string TitleAndYearField = "title_and_year";
|
internal const string TitleAndYearField = "title_and_year";
|
||||||
internal const string JumpLetterField = "jump_letter";
|
internal const string JumpLetterField = "jump_letter";
|
||||||
internal const string StudioField = "studio";
|
internal const string StudioField = "studio";
|
||||||
@@ -1303,6 +1304,19 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
|||||||
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
IEnumerable<int> libraryFolderIds = image.MediaVersions
|
||||||
|
.SelectMany(mv => mv.MediaFiles)
|
||||||
|
.SelectMany(mf => Optional(mf.LibraryFolderId));
|
||||||
|
|
||||||
|
foreach (int libraryFolderId in libraryFolderIds)
|
||||||
|
{
|
||||||
|
doc.Add(
|
||||||
|
new StringField(
|
||||||
|
LibraryFolderIdField,
|
||||||
|
libraryFolderId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Field.Store.NO));
|
||||||
|
}
|
||||||
|
|
||||||
await AddLanguages(searchRepository, doc, image.MediaVersions);
|
await AddLanguages(searchRepository, doc, image.MediaVersions);
|
||||||
|
|
||||||
AddStatistics(doc, image.MediaVersions);
|
AddStatistics(doc, image.MediaVersions);
|
||||||
|
|||||||
@@ -123,4 +123,7 @@ public class ElasticSearchItem : MinimalElasticSearchItem
|
|||||||
|
|
||||||
[JsonPropertyName(LuceneSearchIndex.MoodField)]
|
[JsonPropertyName(LuceneSearchIndex.MoodField)]
|
||||||
public List<string> Mood { get; set; }
|
public List<string> Mood { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName(LuceneSearchIndex.LibraryFolderIdField)]
|
||||||
|
public int LibraryFolderId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public interface ILocalMetadataProvider
|
|||||||
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
||||||
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
|
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
|
||||||
Task<bool> RefreshTagMetadata(Song song);
|
Task<bool> RefreshTagMetadata(Song song);
|
||||||
Task<bool> RefreshTagMetadata(Image image);
|
Task<bool> RefreshTagMetadata(Image image, int? durationSeconds);
|
||||||
Task<bool> RefreshFallbackMetadata(Movie movie);
|
Task<bool> RefreshFallbackMetadata(Movie movie);
|
||||||
Task<bool> RefreshFallbackMetadata(Episode episode);
|
Task<bool> RefreshFallbackMetadata(Episode episode);
|
||||||
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
|
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
|
||||||
|
|||||||
@@ -144,7 +144,27 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walk up to get duration, if needed
|
||||||
|
LibraryFolder? currentFolder = knownFolder;
|
||||||
|
int? durationSeconds = currentFolder.ImageFolderDuration?.DurationSeconds;
|
||||||
|
while (durationSeconds is null && currentFolder?.ParentId is not null)
|
||||||
|
{
|
||||||
|
Option<LibraryFolder> maybeParent = libraryPath.LibraryFolders
|
||||||
|
.Find(lf => lf.Id == currentFolder.ParentId);
|
||||||
|
|
||||||
|
if (maybeParent.IsNone)
|
||||||
|
{
|
||||||
|
currentFolder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryFolder parent in maybeParent)
|
||||||
|
{
|
||||||
|
currentFolder = parent;
|
||||||
|
durationSeconds = currentFolder.ImageFolderDuration?.DurationSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogDebug("UPDATE: Etag has changed for folder {Folder}", imageFolder);
|
_logger.LogDebug("UPDATE: Etag has changed for folder {Folder}", imageFolder);
|
||||||
|
|
||||||
var hasErrors = false;
|
var hasErrors = false;
|
||||||
@@ -155,7 +175,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
|||||||
.GetOrAdd(libraryPath, knownFolder, file)
|
.GetOrAdd(libraryPath, knownFolder, file)
|
||||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||||
.BindT(video => UpdateLibraryFolderId(video, knownFolder))
|
.BindT(video => UpdateLibraryFolderId(video, knownFolder))
|
||||||
.BindT(UpdateMetadata)
|
.BindT(video => UpdateMetadata(video, durationSeconds))
|
||||||
//.BindT(video => UpdateThumbnail(video, cancellationToken))
|
//.BindT(video => UpdateThumbnail(video, cancellationToken))
|
||||||
//.BindT(UpdateSubtitles)
|
//.BindT(UpdateSubtitles)
|
||||||
.BindT(FlagNormal);
|
.BindT(FlagNormal);
|
||||||
@@ -244,7 +264,8 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, MediaItemScanResult<Image>>> UpdateMetadata(
|
private async Task<Either<BaseError, MediaItemScanResult<Image>>> UpdateMetadata(
|
||||||
MediaItemScanResult<Image> result)
|
MediaItemScanResult<Image> result,
|
||||||
|
int? durationSeconds)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -253,7 +274,8 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
|||||||
|
|
||||||
bool shouldUpdate = Optional(image.ImageMetadata).Flatten().HeadOrNone().Match(
|
bool shouldUpdate = Optional(image.ImageMetadata).Flatten().HeadOrNone().Match(
|
||||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||||
m.DateUpdated != _localFileSystem.GetLastWriteTime(path),
|
m.DateUpdated != _localFileSystem.GetLastWriteTime(path) ||
|
||||||
|
m.DurationSeconds != durationSeconds,
|
||||||
true);
|
true);
|
||||||
|
|
||||||
if (shouldUpdate)
|
if (shouldUpdate)
|
||||||
@@ -261,7 +283,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
|||||||
image.ImageMetadata ??= [];
|
image.ImageMetadata ??= [];
|
||||||
|
|
||||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
|
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
|
||||||
if (await _localMetadataProvider.RefreshTagMetadata(image))
|
if (await _localMetadataProvider.RefreshTagMetadata(image, durationSeconds))
|
||||||
{
|
{
|
||||||
result.IsUpdated = true;
|
result.IsUpdated = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
|||||||
return await RefreshFallbackMetadata(song);
|
return await RefreshFallbackMetadata(song);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> RefreshTagMetadata(Image image)
|
public async Task<bool> RefreshTagMetadata(Image image, int? durationSeconds)
|
||||||
{
|
{
|
||||||
Option<ImageMetadata> maybeMetadata = LoadImageMetadata(image);
|
Option<ImageMetadata> maybeMetadata = LoadImageMetadata(image, durationSeconds);
|
||||||
foreach (ImageMetadata metadata in maybeMetadata)
|
foreach (ImageMetadata metadata in maybeMetadata)
|
||||||
{
|
{
|
||||||
return await ApplyMetadataUpdate(image, metadata);
|
return await ApplyMetadataUpdate(image, metadata);
|
||||||
@@ -406,7 +406,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Option<ImageMetadata> LoadImageMetadata(Image image)
|
private Option<ImageMetadata> LoadImageMetadata(Image image, int? durationSeconds)
|
||||||
{
|
{
|
||||||
string path = image.GetHeadVersion().MediaFiles.Head().Path;
|
string path = image.GetHeadVersion().MediaFiles.Head().Path;
|
||||||
|
|
||||||
@@ -423,6 +423,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
|||||||
MetadataKind = MetadataKind.Embedded,
|
MetadataKind = MetadataKind.Embedded,
|
||||||
DateAdded = DateTime.UtcNow,
|
DateAdded = DateTime.UtcNow,
|
||||||
DateUpdated = File.GetLastWriteTimeUtc(path),
|
DateUpdated = File.GetLastWriteTimeUtc(path),
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
|
||||||
Artwork = [],
|
Artwork = [],
|
||||||
Actors = [],
|
Actors = [],
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
@page "/media/browser/images"
|
||||||
|
@using S=System.Collections.Generic
|
||||||
|
@using ErsatzTV.Application.Images
|
||||||
|
@using System.Net
|
||||||
|
@implements IDisposable
|
||||||
|
@inject IDialogService Dialog
|
||||||
|
@inject IMediator Mediator
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="8">
|
||||||
|
<MudCard>
|
||||||
|
<MudTreeView ServerData="LoadServerData" Items="@TreeItems" Hover="true" ExpandOnClick="true">
|
||||||
|
<ItemTemplate Context="item">
|
||||||
|
<MudTreeViewItem Items="@item.TreeItems" Icon="@item.Icon" CanExpand="@item.CanExpand" Value="@item">
|
||||||
|
<BodyContent>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||||
|
<div style="justify-self: start;">
|
||||||
|
<MudText>@item.Text</MudText>
|
||||||
|
</div>
|
||||||
|
<div style="justify-self: end;">
|
||||||
|
<span>@item.EndText</span>
|
||||||
|
<MudTooltip Text="Edit Image Folder Duration" ShowOnHover="true" ShowOnClick="false" ShowOnFocus="false">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||||
|
OnClick="@(_ => EditImageFolderDuration(item))">
|
||||||
|
</MudIconButton>
|
||||||
|
</MudTooltip>
|
||||||
|
@{
|
||||||
|
string query = GetSearchQuery(item);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
<MudIconButton
|
||||||
|
Icon="@Icons.Material.Filled.Search"
|
||||||
|
Link="@($"search?query={query}")"/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BodyContent>
|
||||||
|
</MudTreeViewItem>
|
||||||
|
</ItemTemplate>
|
||||||
|
</MudTreeView>
|
||||||
|
</MudCard>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private S.HashSet<ImageTreeItemViewModel> TreeItems { get; set; } = [];
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
await ReloadImageFolders();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadImageFolders()
|
||||||
|
{
|
||||||
|
List<ImageFolderViewModel> imageFolders = await Mediator.Send(new GetImageFolders(Option<int>.None), _cts.Token);
|
||||||
|
TreeItems = imageFolders.Map(g => new ImageTreeItemViewModel(g)).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<S.HashSet<ImageTreeItemViewModel>> LoadServerData(ImageTreeItemViewModel parentNode)
|
||||||
|
{
|
||||||
|
List<ImageFolderViewModel> result = await Mediator.Send(new GetImageFolders(parentNode.LibraryFolderId), _cts.Token);
|
||||||
|
foreach (ImageFolderViewModel imageFolder in result)
|
||||||
|
{
|
||||||
|
parentNode.TreeItems.Add(new ImageTreeItemViewModel(imageFolder));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentNode.TreeItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSearchQuery(ImageTreeItemViewModel item)
|
||||||
|
{
|
||||||
|
var query = $"library_folder_id:{item.LibraryFolderId}";
|
||||||
|
return WebUtility.UrlEncode(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EditImageFolderDuration(ImageTreeItemViewModel item)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters { { "ImageFolderDuration", item.ImageFolderDuration } };
|
||||||
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
|
||||||
|
|
||||||
|
IDialogReference dialog = await Dialog.ShowAsync<EditImageFolderDurationDialog>("Edit Image Folder Duration", parameters, options);
|
||||||
|
DialogResult result = await dialog.Result;
|
||||||
|
if (!result.Canceled)
|
||||||
|
{
|
||||||
|
var duration = result.Data as int?;
|
||||||
|
await Mediator.Send(new UpdateImageFolderDuration(item.LibraryFolderId, duration), _cts.Token);
|
||||||
|
item.UpdateDuration(duration);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,6 +321,35 @@
|
|||||||
}
|
}
|
||||||
</MudContainer>
|
</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" } })">
|
||||||
|
Songs
|
||||||
|
</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"
|
||||||
|
DeleteClicked="@DeleteItemFromDatabase"
|
||||||
|
SelectColor="@Color.Error"
|
||||||
|
SelectClicked="@(e => SelectClicked(card, e))"
|
||||||
|
IsSelected="@IsSelected(card)"
|
||||||
|
IsSelectMode="@IsSelectMode()"/>
|
||||||
|
}
|
||||||
|
</MudContainer>
|
||||||
|
}
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -355,7 +384,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool IsNotEmpty =>
|
private bool IsNotEmpty =>
|
||||||
_movies?.Count > 0 || _shows?.Count > 0 || _seasons?.Count > 0 || _episodes?.Count > 0 || _musicVideos?.Count > 0 || _otherVideos?.Count > 0 || _songs?.Count > 0 || _artists?.Count > 0;
|
_movies?.Count > 0 || _shows?.Count > 0 || _seasons?.Count > 0 || _episodes?.Count > 0 || _musicVideos?.Count > 0 || _otherVideos?.Count > 0 || _songs?.Count > 0 || _artists?.Count > 0 || _images?.Count > 0;
|
||||||
|
|
||||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -369,6 +398,7 @@
|
|||||||
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
|
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
|
||||||
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
|
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
|
||||||
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
|
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
|
||||||
|
.Append(_images.Cards.OrderBy(i => i.SortTitle))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +493,17 @@
|
|||||||
return uri;
|
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 Task DeleteFromDatabase() => DeleteItemsFromDatabase(
|
private Task DeleteFromDatabase() => DeleteItemsFromDatabase(
|
||||||
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
||||||
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
|
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
|
||||||
@@ -563,6 +604,10 @@
|
|||||||
request = new DeleteItemsFromDatabase(new List<int> { song.SongId });
|
request = new DeleteItemsFromDatabase(new List<int> { song.SongId });
|
||||||
await DeleteItemsWithConfirmation("song", $"{song.Title} ({song.Subtitle})", request);
|
await DeleteItemsWithConfirmation("song", $"{song.Title} ({song.Subtitle})", request);
|
||||||
break;
|
break;
|
||||||
|
case ImageCardViewModel image:
|
||||||
|
request = new DeleteItemsFromDatabase(new List<int> { image.ImageId });
|
||||||
|
await DeleteItemsWithConfirmation("image", $"{image.Title} ({image.Subtitle})", request);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +631,7 @@
|
|||||||
private async Task EmptyTrash()
|
private async Task EmptyTrash()
|
||||||
{
|
{
|
||||||
int count = _movies.Count + _shows.Count + _seasons.Count + _episodes.Count + _artists.Count +
|
int count = _movies.Count + _shows.Count + _seasons.Count + _episodes.Count + _artists.Count +
|
||||||
_musicVideos.Count + _otherVideos.Count + _songs.Count;
|
_musicVideos.Count + _otherVideos.Count + _songs.Count + _images.Count;
|
||||||
|
|
||||||
var parameters = new DialogParameters { { "EntityType", count.ToString() }, { "EntityName", "missing items" } };
|
var parameters = new DialogParameters { { "EntityType", count.ToString() }, { "EntityName", "missing items" } };
|
||||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<MudContainer Class="mb-6">
|
||||||
|
<MudText>
|
||||||
|
Edit the image folder duration
|
||||||
|
</MudText>
|
||||||
|
</MudContainer>
|
||||||
|
<MudTextField Label="Duration" @bind-Value="@_imageDurationSeconds"
|
||||||
|
For="@(() => _imageDurationSeconds)"
|
||||||
|
Adornment="Adornment.End"
|
||||||
|
AdornmentText="seconds"
|
||||||
|
Immediate="true"/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="@(_imageDurationSeconds == ImageFolderDuration)" OnClick="Submit">
|
||||||
|
Save Changes
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int? ImageFolderDuration { get; set; }
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
MudDialogInstance MudDialog { get; set; }
|
||||||
|
|
||||||
|
private int? _imageDurationSeconds;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_imageDurationSeconds = ImageFolderDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Submit() => MudDialog.Close(DialogResult.Ok(_imageDurationSeconds));
|
||||||
|
|
||||||
|
private void Cancel() => MudDialog.Cancel();
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<MudNavLink Href="media/music/artists">Music</MudNavLink>
|
<MudNavLink Href="media/music/artists">Music</MudNavLink>
|
||||||
<MudNavLink Href="media/other/videos">Other Videos</MudNavLink>
|
<MudNavLink Href="media/other/videos">Other Videos</MudNavLink>
|
||||||
<MudNavLink Href="media/music/songs">Songs</MudNavLink>
|
<MudNavLink Href="media/music/songs">Songs</MudNavLink>
|
||||||
<MudNavLink Href="media/images">Images</MudNavLink>
|
<MudNavLink Href="media/browser/images">Images</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
<MudNavGroup Title="Lists" Expanded="true">
|
<MudNavGroup Title="Lists" Expanded="true">
|
||||||
<MudNavLink Href="media/collections">Collections</MudNavLink>
|
<MudNavLink Href="media/collections">Collections</MudNavLink>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ErsatzTV.Application.Images;
|
||||||
|
using MudBlazor;
|
||||||
|
using S = System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.ViewModels;
|
||||||
|
|
||||||
|
public class ImageTreeItemViewModel
|
||||||
|
{
|
||||||
|
private string _imageCount;
|
||||||
|
|
||||||
|
public ImageTreeItemViewModel(ImageFolderViewModel imageFolder)
|
||||||
|
{
|
||||||
|
LibraryFolderId = imageFolder.LibraryFolderId;
|
||||||
|
Text = imageFolder.Name;
|
||||||
|
FullPath = imageFolder.FullPath;
|
||||||
|
TreeItems = [];
|
||||||
|
CanExpand = imageFolder.SubfolderCount > 0;
|
||||||
|
|
||||||
|
_imageCount = imageFolder.ImageCount switch
|
||||||
|
{
|
||||||
|
> 1 => $"{imageFolder.ImageCount} images",
|
||||||
|
1 => "1 image",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (int durationSeconds in imageFolder.DurationSeconds)
|
||||||
|
{
|
||||||
|
ImageFolderDuration = durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDuration(ImageFolderDuration);
|
||||||
|
|
||||||
|
Icon = Icons.Material.Filled.Folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public string EndText { get; private set; }
|
||||||
|
|
||||||
|
public string FullPath { get; }
|
||||||
|
|
||||||
|
public string Icon { get; }
|
||||||
|
|
||||||
|
public int LibraryFolderId { get; }
|
||||||
|
public int? ImageFolderDuration { get; private set; }
|
||||||
|
|
||||||
|
public bool CanExpand { get; }
|
||||||
|
|
||||||
|
public S.HashSet<ImageTreeItemViewModel> TreeItems { get; }
|
||||||
|
|
||||||
|
public void UpdateDuration(int? imageFolderDuration)
|
||||||
|
{
|
||||||
|
ImageFolderDuration = imageFolderDuration;
|
||||||
|
|
||||||
|
string duration = string.Empty;
|
||||||
|
|
||||||
|
foreach (int durationSeconds in Optional(imageFolderDuration))
|
||||||
|
{
|
||||||
|
duration = durationSeconds switch
|
||||||
|
{
|
||||||
|
> 1 => $"{durationSeconds} seconds",
|
||||||
|
1 => "1 second",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_imageCount))
|
||||||
|
{
|
||||||
|
duration += " - ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EndText = $"{duration}{_imageCount}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user