mirror of https://github.com/ErsatzTV/ErsatzTV.git
31 changed files with 5862 additions and 19 deletions
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Images; |
||||
|
||||
public record UpdateImageFolderDuration(int LibraryFolderId, int? ImageFolderDuration) : IRequest; |
@ -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); |
||||
} |
@ -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); |
@ -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); |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Images; |
||||
|
||||
public record GetImageFolders(Option<int> LibraryFolderId) : IRequest<List<ImageFolderViewModel>>; |
@ -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; |
||||
} |
||||
} |
@ -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; } |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
@ -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"); |
||||
} |
@ -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); |
||||
} |
||||
} |
||||
} |
@ -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(); |
||||
} |
@ -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…
Reference in new issue