mirror of https://github.com/ErsatzTV/ErsatzTV.git
31 changed files with 5862 additions and 19 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
@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 @@ |
|||||||
|
@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 @@ |
|||||||
|
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