mirror of https://github.com/ErsatzTV/ErsatzTV.git
33 changed files with 2059 additions and 186 deletions
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands |
||||
{ |
||||
public record UpdatePlexPathReplacements |
||||
( |
||||
int PlexMediaSourceId, |
||||
List<PlexPathReplacementItem> PathReplacements) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
|
||||
public record PlexPathReplacementItem(int Id, string PlexPath, string LocalPath); |
||||
} |
||||
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands |
||||
{ |
||||
public class |
||||
UpdatePlexPathReplacementsHandler : MediatR.IRequestHandler<UpdatePlexPathReplacements, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public UpdatePlexPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<Either<BaseError, Unit>> Handle( |
||||
UpdatePlexPathReplacements request, |
||||
CancellationToken cancellationToken) => |
||||
Validate(request) |
||||
.MapT(pms => MergePathReplacements(request, pms)) |
||||
.Bind(v => v.ToEitherAsync()); |
||||
|
||||
private Task<Unit> MergePathReplacements(UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource) |
||||
{ |
||||
plexMediaSource.PathReplacements ??= new List<PlexPathReplacement>(); |
||||
|
||||
var incoming = request.PathReplacements.Map(Project).ToList(); |
||||
|
||||
var toAdd = incoming.Filter(r => r.Id < 1).ToList(); |
||||
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); |
||||
var toUpdate = incoming.Except(toAdd).ToList(); |
||||
|
||||
plexMediaSource.PathReplacements.AddRange(toAdd); |
||||
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr)); |
||||
foreach (PlexPathReplacement pathReplacement in toUpdate) |
||||
{ |
||||
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id)) |
||||
.IfSome( |
||||
pr => |
||||
{ |
||||
pr.PlexPath = pathReplacement.PlexPath; |
||||
pr.LocalPath = pathReplacement.LocalPath; |
||||
}); |
||||
} |
||||
|
||||
return _mediaSourceRepository.Update(plexMediaSource).ToUnit(); |
||||
} |
||||
|
||||
private static PlexPathReplacement Project(PlexPathReplacementItem vm) => |
||||
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; |
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> Validate(UpdatePlexPathReplacements request) => |
||||
PlexMediaSourceMustExist(request); |
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist( |
||||
UpdatePlexPathReplacements request) => |
||||
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId) |
||||
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist.")); |
||||
} |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Plex |
||||
{ |
||||
public record PlexPathReplacementViewModel(int Id, string PlexPath, string LocalPath); |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Plex.Queries |
||||
{ |
||||
public record GetPlexPathReplacementsBySourceId |
||||
(int PlexMediaSourceId) : IRequest<List<PlexPathReplacementViewModel>>; |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.Plex.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Plex.Queries |
||||
{ |
||||
public class GetPlexPathReplacementsBySourceIdHandler : IRequestHandler<GetPlexPathReplacementsBySourceId, |
||||
List<PlexPathReplacementViewModel>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public GetPlexPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<List<PlexPathReplacementViewModel>> Handle( |
||||
GetPlexPathReplacementsBySourceId request, |
||||
CancellationToken cancellationToken) => |
||||
_mediaSourceRepository.GetPlexPathReplacements(request.PlexMediaSourceId) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class PlexPathReplacement |
||||
{ |
||||
public int Id { get; set; } |
||||
public string PlexPath { get; set; } |
||||
public string LocalPath { get; set; } |
||||
public int PlexMediaSourceId { get; set; } |
||||
public PlexMediaSource PlexMediaSource { get; set; } |
||||
} |
||||
} |
||||
@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using System; |
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata |
||||
{ |
||||
public interface IFallbackMetadataProvider |
||||
{ |
||||
ShowMetadata GetFallbackMetadataForShow(string showFolder); |
||||
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode); |
||||
MovieMetadata GetFallbackMetadata(Movie movie); |
||||
string GetSortTitle(string title); |
||||
} |
||||
} |
||||
|
||||
@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Plex |
||||
{ |
||||
public class PlexMediaEntry |
||||
{ |
||||
public int Id { get; set; } |
||||
public int Duration { get; set; } |
||||
public int Bitrate { get; set; } |
||||
public int Width { get; set; } |
||||
public int Height { get; set; } |
||||
public double AspectRatio { get; set; } |
||||
public int AudioChannels { get; set; } |
||||
public string AudioCodec { get; set; } |
||||
public string VideoCodec { get; set; } |
||||
public string Container { get; set; } |
||||
public string VideoFrameRate { get; set; } |
||||
public List<PlexPartEntry> Part { get; set; } |
||||
} |
||||
} |
||||
@ -1,19 +0,0 @@
@@ -1,19 +0,0 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Plex |
||||
{ |
||||
public class PlexMetadataEntry |
||||
{ |
||||
public string Key { get; set; } |
||||
public string Title { get; set; } |
||||
public string Summary { get; set; } |
||||
public int Year { get; set; } |
||||
public string Tagline { get; set; } |
||||
public string Thumb { get; set; } |
||||
public string Art { get; set; } |
||||
public string OriginallyAvailableAt { get; set; } |
||||
public int AddedAt { get; set; } |
||||
public int UpdatedAt { get; set; } |
||||
public List<PlexMediaEntry> Media { get; set; } |
||||
} |
||||
} |
||||
@ -1,11 +0,0 @@
@@ -1,11 +0,0 @@
|
||||
namespace ErsatzTV.Core.Plex |
||||
{ |
||||
public class PlexPartEntry |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Key { get; set; } |
||||
public int Duration { get; set; } |
||||
public string File { get; set; } |
||||
public int Size { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class PlexPathReplacementConfiguration : IEntityTypeConfiguration<PlexPathReplacement> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<PlexPathReplacement> builder) => builder.ToTable("PlexPathReplacement"); |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Add_PlexPathReplacement : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.CreateTable( |
||||
"PlexPathReplacement", |
||||
table => new |
||||
{ |
||||
Id = table.Column<int>("INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true), |
||||
PlexPath = table.Column<string>("TEXT", nullable: true), |
||||
LocalPath = table.Column<string>("TEXT", nullable: true), |
||||
PlexMediaSourceId = table.Column<int>("INTEGER", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_PlexPathReplacement", x => x.Id); |
||||
table.ForeignKey( |
||||
"FK_PlexPathReplacement_PlexMediaSource_PlexMediaSourceId", |
||||
x => x.PlexMediaSourceId, |
||||
"PlexMediaSource", |
||||
"Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_PlexPathReplacement_PlexMediaSourceId", |
||||
"PlexPathReplacement", |
||||
"PlexMediaSourceId"); |
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) => |
||||
migrationBuilder.DropTable( |
||||
"PlexPathReplacement"); |
||||
} |
||||
} |
||||
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
@page "/media/sources/plex/{Id:int}/paths" |
||||
@using ErsatzTV.Application.Plex |
||||
@using ErsatzTV.Application.Plex.Commands |
||||
@using ErsatzTV.Application.Plex.Queries |
||||
@inject NavigationManager NavigationManager |
||||
@inject ILogger<ScheduleItemsEditor> Logger |
||||
@inject ISnackbar Snackbar |
||||
@inject IMediator Mediator |
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge"> |
||||
<MudTable Hover="true" Items="_pathReplacements.OrderBy(r => r.Id)" Dense="true" @bind-SelectedItem="_selectedItem"> |
||||
<ToolBarContent> |
||||
<MudText Typo="Typo.h6"><b>@_source.Name</b> Path Replacements</MudText> |
||||
</ToolBarContent> |
||||
<ColGroup> |
||||
<col/> |
||||
<col/> |
||||
<col style="width: 60px;"/> |
||||
</ColGroup> |
||||
<HeaderContent> |
||||
<MudTh>Plex Path</MudTh> |
||||
<MudTh>Local Path</MudTh> |
||||
<MudTh/> |
||||
</HeaderContent> |
||||
<RowTemplate> |
||||
<MudTd DataLabel="Plex Path"> |
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)"> |
||||
@context.PlexPath |
||||
</MudText> |
||||
</MudTd> |
||||
<MudTd DataLabel="Local Path"> |
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)"> |
||||
@context.LocalPath |
||||
</MudText> |
||||
</MudTd> |
||||
<MudTd> |
||||
<MudTooltip Text="Delete Path Replacement"> |
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" |
||||
OnClick="@(_ => RemovePathReplacement(context))"> |
||||
</MudIconButton> |
||||
</MudTooltip> |
||||
</MudTd> |
||||
</RowTemplate> |
||||
</MudTable> |
||||
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddPathReplacement())" Class="mt-4"> |
||||
Add Path Replacement |
||||
</MudButton> |
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4"> |
||||
Save Changes |
||||
</MudButton> |
||||
|
||||
@if (_selectedItem is not null) |
||||
{ |
||||
<div style="max-width: 400px;"> |
||||
<EditForm Model="_selectedItem"> |
||||
<FluentValidator/> |
||||
<MudCard Class="mt-6"> |
||||
<MudCardContent> |
||||
<MudTextField Label="Plex Path" |
||||
@bind-Value="@_selectedItem.PlexPath" |
||||
For="@(() => _selectedItem.PlexPath)"/> |
||||
<MudTextField Class="mt-3" |
||||
Label="Local Path" |
||||
@bind-Value="@_selectedItem.LocalPath" |
||||
For="@(() => _selectedItem.LocalPath)"/> |
||||
</MudCardContent> |
||||
</MudCard> |
||||
</EditForm> |
||||
</div> |
||||
} |
||||
</MudContainer> |
||||
|
||||
@code { |
||||
|
||||
[Parameter] |
||||
public int Id { get; set; } |
||||
|
||||
private PlexMediaSourceViewModel _source; |
||||
private List<PlexPathReplacementEditViewModel> _pathReplacements; |
||||
|
||||
private PlexPathReplacementEditViewModel _selectedItem; |
||||
|
||||
protected override Task OnParametersSetAsync() => LoadData(); |
||||
|
||||
private async Task LoadData() |
||||
{ |
||||
Option<PlexMediaSourceViewModel> maybeSource = await Mediator.Send(new GetPlexMediaSourceById(Id)); |
||||
await maybeSource.Match( |
||||
async source => |
||||
{ |
||||
_source = source; |
||||
_pathReplacements = await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id)) |
||||
.Map(list => list.Map(ProjectToEditViewModel).ToList()); |
||||
}, |
||||
() => |
||||
{ |
||||
NavigationManager.NavigateTo("404"); |
||||
return Task.CompletedTask; |
||||
}); |
||||
} |
||||
|
||||
private PlexPathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) => |
||||
new() { Id = item.Id, PlexPath = item.PlexPath, LocalPath = item.LocalPath }; |
||||
|
||||
private void AddPathReplacement() |
||||
{ |
||||
var item = new PlexPathReplacementEditViewModel(); |
||||
_pathReplacements.Add(item); |
||||
_selectedItem = item; |
||||
} |
||||
|
||||
private void RemovePathReplacement(PlexPathReplacementEditViewModel item) |
||||
{ |
||||
_selectedItem = null; |
||||
_pathReplacements.Remove(item); |
||||
} |
||||
|
||||
private async Task SaveChanges() |
||||
{ |
||||
var items = _pathReplacements |
||||
.Map(item => new PlexPathReplacementItem(item.Id, item.PlexPath, item.LocalPath)) |
||||
.ToList(); |
||||
|
||||
Seq<BaseError> errorMessages = await Mediator.Send(new UpdatePlexPathReplacements(Id, items)).Map(e => e.LeftToSeq()); |
||||
|
||||
errorMessages.HeadOrNone().Match( |
||||
error => |
||||
{ |
||||
Snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error); |
||||
Logger.LogError("Unexpected error saving path replacements: {Error}", error.Value); |
||||
}, |
||||
() => NavigationManager.NavigateTo("/media/plex")); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.ViewModels; |
||||
using FluentValidation; |
||||
|
||||
namespace ErsatzTV.Validators |
||||
{ |
||||
public class PlexPathReplacementEditViewModelValidator : AbstractValidator<PlexPathReplacementEditViewModel> |
||||
{ |
||||
public PlexPathReplacementEditViewModelValidator() |
||||
{ |
||||
RuleFor(vm => vm.PlexPath).NotEmpty(); |
||||
RuleFor(vm => vm.LocalPath).NotEmpty(); |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue