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
|
||||
- Add *experimental* and *incomplete* `Images` library kind
|
||||
- Image libraries have fallback metadata added like Other Video libraries (every folder is a tag)
|
||||
- Image library items currently *all* have a duration of 15 seconds
|
||||
- Future updates will allow custom/distinct durations
|
||||
- Image library items currently default to a duration of 15 seconds
|
||||
- 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
|
||||
- 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 ICollection<LibraryFolder> Children { get; set; }
|
||||
public ICollection<MediaFile> MediaFiles { get; set; }
|
||||
public ImageFolderDuration ImageFolderDuration { 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)
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -179,9 +179,9 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
|
||||
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();
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3426,6 +3446,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.Image", "Image")
|
||||
@@ -4815,6 +4845,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("ImageFolderDuration");
|
||||
|
||||
b.Navigation("MediaFiles");
|
||||
});
|
||||
|
||||
|
||||
@@ -15,5 +15,11 @@ public class LibraryFolderConfiguration : IEntityTypeConfiguration<LibraryFolder
|
||||
.HasForeignKey(f => f.ParentId)
|
||||
.IsRequired(false)
|
||||
.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
|
||||
.Include(l => l.Paths)
|
||||
.ThenInclude(p => p.LibraryFolders)
|
||||
.ThenInclude(lf => lf.ImageFolderDuration)
|
||||
.OrderBy(l => l.Id)
|
||||
.SingleOrDefaultAsync(l => l.Id == libraryId)
|
||||
.Map(Optional);
|
||||
|
||||
@@ -72,7 +72,7 @@ public class MediaItemRepository : IMediaItemRepository
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
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
|
||||
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||
new { LibraryPathId = libraryPath.Id, Path = path })
|
||||
|
||||
@@ -148,6 +148,8 @@ public class SearchRepository : ISearchRepository
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(mi => (mi as Image).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Image).MediaVersions)
|
||||
.ThenInclude(mm => mm.MediaFiles)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
|
||||
@@ -371,6 +373,8 @@ public class SearchRepository : ISearchRepository
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(mi => (mi as Image).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Image).MediaVersions)
|
||||
.ThenInclude(mm => mm.MediaFiles)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.AsAsyncEnumerable();
|
||||
|
||||
@@ -55,6 +55,7 @@ public class TvContext : DbContext
|
||||
public DbSet<SongMetadata> SongMetadata { get; set; }
|
||||
public DbSet<Image> Images { get; set; }
|
||||
public DbSet<ImageMetadata> ImageMetadata { get; set; }
|
||||
public DbSet<ImageFolderDuration> ImageFolderDurations { get; set; }
|
||||
public DbSet<Show> Shows { get; set; }
|
||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||
public DbSet<Season> Seasons { get; set; }
|
||||
|
||||
@@ -137,6 +137,9 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
case Song song:
|
||||
await UpdateSong(searchRepository, song);
|
||||
break;
|
||||
case Image image:
|
||||
await UpdateImage(searchRepository, image);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +729,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) =>
|
||||
metadataReleaseDate?.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
internal const string PlotField = "plot";
|
||||
internal const string LibraryNameField = "library_name";
|
||||
internal const string LibraryIdField = "library_id";
|
||||
internal const string LibraryFolderIdField = "library_folder_id";
|
||||
internal const string TitleAndYearField = "title_and_year";
|
||||
internal const string JumpLetterField = "jump_letter";
|
||||
internal const string StudioField = "studio";
|
||||
@@ -1303,6 +1304,19 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
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);
|
||||
|
||||
AddStatistics(doc, image.MediaVersions);
|
||||
|
||||
@@ -123,4 +123,7 @@ public class ElasticSearchItem : MinimalElasticSearchItem
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.MoodField)]
|
||||
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(OtherVideo otherVideo, string nfoFileName);
|
||||
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(Episode episode);
|
||||
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
|
||||
|
||||
@@ -145,6 +145,26 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
||||
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);
|
||||
|
||||
var hasErrors = false;
|
||||
@@ -155,7 +175,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
||||
.GetOrAdd(libraryPath, knownFolder, file)
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(video => UpdateLibraryFolderId(video, knownFolder))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(video => UpdateMetadata(video, durationSeconds))
|
||||
//.BindT(video => UpdateThumbnail(video, cancellationToken))
|
||||
//.BindT(UpdateSubtitles)
|
||||
.BindT(FlagNormal);
|
||||
@@ -244,7 +264,8 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Image>>> UpdateMetadata(
|
||||
MediaItemScanResult<Image> result)
|
||||
MediaItemScanResult<Image> result,
|
||||
int? durationSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -253,7 +274,8 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
||||
|
||||
bool shouldUpdate = Optional(image.ImageMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated != _localFileSystem.GetLastWriteTime(path),
|
||||
m.DateUpdated != _localFileSystem.GetLastWriteTime(path) ||
|
||||
m.DurationSeconds != durationSeconds,
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
@@ -261,7 +283,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
|
||||
image.ImageMetadata ??= [];
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshTagMetadata(image))
|
||||
if (await _localMetadataProvider.RefreshTagMetadata(image, durationSeconds))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
@@ -210,9 +210,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -423,6 +423,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
MetadataKind = MetadataKind.Embedded,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(path),
|
||||
DurationSeconds = durationSeconds,
|
||||
|
||||
Artwork = [],
|
||||
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>
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
@code {
|
||||
@@ -355,7 +384,7 @@
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -369,6 +398,7 @@
|
||||
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
|
||||
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
|
||||
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
|
||||
.Append(_images.Cards.OrderBy(i => i.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -463,6 +493,17 @@
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetImagesLink()
|
||||
{
|
||||
var uri = "media/images/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private Task DeleteFromDatabase() => DeleteItemsFromDatabase(
|
||||
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
||||
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
|
||||
@@ -563,6 +604,10 @@
|
||||
request = new DeleteItemsFromDatabase(new List<int> { song.SongId });
|
||||
await DeleteItemsWithConfirmation("song", $"{song.Title} ({song.Subtitle})", request);
|
||||
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()
|
||||
{
|
||||
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 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/other/videos">Other Videos</MudNavLink>
|
||||
<MudNavLink Href="media/music/songs">Songs</MudNavLink>
|
||||
<MudNavLink Href="media/images">Images</MudNavLink>
|
||||
<MudNavLink Href="media/browser/images">Images</MudNavLink>
|
||||
</MudNavGroup>
|
||||
<MudNavGroup Title="Lists" Expanded="true">
|
||||
<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