Stream custom live channels using your own media
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

260 lines
10 KiB

@page "/media/sources/local/{Id:int}/edit"
@page "/media/sources/local/add"
@using ErsatzTV.Application.Libraries
@implements IDisposable
@inject IDialogService Dialog
@inject IEntityLocker Locker
@inject IMediator Mediator
@inject ILogger<LocalLibraryEditor> Logger
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
<MudForm Model="@_model" @bind-IsValid="@_success" Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="SaveChangesAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Local Library" : "Add Local Library")</MudButton>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Local Library</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Name</MudText>
</div>
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Local library name is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Media Kind</MudText>
</div>
<MudSelect Disabled="IsEdit" @bind-Value="_model.MediaKind" For="@(() => _model.MediaKind)">
@foreach (LibraryMediaKind mediaKind in Enum.GetValues<LibraryMediaKind>())
{
<MudSelectItem Value="@mediaKind">@mediaKind</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Path</MudText>
</div>
<MudTextField @bind-Value="_newPath.Path" For="@(() => _newPath.Path)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddLibraryPath())" StartIcon="@Icons.Material.Filled.Add">
Add Path
</MudButton>
</MudStack>
<MudTable Hover="true" Items="_model.Paths" Class="mt-6">
<ToolBarContent>
<MudText Typo="Typo.h6">Library Paths</MudText>
</ToolBarContent>
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Path</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Path</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Move Library Path">
<MudIconButton Icon="@Icons.Material.Filled.DriveFileMove"
Disabled="@(_model.Id == 0 || context.Id == 0 || Locker.IsLibraryLocked(_model.Id))"
OnClick="@(() => MoveLibraryPath(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Library Path">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteLibraryPath(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private readonly CancellationTokenSource _cts = new();
[Parameter]
public int Id { get; set; }
private readonly LocalLibraryEditViewModel _model = new();
private readonly LocalLibraryPathEditViewModel _newPath = new();
private bool _success;
private bool IsEdit => Id != 0;
protected override async Task OnParametersSetAsync()
{
if (IsEdit)
{
Option<LocalLibraryViewModel> maybeLibrary = await Mediator.Send(new GetLocalLibraryById(Id), _cts.Token);
await maybeLibrary.Match(
async library =>
{
_model.Id = library.Id;
_model.Name = library.Name;
_model.MediaKind = library.MediaKind;
await LoadLibraryPaths();
},
() =>
{
NavigationManager.NavigateTo("404");
return Task.CompletedTask;
});
}
else
{
_model.HasChanges = true;
_model.Name = "New Local Library";
_model.MediaKind = LibraryMediaKind.Movies;
_model.Paths = new List<LocalLibraryPathEditViewModel>();
}
}
protected override void OnInitialized()
{
Locker.OnLibraryChanged += LockChanged;
}
private void LockChanged(object sender, EventArgs e) =>
InvokeAsync(StateHasChanged);
private async Task LoadLibraryPaths()
{
_model.HasChanges = false;
_model.Paths = await Mediator.Send(new GetLocalLibraryPaths(Id), _cts.Token)
.Map(list => list.Map(vm => new LocalLibraryPathEditViewModel
{
Id = vm.Id,
Path = vm.Path
}).ToList());
}
private async Task MoveLibraryPath(LocalLibraryPathEditViewModel libraryPath)
{
var parameters = new DialogParameters { { "MediaKind", _model.MediaKind }, { "SourceLibraryId", Id } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<MoveLocalLibraryPathDialog>("Move Local Library Path", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: LocalLibraryViewModel library })
{
var request = new MoveLocalLibraryPath(libraryPath.Id, library.Id);
Either<BaseError, Unit> moveResult = await Mediator.Send(request, _cts.Token);
moveResult.Match(
_ => NavigationManager.NavigateTo($"media/sources/local/{library.Id}/edit"),
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error moving local library path: {Error}", error.Value);
});
}
}
private async Task DeleteLibraryPath(LocalLibraryPathEditViewModel libraryPath)
{
if (libraryPath.Id == 0)
{
_model.HasChanges = true;
_model.Paths.Remove(libraryPath);
}
int count = await Mediator.Send(new CountMediaItemsByLibraryPath(libraryPath.Id), _cts.Token);
var parameters = new DialogParameters
{
{ "EntityType", "library path" },
{ "EntityName", libraryPath.Path },
{ "DetailText", $"This library path contains {count} media items." },
{ "DetailHighlight", count.ToString() }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Library Path", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
_model.HasChanges = true;
_model.Paths.Remove(libraryPath);
}
}
private void AddLibraryPath()
{
if (string.IsNullOrWhiteSpace(_newPath.Path))
{
return;
}
if (!Directory.Exists(_newPath.Path))
{
Snackbar.Add("Path must exist on filesystem", Severity.Error);
return;
}
if (!string.IsNullOrWhiteSpace(_newPath.Path) && _model.Paths.All(p => NormalizePath(p.Path) != NormalizePath(_newPath.Path)))
{
_model.HasChanges = true;
_model.Paths.Add(
new LocalLibraryPathEditViewModel
{
Path = _newPath.Path
});
}
_newPath.Path = null;
}
private async Task SaveChangesAsync()
{
if (_success)
{
Either<BaseError, LocalLibraryViewModel> result = IsEdit
? await Mediator.Send(
new UpdateLocalLibrary(
_model.Id,
_model.Name,
_model.Paths.Map(p => new UpdateLocalLibraryPath(p.Id, p.Path)).ToList()),
_cts.Token)
: await Mediator.Send(
new CreateLocalLibrary(
_model.Name,
_model.MediaKind,
_model.Paths.Map(p => p.Path).ToList()),
_cts.Token);
result.Match(
_ => NavigationManager.NavigateTo("media/sources/local"),
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving local library: {Error}", error.Value);
});
}
}
private string NormalizePath(string path) => Path.GetFullPath(new Uri(path).LocalPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.ToUpperInvariant();
void IDisposable.Dispose()
{
Locker.OnLibraryChanged -= LockChanged;
_cts.Cancel();
_cts.Dispose();
}
}