Browse Source

add configurable image duration (#1617)

pull/1618/head
Jason Dove 1 year ago committed by GitHub
parent
commit
707292c50f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Images/Commands/UpdateImageFolderDuration.cs
  3. 117
      ErsatzTV.Application/Images/Commands/UpdateImageFolderDurationHandler.cs
  4. 9
      ErsatzTV.Application/Images/ImageFolderViewModel.cs
  5. 18
      ErsatzTV.Application/Images/Mapper.cs
  6. 3
      ErsatzTV.Application/Images/Queries/GetImageFolders.cs
  7. 49
      ErsatzTV.Application/Images/Queries/GetImageFoldersHandler.cs
  8. 1
      ErsatzTV.Core/Domain/Library/LibraryFolder.cs
  9. 9
      ErsatzTV.Core/Domain/MediaItem/ImageFolderDuration.cs
  10. 4
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs
  11. 4
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  12. 5166
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240216163258_Add_ImageFolderDuration.Designer.cs
  13. 47
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240216163258_Add_ImageFolderDuration.cs
  14. 32
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  15. 6
      ErsatzTV.Infrastructure/Data/Configurations/Library/LibraryFolderConfiguration.cs
  16. 10
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/ImageFolderDurationConfiguration.cs
  17. 3
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  18. 2
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  19. 4
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  20. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  21. 56
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  22. 14
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  23. 3
      ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs
  24. 2
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  25. 30
      ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs
  26. 7
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  27. 102
      ErsatzTV/Pages/ImageBrowser.razor
  28. 49
      ErsatzTV/Pages/Trash.razor
  29. 49
      ErsatzTV/Shared/EditImageFolderDurationDialog.razor
  30. 2
      ErsatzTV/Shared/MainLayout.razor
  31. 74
      ErsatzTV/ViewModels/ImageTreeItemViewModel.cs

5
CHANGELOG.md

@ -23,8 +23,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

3
ErsatzTV.Application/Images/Commands/UpdateImageFolderDuration.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Images;
public record UpdateImageFolderDuration(int LibraryFolderId, int? ImageFolderDuration) : IRequest;

117
ErsatzTV.Application/Images/Commands/UpdateImageFolderDurationHandler.cs

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

9
ErsatzTV.Application/Images/ImageFolderViewModel.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Images;
public record ImageFolderViewModel(
int LibraryFolderId,
string Name,
string FullPath,
int SubfolderCount,
int ImageCount,
Option<int> DurationSeconds);

18
ErsatzTV.Application/Images/Mapper.cs

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

3
ErsatzTV.Application/Images/Queries/GetImageFolders.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Images;
public record GetImageFolders(Option<int> LibraryFolderId) : IRequest<List<ImageFolderViewModel>>;

49
ErsatzTV.Application/Images/Queries/GetImageFoldersHandler.cs

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

1
ErsatzTV.Core/Domain/Library/LibraryFolder.cs

@ -10,5 +10,6 @@ public class LibraryFolder @@ -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; }
}

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

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

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

@ -349,9 +349,9 @@ public class BlockPlayoutBuilder( @@ -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();

4
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -179,9 +179,9 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe @@ -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();

5166
ErsatzTV.Infrastructure.Sqlite/Migrations/20240216163258_Add_ImageFolderDuration.Designer.cs generated

File diff suppressed because it is too large Load Diff

47
ErsatzTV.Infrastructure.Sqlite/Migrations/20240216163258_Add_ImageFolderDuration.cs

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

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

@ -733,6 +733,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -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 @@ -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 @@ -4815,6 +4845,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
b.Navigation("Children");
b.Navigation("ImageFolderDuration");
b.Navigation("MediaFiles");
});

6
ErsatzTV.Infrastructure/Data/Configurations/Library/LibraryFolderConfiguration.cs

@ -15,5 +15,11 @@ public class LibraryFolderConfiguration : IEntityTypeConfiguration<LibraryFolder @@ -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
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/ImageFolderDurationConfiguration.cs

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

3
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -32,6 +32,7 @@ public class LibraryRepository : ILibraryRepository @@ -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);
@ -185,7 +186,7 @@ public class LibraryRepository : ILibraryRepository @@ -185,7 +186,7 @@ public class LibraryRepository : ILibraryRepository
await dbContext.LibraryFolders.AddAsync(knownFolder);
await dbContext.SaveChangesAsync();
}
return knownFolder;
}

2
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -72,7 +72,7 @@ public class MediaItemRepository : IMediaItemRepository @@ -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 })

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

@ -148,6 +148,8 @@ public class SearchRepository : ISearchRepository @@ -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 @@ -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();

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -55,6 +55,7 @@ public class TvContext : DbContext @@ -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; }

56
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -137,6 +137,9 @@ public class ElasticSearchIndex : ISearchIndex @@ -137,6 +137,9 @@ public class ElasticSearchIndex : ISearchIndex
case Song song:
await UpdateSong(searchRepository, song);
break;
case Image image:
await UpdateImage(searchRepository, image);
break;
}
}
@ -725,6 +728,59 @@ public class ElasticSearchIndex : ISearchIndex @@ -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) =>
metadataReleaseDate?.ToString("yyyyMMdd", CultureInfo.InvariantCulture);

14
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -41,6 +41,7 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -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 @@ -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);

3
ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs

@ -123,4 +123,7 @@ public class ElasticSearchItem : MinimalElasticSearchItem @@ -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; }
}

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

@ -13,7 +13,7 @@ public interface ILocalMetadataProvider @@ -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);

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

@ -144,7 +144,27 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner @@ -144,7 +144,27 @@ 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 @@ -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 @@ -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 @@ -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 @@ -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;
}

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

@ -210,9 +210,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -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 @@ -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 @@ -423,6 +423,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
DurationSeconds = durationSeconds,
Artwork = [],
Actors = [],

102
ErsatzTV/Pages/ImageBrowser.razor

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

49
ErsatzTV/Pages/Trash.razor

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

49
ErsatzTV/Shared/EditImageFolderDurationDialog.razor

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

2
ErsatzTV/Shared/MainLayout.razor

@ -113,7 +113,7 @@ @@ -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>

74
ErsatzTV/ViewModels/ImageTreeItemViewModel.cs

@ -0,0 +1,74 @@ @@ -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}";
}
}
Loading…
Cancel
Save