Compare commits

..
4 Commits
Author SHA1 Message Date
timothy 78262ea4cb Merge remote-tracking branch 'origin/main' into feat/91b-blazor-removal
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
2026-07-11 21:17:03 +02:00
timothyandClaude Opus 4.8 3c5d05908a chore(91b): clean up dead Blazor residue (Serilog override + stale comment)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Codex-review nits: drop the now-inert "MudBlazor" Serilog level override in
appsettings.json (package removed) and reword the PlaylistController comment
that referenced the deleted Blazor MultiSelectBase.AddItemsToPlaylist path. No
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:10:15 +02:00
timothyandClaude Opus 4.8 4407dd53af fix(91b): delete orphaned Blazor NavigationManagerExtensions
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold-review Low-1: ErsatzTV/Extensions/NavigationManagerExtensions.cs survived
the removal — the last non-deleted .cs still importing
Microsoft.AspNetCore.Components/JSInterop and calling the deleted
blazorHelpers.scrollToFragment JS. Fully unreferenced (compiled only via the
shared framework). Removing it completes the Blazor deletion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:09:26 +02:00
timothyandClaude Opus 4.8 408b0deb89 feat(91b): remove legacy Blazor Server UI (#91 phase b)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The ChicoryTV React SPA (web/, served at /app) now has full parity for every
route the Blazor UI served, so the legacy Blazor Server / MudBlazor UI is
deleted. This is the milestone-capping removal of #91 phase (b).

Deleted: ErsatzTV/Pages/**, Shared/**, ViewModels/** (39 edit VMs),
Validators/** (10 edit-VM validators), App.razor, _Imports.razor,
Locals/{Shared,Pages}/** (Blazor loc resx; Locals/Resources.* kept),
wwwroot/css + wwwroot/lib, libman.json, and the orphaned MultiSelectBaseTests.

Startup.cs (surgical, not wholesale): removed AddRazorPages/AuthorizeFolder,
AddServerSideBlazor, AddMudServices, AddSortable, AddCourier, the HtmlSanitizer
registration, the Blazor-attached OIDC UseAuthentication/UseAuthorization
middleware (per the #206 auth-posture sign-off), MapBlazorHub, and
MapFallbackToPage("/_Host"). Renamed the branch blazor->legacy; it still
co-hosts MapControllers, /docs (Scalar), dev MapOpenApi and the redirect
middleware. Replaced the _Host fallback with a catch-all (MapFallback ->
302 /app) that excludes /api|/artwork|/docs|/openapi (genuine 404) per #204.
Kept all OIDC/JWT/API-key service wiring (inert unless configured; real auth
is #197), ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter.

Pruned 9 now-unused packages (all verified zero remaining consumers) from
Directory.Packages.props + ErsatzTV.csproj: MudBlazor, Heron.MudCalendar,
Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection,
Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension. Also removed the
now-dead #25 razor-Sonar NoWarn.

LegacyUiRedirects: added the 14 /media/sources/* -> /app/libraries/* redirects
(SPA screens landed in #202) and lifted the #204-era /media/sources prefix ban.

Tests: Release build clean; full solution suite green. Updated Startup
source-text tests + added regression coverage that Blazor wiring is gone, the
catch-all is wired, and all 14 media-sources routes redirect.

Docs: blazor-route-parity.md (phase b COMPLETE), decisions.md (removal entry),
CLAUDE.md, contributing.md, README.md all updated in this PR.

Rollback: tag blazor-final is cut on pre-merge main as the first merge action.

Part of #91.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:01:04 +02:00
220 changed files with 294 additions and 34266 deletions
+4 -4
View File
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE)
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
@@ -15,7 +15,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
@@ -55,7 +55,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
@@ -70,7 +70,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
- Keep UI thin: the SPA talks to `/api/*` only; controllers delegate to MediatR handlers. All UI is in the SPA (`web/`)
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
-9
View File
@@ -5,10 +5,7 @@
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="6.0.2" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" />
@@ -22,8 +19,6 @@
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" />
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
<PackageVersion Include="Heron.MudCalendar" Version="3.4.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
<PackageVersion Include="Jint" Version="4.5.0" />
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
@@ -33,9 +28,7 @@
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="MediatR" Version="[12.5.0]" />
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
@@ -61,8 +54,6 @@
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
<PackageVersion Include="MudBlazor" Version="8.15.0" />
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
<PackageVersion Include="NCalcSync" Version="6.3.2" />
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
+33 -9
View File
@@ -121,9 +121,32 @@ public class LegacyUiRedirectsTests
target.ShouldBe(expected);
}
[TestCase("/system/health")] // Blazor home escape hatch
[TestCase("/media/sources/plex")] // media sources have no SPA UI (#202)
[TestCase("/media/sources/jellyfin")]
// #202 replaced the Blazor /media/sources/* pages with the /app/libraries SPA screens;
// #91b deletes the Blazor pages and adds these redirects (the #204-era prefix ban is lifted).
[TestCase("/media/sources/local", "/app/libraries")]
[TestCase("/media/sources/local/add", "/app/libraries/local/new")]
[TestCase("/media/sources/local/5/edit", "/app/libraries/local/5")]
[TestCase("/media/sources/plex", "/app/libraries/plex")]
[TestCase("/media/sources/plex/5/libraries", "/app/libraries/plex/5/sync")]
[TestCase("/media/sources/plex/5/paths", "/app/libraries/plex/5/path-replacements")]
[TestCase("/media/sources/jellyfin", "/app/libraries/jellyfin")]
[TestCase("/media/sources/jellyfin/edit", "/app/libraries/jellyfin/connection")]
[TestCase("/media/sources/jellyfin/5/libraries", "/app/libraries/jellyfin/5/sync")]
[TestCase("/media/sources/jellyfin/5/paths", "/app/libraries/jellyfin/5/path-replacements")]
[TestCase("/media/sources/emby", "/app/libraries/emby")]
[TestCase("/media/sources/emby/edit", "/app/libraries/emby/connection")]
[TestCase("/media/sources/emby/5/libraries", "/app/libraries/emby/5/sync")]
[TestCase("/media/sources/emby/5/paths", "/app/libraries/emby/5/path-replacements")]
public void Media_Sources_Routes_Should_Redirect_To_Libraries_Screens(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/system/health")] // former Blazor home; not mapped (Startup catch-all -> /app)
[TestCase("/media/sources")] // bare prefix, no such route/screen
[TestCase("/media/sources/plex/paths")] // 4-seg, non-numeric id position
[TestCase("/media/sources/plex/abc/libraries")] // id must be a positive integer
[TestCase("/api/foo")] // API surface
[TestCase("/artwork/1")] // artwork surface
[TestCase("/docs")] // docs surface
@@ -167,7 +190,7 @@ public class LegacyUiRedirectsTests
{
string[] forbidden =
{
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app", "/media/sources"
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app"
};
foreach (string key in LegacyUiRedirects.Map.Keys)
@@ -193,15 +216,16 @@ public class LegacyUiRedirectsTests
}
[Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
public void Startup_Should_Redirect_Legacy_Routes_In_Legacy_Branch_Before_Routing()
{
// The redirect middleware must be wired inside the blazor branch and run
// before UseRouting so migrated routes never reach the Blazor fallback.
// The redirect middleware must be wired inside the legacy branch and run before
// UseRouting so migrated routes never reach the SPA catch-all fallback (#91 phase b:
// the Blazor host is gone; unmatched non-API routes 302 to /app).
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
redirectIndex.ShouldBeGreaterThan(-1);
// The Blazor branch's UseRouting call that follows the redirect middleware.
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
// The legacy branch's UseRouting call that follows the redirect middleware.
int routingIndex = StartupSource.IndexOf("legacy.UseRouting()", StringComparison.Ordinal);
routingIndex.ShouldBeGreaterThan(-1);
redirectIndex.ShouldBeLessThan(routingIndex);
@@ -1,35 +0,0 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Pages;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Pages;
[TestFixture]
public class MultiSelectBaseTests
{
[Test]
public void Should_replace_existing_selection_and_return_last_card()
{
var existingCard = new MediaCardViewModel(1, "Existing", "Sub", "Existing", "", Core.Domain.MediaItemState.Normal, false);
var selected = new HashSet<MediaCardViewModel> { existingCard };
var first = new MediaCardViewModel(2, "First", "Sub", "First", "", Core.Domain.MediaItemState.Normal, false);
var second = new MediaCardViewModel(3, "Second", "Sub", "Second", "", Core.Domain.MediaItemState.Normal, false);
MultiSelectBase<Search>.ResetSelectionWithCards(selected, [first, second]);
selected.ShouldBe([first, second], ignoreOrder: true);
}
[Test]
public void Should_clear_selection_when_no_cards()
{
var existingCard = new MediaCardViewModel(1, "Existing", "Sub", "Existing", "", Core.Domain.MediaItemState.Normal, false);
var selected = new HashSet<MediaCardViewModel> { existingCard };
MultiSelectBase<Search>.ResetSelectionWithCards(selected, []);
selected.ShouldBeEmpty();
}
}
+28 -1
View File
@@ -19,12 +19,39 @@ public class StartupSpaHostingTests
}
[Test]
public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback()
public void Startup_Should_Keep_App_Routes_Out_Of_The_Legacy_Catch_All()
{
// /app is excluded from the legacy branch's MapWhen predicate, so SPA routes never
// reach the catch-all fallback (which 302s unmatched non-API paths to /app — an
// included /app route would loop).
StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)");
StartupSource.ShouldContain("bool IsSpaPath(PathString path)");
}
[Test]
public void Startup_Should_Have_Removed_The_Blazor_Server_Ui()
{
// ersatztv#91 phase (b): the legacy Blazor Server UI is deleted. Guard against
// reintroduction — none of its wiring may return.
StartupSource.ShouldNotContain("MapBlazorHub");
StartupSource.ShouldNotContain("_Host");
StartupSource.ShouldNotContain("AddServerSideBlazor");
StartupSource.ShouldNotContain("AddMudServices");
StartupSource.ShouldNotContain("AddRazorPages");
}
[Test]
public void Startup_Should_Catch_All_Unmatched_Legacy_Routes_To_The_Spa()
{
// With _Host gone, a retired Blazor route that matches no controller/redirect 302s to
// the SPA rather than hard-404ing; /api, /artwork, /docs, /openapi are excluded so an
// unmatched one of those is a genuine 404 (docs/decisions.md #204).
StartupSource.ShouldContain("endpoints.MapFallback(");
StartupSource.ShouldContain("context.Response.Redirect(context.Request.PathBase + \"/app\")");
StartupSource.ShouldContain("path.StartsWithSegments(\"/api\")");
StartupSource.ShouldContain("path.StartsWithSegments(\"/artwork\")");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
-13
View File
@@ -1,13 +0,0 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
@@ -280,8 +280,8 @@ public class PlaylistController(IMediator mediator) : ControllerBase
}
// System (generated) playlists must not have items added. The AddItemsToPlaylistHandler
// also enforces this (defense-in-depth for the Blazor path, which calls the same command
// from MultiSelectBase.AddItemsToPlaylist).
// also enforces this (defense-in-depth: the handler is the authoritative guard regardless
// of caller).
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
-57
View File
@@ -7,12 +7,6 @@
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<!-- ersatztv#25: SonarAnalyzer findings in Blazor .razor @code can't be downgraded via
.editorconfig (Razor source-generator limitation), so under TreatWarningsAsErrors they
would break the build. Temporarily suppressed here; the #25 burn-down removes each ID
as its .razor findings are fixed. (These same rules run at `suggestion` on .cs via
.editorconfig — visible, non-blocking.) -->
<NoWarn>$(NoWarn);S6966;S3267;S2325;S3260;S2094;S3458;S3358;S6667;S125;S1135;S1751;S1125;S1066;S127;S927;S6610;S6580;S5693;S3440;S2933;S1871</NoWarn>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<Configurations>Debug;Release;Debug No Sync</Configurations>
<Platforms>AnyCPU</Platforms>
@@ -36,16 +30,9 @@
<ItemGroup>
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" /> -->
<PackageReference Include="Blazored.FluentValidation" />
<PackageReference Include="BlazorSortable" />
<PackageReference Include="Chronic.Core" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.AspNetCore" />
<PackageReference Include="Heron.MudCalendar" />
<PackageReference Include="HtmlSanitizer" />
<PackageReference Include="LanguageExt.Core" />
<PackageReference Include="Markdig" />
<PackageReference Include="MediatR.Courier.DependencyInjection" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" />
@@ -59,8 +46,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" />
<PackageReference Include="NaturalSort.Extension" />
<PackageReference Include="Refit.HttpClientFactory" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog" />
@@ -112,18 +97,6 @@
<EmbeddedResource Include="Resources\empty.sqlite3" />
<EmbeddedResource Include="Resources\test.avs" />
<EmbeddedResource Include="Resources\Templates\_remoteStream.sbntxt" />
<EmbeddedResource Update="Locals\Shared\MainLayout.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>MainLayout.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Locals\Pages\Channels.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Channels.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Locals\Shared\Common.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Common.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
@@ -133,36 +106,6 @@
</Content>
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\FONT-LICENSE" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\css\open-iconic-bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.eot" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.otf" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.svg" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.woff" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\ICON-LICENSE" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\README.md" />
</ItemGroup>
<ItemGroup>
<Compile Update="Locals\Shared\MainLayout.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>MainLayout.resx</DependentUpon>
</Compile>
<Compile Update="Locals\Pages\Channels.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Channels.resx</DependentUpon>
</Compile>
<Compile Update="Locals\Shared\Common.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Common.resx</DependentUpon>
</Compile>
</ItemGroup>
<ProjectExtensions>
<VisualStudio>
<UserProperties client-app_4package_1json__JsonSchema="https://json.schemastore.org/phraseapp.json" />
@@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace ErsatzTV.Extensions;
public static class NavigationManagerExtensions
{
public static async Task NavigateToFragmentAsync(this NavigationManager navigationManager, IJSRuntime jSRuntime)
{
Uri uri = navigationManager.ToAbsoluteUri(navigationManager.Uri);
if (uri.Fragment.Length > 0)
{
await Task.Delay(250);
await jSRuntime.InvokeVoidAsync("blazorHelpers.scrollToFragment", uri.Fragment.Substring(1));
}
}
}
+37 -16
View File
@@ -3,19 +3,19 @@ using Microsoft.AspNetCore.Http;
namespace ErsatzTV;
// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91).
// Blazor -> ChicoryTV SPA cutover (ersatztv#91).
//
// The React SPA (served under /app) is now the default UI: the legacy Blazor
// routes below 302-redirect to their SPA equivalents. Only routes that already
// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are
// deliberately left reachable (no redirect) so their functionality stays
// available while the SPA catches up:
// /system/health (Blazor home escape hatch; Index.razor also lives here),
// /media/sources/* (source add/edit has no SPA UI yet — ersatztv#202).
// The React SPA (served under /app) is the default UI: the legacy Blazor routes
// below 302-redirect to their SPA equivalents. As of phase (b) the Blazor pages
// themselves are DELETED (ersatztv#91). A legacy route that is not in this map
// (nor a live controller/artwork/docs route) is caught by the Startup catch-all
// and 302s to /app, so nothing hard-404s — but a catch-all can only reach the SPA
// root, so this map stays the source of truth for the precise legacy-route ->
// SPA-screen mapping.
//
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
// full SPA parity for every route in this map. Until then this map is the
// single source of truth for "what has migrated" and is expected to grow.
// The one Blazor route intentionally never redirected was /system/health (the old
// Blazor home / escape hatch); it is deleted with Blazor in phase (b) and now
// falls through to the catch-all like any other retired route.
//
// Matching is two-tier (see TryGetRedirect):
// Tier 1 = the exact-path dictionary Map below (parameterless routes, plus
@@ -26,10 +26,12 @@ namespace ErsatzTV;
//
// GUARD INVARIANT (see also the collision-freedom note above Patterns): no
// Tier-1 key and no Tier-2 template may begin with /api, /artwork, /docs,
// /openapi, /iptv, /app, or /media/sources. Rules are always full, specific
// templates — never prefix wildcards (a bare "/media/{any}" style rule is
// forbidden). The blazor branch does not prefix-guard /api|/artwork|/docs|
// /openapi, /iptv, or /app. Rules are always full, specific templates — never
// prefix wildcards (a bare "/media/{any}" style rule is forbidden). The legacy
// branch (and its catch-all fallback) does not prefix-guard /api|/artwork|/docs|
// /openapi, so the matcher's specificity is part of their protection.
// (/media/sources IS redirected as of #202/#91b — its Blazor pages were replaced
// by the /app/libraries SPA screens, so the #204-era prefix ban is lifted.)
public static class LegacyUiRedirects
{
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
@@ -94,7 +96,17 @@ public static class LegacyUiRedirects
["/media/music/songs"] = "/app/media?kind=songs",
["/media/other/videos"] = "/app/media?kind=other-videos",
["/media/remote/streams"] = "/app/media?kind=remote-streams",
["/media/images"] = "/app/media?kind=images"
["/media/images"] = "/app/media?kind=images",
// (F) media sources — SPA screens landed in #202; Blazor pages deleted in #91b.
// The parameterized ({id}) source routes are Tier-2 patterns below.
["/media/sources/local"] = "/app/libraries",
["/media/sources/local/add"] = "/app/libraries/local/new",
["/media/sources/plex"] = "/app/libraries/plex",
["/media/sources/jellyfin"] = "/app/libraries/jellyfin",
["/media/sources/jellyfin/edit"] = "/app/libraries/jellyfin/connection",
["/media/sources/emby"] = "/app/libraries/emby",
["/media/sources/emby/edit"] = "/app/libraries/emby/connection"
};
// Tier-2 segment-template rules, consulted only after the exact Map misses.
@@ -157,7 +169,16 @@ public static class LegacyUiRedirects
new("/media/music/songs/page/{id}", "/app/media?kind=songs"),
new("/media/other/videos/page/{id}", "/app/media?kind=other-videos"),
new("/media/remote/streams/page/{id}", "/app/media?kind=remote-streams"),
new("/media/images/page/{id}", "/app/media?kind=images")
new("/media/images/page/{id}", "/app/media?kind=images"),
// (F) media sources — id-carrying source sub-routes (#202 SPA; #91b redirect).
new("/media/sources/local/{id}/edit", "/app/libraries/local/{id}"),
new("/media/sources/plex/{id}/libraries", "/app/libraries/plex/{id}/sync"),
new("/media/sources/plex/{id}/paths", "/app/libraries/plex/{id}/path-replacements"),
new("/media/sources/jellyfin/{id}/libraries", "/app/libraries/jellyfin/{id}/sync"),
new("/media/sources/jellyfin/{id}/paths", "/app/libraries/jellyfin/{id}/path-replacements"),
new("/media/sources/emby/{id}/libraries", "/app/libraries/emby/{id}/sync"),
new("/media/sources/emby/{id}/paths", "/app/libraries/emby/{id}/path-replacements")
};
public static bool TryGetRedirect(PathString path, out string target)
-180
View File
@@ -1,180 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace ErsatzTV.Locals.Pages {
using System;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Channels {
private static System.Resources.ResourceManager resourceMan;
private static System.Globalization.CultureInfo resourceCulture;
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Channels() {
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Resources.ResourceManager ResourceManager {
get {
if (object.Equals(null, resourceMan)) {
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("ErsatzTV.Locals.Pages.Channels", typeof(Channels).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
internal static string ButtonAddChannel {
get {
return ResourceManager.GetString("ButtonAddChannel", resourceCulture);
}
}
internal static string ButtonEditChannelNumbers {
get {
return ResourceManager.GetString("ButtonEditChannelNumbers", resourceCulture);
}
}
internal static string LabelChannels {
get {
return ResourceManager.GetString("LabelChannels", resourceCulture);
}
}
internal static string ButtonShowDisabled {
get {
return ResourceManager.GetString("ButtonShowDisabled", resourceCulture);
}
}
internal static string LabelNumber {
get {
return ResourceManager.GetString("LabelNumber", resourceCulture);
}
}
internal static string LabelLogo {
get {
return ResourceManager.GetString("LabelLogo", resourceCulture);
}
}
internal static string LabelName {
get {
return ResourceManager.GetString("LabelName", resourceCulture);
}
}
internal static string LabelLanguage {
get {
return ResourceManager.GetString("LabelLanguage", resourceCulture);
}
}
internal static string LabelStreamingMode {
get {
return ResourceManager.GetString("LabelStreamingMode", resourceCulture);
}
}
internal static string LabelFFmpegProfile {
get {
return ResourceManager.GetString("LabelFFmpegProfile", resourceCulture);
}
}
internal static string LabelChannelDisabledHelp {
get {
return ResourceManager.GetString("LabelChannelDisabledHelp", resourceCulture);
}
}
internal static string LabelChannelHiddenHelp {
get {
return ResourceManager.GetString("LabelChannelHiddenHelp", resourceCulture);
}
}
internal static string LabelChannelHiddenAbbreviation {
get {
return ResourceManager.GetString("LabelChannelHiddenAbbreviation", resourceCulture);
}
}
internal static string LabelChannelDisabledAbbreviation {
get {
return ResourceManager.GetString("LabelChannelDisabledAbbreviation", resourceCulture);
}
}
internal static string ButtonPreviewChannelHelp {
get {
return ResourceManager.GetString("ButtonPreviewChannelHelp", resourceCulture);
}
}
internal static string ButtonPreviewChannelHelpNormalize {
get {
return ResourceManager.GetString("ButtonPreviewChannelHelpNormalize", resourceCulture);
}
}
internal static string ButtonPreviewChannelHelpBrowser {
get {
return ResourceManager.GetString("ButtonPreviewChannelHelpBrowser", resourceCulture);
}
}
internal static string ButtonPreviewChannelHelpSettings {
get {
return ResourceManager.GetString("ButtonPreviewChannelHelpSettings", resourceCulture);
}
}
internal static string ButtonStopTranscodeHelp {
get {
return ResourceManager.GetString("ButtonStopTranscodeHelp", resourceCulture);
}
}
internal static string ButtonEditChannelHelp {
get {
return ResourceManager.GetString("ButtonEditChannelHelp", resourceCulture);
}
}
internal static string ButtonTroubleshootChannelHelp {
get {
return ResourceManager.GetString("ButtonTroubleshootChannelHelp", resourceCulture);
}
}
internal static string ButtonDeleteChannelHelp {
get {
return ResourceManager.GetString("ButtonDeleteChannelHelp", resourceCulture);
}
}
}
}
-80
View File
@@ -1,80 +0,0 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ButtonAddChannel" xml:space="preserve">
<value>Dodaj kanał</value>
</data>
<data name="ButtonEditChannelNumbers" xml:space="preserve">
<value>Edytuj numerację kanałów</value>
</data>
<data name="LabelChannels" xml:space="preserve">
<value>Kanały</value>
</data>
<data name="ButtonShowDisabled" xml:space="preserve">
<value>Pokaż wyłączone</value>
</data>
<data name="LabelNumber" xml:space="preserve">
<value>Numer</value>
</data>
<data name="LabelLogo" xml:space="preserve">
<value>Logo</value>
</data>
<data name="LabelName" xml:space="preserve">
<value>Nazwa</value>
</data>
<data name="LabelLanguage" xml:space="preserve">
<value>Język</value>
</data>
<data name="LabelStreamingMode" xml:space="preserve">
<value>Tryb</value>
</data>
<data name="LabelFFmpegProfile" xml:space="preserve">
<value>Profil FFmpeg</value>
</data>
<data name="LabelChannelDisabledHelp" xml:space="preserve">
<value>Kanał jest wyłączony</value>
</data>
<data name="LabelChannelHiddenHelp" xml:space="preserve">
<value>Kanał jest ukryty w EPG</value>
</data>
<data name="LabelChannelHiddenAbbreviation" xml:space="preserve">
<value>U</value>
</data>
<data name="LabelChannelDisabledAbbreviation" xml:space="preserve">
<value>W</value>
</data>
<data name="ButtonPreviewChannelHelp" xml:space="preserve">
<value>Podgląd kanału</value>
</data>
<data name="ButtonPreviewChannelHelpNormalize" xml:space="preserve">
<value>Podgląd kanału; obraz lub dźwięk mogą nie działać poprawnie bez normalizacji</value>
</data>
<data name="ButtonPreviewChannelHelpBrowser" xml:space="preserve">
<value>Podgląd kanału wymaga profilu FFmpeg kompatybilnego z tą przeglądarką</value>
</data>
<data name="ButtonPreviewChannelHelpSettings" xml:space="preserve">
<value>Podgląd kanału wymaga funkcji playout, segmentera MPEG-TS/HLS oraz kompatybilnego profilu FFmpeg</value>
</data>
<data name="ButtonStopTranscodeHelp" xml:space="preserve">
<value>Zatrzymaj transkodowanie</value>
</data>
<data name="ButtonEditChannelHelp" xml:space="preserve">
<value>Edytuj kanał</value>
</data>
<data name="ButtonTroubleshootChannelHelp" xml:space="preserve">
<value>Diagnostyka kanału</value>
</data>
<data name="ButtonDeleteChannelHelp" xml:space="preserve">
<value>Usuń kanał</value>
</data>
</root>
-14
View File
@@ -1,14 +0,0 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
-87
View File
@@ -1,87 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ButtonAddChannel" xml:space="preserve">
<value>Add Channel</value>
</data>
<data name="ButtonEditChannelNumbers" xml:space="preserve">
<value>Edit Channel Numbers</value>
</data>
<data name="LabelChannels" xml:space="preserve">
<value>Channels</value>
</data>
<data name="ButtonShowDisabled" xml:space="preserve">
<value>Show Disabled</value>
</data>
<data name="LabelNumber" xml:space="preserve">
<value>Number</value>
</data>
<data name="LabelLogo" xml:space="preserve">
<value>Logo</value>
</data>
<data name="LabelName" xml:space="preserve">
<value>Name</value>
</data>
<data name="LabelLanguage" xml:space="preserve">
<value>Language</value>
</data>
<data name="LabelStreamingMode" xml:space="preserve">
<value>Mode</value>
</data>
<data name="LabelFFmpegProfile" xml:space="preserve">
<value>FFmpeg Profile</value>
</data>
<data name="LabelChannelDisabledHelp" xml:space="preserve">
<value>Channel is disabled</value>
</data>
<data name="LabelChannelHiddenHelp" xml:space="preserve">
<value>Channel is hidden from EPG</value>
</data>
<data name="LabelChannelHiddenAbbreviation" xml:space="preserve">
<value>H</value>
</data>
<data name="LabelChannelDisabledAbbreviation" xml:space="preserve">
<value>D</value>
</data>
<data name="ButtonPreviewChannelHelp" xml:space="preserve">
<value>Preview Channel</value>
</data>
<data name="ButtonPreviewChannelHelpNormalize" xml:space="preserve">
<value>Preview Channel; audio and/or video may not work without normalization</value>
</data>
<data name="ButtonPreviewChannelHelpBrowser" xml:space="preserve">
<value>Channel preview requires FFmpeg Profile compatible with this browser</value>
</data>
<data name="ButtonPreviewChannelHelpSettings" xml:space="preserve">
<value>Channel preview requires playout, MPEG-TS/HLS Segmenter, and compatible FFmpeg Profile</value>
</data>
<data name="ButtonStopTranscodeHelp" xml:space="preserve">
<value>Stop Transcode Session</value>
</data>
<data name="ButtonEditChannelHelp" xml:space="preserve">
<value>Edit Channel</value>
</data>
<data name="ButtonTroubleshootChannelHelp" xml:space="preserve">
<value>Troubleshoot Channel</value>
</data>
<data name="ButtonDeleteChannelHelp" xml:space="preserve">
<value>Delete Channel</value>
</data>
</root>
-54
View File
@@ -1,54 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace ErsatzTV.Locals.Shared {
using System;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Common {
private static System.Resources.ResourceManager resourceMan;
private static System.Globalization.CultureInfo resourceCulture;
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Common() {
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Resources.ResourceManager ResourceManager {
get {
if (object.Equals(null, resourceMan)) {
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("ErsatzTV.Locals.Shared.Common", typeof(Common).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
internal static string LabelRowsPerPage {
get {
return ResourceManager.GetString("LabelRowsPerPage", resourceCulture);
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="LabelRowsPerPage" xml:space="preserve">
<value>Wierszy na stronę:</value>
</data>
</root>
-14
View File
@@ -1,14 +0,0 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
-24
View File
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="LabelRowsPerPage" xml:space="preserve">
<value>Rows per page:</value>
</data>
</root>
-414
View File
@@ -1,414 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace ErsatzTV.Locals.Shared {
using System;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class MainLayout {
private static System.Resources.ResourceManager resourceMan;
private static System.Globalization.CultureInfo resourceCulture;
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal MainLayout() {
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Resources.ResourceManager ResourceManager {
get {
if (object.Equals(null, resourceMan)) {
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("ErsatzTV.Locals.Shared.MainLayout", typeof(MainLayout).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
internal static string ButtonChannels {
get {
return ResourceManager.GetString("ButtonChannels", resourceCulture);
}
}
internal static string ButtonFFmpegProfiles {
get {
return ResourceManager.GetString("ButtonFFmpegProfiles", resourceCulture);
}
}
internal static string ButtonWatermarks {
get {
return ResourceManager.GetString("ButtonWatermarks", resourceCulture);
}
}
internal static string ButtonMediaSources {
get {
return ResourceManager.GetString("ButtonMediaSources", resourceCulture);
}
}
internal static string ButtonMedia {
get {
return ResourceManager.GetString("ButtonMedia", resourceCulture);
}
}
internal static string ButtonLists {
get {
return ResourceManager.GetString("ButtonLists", resourceCulture);
}
}
internal static string ButtonSettings {
get {
return ResourceManager.GetString("ButtonSettings", resourceCulture);
}
}
internal static string ButtonMediaSourcesLocal {
get {
return ResourceManager.GetString("ButtonMediaSourcesLocal", resourceCulture);
}
}
internal static string ButtonMediaSourcesEmby {
get {
return ResourceManager.GetString("ButtonMediaSourcesEmby", resourceCulture);
}
}
internal static string ButtonMediaSourcesJellyfin {
get {
return ResourceManager.GetString("ButtonMediaSourcesJellyfin", resourceCulture);
}
}
internal static string ButtonMediaSourcesPlex {
get {
return ResourceManager.GetString("ButtonMediaSourcesPlex", resourceCulture);
}
}
internal static string ButtonMediaLibraries {
get {
return ResourceManager.GetString("ButtonMediaLibraries", resourceCulture);
}
}
internal static string ButtonMediaTrash {
get {
return ResourceManager.GetString("ButtonMediaTrash", resourceCulture);
}
}
internal static string ButtonMediaTvShows {
get {
return ResourceManager.GetString("ButtonMediaTvShows", resourceCulture);
}
}
internal static string ButtonMediaMovies {
get {
return ResourceManager.GetString("ButtonMediaMovies", resourceCulture);
}
}
internal static string ButtonMediaMusic {
get {
return ResourceManager.GetString("ButtonMediaMusic", resourceCulture);
}
}
internal static string ButtonMediaOtherVideos {
get {
return ResourceManager.GetString("ButtonMediaOtherVideos", resourceCulture);
}
}
internal static string ButtonMediaSongs {
get {
return ResourceManager.GetString("ButtonMediaSongs", resourceCulture);
}
}
internal static string ButtonMediaImages {
get {
return ResourceManager.GetString("ButtonMediaImages", resourceCulture);
}
}
internal static string ButtonMediaRemoteStreams {
get {
return ResourceManager.GetString("ButtonMediaRemoteStreams", resourceCulture);
}
}
internal static string ButtonListsManualCollections {
get {
return ResourceManager.GetString("ButtonListsManualCollections", resourceCulture);
}
}
internal static string ButtonListsSmartCollections {
get {
return ResourceManager.GetString("ButtonListsSmartCollections", resourceCulture);
}
}
internal static string ButtonListsMultiCollections {
get {
return ResourceManager.GetString("ButtonListsMultiCollections", resourceCulture);
}
}
internal static string ButtonListsRerunCollections {
get {
return ResourceManager.GetString("ButtonListsRerunCollections", resourceCulture);
}
}
internal static string ButtonListsPlaylists {
get {
return ResourceManager.GetString("ButtonListsPlaylists", resourceCulture);
}
}
internal static string ButtonListsTraktLists {
get {
return ResourceManager.GetString("ButtonListsTraktLists", resourceCulture);
}
}
internal static string ButtonListsFillerPresets {
get {
return ResourceManager.GetString("ButtonListsFillerPresets", resourceCulture);
}
}
internal static string ButtonScheduling {
get {
return ResourceManager.GetString("ButtonScheduling", resourceCulture);
}
}
internal static string ButtonSchedulingSchedules {
get {
return ResourceManager.GetString("ButtonSchedulingSchedules", resourceCulture);
}
}
internal static string ButtonSchedulingBlocks {
get {
return ResourceManager.GetString("ButtonSchedulingBlocks", resourceCulture);
}
}
internal static string ButtonSchedulingTemplates {
get {
return ResourceManager.GetString("ButtonSchedulingTemplates", resourceCulture);
}
}
internal static string ButtonSchedulingDecos {
get {
return ResourceManager.GetString("ButtonSchedulingDecos", resourceCulture);
}
}
internal static string ButtonSchedulingDecoTemplates {
get {
return ResourceManager.GetString("ButtonSchedulingDecoTemplates", resourceCulture);
}
}
internal static string ButtonSchedulingPlayouts {
get {
return ResourceManager.GetString("ButtonSchedulingPlayouts", resourceCulture);
}
}
internal static string ButtonSettingsFFmpeg {
get {
return ResourceManager.GetString("ButtonSettingsFFmpeg", resourceCulture);
}
}
internal static string ButtonSettingsLogging {
get {
return ResourceManager.GetString("ButtonSettingsLogging", resourceCulture);
}
}
internal static string ButtonSettingsHdHomeRun {
get {
return ResourceManager.GetString("ButtonSettingsHdHomeRun", resourceCulture);
}
}
internal static string ButtonSettingsScanner {
get {
return ResourceManager.GetString("ButtonSettingsScanner", resourceCulture);
}
}
internal static string ButtonSettingsPlayout {
get {
return ResourceManager.GetString("ButtonSettingsPlayout", resourceCulture);
}
}
internal static string ButtonSettingsUi {
get {
return ResourceManager.GetString("ButtonSettingsUi", resourceCulture);
}
}
internal static string ButtonSettingsXmltv {
get {
return ResourceManager.GetString("ButtonSettingsXmltv", resourceCulture);
}
}
internal static string ButtonSupport {
get {
return ResourceManager.GetString("ButtonSupport", resourceCulture);
}
}
internal static string ButtonSupportHealthChecks {
get {
return ResourceManager.GetString("ButtonSupportHealthChecks", resourceCulture);
}
}
internal static string ButtonSupportLogs {
get {
return ResourceManager.GetString("ButtonSupportLogs", resourceCulture);
}
}
internal static string ButtonSupportTroubleshooting {
get {
return ResourceManager.GetString("ButtonSupportTroubleshooting", resourceCulture);
}
}
internal static string LabelErsatzTVVersion {
get {
return ResourceManager.GetString("LabelErsatzTVVersion", resourceCulture);
}
}
internal static string LabelSearchTargetChannel {
get {
return ResourceManager.GetString("LabelSearchTargetChannel", resourceCulture);
}
}
internal static string LabelSearchTargetFFmpegProfile {
get {
return ResourceManager.GetString("LabelSearchTargetFFmpegProfile", resourceCulture);
}
}
internal static string LabelSearchTargetChannelWatermark {
get {
return ResourceManager.GetString("LabelSearchTargetChannelWatermark", resourceCulture);
}
}
internal static string LabelSearchTargetCollection {
get {
return ResourceManager.GetString("LabelSearchTargetCollection", resourceCulture);
}
}
internal static string LabelSearchTargetMultiCollection {
get {
return ResourceManager.GetString("LabelSearchTargetMultiCollection", resourceCulture);
}
}
internal static string LabelSearchTargetSmartCollection {
get {
return ResourceManager.GetString("LabelSearchTargetSmartCollection", resourceCulture);
}
}
internal static string LabelSearchTargetSchedule {
get {
return ResourceManager.GetString("LabelSearchTargetSchedule", resourceCulture);
}
}
internal static string LabelSearchTargetScheduleItems {
get {
return ResourceManager.GetString("LabelSearchTargetScheduleItems", resourceCulture);
}
}
internal static string ButtonM3U {
get {
return ResourceManager.GetString("ButtonM3U", resourceCulture);
}
}
internal static string ButtonXmltv {
get {
return ResourceManager.GetString("ButtonXmltv", resourceCulture);
}
}
internal static string ButtonApi {
get {
return ResourceManager.GetString("ButtonApi", resourceCulture);
}
}
internal static string ButtonDocumentation {
get {
return ResourceManager.GetString("ButtonDocumentation", resourceCulture);
}
}
internal static string ButtonGitHub {
get {
return ResourceManager.GetString("ButtonGitHub", resourceCulture);
}
}
internal static string ButtonLogout {
get {
return ResourceManager.GetString("ButtonLogout", resourceCulture);
}
}
internal static string ButtonContact {
get {
return ResourceManager.GetString("ButtonContact", resourceCulture);
}
}
}
}
-194
View File
@@ -1,194 +0,0 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ButtonChannels" xml:space="preserve">
<value>Kanały</value>
</data>
<data name="ButtonFFmpegProfiles" xml:space="preserve">
<value>Profile FFmpeg</value>
</data>
<data name="ButtonWatermarks" xml:space="preserve">
<value>Znaki wodne</value>
</data>
<data name="ButtonMediaSources" xml:space="preserve">
<value>Źródła multimediów</value>
</data>
<data name="ButtonMedia" xml:space="preserve">
<value>Multimedia</value>
</data>
<data name="ButtonLists" xml:space="preserve">
<value>Listy</value>
</data>
<data name="ButtonSettings" xml:space="preserve">
<value>Ustawienia</value>
</data>
<data name="ButtonMediaSourcesLocal" xml:space="preserve">
<value>Lokalne</value>
</data>
<data name="ButtonMediaSourcesEmby" xml:space="preserve">
<value>Emby</value>
</data>
<data name="ButtonMediaSourcesJellyfin" xml:space="preserve">
<value>Jellyfin</value>
</data>
<data name="ButtonMediaSourcesPlex" xml:space="preserve">
<value>Plex</value>
</data>
<data name="ButtonMediaLibraries" xml:space="preserve">
<value>Biblioteki</value>
</data>
<data name="ButtonMediaTrash" xml:space="preserve">
<value>Kosz</value>
</data>
<data name="ButtonMediaTvShows" xml:space="preserve">
<value>Seriale</value>
</data>
<data name="ButtonMediaMovies" xml:space="preserve">
<value>Filmy</value>
</data>
<data name="ButtonMediaMusic" xml:space="preserve">
<value>Muzyka</value>
</data>
<data name="ButtonMediaOtherVideos" xml:space="preserve">
<value>Inne wideo</value>
</data>
<data name="ButtonMediaSongs" xml:space="preserve">
<value>Utwory</value>
</data>
<data name="ButtonMediaImages" xml:space="preserve">
<value>Obrazy</value>
</data>
<data name="ButtonMediaRemoteStreams" xml:space="preserve">
<value>Strumienie zdalne</value>
</data>
<data name="ButtonListsManualCollections" xml:space="preserve">
<value>Kolekcje ręczne</value>
</data>
<data name="ButtonListsSmartCollections" xml:space="preserve">
<value>Inteligentne kolekcje</value>
</data>
<data name="ButtonListsMultiCollections" xml:space="preserve">
<value>Multikolekcje</value>
</data>
<data name="ButtonListsRerunCollections" xml:space="preserve">
<value>Kolekcje powtórek</value>
</data>
<data name="ButtonListsPlaylists" xml:space="preserve">
<value>Playlisty</value>
</data>
<data name="ButtonListsTraktLists" xml:space="preserve">
<value>Listy Trakt</value>
</data>
<data name="ButtonListsFillerPresets" xml:space="preserve">
<value>Szablony wypełniaczy</value>
</data>
<data name="ButtonScheduling" xml:space="preserve">
<value>Planowanie</value>
</data>
<data name="ButtonSchedulingSchedules" xml:space="preserve">
<value>Harmonogramy</value>
</data>
<data name="ButtonSchedulingBlocks" xml:space="preserve">
<value>Bloki</value>
</data>
<data name="ButtonSchedulingTemplates" xml:space="preserve">
<value>Szablony</value>
</data>
<data name="ButtonSchedulingDecos" xml:space="preserve">
<value>Dekoracje</value>
</data>
<data name="ButtonSchedulingDecoTemplates" xml:space="preserve">
<value>Szablony dekoracji</value>
</data>
<data name="ButtonSchedulingPlayouts" xml:space="preserve">
<value>Plany emisji</value>
</data>
<data name="ButtonSettingsFFmpeg" xml:space="preserve">
<value>FFmpeg</value>
</data>
<data name="ButtonSettingsLogging" xml:space="preserve">
<value>Dzienniki zdarzeń</value>
</data>
<data name="ButtonSettingsHdHomeRun" xml:space="preserve">
<value>HDHomeRun</value>
</data>
<data name="ButtonSettingsScanner" xml:space="preserve">
<value>Skaner</value>
</data>
<data name="ButtonSettingsPlayout" xml:space="preserve">
<value>Emisja</value>
</data>
<data name="ButtonSettingsUi" xml:space="preserve">
<value>Interfejs</value>
</data>
<data name="ButtonSettingsXmltv" xml:space="preserve">
<value>XMLTV</value>
</data>
<data name="ButtonSupport" xml:space="preserve">
<value>Pomoc</value>
</data>
<data name="ButtonSupportHealthChecks" xml:space="preserve">
<value>Diagnostyka</value>
</data>
<data name="ButtonSupportLogs" xml:space="preserve">
<value>Logi</value>
</data>
<data name="ButtonSupportTroubleshooting" xml:space="preserve">
<value>Rozwiązywanie problemów</value>
</data>
<data name="LabelErsatzTVVersion" xml:space="preserve">
<value>Wersja ErsatzTV</value>
</data>
<data name="LabelSearchTargetChannel" xml:space="preserve">
<value>Kanał</value>
</data>
<data name="LabelSearchTargetFFmpegProfile" xml:space="preserve">
<value>Profil FFmpeg</value>
</data>
<data name="LabelSearchTargetChannelWatermark" xml:space="preserve">
<value>Znak wodny kanału</value>
</data>
<data name="LabelSearchTargetCollection" xml:space="preserve">
<value>Kolekcja</value>
</data>
<data name="LabelSearchTargetMultiCollection" xml:space="preserve">
<value>Multikolekcja</value>
</data>
<data name="LabelSearchTargetSmartCollection" xml:space="preserve">
<value>Inteligentna kolekcja</value>
</data>
<data name="LabelSearchTargetSchedule" xml:space="preserve">
<value>Harmonogram</value>
</data>
<data name="LabelSearchTargetScheduleItems" xml:space="preserve">
<value>Pozycje harmonogramu</value>
</data>
<data name="ButtonM3U" xml:space="preserve">
<value>M3U</value>
</data>
<data name="ButtonXmltv" xml:space="preserve">
<value>XMLTV</value>
</data>
<data name="ButtonApi" xml:space="preserve">
<value>API</value>
</data>
<data name="ButtonDocumentation" xml:space="preserve">
<value>Dokumentacja</value>
</data>
<data name="ButtonGitHub" xml:space="preserve">
<value>GitHub</value>
</data>
<data name="ButtonLogout" xml:space="preserve">
<value>Wyloguj</value>
</data>
</root>
@@ -1,59 +0,0 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ButtonChannels" xml:space="preserve">
<value>Canais</value>
</data>
<data name="ButtonFFmpegProfiles" xml:space="preserve">
<value>Perfis FFmpeg</value>
</data>
<data name="ButtonWatermarks" xml:space="preserve">
<value>Marcas d'água</value>
</data>
<data name="ButtonMediaSources" xml:space="preserve">
<value>Fontes de mídia</value>
</data>
<data name="ButtonMedia" xml:space="preserve">
<value>Mídias</value>
</data>
<data name="ButtonLists" xml:space="preserve">
<value>Listas</value>
</data>
<data name="ButtonSettings" xml:space="preserve">
<value>Definições</value>
</data>
<data name="ButtonMediaTvShows" xml:space="preserve">
<value>Séries</value>
</data>
<data name="ButtonMediaTrash" xml:space="preserve">
<value>Lixo</value>
</data>
<data name="ButtonMediaMovies" xml:space="preserve">
<value>Filmes</value>
</data>
<data name="ButtonMediaMusic" xml:space="preserve">
<value>Vídeos de música</value>
</data>
<data name="ButtonMediaLibraries" xml:space="preserve">
<value>Bibliotecas</value>
</data>
<data name="ButtonSchedulingSchedules" xml:space="preserve">
<value>Horários</value>
</data>
<data name="ButtonListsFillerPresets" xml:space="preserve">
<value>Predefinições de preenchimento</value>
</data>
<data name="ButtonListsTraktLists" xml:space="preserve">
<value>Listas Trakt</value>
</data>
</root>
-207
View File
@@ -1,207 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ButtonChannels" xml:space="preserve">
<value>Channels</value>
</data>
<data name="ButtonFFmpegProfiles" xml:space="preserve">
<value>FFmpeg Profiles</value>
</data>
<data name="ButtonWatermarks" xml:space="preserve">
<value>Watermarks</value>
</data>
<data name="ButtonMediaSources" xml:space="preserve">
<value>Media Sources</value>
</data>
<data name="ButtonMedia" xml:space="preserve">
<value>Media</value>
</data>
<data name="ButtonLists" xml:space="preserve">
<value>Lists</value>
</data>
<data name="ButtonSettings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="ButtonMediaSourcesLocal" xml:space="preserve">
<value>Local</value>
</data>
<data name="ButtonMediaSourcesEmby" xml:space="preserve">
<value>Emby</value>
</data>
<data name="ButtonMediaSourcesJellyfin" xml:space="preserve">
<value>Jellyfin</value>
</data>
<data name="ButtonMediaSourcesPlex" xml:space="preserve">
<value>Plex</value>
</data>
<data name="ButtonMediaLibraries" xml:space="preserve">
<value>Libraries</value>
</data>
<data name="ButtonMediaTrash" xml:space="preserve">
<value>Trash</value>
</data>
<data name="ButtonMediaTvShows" xml:space="preserve">
<value>TV Shows</value>
</data>
<data name="ButtonMediaMovies" xml:space="preserve">
<value>Movies</value>
</data>
<data name="ButtonMediaMusic" xml:space="preserve">
<value>Music</value>
</data>
<data name="ButtonMediaOtherVideos" xml:space="preserve">
<value>Other Videos</value>
</data>
<data name="ButtonMediaSongs" xml:space="preserve">
<value>Songs</value>
</data>
<data name="ButtonMediaImages" xml:space="preserve">
<value>Images</value>
</data>
<data name="ButtonMediaRemoteStreams" xml:space="preserve">
<value>Remote Streams</value>
</data>
<data name="ButtonListsManualCollections" xml:space="preserve">
<value>Manual Collections</value>
</data>
<data name="ButtonListsSmartCollections" xml:space="preserve">
<value>Smart Collections</value>
</data>
<data name="ButtonListsMultiCollections" xml:space="preserve">
<value>Multi Collections</value>
</data>
<data name="ButtonListsRerunCollections" xml:space="preserve">
<value>Rerun Collections</value>
</data>
<data name="ButtonListsPlaylists" xml:space="preserve">
<value>Playlists</value>
</data>
<data name="ButtonListsTraktLists" xml:space="preserve">
<value>Trakt Lists</value>
</data>
<data name="ButtonListsFillerPresets" xml:space="preserve">
<value>Filler Presets</value>
</data>
<data name="ButtonScheduling" xml:space="preserve">
<value>Scheduling</value>
</data>
<data name="ButtonSchedulingSchedules" xml:space="preserve">
<value>Schedules</value>
</data>
<data name="ButtonSchedulingBlocks" xml:space="preserve">
<value>Blocks</value>
</data>
<data name="ButtonSchedulingTemplates" xml:space="preserve">
<value>Templates</value>
</data>
<data name="ButtonSchedulingDecos" xml:space="preserve">
<value>Decos</value>
</data>
<data name="ButtonSchedulingDecoTemplates" xml:space="preserve">
<value>Deco Templates</value>
</data>
<data name="ButtonSchedulingPlayouts" xml:space="preserve">
<value>Playouts</value>
</data>
<data name="ButtonSettingsFFmpeg" xml:space="preserve">
<value>FFmpeg</value>
</data>
<data name="ButtonSettingsLogging" xml:space="preserve">
<value>Logging</value>
</data>
<data name="ButtonSettingsHdHomeRun" xml:space="preserve">
<value>HDHomeRun</value>
</data>
<data name="ButtonSettingsScanner" xml:space="preserve">
<value>Scanner</value>
</data>
<data name="ButtonSettingsPlayout" xml:space="preserve">
<value>Playout</value>
</data>
<data name="ButtonSettingsUi" xml:space="preserve">
<value>UI</value>
</data>
<data name="ButtonSettingsXmltv" xml:space="preserve">
<value>XMLTV</value>
</data>
<data name="ButtonOpensInChicoryTv" xml:space="preserve">
<value>Opens in ChicoryTV</value>
</data>
<data name="ButtonSupport" xml:space="preserve">
<value>Support</value>
</data>
<data name="ButtonSupportHealthChecks" xml:space="preserve">
<value>Health Checks</value>
</data>
<data name="ButtonSupportLogs" xml:space="preserve">
<value>Logs</value>
</data>
<data name="ButtonSupportTroubleshooting" xml:space="preserve">
<value>Troubleshooting</value>
</data>
<data name="LabelErsatzTVVersion" xml:space="preserve">
<value>ErsatzTV Version</value>
</data>
<data name="LabelSearchTargetChannel" xml:space="preserve">
<value>Channel</value>
</data>
<data name="LabelSearchTargetFFmpegProfile" xml:space="preserve">
<value>FFmpeg Profile</value>
</data>
<data name="LabelSearchTargetChannelWatermark" xml:space="preserve">
<value>Channel Watermark</value>
</data>
<data name="LabelSearchTargetCollection" xml:space="preserve">
<value>Collection</value>
</data>
<data name="LabelSearchTargetMultiCollection" xml:space="preserve">
<value>Multi Collection</value>
</data>
<data name="LabelSearchTargetSmartCollection" xml:space="preserve">
<value>Smart Collection</value>
</data>
<data name="LabelSearchTargetSchedule" xml:space="preserve">
<value>Schedule</value>
</data>
<data name="LabelSearchTargetScheduleItems" xml:space="preserve">
<value>Schedule Items</value>
</data>
<data name="ButtonM3U" xml:space="preserve">
<value>M3U</value>
</data>
<data name="ButtonXmltv" xml:space="preserve">
<value>XMLTV</value>
</data>
<data name="ButtonApi" xml:space="preserve">
<value>API</value>
</data>
<data name="ButtonDocumentation" xml:space="preserve">
<value>Documentation</value>
</data>
<data name="ButtonGitHub" xml:space="preserve">
<value>GitHub</value>
</data>
<data name="ButtonLogout" xml:space="preserve">
<value>Logout</value>
</data>
<data name="ButtonContact" xml:space="preserve">
<value>Contact</value>
</data>
</root>
-339
View File
@@ -1,339 +0,0 @@
@page "/media/music/artists/{ArtistId:int}"
@using System.Globalization
@using ErsatzTV.Application.Artists
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Core.Search
@using ErsatzTV.Extensions
@implements IDisposable
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ILogger<Artist> Logger
@inject ISnackbar Snackbar
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_artist?.FanArt))
{
<img src="@($"artwork/fanart/{_artist.FanArt}")" alt="fan art"/>
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
@if (!string.IsNullOrWhiteSpace(_artist?.Thumbnail))
{
<MudImage Elevation="2" Src="@($"artwork/thumbnails/{_artist.Thumbnail}")" Class="rounded-lg z-10" Style="height: 220px; width: 220px; margin-left: auto; margin-right: auto;"/>
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Class="rounded-lg z-10" Animation="Animation.False" Height="220px" Width="220px" Style="margin-left: auto; margin-right: auto"/>
}
<div style="display: flex; flex-direction: column; height: 100%; width: 100%" class="z-10">
<MudStack Row="false">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_artist?.Name</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_artist?.Name</MudText>
</MudHidden>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_artist?.Disambiguation</MudText>
</MudStack>
<MudCard Elevation="2" Class="mb-6">
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
@if (!string.IsNullOrWhiteSpace(_artist?.Biography))
{
<MudText Style="flex-grow: 1">
@if (_artist.Biography.Length > 400)
{
@(_artist.Biography.Substring(0, 400) + "...")
}
else
{
@_artist.Biography
}
</MudText>
}
else
{
<div style="height: 130px; flex-grow: 1">
</div>
}
</MudCardContent>
</MudCard>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
</MudStack>
</div>
</MudStack>
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
}
</div>
}
@if (_sortedGenres.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@if (_sortedStyles.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Styles:&nbsp;</MudText>
<MudLink Href="@(@$"style:""{_sortedStyles.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStyles.Head()</MudLink>
@foreach (string style in _sortedStyles.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"style:""{style.ToLowerInvariant()}""".GetRelativeSearchQuery())">@style</MudLink>
}
</div>
}
@if (_sortedMoods.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Moods:&nbsp;</MudText>
<MudLink Href="@(@$"mood:""{_sortedMoods.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedMoods.Head()</MudLink>
@foreach (string mood in _sortedMoods.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"mood:""{mood.ToLowerInvariant()}""".GetRelativeSearchQuery())">@mood</MudLink>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="false" Spacing="6">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
{
<MudCard>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" id="@($"music-video-{musicVideo.MusicVideoId}")">
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster))
{
<div style="display: flex; flex-direction: column; position: relative">
<MudImage Src="@($"artwork/thumbnails/{musicVideo.Poster}")" Style="height: 220px; max-width: 265px; margin-left: auto; margin-right: auto;"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
else
{
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
@if (musicVideo.State is not MediaItemState.Normal and not MediaItemState.RemoteOnly)
{
<div style="display: flex; position: relative; height: 220px; width: 265px; ">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<div style="display: flex; position: relative; height: 220px; width: 265px; ">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
</MudHidden>
}
<MudCardContent>
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@musicVideo.Title</MudText>
@if (!string.IsNullOrWhiteSpace(musicVideo.Album))
{
<div style="display: flex; flex-direction: row">
<MudText GutterBottom="true">Album:&nbsp;</MudText>
<MudLink Href="@(@$"album:""{musicVideo.Album}""".GetRelativeSearchQuery())">@musicVideo.Album</MudLink>
</div>
}
<MudText Style="flex-grow: 1">@musicVideo.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddMusicVideoToCollection(musicVideo))">
Add To Collection
</MudButton>
</div>
</div>
</MudCardContent>
</MudStack>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@musicVideo.Path</MudText>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
<MudText>Unavailable:&nbsp;</MudText>
<MudText>@musicVideo.LocalPath</MudText>
</div>
}
</MudCard>
}
</MudStack>
</MudContainer>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int ArtistId { get; set; }
private ArtistViewModel _artist;
private List<CultureInfo> _sortedLanguages = [];
private List<string> _sortedGenres = [];
private List<string> _sortedStyles = [];
private List<string> _sortedMoods = [];
private MusicVideoCardResultsViewModel _musicVideos = new(0, [], new SearchPageMap([]));
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await Mediator.Send(new GetArtistById(ArtistId), token).IfSomeAsync(vm =>
{
_artist = vm;
_sortedLanguages = _artist.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedGenres = _artist.Genres.OrderBy(g => g).ToList();
_sortedStyles = _artist.Styles.OrderBy(s => s).ToList();
_sortedMoods = _artist.Moods.OrderBy(m => m).ToList();
});
_musicVideos = await Mediator.Send(new GetMusicVideoCards(ArtistId, 1, 100), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task AddToCollection()
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
await Mediator.Send(new AddArtistToCollection(collection.Id, ArtistId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
}
}
private async Task AddToSchedule()
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.Artist, ArtistId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/music/artists"
@page "/media/music/artists/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<MusicVideoList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="ArtistCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href="@($"media/music/artists/{context.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/music/artists"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private ArtistCardResultsViewModel _data = new(0, new List<ArtistCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:artist" : $"type:artist AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexArtists(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/music/artists/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/music/artists/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is ArtistCardViewModel artist)
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddArtistToCollection(collection.Id, artist.ArtistId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}");
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-633
View File
@@ -1,633 +0,0 @@
@page "/blocks/{Id:int}"
@using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<BlockEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@SaveChanges" StartIcon="@Icons.Material.Filled.Save">
Save Block
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@AddBlockItem" StartIcon="@Icons.Material.Filled.PlaylistAdd">
Add Block Item
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@PreviewPlayout" StartIcon="@Icons.Material.Filled.Preview">
Preview Block Playout
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Block" OnClick="@SaveChanges"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Block Item" OnClick="@AddBlockItem"/>
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Block Playout" OnClick="@PreviewPlayout"/>
</MudMenu>
</div>
</div>
</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">Block</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>Block Group Name</MudText>
</div>
<MudTextField @bind-Value="_block.GroupName" Disabled="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Block Name</MudText>
</div>
<MudTextField @bind-Value="_block.Name" For="@(() => _block.Name)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Duration</MudText>
</div>
<MudTextField T="int"
@bind-Value="_durationHours"
Adornment="Adornment.End"
AdornmentText="hours"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_durationMinutes" Adornment="Adornment.End" AdornmentText="minutes">
<MudSelectItem Value="0"/>
<MudSelectItem Value="5"/>
<MudSelectItem Value="10"/>
<MudSelectItem Value="15"/>
<MudSelectItem Value="20"/>
<MudSelectItem Value="25"/>
<MudSelectItem Value="30"/>
<MudSelectItem Value="35"/>
<MudSelectItem Value="40"/>
<MudSelectItem Value="45"/>
<MudSelectItem Value="50"/>
<MudSelectItem Value="55"/>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Stop scheduling block items</MudText>
</div>
<MudSelect T="BlockStopScheduling"
@bind-Value="_block.StopScheduling"
For="@(() => _block.StopScheduling)">
<MudSelectItem Value="BlockStopScheduling.BeforeDurationEnd">Before Duration End</MudSelectItem>
<MudSelectItem Value="BlockStopScheduling.AfterDurationEnd">After Duration End</MudSelectItem>
</MudSelect>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Block Items</MudText>
<MudDivider Class="mb-6"/>
<MudTable Class="mt-6" Hover="true" Items="_block.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col/>
<col style="width: 240px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Collection</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh>Disable Watermarks</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Collection">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.CollectionName
</MudText>
</MudTd>
<MudTd DataLabel="Playback Order">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlaybackOrder
</MudText>
</MudTd>
<MudTd DataLabel="Show In EPG">
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))"/>
</MudTd>
<MudTd DataLabel="Disable Watermarks">
<MudCheckBox T="bool" Value="@context.DisableWatermarks" ValueChanged="@(e => UpdateWatermark(context, e))"/>
</MudTd>
<MudTd>
<div class="d-flex">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyItem(context))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_block.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_block.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveBlockItem(context))">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedItem is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Block Item</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>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
@* <MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem> *@
@* <MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem> *@
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SearchQuery">Search Query</MudSelectItem>
</MudSelect>
</MudStack>
@if (_selectedItem.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_selectedItem.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_selectedItem.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_selectedItem.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.SearchQuery)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Search Title</MudText>
</div>
<MudTextField @bind-Value="@_selectedItem.SearchTitle"
For="@(() => _selectedItem.SearchTitle)"
HelperText="Optional title that will be displayed in the UI"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Search Query</MudText>
</div>
<MudTextField @bind-Value="@_selectedItem.SearchQuery" For="@(() => _selectedItem.SearchQuery)"/>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
@switch (_selectedItem.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.RandomRotation">Random Rotation</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem> *@
break;
case CollectionType.TelevisionSeason:
case CollectionType.Artist:
case CollectionType.FakeCollection:
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermarks</MudText>
</div>
<MudSelect T="WatermarkViewModel"
@bind-SelectedValues="_selectedItem.Watermarks"
ToStringFunc="@(wm => wm?.Name)"
Clearable="true"
MultiSelection="true">
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem Value="@watermark">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Graphics Elements</MudText>
</div>
<MudSelect T="GraphicsElementViewModel"
@bind-SelectedValues="_selectedItem.GraphicsElements"
ToStringFunc="@(ge => ge?.Name)"
Clearable="true"
MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
{
<MudSelectItem Value="@graphicsElement">@graphicsElement.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
else if (_previewItems != null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
Items="_previewItems">
<HeaderContent>
<MudTh>Start</MudTh>
<MudTh>Finish</MudTh>
<MudTh>Media Item</MudTh>
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start">@context.Start.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Finish">@context.Finish.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Media Item">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private BlockItemsEditViewModel _block = new() { Items = [] };
private BlockItemEditViewModel _selectedItem;
private List<WatermarkViewModel> _watermarks = [];
private List<GraphicsElementViewModel> _graphicsElements = [];
private List<PlayoutItemPreviewViewModel> _previewItems;
private int _durationHours;
private int _durationMinutes = 15;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<BlockViewModel> maybeBlock = await Mediator.Send(new GetBlockById(Id), token);
if (maybeBlock.IsNone)
{
NavigationManager.NavigateTo("blocks");
return;
}
_watermarks = await Mediator.Send(new GetAllWatermarks(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_graphicsElements = await Mediator.Send(new GetAllGraphicsElements(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
foreach (BlockViewModel block in maybeBlock)
{
_block = new BlockItemsEditViewModel
{
GroupId = block.GroupId,
GroupName = block.GroupName,
Name = block.Name,
Minutes = block.Minutes,
StopScheduling = block.StopScheduling,
Items = []
};
_durationHours = _block.Minutes / 60;
_durationMinutes = _block.Minutes % 60;
}
Option<IEnumerable<BlockItemViewModel>> maybeResults = await Mediator.Send(new GetBlockItems(Id), token);
foreach (IEnumerable<BlockItemViewModel> items in maybeResults)
{
_block.Items.AddRange(items.Map(ProjectToEditViewModel));
if (_block.Items.Count == 1)
{
_selectedItem = _block.Items.Head();
}
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
// private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value, CancellationToken cancellationToken)
// {
// if (string.IsNullOrWhiteSpace(value))
// {
// return new List<NamedMediaItemViewModel>();
// }
//
// return await Mediator.Send(new SearchArtists(value), _cts.Token);
// }
private static BlockItemEditViewModel ProjectToEditViewModel(BlockItemViewModel item) =>
new()
{
Id = item.Id,
Index = item.Index,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
SearchTitle = item.SearchTitle,
SearchQuery = item.SearchQuery,
PlaybackOrder = item.PlaybackOrder,
IncludeInProgramGuide = item.IncludeInProgramGuide,
DisableWatermarks = item.DisableWatermarks,
Watermarks = item.Watermarks,
GraphicsElements = item.GraphicsElements
};
private void AddBlockItem()
{
var item = new BlockItemEditViewModel
{
Index = _block.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
PlaybackOrder = PlaybackOrder.Chronological,
CollectionType = CollectionType.Collection,
Watermarks = [],
GraphicsElements = []
};
_block.Items.Add(item);
_selectedItem = item;
}
private void CopyItem(BlockItemEditViewModel item)
{
var newItem = new BlockItemEditViewModel
{
Index = item.Index + 1,
PlaybackOrder = item.PlaybackOrder,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
SearchTitle = item.SearchTitle,
SearchQuery = item.SearchQuery,
IncludeInProgramGuide = item.IncludeInProgramGuide,
DisableWatermarks = item.DisableWatermarks,
Watermarks = item.Watermarks.ToList(),
GraphicsElements = item.GraphicsElements.ToList()
};
foreach (BlockItemEditViewModel i in _block.Items.Filter(bi => bi.Index >= newItem.Index))
{
i.Index += 1;
}
_block.Items.Add(newItem);
_selectedItem = newItem;
}
private void RemoveBlockItem(BlockItemEditViewModel item)
{
_selectedItem = null;
_block.Items.Remove(item);
}
private void MoveItemUp(BlockItemEditViewModel item)
{
// swap with lower index
BlockItemEditViewModel toSwap = _block.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(BlockItemEditViewModel item)
{
// swap with higher index
BlockItemEditViewModel toSwap = _block.Items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private async Task SaveChanges()
{
Seq<BaseError> errorMessages = await Mediator
.Send(GenerateReplaceRequest(), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving block: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving block: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("blocks"));
}
private ReplaceBlockItems GenerateReplaceRequest()
{
var items = _block.Items.Map(item => new ReplaceBlockItem(
item.Index,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.SearchTitle,
item.SearchQuery,
item.PlaybackOrder,
item.IncludeInProgramGuide,
item.DisableWatermarks,
item.Watermarks.Map(wm => wm.Id).ToList(),
item.GraphicsElements.Map(wm => wm.Id).ToList())
).ToList();
_block.Minutes = _durationHours * 60 + _durationMinutes;
return new ReplaceBlockItems(_block.GroupId, Id, _block.Name, _block.Minutes, _block.StopScheduling, items);
}
private async Task PreviewPlayout()
{
_selectedItem = null;
_previewItems = await Mediator.Send(new PreviewBlockPlayout(GenerateReplaceRequest()), _cts.Token);
}
private static void UpdateEPG(BlockItemEditViewModel context, bool includeInProgramGuide) => context.IncludeInProgramGuide = includeInProgramGuide;
private static void UpdateWatermark(BlockItemEditViewModel context, bool disableWatermarks) => context.DisableWatermarks = disableWatermarks;
private string SelectedRowClassFunc(BlockItemEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
}
-175
View File
@@ -1,175 +0,0 @@
@page "/playouts/block/{Id:int}"
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker EntityLocker;
@inject ILogger<BlockPlayoutEditor> Logger
<MudForm 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="@(_ => SaveDefaultDeco())" StartIcon="@Icons.Material.Filled.Save">
Save Default Deco
</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">@_channelName - Block Playout</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>Playout Templates</MudText>
</div>
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Primary" Href="@($"playouts/{Id}/templates")" StartIcon="@Icons.Material.Filled.Edit">
Edit Templates
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Default Deco</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>Enable Default Deco</MudText>
</div>
<MudCheckBox @bind-Value="_enableDefaultDeco" Dense="true"/>
</MudStack>
@if (_enableDefaultDeco)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Group</MudText>
</div>
<MudSelect T="DecoGroupViewModel"
Value="@_selectedDefaultDecoGroup"
ValueChanged="@(vm => UpdateDefaultDecoTemplateGroupItems(vm))">
@foreach (DecoGroupViewModel decoGroup in _decoGroups)
{
<MudSelectItem Value="@decoGroup">@decoGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco</MudText>
</div>
<MudSelect @bind-Value="_defaultDeco"
For="@(() => _defaultDeco)"
ToStringFunc="@(d => d?.Name)">
@foreach (DecoViewModel deco in _decos)
{
<MudSelectItem Value="@deco">@deco.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
</div>
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Warning" OnClick="@(_ => EraseItems(eraseHistory: false))" StartIcon="@Icons.Material.Filled.Delete">
Erase Items
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItems(eraseHistory: true))" StartIcon="@Icons.Material.Filled.Delete">
Erase Items and History
</MudButton>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly List<DecoGroupViewModel> _decoGroups = [];
private readonly List<DecoViewModel> _decos = [];
private string _channelName;
private bool _enableDefaultDeco;
private DecoGroupViewModel _selectedDefaultDecoGroup;
private DecoViewModel _defaultDeco;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<string> maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), token);
if (maybeName.IsNone)
{
NavigationManager.NavigateTo("playouts");
return;
}
foreach (string name in maybeName)
{
_channelName = name;
}
_decoGroups.Clear();
_decoGroups.AddRange(await Mediator.Send(new GetAllDecoGroups(), token));
Option<DecoViewModel> maybeDefaultDeco = await Mediator.Send(new GetDecoByPlayoutId(Id), token);
foreach (DecoViewModel defaultDeco in maybeDefaultDeco)
{
_enableDefaultDeco = true;
_selectedDefaultDecoGroup = _decoGroups.SingleOrDefault(dg => dg.Id == defaultDeco.DecoGroupId);
await UpdateDefaultDecoTemplateGroupItems(_selectedDefaultDecoGroup);
_defaultDeco = defaultDeco;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task UpdateDefaultDecoTemplateGroupItems(DecoGroupViewModel decoGroup)
{
_selectedDefaultDecoGroup = decoGroup;
_decos.Clear();
_decos.AddRange(await Mediator.Send(new GetDecosByDecoGroupId(_selectedDefaultDecoGroup.Id), _cts.Token));
}
private async Task EraseItems(bool eraseHistory)
{
IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id);
await Mediator.Send(request, _cts.Token);
string message = eraseHistory ? "Erased playout items and history" : "Erased playout items";
Snackbar.Add(message, Severity.Info);
}
private async Task SaveDefaultDeco()
{
int? decoId = _enableDefaultDeco ? _defaultDeco?.Id : null;
Option<BaseError> result = await Mediator.Send(new UpdateDefaultDeco(Id, decoId), _cts.Token);
result.Match(
error =>
{
Snackbar.Add($"Unexpected error saving default deco: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving default deco: {Error}", error.Value);
},
() => { Snackbar.Add($"Saved default deco for playout {_channelName}", Severity.Success); });
}
}
-303
View File
@@ -1,303 +0,0 @@
@page "/blocks"
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject ILogger<Blocks> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<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">Block Groups</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>Block Group Name</MudText>
</div>
<MudTextField @bind-Value="_blockGroupName" For="@(() => _blockGroupName)"/>
</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.Primary" OnClick="@(_ => AddBlockGroup())" StartIcon="@Icons.Material.Filled.Add">
Add Block Group
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Blocks</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>Block Group</MudText>
</div>
<MudSelect T="string" ValueChanged="@(name => UpdateSelectedBlockGroup(name))">
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
{
<MudSelectItem Value="@blockGroup.Name">@blockGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Block Name</MudText>
</div>
<MudTextField @bind-Value="_blockName" For="@(() => _blockName)"/>
</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.Primary" OnClick="@(_ => AddBlock())" StartIcon="@Icons.Material.Filled.Add">
Add Block
</MudButton>
</MudStack>
<MudTable Hover="true"
Dense="true"
Class="mt-8"
Items="@_blocks"
GroupBy="@_groupDefinition"
GroupHeaderStyle="background-color:var(--mud-palette-appbarbackground)"
RowStyle="background-color:var(--mud-palette-background-gray)"
Filter="new Func<BlockViewModel,bool>(FilterBlocks)">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col style="width: 60px;"/>
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for blocks"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<GroupHeaderTemplate>
<MudTd Class="mud-table-cell-custom-group">
@($"{context.Key}")
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<div style="width: 48px;"></div>
<div style="width: 48px;"></div>
<MudTooltip Text="Delete Block Group">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteBlockGroup(context.Items?.FirstOrDefault()))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</GroupHeaderTemplate>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>
<div class="d-flex">
@if (context.Id >= 0)
{
<MudTooltip Text="Edit Block">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"blocks/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Copy Block">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyBlock(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Block">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteBlock(context))">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="height: 48px; width: 48px"></div>
}
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<BlockViewModel> _blocks = [];
private List<BlockGroupViewModel> _blockGroups = [];
private BlockGroupViewModel _selectedBlockGroup;
private string _blockGroupName;
private string _blockName;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), token);
_blocks.Clear();
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), token));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private readonly TableGroupDefinition<BlockViewModel> _groupDefinition = new()
{
GroupName = "Group",
Indentation = false,
Expandable = true,
Selector = (e) => e.GroupName
};
private async Task AddBlockGroup()
{
if (!string.IsNullOrWhiteSpace(_blockGroupName))
{
Either<BaseError, BlockGroupViewModel> result = await Mediator.Send(new CreateBlockGroup(_blockGroupName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding block group: {Error}", error.Value);
}
foreach (BlockGroupViewModel blockGroup in result.RightToSeq())
{
_blockGroupName = null;
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), _cts.Token);
_selectedBlockGroup = _blockGroups.Find(bg => bg.Id == blockGroup.Id);
_blocks.Clear();
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
await InvokeAsync(StateHasChanged);
}
}
}
private void UpdateSelectedBlockGroup(string blockGroupName)
{
_selectedBlockGroup = _blockGroups.Find(bg => bg.Name == blockGroupName);
InvokeAsync(StateHasChanged);
}
private async Task AddBlock()
{
if (_selectedBlockGroup is not null && !string.IsNullOrWhiteSpace(_blockName))
{
Either<BaseError, BlockViewModel> result = await Mediator.Send(new CreateBlock(_selectedBlockGroup.Id, _blockName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding block: {Error}", error.Value);
}
if (result.IsRight)
{
_blocks.Clear();
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
_blockName = null;
await InvokeAsync(StateHasChanged);
}
}
}
private void OnSearch(string query)
{
_searchString = query;
}
private bool FilterBlocks(BlockViewModel block) => FilterBlocks(block, _searchString);
private bool FilterBlocks(BlockViewModel block, string searchString)
{
if (string.IsNullOrWhiteSpace(searchString))
{
return true;
}
if (block.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private async Task DeleteBlockGroup(BlockViewModel block)
{
if (block is null)
{
return;
}
var parameters = new DialogParameters { { "EntityType", "block group" }, { "EntityName", block.GroupName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Block Group", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteBlockGroup(block.GroupId), _cts.Token);
if (_selectedBlockGroup?.Id == block.GroupId)
{
_selectedBlockGroup = null;
}
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), _cts.Token);
_blocks.Clear();
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
await InvokeAsync(StateHasChanged);
}
}
private async Task CopyBlock(BlockViewModel block)
{
var parameters = new DialogParameters { { "BlockId", block.Id } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<CopyBlockDialog>("Copy Block", parameters, options);
DialogResult dialogResult = await dialog.Result;
if (dialogResult is { Canceled: false, Data: BlockViewModel data })
{
NavigationManager.NavigateTo($"blocks/{data.Id}");
}
}
private async Task DeleteBlock(BlockViewModel block)
{
var parameters = new DialogParameters { { "EntityType", "block" }, { "EntityName", block.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Block", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteBlock(block.Id), _cts.Token);
_blocks.Clear();
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
await InvokeAsync(StateHasChanged);
}
}
}
-527
View File
@@ -1,527 +0,0 @@
@page "/channels/{Id:int?}"
@page "/channels/add"
@using System.Globalization
@using ErsatzTV.Application.Artworks
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.Images
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Templates
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.Validators
@using FluentValidation.Results
@using static Prelude
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<ChannelEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" 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="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Channel" : "Add Channel")</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">Channel</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>Number</MudText>
</div>
<MudTextField @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
</MudStack>
<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)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Group</MudText>
</div>
<MudTextField @bind-Value="_model.Group" For="@(() => _model.Group)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Categories</MudText>
</div>
<MudTextField @bind-Value="_model.Categories" For="@(() => _model.Categories)" HelperText="Comma-separated list of categories"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Transcode Mode</MudText>
</div>
<MudSelect @bind-Value="_model.TranscodeMode" For="@(() => _model.TranscodeMode)" Disabled="true" HelperText="Controls when the transcoding process is active">
<MudSelectItem Value="@(ChannelTranscodeMode.OnDemand)">On Demand</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Idle Behavior</MudText>
</div>
<MudSelect @bind-Value="_model.IdleBehavior" For="@(() => _model.IdleBehavior)" HelperText="Controls how the transcoding process behaves after all clients have disconnected">
<MudSelectItem Value="@(ChannelIdleBehavior.StopOnDisconnect)">Stop On Disconnect</MudSelectItem>
<MudSelectItem Value="@(ChannelIdleBehavior.KeepRunning)">Keep Running</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Source</MudText>
</div>
<MudSelect @bind-Value="_model.PlayoutSource"
For="@(() => _model.PlayoutSource)"
HelperText="Controls the source of the channel's content. This cannot be changed when the channel already has a playout."
Disabled="@(_channels.Any(c => c.PlayoutSource is ChannelPlayoutSource.Generated && c.Id == Id && c.PlayoutCount > 0))">
<MudSelectItem Value="@(ChannelPlayoutSource.Generated)">Generated</MudSelectItem>
<MudSelectItem Value="@(ChannelPlayoutSource.Mirror)">Mirror</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.PlayoutSource is ChannelPlayoutSource.Mirror)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Mirror Source Channel</MudText>
</div>
<MudSelect @bind-Value="_model.MirrorSourceChannelId" For="@(() => _model.MirrorSourceChannelId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (ChannelViewModel channel in _channels
.Where(c => c.Id != Id && c.PlayoutSource is ChannelPlayoutSource.Generated)
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)))
{
<MudSelectItem T="int?" Value="@channel.Id">(@channel.Number) - @channel.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Offset</MudText>
</div>
<MudTextField T="int"
@bind-Value="_model.PlayoutOffsetHours"
Adornment="Adornment.End"
AdornmentText="hours"/>
</MudStack>
}
else
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Mode</MudText>
</div>
<MudSelect @bind-Value="_model.PlayoutMode" For="@(() => _model.PlayoutMode)" HelperText="Controls the progression of the channel's playout">
<MudSelectItem Value="@(ChannelPlayoutMode.Continuous)">Continuous</MudSelectItem>
<MudSelectItem Value="@(ChannelPlayoutMode.OnDemand)">On Demand</MudSelectItem>
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Is Enabled</MudText>
</div>
<MudCheckBox @bind-Value="_model.IsEnabled" For="@(() => _model.IsEnabled)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Show In EPG</MudText>
</div>
<MudCheckBox @bind-Value="_model.ShowInEpg" For="@(() => _model.ShowInEpg)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Streaming Mode</MudText>
</div>
<MudSelect @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
<MudSelectItem Value="@(StreamingMode.TransportStreamHybrid)">MPEG-TS</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS (Legacy)</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>FFmpeg Profile</MudText>
</div>
<MudSelect @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Slug Seconds</MudText>
</div>
<MudSelect T="double?" @bind-Value="_model.SlugSeconds" For="@(() => _model.SlugSeconds)" HelperText="Seconds of black video and silent audio to insert between *every* playout item">
<MudSelectItem Value="@((double?)null)">(none)</MudSelectItem>
<MudSelectItem Value="@((double?)0.5)">0.5 seconds</MudSelectItem>
<MudSelectItem Value="@((double?)1)">1 second</MudSelectItem>
<MudSelectItem Value="@((double?)2)">2 seconds</MudSelectItem>
<MudSelectItem Value="@((double?)3)">3 seconds</MudSelectItem>
<MudSelectItem Value="@((double?)5)">5 seconds</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Stream Selector Mode</MudText>
</div>
<MudSelect @bind-Value="_model.StreamSelectorMode" For="@(() => _model.StreamSelectorMode)">
<MudSelectItem Value="@(ChannelStreamSelectorMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelStreamSelectorMode.Custom)">Custom</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.StreamSelectorMode is ChannelStreamSelectorMode.Default)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preferred Audio Language</MudText>
</div>
<MudSelect @bind-Value="_model.PreferredAudioLanguageCode"
For="@(() => _model.PreferredAudioLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preferred Audio Title</MudText>
</div>
<MudTextField @bind-Value="_model.PreferredAudioTitle" For="@(() => _model.PreferredAudioTitle)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preferred Subtitle Language</MudText>
</div>
<MudSelect @bind-Value="_model.PreferredSubtitleLanguageCode"
For="@(() => _model.PreferredSubtitleLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Subtitle Mode</MudText>
</div>
<MudSelect @bind-Value="_model.SubtitleMode" For="@(() => _model.SubtitleMode)">
<MudSelectItem Value="@(ChannelSubtitleMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
</MudSelect>
</MudStack>
}
else
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Stream Selector</MudText>
</div>
<MudSelect @bind-Value="_model.StreamSelector"
For="@(() => _model.StreamSelector)">
<MudSelectItem T="string" Value="@((string)null)">(none)</MudSelectItem>
@foreach (string selector in _streamSelectors)
{
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
}
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Music Video Credits Mode</MudText>
</div>
<MudSelect @bind-Value="_model.MusicVideoCreditsMode" For="@(() => _model.MusicVideoCreditsMode)">
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Music Video Credits Template</MudText>
</div>
<MudSelect @bind-Value="_model.MusicVideoCreditsTemplate"
For="@(() => _model.MusicVideoCreditsTemplate)"
Disabled="@(_model.MusicVideoCreditsMode != ChannelMusicVideoCreditsMode.GenerateSubtitles)">
<MudSelectItem T="string" Value="@((string)null)">(none)</MudSelectItem>
@foreach (string template in _musicVideoCreditsTemplates)
{
<MudSelectItem T="string" Value="@template">@template</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Song Video Mode</MudText>
</div>
<MudSelect @bind-Value="_model.SongVideoMode" For="@(() => _model.SongVideoMode)">
<MudSelectItem Value="@(ChannelSongVideoMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelSongVideoMode.WithProgress)">With Progress</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Logo</MudText>
</div>
<InputFile id="fileInput" OnChange="@UploadLogo" style="display: none;"/>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
for="fileInput">
Upload Logo
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>External Logo URL</MudText>
</div>
<MudTextField @bind-Value="_model.ExternalLogoUrl" For="@(() => _model.ExternalLogoUrl)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Logo Preview</MudText>
</div>
@if (!string.IsNullOrWhiteSpace(_model.Logo?.Path) || !string.IsNullOrWhiteSpace(_model.ExternalLogoUrl))
{
<MudElement HtmlTag="img" src="@(string.IsNullOrWhiteSpace(_model.ExternalLogoUrl) ? _model.Logo.UrlWithContentType : _model.ExternalLogoUrl)" Style="max-height: 50px"/>
}
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermark</MudText>
</div>
<MudSelect @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Fallback Filler</MudText>
</div>
<MudSelect @bind-Value="_model.FallbackFillerId" For="@(() => _model.FallbackFillerId)" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
{
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int? Id { get; set; }
private readonly ChannelEditViewModel _model = new();
private readonly ChannelEditViewModelValidator _validator = new();
private MudForm _form;
private List<ChannelViewModel> _channels = [];
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<LanguageCodeViewModel> _availableCultures = [];
private List<WatermarkViewModel> _watermarks = [];
private List<FillerPresetViewModel> _fillerPresets = [];
private List<string> _musicVideoCreditsTemplates = [];
private List<string> _streamSelectors = [];
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
var loadingTasks = new List<Task>
{
LoadDataAsync(() => Mediator.Send(new GetAllChannels(), token), r => _channels = r),
LoadDataAsync(() => Mediator.Send(new GetAllFFmpegProfiles(), token), r => _ffmpegProfiles = r),
LoadDataAsync(() => Mediator.Send(new GetAllLanguageCodes(), token), r => _availableCultures = r),
LoadDataAsync(() => Mediator.Send(new GetAllWatermarks(), token), r => _watermarks = r),
LoadDataAsync(() => Mediator.Send(new GetAllFillerPresets(), token)
.Map(list => list.Filter(vm => vm.FillerKind == FillerKind.Fallback).ToList()), r => _fillerPresets = r),
LoadDataAsync(() => Mediator.Send(new GetMusicVideoCreditTemplates(), token), r => _musicVideoCreditsTemplates = r),
LoadDataAsync(() => Mediator.Send(new GetChannelStreamSelectors(), token), r => _streamSelectors = r),
};
await Task.WhenAll(loadingTasks);
if (Id.HasValue)
{
Option<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelById(Id.Value), token);
maybeChannel.Match(
channelViewModel =>
{
_model.Id = channelViewModel.Id;
_model.Name = channelViewModel.Name;
_model.Group = channelViewModel.Group;
_model.Categories = channelViewModel.Categories;
_model.Number = channelViewModel.Number;
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
_model.SlugSeconds = channelViewModel.SlugSeconds;
if (channelViewModel.Logo.IsExternalUrl)
{
_model.ExternalLogoUrl = channelViewModel.Logo.Path;
}
else
{
_model.Logo = channelViewModel.Logo;
}
_model.PlayoutSource = channelViewModel.PlayoutSource;
_model.PlayoutMode = channelViewModel.PlayoutMode;
_model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId;
_model.PlayoutOffset = channelViewModel.PlayoutOffset;
_model.StreamingMode = channelViewModel.StreamingMode;
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
_model.StreamSelector = channelViewModel.StreamSelector;
_model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode;
_model.PreferredAudioTitle = channelViewModel.PreferredAudioTitle;
_model.WatermarkId = channelViewModel.WatermarkId;
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
_model.PreferredSubtitleLanguageCode = channelViewModel.PreferredSubtitleLanguageCode;
_model.SubtitleMode = channelViewModel.SubtitleMode;
_model.MusicVideoCreditsMode = channelViewModel.MusicVideoCreditsMode;
_model.MusicVideoCreditsTemplate = channelViewModel.MusicVideoCreditsTemplate;
_model.SongVideoMode = channelViewModel.SongVideoMode;
_model.TranscodeMode = channelViewModel.TranscodeMode;
_model.IdleBehavior = channelViewModel.IdleBehavior;
_model.IsEnabled = channelViewModel.IsEnabled;
_model.ShowInEpg = channelViewModel.ShowInEpg;
},
() => NavigationManager.NavigateTo("404"));
}
else
{
FFmpegSettingsViewModel ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), token);
// TODO: command for new channel
IEnumerable<int> channelNumbers = await Mediator.Send(new GetAllChannels(), token)
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
_model.Number = (maxNumber + 1).ToString();
_model.Name = "New Channel";
_model.Group = "ErsatzTV";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingMode = StreamingMode.TransportStreamHybrid;
_model.IsEnabled = true;
_model.ShowInEpg = true;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task LoadDataAsync<T>(Func<Task<T>> fetch, Action<T> assign)
{
assign(await fetch());
}
private bool IsEdit => Id.HasValue;
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (!_form.IsValid)
{
return;
}
try
{
var token = _cts?.Token ?? CancellationToken.None;
ValidationResult result = await _validator.ValidateAsync(_model, token);
if (result.IsValid)
{
Seq<BaseError> errorMessage = IsEdit
? (await Mediator.Send(_model.ToUpdate(), token)).LeftToSeq()
: (await Mediator.Send(_model.ToCreate(), token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving channel: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("channels"));
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task UploadLogo(InputFileChangeEventArgs e)
{
try
{
var token = _cts?.Token ?? CancellationToken.None;
Either<BaseError, string> maybeCacheFileName =
await Mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo, e.File.ContentType), token);
maybeCacheFileName.Match(
relativeFileName =>
{
_model.Logo = new ArtworkContentTypeModel($"iptv/logos/{relativeFileName}", e.File.ContentType);
_model.ExternalLogoUrl = null;
StateHasChanged();
},
error =>
{
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
});
}
catch (OperationCanceledException)
{
// do nothing
}
catch (IOException)
{
Snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
Logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
}
catch (Exception ex)
{
Snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
Logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
}
}
}
-109
View File
@@ -1,109 +0,0 @@
@page "/channels/numbers"
@using ErsatzTV.Application.Channels
@implements IDisposable
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<ChannelNumbers> Logger
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="@SaveChannels">
Save Channels
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Error" Class="ml-3" StartIcon="@Icons.Material.Filled.Undo" OnClick="@ReloadAllChannels">
Reset Channels
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Channels" OnClick="@SaveChannels"/>
<MudMenuItem Icon="@Icons.Material.Filled.Undo" Label="Reset Channels" OnClick="@ReloadAllChannels"/>
</MudMenu>
</div>
</div>
</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">Edit Channel Numbers</MudText>
<MudDivider Class="mb-6"/>
<Sortable TItem="ChannelSortViewModel" Items="_channels" OnUpdate="OnUpdate">
<MudPaper Class="@($"pa-2 ma-2 ")"
Style="@($"max-width: 500px; {(context.HasChanged ? "background-color: var(--mud-palette-dark-lighten)" : "")}")">
<div class="d-flex align-center">
<MudText Style="min-width: 6ch">@context.Number</MudText>
<MudText Class="flex-grow-1">
@context.Name
</MudText>
<MudText Style="@(context.HasChanged ? "" : "visibility: hidden;")">
(@context.OriginalNumber)
</MudText>
</div>
</MudPaper>
</Sortable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private List<ChannelSortViewModel> _channels = [];
private List<string> _channelNumbers = [];
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
await ReloadAllChannels();
}
private async Task ReloadAllChannels()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_channels = await Mediator.Send(new GetAllChannelsForSort(), token);
_channelNumbers = _channels.Map(c => c.Number).ToList();
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task SaveChannels()
{
var changedChannels = _channels.Where(c => c.HasChanged).ToList();
Option<BaseError> maybeError = await Mediator.Send(new UpdateChannelNumbers(changedChannels), _cts.Token);
maybeError.Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving channel numbers: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("channels"));
}
private void OnUpdate(SortableEventArgs<ChannelSortViewModel> obj)
{
for (var i = 0; i < _channels.Count; i++)
{
_channels[i].Number = _channelNumbers[i];
}
StateHasChanged();
}
}
-417
View File
@@ -1,417 +0,0 @@
@page "/channels"
@using System.Globalization
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Core.Interfaces.FFmpeg
@implements IDisposable
@inject IDialogService Dialog
@inject IJSRuntime JsRuntime
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject IFFmpegSegmenterService SegmenterService
@inject IStringLocalizer<ErsatzTV.Locals.Pages.Channels> Loc;
@inject IStringLocalizer<ErsatzTV.Locals.Shared.Common> CommonLoc;
@inject IStringLocalizer<ErsatzTV.Locals.Shared.MainLayout> MainLayoutLoc;
<MudForm 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" StartIcon="@Icons.Material.Filled.Add" Href="channels/add">
@Loc["ButtonAddChannel"]
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Class="ml-3" StartIcon="@Icons.Material.Filled.Edit" Href="channels/numbers">
@Loc["ButtonEditChannelNumbers"]
</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">@MainLayoutLoc["ButtonChannels"]</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ChannelViewModel>>>(ServerReload))"
@ref="_table"
RowClassFunc="ChannelRowClassFunc">
<ToolBarContent>
<MudSwitch T="bool" Class="ml-6" @bind-Value="@ShowDisabled" Color="Color.Secondary" Label="@Loc["ButtonShowDisabled"]"/>
</ToolBarContent>
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col style="width: 60px;"/>
<col/>
<col style="width: 15%"/>
<col style="width: 15%"/>
<col style="width: 15%"/>
<col style="width: 15%"/>
<col style="width: 300px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>
<MudTableSortLabel InitialDirection="SortDirection.Ascending" SortBy="new Func<ChannelViewModel, object>(x => decimal.Parse(x.Number, CultureInfo.InvariantCulture))">@Loc["LabelNumber"]</MudTableSortLabel>
</MudTh>
<MudTh>@Loc["LabelLogo"]</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<ChannelViewModel, object>(x => x.Name)">@Loc["LabelName"]</MudTableSortLabel>
</MudTh>
<MudTh>@Loc["LabelLanguage"]</MudTh>
<MudTh>@Loc["LabelStreamingMode"]</MudTh>
<MudTh>@Loc["LabelFFmpegProfile"]</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="@Loc["LabelNumber"]">
@if (!context.IsEnabled)
{
<MudTooltip Text="@Loc["LabelChannelDisabledHelp"]">
@($"({Loc["LabelChannelDisabledAbbreviation"]}) {context.Number}")
</MudTooltip>
}
else if (!context.ShowInEpg)
{
<MudTooltip Text="@Loc["LabelChannelHiddenHelp"]">
@($"({Loc["LabelChannelHiddenAbbreviation"]}) {context.Number}")
</MudTooltip>
}
else
{
@context.Number
}
</MudTd>
<MudTd DataLabel="@Loc["LabelLogo"]">
@if (!string.IsNullOrWhiteSpace(context.Logo?.Path))
{
<MudElement HtmlTag="img" src="@context.Logo.UrlWithContentType" Style="max-height: 50px"/>
}
else
{
<MudElement HtmlTag="img" src="@($"iptv/logos/gen?text={context.WebEncodedName}")" Style="max-height: 50px"/>
}
</MudTd>
<MudTd DataLabel="@Loc["LabelName"]">@context.Name</MudTd>
<MudTd DataLabel="@Loc["LabelLanguage"]">@context.PreferredAudioLanguageCode</MudTd>
<MudTd DataLabel="@Loc["LabelStreamingMode"]">@GetStreamingMode(context.StreamingMode)</MudTd>
<MudTd DataLabel="@Loc["LabelFFmpegProfile"]">
@if (context.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
{
@_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name
}
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
@if (_channelsThatCanPreview.Contains(context.Id))
{
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId));
bool notNormalizing = maybeProfile.Any(p => !p.NormalizeVideo || !p.NormalizeAudio);
if (notNormalizing)
{
<MudTooltip Text="@Loc["ButtonPreviewChannelHelpNormalize"]">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
OnClick="@(_ => PreviewChannel(context))"
Style="color: var(--mud-palette-warning-lighten);">
</MudIconButton>
</MudTooltip>
}
else
{
<MudTooltip Text="@Loc["ButtonPreviewChannelHelp"]">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
OnClick="@(_ => PreviewChannel(context))">
</MudIconButton>
</MudTooltip>
}
}
else if (CanPreviewChannel(context) && _ffmpegProfilesThatCanPreview.ContainsKey(context.FFmpegProfileId) && !_ffmpegProfilesThatCanPreview[context.FFmpegProfileId])
{
<MudTooltip Text="@Loc["ButtonPreviewChannelHelpBrowser"]">
<div style="height: 48px; width: 48px; align-items: center; display: flex; justify-content: center">
<!--suppress CssUnresolvedCustomProperty -->
<MudIcon Icon="@Icons.Material.Filled.PlayCircle" Style="color: var(--mud-palette-error-lighten);">
</MudIcon>
</div>
</MudTooltip>
}
else
{
<MudTooltip Text="@Loc["ButtonPreviewChannelHelpSettings"]">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" Disabled="true">
</MudIconButton>
</MudTooltip>
}
@if (SegmenterService.IsActive(context.Number))
{
<MudTooltip Text="@Loc["ButtonStopTranscodeHelp"]">
<MudIconButton Icon="@Icons.Material.Filled.Stop"
OnClick="@(_ => StopChannel(context))">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="width: 48px"></div>
}
<MudTooltip Text="@Loc["ButtonEditChannelHelp"]">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"channels/{context.Id}")">
</MudIconButton>
</MudTooltip>
@if (context.PlayoutCount > 0 && context.IsEnabled)
{
<MudTooltip Text="@Loc["ButtonTroubleshootChannelHelp"]">
<MudIconButton Icon="@Icons.Material.Filled.Troubleshoot"
Href="@($"system/troubleshooting/playback?channel={context.Id}")">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="width: 48px"></div>
}
<MudTooltip Text="@Loc["ButtonDeleteChannelHelp"]">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteChannelAsync(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager RowsPerPageString="@CommonLoc["LabelRowsPerPage"]" />
</PagerContent>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<ChannelViewModel> _table;
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private readonly System.Collections.Generic.HashSet<int> _channelsThatCanPreview = [];
private readonly Dictionary<int, bool> _ffmpegProfilesThatCanPreview = [];
private bool _showDisabled;
private int _rowsPerPage = 10;
private bool ShowDisabled
{
get => _showDisabled;
set
{
if (_showDisabled != value)
{
_showDisabled = value;
_table?.ReloadServerData();
}
}
}
protected override void OnInitialized() => SegmenterService.OnWorkersChanged += WorkersChanged;
private void WorkersChanged(object sender, EventArgs e) =>
InvokeAsync(StateHasChanged);
public void Dispose()
{
SegmenterService.OnWorkersChanged -= WorkersChanged;
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), token);
token.ThrowIfCancellationRequested();
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_showDisabled = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsShowDisabled), token)
.Map(maybeShow => maybeShow.Match(ce => bool.TryParse(ce.Value, out bool show) && show, () => false));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task StopChannel(ChannelViewModel channel)
{
using var cts = new CancellationTokenSource();
await SegmenterService.StopChannel(channel.Number, cts.Token);
}
private bool CanPreviewChannel(ChannelViewModel channel)
{
if (!channel.IsEnabled)
{
return false;
}
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect or StreamingMode.TransportStream)
{
return false;
}
if (channel.PlayoutCount < 1)
{
return false;
}
return true;
}
private async Task<bool> CanPreviewFFmpegProfile(int ffmpegProfileId)
{
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == ffmpegProfileId));
foreach (FFmpegProfileViewModel profile in maybeProfile)
{
string videoCodec = profile.VideoFormat switch
{
FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08",
FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0",
FFmpegProfileVideoFormat.H264 => "avc1.4D4028",
_ => string.Empty
};
string audioCodec = profile.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => "ac-3",
FFmpegProfileAudioFormat.Aac => "mp4a.40.2",
_ => string.Empty
};
//Console.WriteLine($"Checking video format {videoCodec} and audio format {audioCodec}");
// good luck
if (profile.VideoFormat == FFmpegProfileVideoFormat.Copy || profile.AudioFormat == FFmpegProfileAudioFormat.Copy)
{
return true;
}
if (string.IsNullOrWhiteSpace(videoCodec) || string.IsNullOrWhiteSpace(audioCodec))
{
return false;
}
return await BrowserSupportsCodec($"{videoCodec}, {audioCodec}");
}
return false;
}
private async Task PreviewChannel(ChannelViewModel channel)
{
if (!CanPreviewChannel(channel) || !await CanPreviewFFmpegProfile(channel.FFmpegProfileId))
{
return;
}
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri));
uri.Path = uri.Path.Replace("/channels", $"/iptv/channel/{channel.Number}.m3u8");
uri.Query = channel.StreamingMode switch
{
_ => "?mode=segmenter"
};
if (JwtHelper.IsEnabled)
{
uri.Query += $"&access_token={JwtHelper.GenerateToken()}";
}
var parameters = new DialogParameters { { "StreamUri", uri.ToString() } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
await Dialog.ShowAsync<ChannelPreviewDialog>("Channel Preview", parameters, options);
}
private async Task DeleteChannelAsync(ChannelViewModel channel)
{
var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Channel", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
using var cts = new CancellationTokenSource();
await Mediator.Send(new DeleteChannel(channel.Id), cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
}
}
private async Task<TableData<ChannelViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), cancellationToken);
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsShowDisabled, _showDisabled.ToString()), cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(_showDisabled), cancellationToken);
// TODO: properly page this data
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
.Skip(state.Page * state.PageSize)
.Take(state.PageSize)
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture));
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
var processedChannels = new List<ChannelViewModel>();
_channelsThatCanPreview.Clear();
_ffmpegProfilesThatCanPreview.Clear();
foreach (ChannelViewModel channel in sorted)
{
Option<CultureInfo> maybeCultureInfo = allCultures.Find(ci => string.Equals(
ci.ThreeLetterISOLanguageName,
channel.PreferredAudioLanguageCode,
StringComparison.OrdinalIgnoreCase));
maybeCultureInfo.Match(
cultureInfo => processedChannels.Add(channel with { PreferredAudioLanguageCode = cultureInfo.EnglishName }),
() => processedChannels.Add(channel));
if (!_ffmpegProfilesThatCanPreview.TryGetValue(channel.FFmpegProfileId, out bool canPreviewFFmpegProfile))
{
canPreviewFFmpegProfile = await CanPreviewFFmpegProfile(channel.FFmpegProfileId);
_ffmpegProfilesThatCanPreview.Add(channel.FFmpegProfileId, canPreviewFFmpegProfile);
}
if (CanPreviewChannel(channel) && canPreviewFFmpegProfile)
{
_channelsThatCanPreview.Add(channel.Id);
}
}
return new TableData<ChannelViewModel>
{
TotalItems = channels.Count,
Items = processedChannels
};
}
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch
{
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.TransportStreamHybrid => "MPEG-TS",
_ => "MPEG-TS (Legacy)"
};
private async Task<bool> BrowserSupportsCodec(string codecString)
{
return await JsRuntime.InvokeAsync<bool>("mediaSourceSupports", codecString);
}
private string ChannelRowClassFunc(ChannelViewModel channel, int index)
{
return channel.IsEnabled && channel.ShowInEpg ? string.Empty : "channel-disabled";
}
}
-97
View File
@@ -1,97 +0,0 @@
@page "/playouts/classic/{Id:int}"
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker EntityLocker;
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
</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">@_channelName - Classic Playout</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>Alternate Schedules</MudText>
</div>
@if (_playoutMode is ChannelPlayoutMode.OnDemand)
{
<MudButton Disabled="@true" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Edit">
Edit Alternate Schedules
</MudButton>
}
else
{
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Primary" Href="@($"playouts/{Id}/alternate-schedules")" StartIcon="@Icons.Material.Filled.Edit">
Edit Alternate Schedules
</MudButton>
}
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
</div>
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItemsAndHistory())" StartIcon="@Icons.Material.Filled.Delete">
Erase Items and History
</MudButton>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private ChannelPlayoutMode _playoutMode;
[Parameter]
public int Id { get; set; }
private string _channelName;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelByPlayoutId(Id), token);
if (maybeChannel.IsNone)
{
NavigationManager.NavigateTo("playouts");
return;
}
foreach (ChannelViewModel channel in maybeChannel)
{
_channelName = channel.Name;
_playoutMode = channel.PlayoutMode;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task EraseItemsAndHistory()
{
await Mediator.Send(new ErasePlayoutHistory(Id), _cts.Token);
Snackbar.Add("Erased playout items and history", Severity.Info);
}
}
-92
View File
@@ -1,92 +0,0 @@
@page "/media/collections/{Id:int}/edit"
@page "/media/collections/add"
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<CollectionEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm @ref="_form" @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="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Collection" : "Add Collection")</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">Collection</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="Collection name is required!"/>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly CollectionEditViewModel _model = new();
private MudForm _form;
private bool _success;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (IsEdit)
{
Option<MediaCollectionViewModel> maybeCollection = await Mediator.Send(new GetCollectionById(Id), token);
maybeCollection.IfSome(collection =>
{
_model.Id = collection.Id;
_model.Name = collection.Name;
});
}
else
{
_model.Name = "New Collection";
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool IsEdit => Id != 0;
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (_success)
{
Seq<BaseError> errorMessage = IsEdit ? (await Mediator.Send(new UpdateCollection(Id, _model.Name), _cts.Token)).LeftToSeq() : (await Mediator.Send(new CreateCollection(_model.Name), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error saving collection: {Error}", error.Value);
},
() => NavigationManager.NavigateTo(_model.Id > 0 ? $"media/collections/{_model.Id}" : "media/collections"));
}
}
}
-592
View File
@@ -1,592 +0,0 @@
@page "/media/collections/{Id:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@inherits MultiSelectBase<CollectionItems>
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
@if (IsSelectMode())
{
<div class="flex-grow-1">
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
</div>
<div style="margin-left: auto" class="d-none d-md-flex">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Remove"
OnClick="@(_ => RemoveSelectionFromCollection(Id))">
Remove From Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Remove" Label="Remove From Collection" OnClick="@(_ => RemoveSelectionFromCollection(Id))"/>
<MudMenuItem Icon="@Icons.Material.Filled.Check" Label="Clear Selection" OnClick="ClearSelection"/>
</MudMenu>
</div>
}
else
{
<div style="align-items: center; display: flex; width: 100%">
<MudText>@_data?.Name</MudText>
<div class="d-none d-md-flex" style="align-items: center">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"media/collections/{Id}/edit")"/>
@if (_data?.MovieCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
}
@if (_data?.ShowCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
}
@if (_data?.SeasonCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")">@_data.SeasonCards.Count Seasons</MudLink>
}
@if (_data?.EpisodeCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
@if (_data?.ArtistCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")">@_data.ArtistCards.Count Artists</MudLink>
}
@if (_data?.MusicVideoCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
}
@if (_data?.OtherVideoCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#other_videos")">@_data.OtherVideoCards.Count Other Videos</MudLink>
}
@if (_data?.SongCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#songs")">@_data.SongCards.Count Songs</MudLink>
}
@if (_data?.ImageCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink>
}
@if (_data?.RemoteStreamCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")">@_data.RemoteStreamCards.Count Remote Streams</MudLink>
}
</div>
@if (SupportsCustomOrdering())
{
<div class="d-none d-md-flex" style="margin-left: auto">
<MudSwitch T="bool"
Value="@(_data?.UseCustomPlaybackOrder == true)"
Color="Color.Primary"
ValueChanged="@OnUseCustomOrderChanged"
Label="Use Custom Playback Order"/>
</div>
}
<div class="d-flex d-sm-none" style="margin-left: auto">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"media/collections/{Id}/edit")"/>
</div>
</div>
}
</div>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@if (_data?.MovieCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" UserAttributes="@(new Dictionary<string, object> { { "id", "sortable-collection" } })" Class="mb-10">
@foreach (MovieCardViewModel card in OrderMovies(_data.MovieCards))
{
<MediaCard Data="@card"
Href="@($"media/movies/{card.MovieId}")"
DeleteClicked="@RemoveMovieFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.ShowCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@RemoveShowFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.SeasonCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
DeleteClicked="@RemoveSeasonFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.EpisodeCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
{
<MediaCard Data="@card"
Href="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.ArtistCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (ArtistCardViewModel card in _data.ArtistCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveArtistFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.MusicVideoCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (MusicVideoCardViewModel card in _data.MusicVideoCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveMusicVideoFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.OtherVideoCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (OtherVideoCardViewModel card in _data.OtherVideoCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveOtherVideoFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.SongCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (SongCardViewModel card in _data.SongCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveSongFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.ImageCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Images
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (ImageCardViewModel card in _data.ImageCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveImageFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_data?.RemoteStreamCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
Remote Streams
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (RemoteStreamCardViewModel card in _data.RemoteStreamCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveRemoteStreamFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
</MudContainer>
</div>
</MudForm>
@code {
[Parameter]
public int Id { get; set; }
private CollectionCardResultsViewModel _data;
private bool SupportsCustomOrdering() =>
_data is not null && _data.MovieCards.Any() && !_data.ShowCards.Any() && !_data.SeasonCards.Any() && !_data.EpisodeCards.Any();
protected override async Task OnParametersSetAsync() => await RefreshData(CancellationToken);
protected override async Task RefreshData(CancellationToken cancellationToken)
{
Either<BaseError, CollectionCardResultsViewModel> maybeResult =
await Mediator.Send(new GetCollectionCards(Id), cancellationToken);
maybeResult.Match(
result => _data = result,
_ => NavigationManager.NavigateTo("404"));
}
private IOrderedEnumerable<MovieCardViewModel> OrderMovies(List<MovieCardViewModel> movies)
{
if (_data.UseCustomPlaybackOrder)
{
return movies.OrderBy(m => m.CustomIndex);
}
return movies.OrderBy(m => m.SortTitle);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try
{
await JsRuntime.InvokeVoidAsync("sortableCollection", Id);
if (_data.UseCustomPlaybackOrder)
{
await JsRuntime.InvokeVoidAsync("enableSorting");
}
else
{
await JsRuntime.InvokeVoidAsync("disableSorting");
}
}
catch (Exception)
{
// ignored
}
await base.OnAfterRenderAsync(firstRender);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.Append(_data.OtherVideoCards.OrderBy(ov => ov.SortTitle))
.Append(_data.SongCards.OrderBy(s => s.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task RemoveMovieFromCollection(MediaCardViewModel vm)
{
if (vm is MovieCardViewModel movie)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [movie.MovieId]
};
await RemoveItemsWithConfirmation("movie", $"{movie.Title} ({movie.Subtitle})", request);
}
}
private async Task RemoveArtistFromCollection(MediaCardViewModel vm)
{
if (vm is ArtistCardViewModel artist)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [artist.ArtistId]
};
await RemoveItemsWithConfirmation(
"artist",
string.IsNullOrWhiteSpace(artist.Subtitle) ? artist.Title : $"{artist.Title} ({artist.Subtitle})",
request);
}
}
private async Task RemoveMusicVideoFromCollection(MediaCardViewModel vm)
{
if (vm is MusicVideoCardViewModel musicVideo)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [musicVideo.MusicVideoId]
};
await RemoveItemsWithConfirmation("music video", $"{musicVideo.Title} ({musicVideo.Subtitle})", request);
}
}
private async Task RemoveShowFromCollection(MediaCardViewModel vm)
{
if (vm is TelevisionShowCardViewModel show)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [show.TelevisionShowId]
};
await RemoveItemsWithConfirmation("show", $"{show.Title} ({show.Subtitle})", request);
}
}
private async Task RemoveSeasonFromCollection(MediaCardViewModel vm)
{
if (vm is TelevisionSeasonCardViewModel season)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [season.TelevisionSeasonId]
};
await RemoveItemsWithConfirmation("season", $"{season.ShowTitle} - {season.Title}", request);
}
}
private async Task RemoveEpisodeFromCollection(TelevisionEpisodeCardViewModel episode)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [episode.EpisodeId]
};
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
}
private async Task RemoveOtherVideoFromCollection(MediaCardViewModel vm)
{
if (vm is OtherVideoCardViewModel otherVideo)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [otherVideo.OtherVideoId]
};
await RemoveItemsWithConfirmation("other video", $"{otherVideo.Title}", request);
}
}
private async Task RemoveSongFromCollection(MediaCardViewModel vm)
{
if (vm is SongCardViewModel song)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [song.SongId]
};
await RemoveItemsWithConfirmation("song", $"{song.Title}", request);
}
}
private async Task RemoveImageFromCollection(MediaCardViewModel vm)
{
if (vm is ImageCardViewModel image)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [image.ImageId]
};
await RemoveItemsWithConfirmation("image", $"{image.Title}", request);
}
}
private async Task RemoveRemoteStreamFromCollection(MediaCardViewModel vm)
{
if (vm is RemoteStreamCardViewModel remoteStream)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [remoteStream.RemoteStreamId]
};
await RemoveItemsWithConfirmation("remote stream", $"{remoteStream.Title}", request);
}
}
private async Task RemoveItemsWithConfirmation(
string entityType,
string entityName,
RemoveItemsFromCollection request)
{
var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>("Remove From Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(request, CancellationToken);
await RefreshData(CancellationToken);
}
}
private async Task OnUseCustomOrderChanged()
{
_data.UseCustomPlaybackOrder = !_data.UseCustomPlaybackOrder;
var request = new UpdateCollection(Id, _data.Name) { UseCustomPlaybackOrder = _data.UseCustomPlaybackOrder };
await Mediator.Send(request, CancellationToken);
}
}
-882
View File
@@ -1,882 +0,0 @@
@page "/decos/{Id:int}"
@using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<DecoEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm 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" OnClick="@(_ => SaveChanges())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
Save Changes
</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">Deco</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>Deco Group Name</MudText>
</div>
<MudTextField @bind-Value="_deco.GroupName" Disabled="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Name</MudText>
</div>
<MudTextField @bind-Value="_deco.Name" For="@(() => _deco.Name)"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Watermark</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>Watermark Mode</MudText>
</div>
<MudSelect @bind-Value="_deco.WatermarkMode" For="@(() => _deco.WatermarkMode)">
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
<MudSelectItem Value="DecoMode.Override">Replace</MudSelectItem>
<MudSelectItem Value="DecoMode.Merge">Merge</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermark</MudText>
</div>
<MudSelect T="WatermarkViewModel"
@bind-SelectedValues="_deco.Watermarks"
Disabled="@(_deco.WatermarkMode is not (DecoMode.Override or DecoMode.Merge))"
ToStringFunc="@(wm => wm?.Name)"
Clearable="true"
MultiSelection="true">
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem Value="@watermark">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Use Watermark During Filler</MudText>
</div>
<MudCheckBox T="bool"
Disabled="@(_deco.WatermarkMode != DecoMode.Override)"
@bind-Value="_deco.UseWatermarkDuringFiller"
Dense="true"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Graphics Elements</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>Graphics Elements Mode</MudText>
</div>
<MudSelect @bind-Value="_deco.GraphicsElementsMode" For="@(() => _deco.GraphicsElementsMode)">
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
<MudSelectItem Value="DecoMode.Override">Replace</MudSelectItem>
<MudSelectItem Value="DecoMode.Merge">Merge</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Graphics Elements</MudText>
</div>
<MudSelect T="GraphicsElementViewModel"
@bind-SelectedValues="_deco.GraphicsElements"
Disabled="@(_deco.GraphicsElementsMode is not (DecoMode.Override or DecoMode.Merge))"
ToStringFunc="@(ge => ge?.Name)"
Clearable="true"
MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
{
<MudSelectItem Value="@graphicsElement">@graphicsElement.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Use Graphics Elements During Filler</MudText>
</div>
<MudCheckBox T="bool"
Disabled="@(_deco.GraphicsElementsMode != DecoMode.Override)"
@bind-Value="_deco.UseGraphicsElementsDuringFiller"
Dense="true"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2" Class="mb-6">Break content can be used to play specific items (using playlists), and to pad blocks to their full configured duration.</MudText>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Break Content Mode</MudText>
</div>
<MudSelect @bind-Value="_deco.BreakContentMode" For="@(() => _deco.BreakContentMode)">
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
</MudSelect>
</MudStack>
@if (_deco.BreakContentMode is DecoMode.Override)
{
<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.Primary" OnClick="@(_ => AddBreakContent())" StartIcon="@Icons.Material.Filled.Add">
Add Break Content
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content Items</MudText>
<MudDivider Class="mb-6"/>
<MudTable T="DecoBreakContentEditViewModel" Class="mt-6" Hover="true" Items="_deco.BreakContent" Dense="true" SelectedItemChanged="@(vm => SelectedBreakContentChanged(vm))" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Type</MudTh>
<MudTh>Name</MudTh>
<MudTh>Placement</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Type">@context.CollectionType.ToString()</MudTd>
<MudTd DataLabel="Name">@context.CollectionName</MudTd>
<MudTd DataLabel="Placement">
@(context.Placement switch {
DecoBreakPlacement.BlockStart => "Block Start",
DecoBreakPlacement.BlockFinish => "Block Finish",
DecoBreakPlacement.BetweenBlockItems => "Between Block Items",
DecoBreakPlacement.ChapterMarkers => "At Chapter Markers",
_ => string.Empty
})
</MudTd>
<MudTd>
<div class="d-flex">
@* <MudIconButton Icon="@Icons.Material.Filled.ContentCopy" *@
@* OnClick="@(_ => CopyItem(context))"> *@
@* </MudIconButton> *@
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveBreakContent(context))">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedBreakContent is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content Item</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>Placement</MudText>
</div>
<MudSelect @bind-Value="_selectedBreakContent.Placement" For="@(() => _selectedBreakContent.Placement)" Disabled="true">
<MudSelectItem Value="DecoBreakPlacement.BlockStart">Block Start</MudSelectItem>
<MudSelectItem Value="DecoBreakPlacement.BetweenBlockItems">Between Block Items</MudSelectItem>
<MudSelectItem Value="DecoBreakPlacement.ChapterMarkers">At Chapter Markers</MudSelectItem>
<MudSelectItem Value="DecoBreakPlacement.BlockFinish">Block Finish</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_selectedBreakContent.CollectionType" For="@(() => _selectedBreakContent.CollectionType)" Disabled="true">
@* <MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem> *@
<MudSelectItem Value="CollectionType.Playlist">Playlist</MudSelectItem>
</MudSelect>
</MudStack>
@if (_selectedBreakContent.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_selectedBreakContent.Collection"
SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_selectedBreakContent.MultiCollection"
SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_selectedBreakContent.SmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedBreakContent.MediaItem"
SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedBreakContent.MediaItem"
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.Playlist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist Group</MudText>
</div>
<MudSelect T="PlaylistGroupViewModel"
Value="@_selectedPlaylistGroup"
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
{
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist</MudText>
</div>
<MudSelect T="PlaylistViewModel" @bind-value="_selectedBreakContent.Playlist">
@foreach (PlaylistViewModel playlist in _playlists)
{
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
}
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Default Filler</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2" Class="mb-6">After all blocks have been scheduled, a second pass will be made to fill unscheduled time using random items from this collection.</MudText>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Default Filler Mode</MudText>
</div>
<MudSelect @bind-Value="_deco.DefaultFillerMode" For="@(() => _deco.DefaultFillerMode)">
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Default Filler Collection Type</MudText>
</div>
<MudSelect Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-Value="_deco.DefaultFillerCollectionType"
For="@(() => _deco.DefaultFillerCollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect>
</MudStack>
@if (_deco.DefaultFillerCollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerCollection"
SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMultiCollection"
SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerSmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMediaItem"
SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-Value="_deco.DefaultFillerMediaItem"
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.Artist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-Value="_deco.DefaultFillerMediaItem"
SearchFunc="@SearchArtists"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Trim To Fit</MudText>
</div>
<MudCheckBox T="bool"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-Value="_deco.DefaultFillerTrimToFit"
Color="Color.Primary"
Dense="true"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Dead Air Fallback</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2">When no playout item is found for the current time, *one* item will be randomly selected from this collection and looped and trimmed to exactly fit until the start of the next playout item.</MudText>
<MudText Typo="Typo.body2" Class="mb-6 mt-3">This replaces the "Channel is Offline" image that would otherwise display.</MudText>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Dead Air Fallback Mode</MudText>
</div>
<MudSelect @bind-Value="_deco.DeadAirFallbackMode" For="@(() => _deco.DeadAirFallbackMode)">
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Dead Air Fallback Collection Type</MudText>
</div>
<MudSelect Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-Value="_deco.DeadAirFallbackCollectionType"
For="@(() => _deco.DeadAirFallbackCollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect>
</MudStack>
@if (_deco.DeadAirFallbackCollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackCollection"
SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMultiCollection"
SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackSmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem"
SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem"
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.Artist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem"
SearchFunc="@SearchArtists"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private DecoEditViewModel _deco = new();
private List<WatermarkViewModel> _watermarks = [];
private List<GraphicsElementViewModel> _graphicsElements = [];
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
private readonly List<PlaylistViewModel> _playlists = [];
private DecoBreakContentEditViewModel _selectedBreakContent;
private PlaylistGroupViewModel _selectedPlaylistGroup;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_watermarks = await Mediator.Send(new GetAllWatermarks(), token);
_graphicsElements = await Mediator.Send(new GetAllGraphicsElements(), token);
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), token));
Option<DecoViewModel> maybeDeco = await Mediator.Send(new GetDecoById(Id), token);
if (maybeDeco.IsNone)
{
NavigationManager.NavigateTo("decos");
return;
}
foreach (DecoViewModel deco in maybeDeco)
{
_deco = new DecoEditViewModel
{
Name = deco.Name,
DecoGroupId = deco.DecoGroupId,
GroupName = deco.DecoGroupName,
WatermarkMode = deco.WatermarkMode,
Watermarks = deco.Watermarks,
UseWatermarkDuringFiller = deco.UseWatermarkDuringFiller,
GraphicsElementsMode = deco.GraphicsElementsMode,
GraphicsElements = deco.GraphicsElements,
UseGraphicsElementsDuringFiller = deco.UseGraphicsElementsDuringFiller,
BreakContentMode = deco.BreakContentMode,
BreakContent = deco.BreakContent.Map(ProjectToEditViewModel).ToList(),
DefaultFillerMode = deco.DefaultFillerMode,
DefaultFillerCollectionType = deco.DefaultFillerCollectionType,
DefaultFillerCollection = deco.DefaultFillerCollection,
DefaultFillerMediaItem = deco.DefaultFillerMediaItem,
DefaultFillerMultiCollection = deco.DefaultFillerMultiCollection,
DefaultFillerSmartCollection = deco.DefaultFillerSmartCollection,
DefaultFillerTrimToFit = deco.DefaultFillerTrimToFit,
DeadAirFallbackMode = deco.DeadAirFallbackMode,
DeadAirFallbackCollectionType = deco.DeadAirFallbackCollectionType,
DeadAirFallbackCollection = deco.DeadAirFallbackCollection,
DeadAirFallbackMediaItem = deco.DeadAirFallbackMediaItem,
DeadAirFallbackMultiCollection = deco.DeadAirFallbackMultiCollection,
DeadAirFallbackSmartCollection = deco.DeadAirFallbackSmartCollection
};
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private static DecoBreakContentEditViewModel ProjectToEditViewModel(DecoBreakContentViewModel vm) =>
new()
{
Id = vm.Id,
CollectionType = vm.CollectionType,
Collection = vm.Collection,
MediaItem = vm.MediaItem,
MultiCollection = vm.MultiCollection,
SmartCollection = vm.SmartCollection,
Playlist = vm.Playlist,
Placement = vm.Placement
};
private async Task SaveChanges()
{
var breakContent = _deco.BreakContent
.Map(vm => new UpdateDecoBreakContent(vm.Id, vm.CollectionType, vm.Collection?.Id, vm.MediaItem?.MediaItemId, vm.MultiCollection?.Id, vm.SmartCollection?.Id, vm.Playlist?.Id, vm.Placement))
.ToList();
var request = new UpdateDeco(
Id,
_deco.DecoGroupId,
_deco.Name,
_deco.WatermarkMode,
_deco.Watermarks.Map(wm => wm.Id).ToList(),
_deco.UseWatermarkDuringFiller,
_deco.GraphicsElementsMode,
_deco.GraphicsElements.Map(ge => ge.Id).ToList(),
_deco.UseGraphicsElementsDuringFiller,
_deco.BreakContentMode,
breakContent,
_deco.DefaultFillerMode,
_deco.DefaultFillerCollectionType,
_deco.DefaultFillerCollection?.Id,
_deco.DefaultFillerMediaItem?.MediaItemId,
_deco.DefaultFillerMultiCollection?.Id,
_deco.DefaultFillerSmartCollection?.Id,
_deco.DefaultFillerTrimToFit,
_deco.DeadAirFallbackMode,
_deco.DeadAirFallbackCollectionType,
_deco.DeadAirFallbackCollection?.Id,
_deco.DeadAirFallbackMediaItem?.MediaItemId,
_deco.DeadAirFallbackMultiCollection?.Id,
_deco.DeadAirFallbackSmartCollection?.Id);
Seq<BaseError> errorMessages = await Mediator
.Send(request, _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving deco: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving deco: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("decos"));
}
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task SelectedBreakContentChanged(DecoBreakContentEditViewModel vm)
{
_selectedBreakContent = vm;
foreach (int playlistGroupId in Optional(_selectedBreakContent.Playlist?.PlaylistGroupId))
{
foreach (PlaylistGroupViewModel group in Optional(_playlistGroups.Find(g => g.Id == playlistGroupId)))
{
_selectedPlaylistGroup = group;
await UpdatePlaylistGroupItems(group);
}
}
}
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup)
{
_selectedPlaylistGroup = playlistGroup;
_playlists.Clear();
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token));
}
private void AddBreakContent()
{
var item = new DecoBreakContentEditViewModel
{
//CollectionType = CollectionType.Collection,
//Collection = null,
CollectionType = CollectionType.Playlist,
Playlist = null,
Placement = DecoBreakPlacement.BlockStart
};
_deco.BreakContent.Add(item);
}
private void RemoveBreakContent(DecoBreakContentEditViewModel item)
{
_deco.BreakContent.Remove(item);
if (_selectedBreakContent == item)
{
_selectedBreakContent = null;
}
}
private string SelectedRowClassFunc(DecoBreakContentEditViewModel element, int rowNumber) => _selectedBreakContent != null && _selectedBreakContent == element ? "selected" : string.Empty;
}
-343
View File
@@ -1,343 +0,0 @@
@page "/deco-templates/{Id:int}"
@using System.Globalization
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<DecoTemplateEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm 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="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
Save Deco Template
</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">General</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>Deco Template Group Name</MudText>
</div>
<MudTextField @bind-Value="_decoTemplate.GroupName" Disabled="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Template Name</MudText>
</div>
<MudTextField @bind-Value="_decoTemplate.Name" For="@(() => _decoTemplate.Name)"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Add Content</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>Deco Group</MudText>
</div>
<MudSelect T="DecoGroupViewModel" ValueChanged="@(vm => UpdateDecoGroupItems(vm))">
@foreach (DecoGroupViewModel decoGroup in _decoGroups)
{
<MudSelectItem Value="@decoGroup">@decoGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco</MudText>
</div>
<MudSelect T="DecoViewModel" @bind-value="_selectedDeco">
@foreach (DecoViewModel deco in _decos)
{
<MudSelectItem Value="@deco">@deco.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Start Time On Or After</MudText>
</div>
<MudSelect T="DateTime" @bind-value="_selectedDecoStart">
@foreach (DateTime startTime in _startTimes)
{
<MudSelectItem Value="@startTime">
@startTime.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern)
</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Duration</MudText>
</div>
<MudTextField T="int"
@bind-Value="_durationHours"
Adornment="Adornment.End"
AdornmentText="hours"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_durationMinutes" Adornment="Adornment.End" AdornmentText="minutes">
<MudSelectItem Value="0"/>
<MudSelectItem Value="5"/>
<MudSelectItem Value="10"/>
<MudSelectItem Value="15"/>
<MudSelectItem Value="20"/>
<MudSelectItem Value="25"/>
<MudSelectItem Value="30"/>
<MudSelectItem Value="35"/>
<MudSelectItem Value="40"/>
<MudSelectItem Value="45"/>
<MudSelectItem Value="50"/>
<MudSelectItem Value="55"/>
</MudSelect>
</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.Primary" OnClick="@(_ => AddDecoToDecoTemplate())" Disabled="@(_selectedDeco is null)" StartIcon="@Icons.Material.Filled.Add">
Add Deco To Template
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Remove Content</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>Deco To Remove</MudText>
</div>
<MudSelect T="DecoTemplateItemEditViewModel" @bind-Value="_decoToRemove">
<MudSelectItem Value="@((DecoTemplateItemEditViewModel)null)">(none)</MudSelectItem>
@foreach (DecoTemplateItemEditViewModel item in _decoTemplate.Items.OrderBy(i => i.Start))
{
<MudSelectItem Value="@item">@item.Start.ToShortTimeString() - @item.Text</MudSelectItem>
}
</MudSelect>
</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.Primary" OnClick="@(_ => RemoveDecoFromDecoTemplate())" Disabled="@(_decoToRemove is null)" StartIcon="@Icons.Material.Filled.Remove">
Remove Deco From Template
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Content</MudText>
<MudDivider Class="mb-6"/>
<MudCalendar T="CalendarItem"
Class="mb-6"
Items="@_decoTemplate.Items"
ShowMonth="false"
ShowWeek="false"
ShowPrevNextButtons="false"
ShowDatePicker="false"
ShowTodayButton="false"
DayTimeInterval="CalendarTimeInterval.Minutes10"
Use24HourClock="@(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains("H"))"
EnableDragItems="true"
EnableResizeItems="false"
ItemChanged="@(ci => CalendarItemChanged(ci))"/>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<DecoGroupViewModel> _decoGroups = [];
private readonly List<DecoViewModel> _decos = [];
private readonly List<DateTime> _startTimes = [];
[Parameter]
public int Id { get; set; }
private DecoTemplateItemsEditViewModel _decoTemplate = new();
private DecoTemplateItemEditViewModel _decoToRemove;
private DecoGroupViewModel _selectedDecoGroup;
private DecoViewModel _selectedDeco;
private DateTime _selectedDecoStart;
private int _durationHours;
private int _durationMinutes = 15;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await LoadDecoTemplateItems(token);
DateTime start = DateTime.Today;
_selectedDecoStart = start;
while (start.Date == DateTime.Today.Date)
{
_startTimes.Add(start);
start = start.AddMinutes(5);
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task LoadDecoTemplateItems(CancellationToken cancellationToken)
{
Option<DecoTemplateViewModel> maybeDecoTemplate = await Mediator.Send(new GetDecoTemplateById(Id), cancellationToken);
if (maybeDecoTemplate.IsNone)
{
NavigationManager.NavigateTo("deco-templates");
return;
}
foreach (DecoTemplateViewModel template in maybeDecoTemplate)
{
_decoTemplate = new DecoTemplateItemsEditViewModel
{
GroupId = template.DecoTemplateGroupId,
GroupName = template.GroupName,
Name = template.Name
};
}
Option<IEnumerable<DecoTemplateItemViewModel>> maybeResults = await Mediator.Send(new GetDecoTemplateItems(Id), cancellationToken);
foreach (IEnumerable<DecoTemplateItemViewModel> items in maybeResults)
{
_decoTemplate.Items.AddRange(items.Map(ProjectToEditViewModel));
}
_decoGroups.AddRange(await Mediator.Send(new GetAllDecoGroups(), cancellationToken));
}
private static DecoTemplateItemEditViewModel ProjectToEditViewModel(DecoTemplateItemViewModel item) =>
new()
{
DecoId = item.DecoId,
DecoName = item.DecoName,
Start = item.StartTime,
End = item.EndTime
};
private async Task UpdateDecoGroupItems(DecoGroupViewModel decoGroup)
{
_selectedDecoGroup = decoGroup;
_decos.Clear();
_decos.AddRange(await Mediator.Send(new GetDecosByDecoGroupId(_selectedDecoGroup.Id), _cts.Token));
}
private void AddDecoToDecoTemplate()
{
// find first time where this deco will fit
DateTime maybeStart = _selectedDecoStart;
while (maybeStart.Date == DateTime.Today)
{
DateTime maybeEnd = maybeStart.AddHours(_durationHours).AddMinutes(_durationMinutes);
if (!IntersectsOthers(null, maybeStart, maybeEnd))
{
var item = new DecoTemplateItemEditViewModel
{
DecoId = _selectedDeco.Id,
DecoName = _selectedDeco.Name,
Start = maybeStart,
End = maybeEnd,
LastStart = maybeStart,
LastEnd = maybeEnd
};
_decoTemplate.Items.Add(item);
break;
}
maybeStart = maybeStart.AddMinutes(5);
}
}
private async Task RemoveDecoFromDecoTemplate()
{
if (_decoToRemove is not null)
{
_decoTemplate.Items.Remove(_decoToRemove);
_decoToRemove = null;
await InvokeAsync(StateHasChanged);
}
}
private void CalendarItemChanged(CalendarItem calendarItem)
{
// don't allow any overlap
if (calendarItem is DecoTemplateItemEditViewModel item)
{
bool intersects = item.End.HasValue && IntersectsOthers(item, item.Start, item.End.Value);
bool crossesMidnight = item.End.HasValue && item.End.Value.TimeOfDay > TimeSpan.Zero && item.End.Value.TimeOfDay < item.Start.TimeOfDay;
if (intersects || crossesMidnight)
{
// roll back
item.Start = item.LastStart;
item.End = item.LastEnd;
}
else
{
// commit
item.LastStart = item.Start;
item.LastEnd = item.End;
}
}
}
private bool IntersectsOthers(DecoTemplateItemEditViewModel item, DateTime start, DateTime end)
{
var willFit = true;
foreach (DecoTemplateItemEditViewModel existing in _decoTemplate.Items)
{
if (existing == item)
{
continue;
}
if (start < existing.End && existing.Start < end)
{
willFit = false;
break;
}
}
return !willFit;
}
// private void RemoveDecoTemplateItem(DecoTemplateItemEditViewModel item)
// {
// _selectedItem = null;
// _decoTemplate.Items.Remove(item);
// }
private async Task SaveChanges()
{
if (_decoTemplate.Items.Any(i => i.End is null))
{
Snackbar.Add("Template item cannot end after midnight", Severity.Error);
return;
}
var items = _decoTemplate.Items.Map(item => new ReplaceDecoTemplateItem(item.DecoId, item.Start.TimeOfDay, item.End!.Value.TimeOfDay)).ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new ReplaceDecoTemplateItems(Id, _decoTemplate.GroupId, _decoTemplate.Name, items), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving template: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving template: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("deco-templates"));
}
}
-212
View File
@@ -1,212 +0,0 @@
@page "/deco-templates"
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Tree
@implements IDisposable
@inject ILogger<DecoTemplates> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudForm Style="max-height: 100%">
<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">Deco Template Groups</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>Deco Template Group Name</MudText>
</div>
<MudTextField @bind-Value="_decoTemplateGroupName" For="@(() => _decoTemplateGroupName)"/>
</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.Primary" OnClick="@(_ => AddDecoTemplateGroup())" StartIcon="@Icons.Material.Filled.Add">
Add Deco Template Group
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Deco Templates</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>Deco Template Group</MudText>
</div>
<MudSelect @bind-Value="_selectedDecoTemplateGroup">
@foreach (DecoTemplateGroupViewModel decoTemplateGroup in _decoTemplateGroups)
{
<MudSelectItem Value="@decoTemplateGroup">@decoTemplateGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Template Name</MudText>
</div>
<MudTextField @bind-Value="_decoTemplateName" For="@(() => _decoTemplateName)"/>
</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.Primary" OnClick="@(_ => AddDecoTemplate())" StartIcon="@Icons.Material.Filled.Add">
Add Deco Template
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudTreeView T="DecoTemplateTreeItemViewModel" Items="@_treeItems" Hover="true" Style="width: 100%">
<ItemTemplate Context="item">
<MudTreeViewItem T="DecoTemplateTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" Value="@item.Value">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudGrid Justify="Justify.FlexStart">
<MudItem xs="5">
<MudText>@item.Value.Text</MudText>
</MudItem>
</MudGrid>
<div style="justify-self: end;">
@foreach (int decoTemplateId in Optional(item.Value.DecoTemplateId))
{
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"deco-templates/{decoTemplateId}")"/>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(_ => DeleteItem(item.Value))"/>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
</ItemTemplate>
</MudTreeView>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<TreeItemData<DecoTemplateTreeItemViewModel>> _treeItems = [];
private List<DecoTemplateGroupViewModel> _decoTemplateGroups = [];
private DecoTemplateGroupViewModel _selectedDecoTemplateGroup;
private string _decoTemplateGroupName;
private string _decoTemplateName;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_decoTemplateGroups = await Mediator.Send(new GetAllDecoTemplateGroups(), token);
_treeItems.Clear();
TreeViewModel tree = await Mediator.Send(new GetDecoTemplateTree(), token);
foreach (TreeGroupViewModel group in tree.Groups)
{
_treeItems.Add(new TreeItemData<DecoTemplateTreeItemViewModel> { Value = new DecoTemplateTreeItemViewModel(group) });
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task AddDecoTemplateGroup()
{
if (!string.IsNullOrWhiteSpace(_decoTemplateGroupName))
{
Either<BaseError, DecoTemplateGroupViewModel> result = await Mediator.Send(new CreateDecoTemplateGroup(_decoTemplateGroupName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding decoTemplate group: {Error}", error.Value);
}
foreach (DecoTemplateGroupViewModel decoTemplateGroup in result.RightToSeq())
{
_treeItems.Add(new TreeItemData<DecoTemplateTreeItemViewModel> { Value = new DecoTemplateTreeItemViewModel(decoTemplateGroup) });
_treeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
_decoTemplateGroupName = null;
_decoTemplateGroups = await Mediator.Send(new GetAllDecoTemplateGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
}
private async Task AddDecoTemplate()
{
if (_selectedDecoTemplateGroup is not null && !string.IsNullOrWhiteSpace(_decoTemplateName))
{
Either<BaseError, DecoTemplateViewModel> result = await Mediator.Send(new CreateDecoTemplate(_selectedDecoTemplateGroup.Id, _decoTemplateName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding decoTemplate: {Error}", error.Value);
}
foreach (DecoTemplateViewModel decoTemplate in result.RightToSeq())
{
foreach (DecoTemplateTreeItemViewModel item in _treeItems.Map(i => i.Value).Where(item => item.DecoTemplateGroupId == _selectedDecoTemplateGroup.Id))
{
item.TreeItems.Add(new TreeItemData<DecoTemplateTreeItemViewModel> { Value = new DecoTemplateTreeItemViewModel(decoTemplate) });
item.TreeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
}
_decoTemplateName = null;
await InvokeAsync(StateHasChanged);
}
}
}
private async Task DeleteItem(DecoTemplateTreeItemViewModel treeItem)
{
foreach (int decoTemplateGroupId in Optional(treeItem.DecoTemplateGroupId))
{
var parameters = new DialogParameters { { "EntityType", "decoTemplate group" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete DecoTemplate Group", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteDecoTemplateGroup(decoTemplateGroupId), _cts.Token);
_treeItems.RemoveAll(i => i.Value?.DecoTemplateGroupId == decoTemplateGroupId);
if (_selectedDecoTemplateGroup?.Id == decoTemplateGroupId)
{
_selectedDecoTemplateGroup = null;
}
_decoTemplateGroups = await Mediator.Send(new GetAllDecoTemplateGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
foreach (int decoTemplateId in Optional(treeItem.DecoTemplateId))
{
var parameters = new DialogParameters { { "EntityType", "decoTemplate" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete DecoTemplate", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteDecoTemplate(decoTemplateId), _cts.Token);
foreach (DecoTemplateTreeItemViewModel parent in _treeItems.Map(i => i.Value))
{
parent.TreeItems.RemoveAll(i => i.Value == treeItem);
}
await InvokeAsync(StateHasChanged);
}
}
}
}
-212
View File
@@ -1,212 +0,0 @@
@page "/decos"
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Tree
@implements IDisposable
@inject ILogger<Decos> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudForm Style="max-height: 100%">
<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">Deco Groups</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>Deco Group Name</MudText>
</div>
<MudTextField @bind-Value="_decoGroupName" For="@(() => _decoGroupName)"/>
</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.Primary" OnClick="@(_ => AddDecoGroup())" StartIcon="@Icons.Material.Filled.Add">
Add Deco Group
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Decos</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>Deco Group</MudText>
</div>
<MudSelect @bind-Value="_selectedDecoGroup">
@foreach (DecoGroupViewModel decoGroup in _decoGroups)
{
<MudSelectItem Value="@decoGroup">@decoGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Name</MudText>
</div>
<MudTextField @bind-Value="_decoName" For="@(() => _decoName)"/>
</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.Primary" OnClick="@(_ => AddDeco())" StartIcon="@Icons.Material.Filled.Add">
Add Deco
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudTreeView T="DecoTreeItemViewModel" Items="@_treeItems" Hover="true" Style="width: 100%">
<ItemTemplate Context="item">
<MudTreeViewItem T="DecoTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" Value="@item.Value">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudGrid Justify="Justify.FlexStart">
<MudItem xs="5">
<MudText>@item.Value.Text</MudText>
</MudItem>
</MudGrid>
<div style="justify-self: end;">
@foreach (int decoId in Optional(item.Value.DecoId))
{
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"decos/{decoId}")"/>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(_ => DeleteItem(item.Value))"/>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
</ItemTemplate>
</MudTreeView>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<TreeItemData<DecoTreeItemViewModel>> _treeItems = [];
private List<DecoGroupViewModel> _decoGroups = [];
private DecoGroupViewModel _selectedDecoGroup;
private string _decoGroupName;
private string _decoName;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_decoGroups = await Mediator.Send(new GetAllDecoGroups(), token);
_treeItems.Clear();
TreeViewModel tree = await Mediator.Send(new GetDecoTree(), token);
foreach (TreeGroupViewModel group in tree.Groups)
{
_treeItems.Add(new TreeItemData<DecoTreeItemViewModel> { Value = new DecoTreeItemViewModel(group) });
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task AddDecoGroup()
{
if (!string.IsNullOrWhiteSpace(_decoGroupName))
{
Either<BaseError, DecoGroupViewModel> result = await Mediator.Send(new CreateDecoGroup(_decoGroupName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding deco group: {Error}", error.Value);
}
foreach (DecoGroupViewModel decoGroup in result.RightToSeq())
{
_treeItems.Add(new TreeItemData<DecoTreeItemViewModel> { Value = new DecoTreeItemViewModel(decoGroup) });
_treeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
_decoGroupName = null;
_decoGroups = await Mediator.Send(new GetAllDecoGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
}
private async Task AddDeco()
{
if (_selectedDecoGroup is not null && !string.IsNullOrWhiteSpace(_decoName))
{
Either<BaseError, DecoViewModel> result = await Mediator.Send(new CreateDeco(_selectedDecoGroup.Id, _decoName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding deco: {Error}", error.Value);
}
foreach (DecoViewModel deco in result.RightToSeq())
{
foreach (DecoTreeItemViewModel item in _treeItems.Map(i => i.Value).Where(item => item.DecoGroupId == _selectedDecoGroup.Id))
{
item.TreeItems.Add(new TreeItemData<DecoTreeItemViewModel> { Value = new DecoTreeItemViewModel(deco) });
item.TreeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
}
_decoName = null;
await InvokeAsync(StateHasChanged);
}
}
}
private async Task DeleteItem(DecoTreeItemViewModel treeItem)
{
foreach (int decoGroupId in Optional(treeItem.DecoGroupId))
{
var parameters = new DialogParameters { { "EntityType", "deco group" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Deco Group", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteDecoGroup(decoGroupId), _cts.Token);
_treeItems.RemoveAll(i => i.Value?.DecoGroupId == decoGroupId);
if (_selectedDecoGroup?.Id == decoGroupId)
{
_selectedDecoGroup = null;
}
_decoGroups = await Mediator.Send(new GetAllDecoGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
foreach (int decoId in Optional(treeItem.DecoId))
{
var parameters = new DialogParameters { { "EntityType", "deco" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Deco", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteDeco(decoId), _cts.Token);
foreach (DecoTreeItemViewModel parent in _treeItems.Map(i => i.Value))
{
parent.TreeItems.RemoveAll(i => i.Value == treeItem);
}
await InvokeAsync(StateHasChanged);
}
}
}
}
-46
View File
@@ -1,46 +0,0 @@
@page "/media/sources/emby/{Id:int}/libraries"
@using ErsatzTV.Application.Emby
@using ErsatzTV.Application.MediaSources
@inject IMediator Mediator
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<RemoteMediaSourceLibrariesEditor
Id="@Id"
Name="Emby"
GetUpdateLibraryRequest="GetUpdateLibraryRequest"
GetLibrariesBySourceId="GetLibrariesBySourceId"
GetMediaSourceById="GetMediaSourceById"
SynchronizeLibraryByIdIfNeeded="SynchronizeLibraryByIdIfNeeded"/>
@code {
[Parameter]
public int Id { get; set; }
private IRequest<Either<BaseError, Unit>> GetUpdateLibraryRequest(List<RemoteMediaSourceLibraryEditViewModel> libraries) =>
new UpdateEmbyLibraryPreferences(
libraries.Map(l => new EmbyLibraryPreference(l.Id, l.ShouldSyncItems)).ToList());
private Task<List<RemoteMediaSourceLibraryEditViewModel>> GetLibrariesBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
Mediator.Send(new GetEmbyLibrariesBySourceId(Id), cancellationToken)
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
private Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int mediaSourceId, CancellationToken cancellationToken) =>
Mediator.Send(new GetEmbyMediaSourceById(Id), cancellationToken)
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(EmbyLibraryViewModel library) => new()
{
Id = library.Id,
Name = library.Name,
MediaKind = library.MediaKind,
ShouldSyncItems = library.ShouldSyncItems
};
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters, CancellationToken cancellationToken)
{
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(parameters.MediaSourceId), cancellationToken);
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraryByIdIfNeeded(parameters.LibraryId), cancellationToken);
return Unit.Default;
}
}
@@ -1,26 +0,0 @@
@page "/media/sources/emby/edit"
@using ErsatzTV.Application.Emby
@using ErsatzTV.Core.Emby
@inject IMediator Mediator
<RemoteMediaSourceEditor
Name="Emby"
LoadSecrets="LoadSecrets"
SaveSecrets="SaveSecrets"/>
@code {
private async Task<Unit> LoadSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
{
EmbySecrets secrets = await Mediator.Send(new GetEmbySecrets(), cancellationToken);
viewModel.Address = secrets.Address;
viewModel.ApiKey = secrets.ApiKey;
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> SaveSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
{
var secrets = new EmbySecrets { Address = viewModel.Address, ApiKey = viewModel.ApiKey };
return await Mediator.Send(new SaveEmbySecrets(secrets), cancellationToken);
}
}
-22
View File
@@ -1,22 +0,0 @@
@page "/media/sources/emby"
@using ErsatzTV.Application.Emby
@using ErsatzTV.Core.Emby
@using ErsatzTV.Core.Interfaces.Emby
@inject IEmbySecretStore EmbySecretStore
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<RemoteMediaSources
TViewModel="EmbyMediaSourceViewModel"
TSecrets="EmbySecrets"
TMediaSource="EmbyMediaSource"
Name="Emby"
GetAllMediaSourcesCommand="@(new GetAllEmbyMediaSources())"
DisconnectCommand="@(new DisconnectEmby())"
RefreshLibrariesCommand="@RefreshLibraries"
SecretStore="@EmbySecretStore"
CanEdit="true"/>
@code {
private async Task RefreshLibraries(int mediaSourceId, CancellationToken cancellationToken) =>
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSourceId), cancellationToken);
}
@@ -1,37 +0,0 @@
@page "/media/sources/emby/{Id:int}/paths"
@using ErsatzTV.Application.Emby
@using ErsatzTV.Application.MediaSources
@inject IMediator Mediator
<RemoteMediaSourcePathReplacementsEditor
Id="@Id"
Name="Emby"
GetMediaSourceById="GetMediaSourceById"
GetUpdatePathReplacementsRequest="GetUpdatePathReplacementsRequest"
GetPathReplacementsBySourceId="GetPathReplacementsBySourceId"/>
@code {
[Parameter]
public int Id { get; set; }
private Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int id, CancellationToken cancellationToken) =>
Mediator.Send(new GetEmbyMediaSourceById(Id), cancellationToken)
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
private Task<List<RemoteMediaSourcePathReplacementEditViewModel>> GetPathReplacementsBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
Mediator.Send(new GetEmbyPathReplacementsBySourceId(Id), cancellationToken)
.Map(list => list.Map(ProjectToEditViewModel).ToList());
private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(EmbyPathReplacementViewModel item) =>
new() { Id = item.Id, RemotePath = item.EmbyPath, LocalPath = item.LocalPath };
private IRequest<Either<BaseError, Unit>> GetUpdatePathReplacementsRequest(List<RemoteMediaSourcePathReplacementEditViewModel> pathReplacements)
{
var items = pathReplacements
.Map(item => new EmbyPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
.ToList();
return new UpdateEmbyPathReplacements(Id, items);
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/tv/episodes"
@page "/media/tv/episodes/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<EpisodeList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="TelevisionEpisodeCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href="@($"media/tv/seasons/{context.SeasonId}#episode-{context.EpisodeId}")"
Subtitle="@($"{context.ShowTitle} - S{context.Season} E{context.Episode}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/tv/episodes"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:episode" : $"type:episode AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexEpisodes(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/tv/episodes/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/tv/episodes/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-141
View File
@@ -1,141 +0,0 @@
@page "/ffmpeg"
@using ErsatzTV.Application.FFmpegProfiles
@implements IDisposable
@inject IDialogService Dialog
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject NavigationManager NavigationManager
<MudForm 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" StartIcon="@Icons.Material.Filled.Add" Href="ffmpeg/add">
Add Profile
</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">FFmpeg Profiles</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true" Items="_ffmpegProfiles">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Resolution</MudTh>
<MudTh>Video Format</MudTh>
<MudTh>Audio Format</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Resolution">@context.Resolution.Name</MudTd>
<MudTd DataLabel="Video Format">
@if (context.HardwareAcceleration != HardwareAccelerationKind.None)
{
@($"{context.VideoFormat.ToString().ToLowerInvariant()} / {context.HardwareAcceleration.ToString().ToLowerInvariant()}")
}
else
{
@context.VideoFormat.ToString().ToLowerInvariant()
}
</MudTd>
<MudTd DataLabel="Audio Format">@context.AudioFormat.ToString().ToLowerInvariant()</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Profile">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"ffmpeg/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Copy Profile">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyProfileAsync(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Profile">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteProfileAsync(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await LoadFFmpegProfilesAsync(token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task LoadFFmpegProfilesAsync(CancellationToken cancellationToken) =>
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), cancellationToken);
private async Task DeleteProfileAsync(FFmpegProfileViewModel ffmpegProfile)
{
var parameters = new DialogParameters { { "EntityType", "ffmpeg profile" }, { "EntityName", ffmpegProfile.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete FFmpeg Profile", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
Either<BaseError, Unit> deleteResult = await Mediator.Send(new DeleteFFmpegProfile(ffmpegProfile.Id), _cts.Token);
foreach (BaseError error in deleteResult.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
}
if (deleteResult.IsRight)
{
await LoadFFmpegProfilesAsync(_cts.Token);
}
}
}
private async Task CopyProfileAsync(FFmpegProfileViewModel ffmpegProfile)
{
var parameters = new DialogParameters { { "FFmpegProfileId", ffmpegProfile.Id } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<CopyFFmpegProfileDialog>("Copy FFmpeg Profile", parameters, options);
DialogResult dialogResult = await dialog.Result;
if (dialogResult is { Canceled: false, Data: FFmpegProfileViewModel data })
{
NavigationManager.NavigateTo($"ffmpeg/{data.Id}");
}
}
}
-504
View File
@@ -1,504 +0,0 @@
@page "/ffmpeg/{Id:int}"
@page "/ffmpeg/add"
@using System.Runtime.InteropServices
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Core.FFmpeg
@using ErsatzTV.FFmpeg.Format
@using ErsatzTV.Validators
@using FluentValidation.Results
@using Microsoft.Extensions.Caching.Memory
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<FFmpegEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IMemoryCache MemoryCache
@inject PersistentComponentState ApplicationState
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" 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="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Profile" : "Add Profile")</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">General</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)" Immediate="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Thread Count</MudText>
</div>
<MudTextField @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Audio</MudText>
</div>
<MudCheckBox @bind-Value="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Video</MudText>
</div>
<MudCheckBox @bind-Value="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preferred Resolution</MudText>
</div>
<MudSelect @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
@foreach (ResolutionViewModel resolution in _resolutions)
{
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
@if (_model.NormalizeVideo)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Scaling Behavior</MudText>
</div>
<MudSelect @bind-Value="_model.ScalingBehavior" For="@(() => _model.ScalingBehavior)">
<MudSelectItem Value="@ScalingBehavior.ScaleAndPad">Scale and Pad</MudSelectItem>
<MudSelectItem Value="@ScalingBehavior.Stretch">Stretch</MudSelectItem>
<MudSelectItem Value="@ScalingBehavior.Crop">Crop</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Pad Mode</MudText>
</div>
<MudSelect @bind-Value="_model.PadMode"
For="@(() => _model.PadMode)"
Disabled="_model.HardwareAcceleration is not HardwareAccelerationKind.Vaapi"
HelperText="If hardware padding is green, software padding should be used instead">
<MudSelectItem Value="@FilterMode.HardwareIfPossible">Hardware If Possible</MudSelectItem>
<MudSelectItem Value="@FilterMode.Software">Software</MudSelectItem>
</MudSelect>
</MudStack>
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Video</MudText>
<MudDivider Class="mb-6"/>
@if (_model.NormalizeVideo)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Format</MudText>
</div>
<MudSelect @bind-Value="_model.VideoFormat" For="@(() => _model.VideoFormat)">
<MudSelectItem Value="@FFmpegProfileVideoFormat.H264">h264</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Hevc">hevc</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Mpeg2Video">mpeg-2</MudSelectItem>
@if (_model.HardwareAcceleration is (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv))
{
<MudSelectItem Value="@FFmpegProfileVideoFormat.Av1">av1</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Profile</MudText>
</div>
<MudSelect @bind-Value="_model.VideoProfile"
For="@(() => _model.VideoProfile)"
Disabled="@(_model.VideoFormat != FFmpegProfileVideoFormat.H264 || !HardwareAccelSupportsH264Profile(_model.HardwareAcceleration))"
Clearable="true">
<MudSelectItem Value="@VideoProfile.Main">main</MudSelectItem>
<MudSelectItem Value="@VideoProfile.High">high</MudSelectItem>
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Nvenc)
{
<MudSelectItem Value="@VideoProfile.High444p">high444p</MudSelectItem>
}
else
{
<MudSelectItem Value="@VideoProfile.High10">high10</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preset</MudText>
</div>
@{
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
_model.HardwareAcceleration,
_model.VideoFormat,
_model.BitDepth);
}
<MudSelect @bind-Value="_model.VideoPreset"
For="@(() => _model.VideoPreset)"
Disabled="@(presets.Count == 0)"
Clearable="true">
@foreach (string preset in presets)
{
if (!string.IsNullOrWhiteSpace(preset))
{
<MudSelectItem Value="@preset">@preset</MudSelectItem>
}
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Allow B-Frames</MudText>
</div>
<MudCheckBox @bind-Value="@_model.AllowBFrames" For="@(() => _model.AllowBFrames)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bit Depth</MudText>
</div>
<MudSelect @bind-Value="_model.BitDepth" For="@(() => _model.BitDepth)">
<MudSelectItem Value="@FFmpegProfileBitDepth.EightBit">8-bit</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileBitDepth.TenBit">10-bit</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bitrate</MudText>
</div>
<MudTextField @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Buffer Size</MudText>
</div>
<MudTextField @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Hardware Acceleration</MudText>
</div>
<MudSelect @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in _hardwareAccelerationKinds)
{
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
}
</MudSelect>
</MudStack>
@if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>VAAPI Driver</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDriver" For="@(() => _model.VaapiDriver)">
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
{
<MudSelectItem Value="@driver">@driver</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>VAAPI Display</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDisplay" For="@(() => _model.VaapiDisplay)">
@foreach (string display in _vaapiDisplays)
{
<MudSelectItem Value="@display">@display</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>@(_model.HardwareAcceleration == HardwareAccelerationKind.Vaapi ? "VAAPI Device" : "QSV Device")</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDevice" For="@(() => _model.VaapiDevice)">
@foreach (string device in _vaapiDevices)
{
<MudSelectItem Value="@device">@device</MudSelectItem>
}
</MudSelect>
</MudStack>
}
}
@if (_model.HardwareAcceleration == HardwareAccelerationKind.Qsv)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>QSV Extra Hardware Frames</MudText>
</div>
<MudTextField @bind-Value="_model.QsvExtraHardwareFrames" For="@(() => _model.QsvExtraHardwareFrames)"/>
</MudStack>
}
else
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Tonemap Algorithm</MudText>
</div>
<MudSelect @bind-Value="_model.TonemapAlgorithm" For="@(() => _model.TonemapAlgorithm)">
@foreach (FFmpegProfileTonemapAlgorithm algorithm in Enum.GetValues<FFmpegProfileTonemapAlgorithm>())
{
<MudSelectItem Value="@algorithm">@algorithm</MudSelectItem>
}
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Frame Rate</MudText>
</div>
<MudCheckBox @bind-Value="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Colors</MudText>
</div>
<MudCheckBox @bind-Value="@_model.NormalizeColors" For="@(() => _model.NormalizeColors)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Auto Deinterlace Video</MudText>
</div>
<MudCheckBox @bind-Value="@_model.DeinterlaceVideo" For="@(() => _model.DeinterlaceVideo)" Dense="true"/>
</MudStack>
}
else
{
<MudText>Video will be copied as-is, including timestamps, which *will* cause issues with most clients</MudText>
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Audio</MudText>
<MudDivider Class="mb-6"/>
@if (_model.NormalizeAudio)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Format</MudText>
</div>
<MudSelect @bind-Value="_model.AudioFormat" For="@(() => _model.AudioFormat)" HelperText="@(_model.AudioFormat is FFmpegProfileAudioFormat.AacLatm ? "aac (latm) is ONLY intended for DVB-C; anything else should use aac instead which uses ADTS" : string.Empty)"> <MudSelectItem Value="@FFmpegProfileAudioFormat.Aac">aac</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.Ac3">ac3</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.AacLatm">aac (latm)</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bitrate</MudText>
</div>
<MudTextField @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Buffer Size</MudText>
</div>
<MudTextField @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Channels</MudText>
</div>
<MudTextField @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Sample Rate</MudText>
</div>
<MudTextField @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Loudness</MudText>
</div>
<MudSelect @bind-Value="_model.NormalizeLoudnessMode" For="@(() => _model.NormalizeLoudnessMode)">
<MudSelectItem Value="@NormalizeLoudnessMode.Off">Off</MudSelectItem>
<MudSelectItem Value="@NormalizeLoudnessMode.LoudNorm">loudnorm</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Target Loudness</MudText>
</div>
<MudTextField @bind-Value="_model.TargetLoudness" For="@(() => _model.TargetLoudness)" Adornment="Adornment.End" AdornmentText="LUFS"/>
</MudStack>
}
}
else
{
<MudText>Audio will be copied as-is, including timestamps, which *will* cause issues with most clients</MudText>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private FFmpegProfileEditViewModel _model = new();
private readonly FFmpegProfileEditViewModelValidator _validator = new();
private MudForm _form;
private List<ResolutionViewModel> _resolutions = [];
private List<HardwareAccelerationKind> _hardwareAccelerationKinds = [];
private List<string> _vaapiDisplays = [];
private List<string> _vaapiDevices = [];
private PersistingComponentStateSubscription _persistingSubscription;
public void Dispose()
{
_persistingSubscription.Dispose();
_cts?.Cancel();
_cts?.Dispose();
}
protected override Task OnInitializedAsync()
{
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
return base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (!ApplicationState.TryTakeFromJson("_resolutions", out List<ResolutionViewModel> restoredResolutions))
{
_resolutions = await Mediator.Send(new GetAllResolutions(), token);
}
else
{
_resolutions = restoredResolutions;
}
if (!ApplicationState.TryTakeFromJson("_hardwareAccelerationKinds", out List<HardwareAccelerationKind> restoredHardwareAccelerationKinds))
{
_hardwareAccelerationKinds = await Mediator.Send(new GetSupportedHardwareAccelerationKinds(), token);
}
else
{
_hardwareAccelerationKinds = restoredHardwareAccelerationKinds;
}
if (IsEdit)
{
if (!ApplicationState.TryTakeFromJson("_model", out FFmpegProfileEditViewModel restoredProfile))
{
Option<FFmpegProfileViewModel> maybeProfile = await Mediator.Send(new GetFFmpegProfileById(Id), token);
foreach (FFmpegProfileViewModel profile in maybeProfile)
{
_model = new FFmpegProfileEditViewModel(profile);
}
if (maybeProfile.IsNone)
{
NavigationManager.NavigateTo("404");
}
}
else
{
_model = restoredProfile;
}
}
else
{
_model = new FFmpegProfileEditViewModel(await Mediator.Send(new NewFFmpegProfile(), token));
}
if (!_hardwareAccelerationKinds.Contains(_model.HardwareAcceleration))
{
_model.HardwareAcceleration = HardwareAccelerationKind.None;
}
if (!MemoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
{
vaapiDevices = ["/dev/dri/renderD128"];
}
_vaapiDevices = vaapiDevices.OrderBy(s => s).ToList();
if (!MemoryCache.TryGetValue("ffmpeg.vaapi_displays", out List<string> vaapiDisplays))
{
vaapiDisplays = ["drm"];
}
_vaapiDisplays = vaapiDisplays.OrderBy(s => s).ToList();
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool HardwareAccelSupportsH264Profile(HardwareAccelerationKind accel) =>
accel switch
{
HardwareAccelerationKind.None => true,
HardwareAccelerationKind.Nvenc => true,
HardwareAccelerationKind.Qsv => true,
HardwareAccelerationKind.Vaapi => true,
HardwareAccelerationKind.VideoToolbox => true,
_ => false,
};
private Task PersistData()
{
ApplicationState.PersistAsJson("_model", _model);
ApplicationState.PersistAsJson("_resolutions", _resolutions);
ApplicationState.PersistAsJson("_hardwareAccelerationKinds", _hardwareAccelerationKinds);
return Task.CompletedTask;
}
private bool IsEdit => Id != 0;
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (!_form.IsValid)
{
return;
}
using var cts = new CancellationTokenSource();
try
{
ValidationResult result = await _validator.ValidateAsync(_model, cts.Token);
if (result.IsValid)
{
Seq<BaseError> errorMessage = IsEdit
? (await Mediator.Send(_model.ToUpdate(), cts.Token)).LeftToSeq()
: (await Mediator.Send(_model.ToCreate(), cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add("Unexpected error saving ffmpeg profile");
Logger.LogError("Unexpected error saving ffmpeg profile: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("ffmpeg"));
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
}
-418
View File
@@ -1,418 +0,0 @@
@page "/media/filler/presets/{Id:int}/edit"
@page "/media/filler/presets/add"
@using ErsatzTV.Application.Artists
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Television
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.Validators
@using FluentValidation.Results
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<FillerPresetEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" 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="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Filler Preset" : "Add Filler Preset")</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">Filler Preset</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)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Kind</MudText>
</div>
<MudSelect @bind-Value="_model.FillerKind" For="@(() => _model.FillerKind)" Disabled="@IsEdit">
<MudSelectItem Value="@(FillerKind.PreRoll)">Pre-Roll</MudSelectItem>
<MudSelectItem Value="@(FillerKind.MidRoll)">Mid-Roll</MudSelectItem>
<MudSelectItem Value="@(FillerKind.PostRoll)">Post-Roll</MudSelectItem>
<MudSelectItem Value="@(FillerKind.Tail)">Tail</MudSelectItem>
<MudSelectItem Value="@(FillerKind.Fallback)">Fallback</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Mode</MudText>
</div>
<MudSelect @bind-Value="_model.FillerMode" For="@(() => _model.FillerMode)"
Disabled="@(_model.FillerKind is FillerKind.Fallback or FillerKind.Tail || _model.CollectionType is CollectionType.Playlist)">
<MudSelectItem Value="@(FillerMode.Duration)">Duration</MudSelectItem>
<MudSelectItem Value="@(FillerMode.Count)">Count</MudSelectItem>
<MudSelectItem Value="@(FillerMode.Pad)">Pad</MudSelectItem>
<MudSelectItem Value="@(FillerMode.RandomCount)">Random Count</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Duration</MudText>
</div>
<MudTextField T="int"
@bind-Value="_model.DurationHours"
Adornment="Adornment.End"
AdornmentText="hours"
Disabled="@(_model.FillerMode != FillerMode.Duration)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudTextField T="int"
@bind-Value="_model.DurationMinutes"
Adornment="Adornment.End"
AdornmentText="minutes"
Disabled="@(_model.FillerMode != FillerMode.Duration)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudTextField T="int"
@bind-Value="_model.DurationSeconds"
Adornment="Adornment.End"
AdornmentText="seconds"
Disabled="@(_model.FillerMode != FillerMode.Duration)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Count</MudText>
</div>
<MudTextField @bind-Value="@_model.Count" For="@(() => _model.Count)" Disabled="@(_model.FillerMode != FillerMode.Count && _model.FillerMode != FillerMode.RandomCount)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Pad To Nearest Minute</MudText>
</div>
<MudSelect @bind-Value="_model.PadToNearestMinute" For="@(() => _model.PadToNearestMinute)" Disabled="@(_model.FillerMode != FillerMode.Pad)">
<MudSelectItem T="int?" Value="5">5 (:00, :05, :10, :15, :20, etc)</MudSelectItem>
<MudSelectItem T="int?" Value="10">10 (:00, :10, :20, :30, :40, :50)</MudSelectItem>
<MudSelectItem T="int?" Value="15">15 (:00, :15, :30, :45)</MudSelectItem>
<MudSelectItem T="int?" Value="30">30 (:00, :30)</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Allow Watermarks</MudText>
</div>
<MudCheckBox @bind-Value="@_model.AllowWatermarks" For="@(() => _model.AllowWatermarks)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_model.CollectionType" For="@(() => _model.CollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
@if (_model.FillerKind is not FillerKind.Fallback and not FillerKind.Tail)
{
<MudSelectItem Value="CollectionType.Playlist">Playlist</MudSelectItem>
}
</MudSelect>
</MudStack>
@if (_model.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudSelect T="MediaCollectionViewModel" @bind-value="_model.Collection">
@foreach (MediaCollectionViewModel collection in _mediaCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudSelect T="MultiCollectionViewModel" @bind-value="_model.MultiCollection">
@foreach (MultiCollectionViewModel collection in _multiCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudSelect T="SmartCollectionViewModel" @bind-value="_model.SmartCollection">
@foreach (SmartCollectionViewModel collection in _smartCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel" @bind-value="_model.MediaItem">
@foreach (NamedMediaItemViewModel show in _televisionShows)
{
<MudSelectItem Value="@show">@show.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel" @bind-value="_model.MediaItem">
@foreach (NamedMediaItemViewModel season in _televisionSeasons)
{
<MudSelectItem Value="@season">@season.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.CollectionType == CollectionType.Artist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel" @bind-value="_model.MediaItem">
@foreach (NamedMediaItemViewModel artist in _artists)
{
<MudSelectItem Value="@artist">@artist.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_model.CollectionType == CollectionType.Playlist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist Group</MudText>
</div>
<MudSelect T="PlaylistGroupViewModel"
Value="@_selectedPlaylistGroup"
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
{
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist</MudText>
</div>
<MudSelect T="PlaylistViewModel" @bind-value="_model.Playlist">
@foreach (PlaylistViewModel playlist in _playlists)
{
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Use Chapters As Media Items</MudText>
</div>
<MudCheckBox @bind-Value="@_model.UseChaptersAsMediaItems"
For="@(() => _model.UseChaptersAsMediaItems)"
Dense="true"
Disabled="@(_model.FillerKind is FillerKind.Fallback)">
<MudText Typo="Typo.caption" Style="font-weight: normal">Schedule individual chapters instead of entire files</MudText>
</MudCheckBox>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Expression</MudText>
</div>
<MudTextField @bind-Value="_model.Expression"
For="@(() => _model.Expression)"
Disabled="@(_model.FillerKind is not FillerKind.MidRoll)"
HelperText="For mid-roll filler, only add filler when this expression evaluates to true for a given mid-roll point."/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="gap-md-8 mb-5">
<div class="d-flex" style="width: 300px"></div>
<MudText Typo="Typo.body2">
<span style="font-weight: bold;">total_points</span>: total number of potential mid-roll points
<br/>
<span style="font-weight: bold;">matched_points</span>: number of mid-roll points that have already matched the expression
<br/>
<span style="font-weight: bold;">total_duration</span>: total duration of the content, in seconds
<br/>
<span style="font-weight: bold;">total_progress</span>: normalized position from 0 to 1
<br/>
<span style="font-weight: bold;">last_mid_filler</span>: seconds since last mid-roll filler
<br/>
<span style="font-weight: bold;">remaining_duration</span>: duration of the content after this mid-roll point, in seconds
<br/>
<span style="font-weight: bold;">point</span>: the position of the mid-roll point, in seconds
<br/>
<span style="font-weight: bold;">num</span>: the mid-roll point number, starting with 1
</MudText>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly FillerPresetEditViewModel _model = new();
private readonly FillerPresetEditViewModelValidator _validator = new();
private MudForm _form;
private readonly List<MediaCollectionViewModel> _mediaCollections = [];
private readonly List<MultiCollectionViewModel> _multiCollections = [];
private readonly List<SmartCollectionViewModel> _smartCollections = [];
private readonly List<NamedMediaItemViewModel> _televisionShows = [];
private readonly List<NamedMediaItemViewModel> _televisionSeasons = [];
private readonly List<NamedMediaItemViewModel> _artists = [];
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
private readonly List<PlaylistViewModel> _playlists = [];
private PlaylistGroupViewModel _selectedPlaylistGroup;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_mediaCollections.AddRange(
await Mediator.Send(new GetAllCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
_multiCollections.AddRange(
await Mediator.Send(new GetAllMultiCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
_smartCollections.AddRange(
await Mediator.Send(new GetAllSmartCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
_televisionShows.AddRange(
await Mediator.Send(new GetAllTelevisionShows(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
_televisionSeasons.AddRange(
await Mediator.Send(new GetAllTelevisionSeasons(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
_artists.AddRange(
await Mediator.Send(new GetAllArtists(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), token));
if (IsEdit)
{
Option<FillerPresetViewModel> maybeFillerPreset = await Mediator.Send(new GetFillerPresetById(Id), token);
maybeFillerPreset.IfSome(fillerPreset =>
{
_model.Id = fillerPreset.Id;
_model.Name = fillerPreset.Name;
_model.FillerKind = fillerPreset.FillerKind;
_model.FillerMode = fillerPreset.FillerMode;
_model.Duration = fillerPreset.Duration;
_model.Count = fillerPreset.Count;
_model.PadToNearestMinute = fillerPreset.PadToNearestMinute;
_model.AllowWatermarks = fillerPreset.AllowWatermarks;
_model.CollectionType = fillerPreset.CollectionType;
_model.Collection = fillerPreset.CollectionId.HasValue
? _mediaCollections.Find(c => c.Id == fillerPreset.CollectionId.Value)
: null;
_model.MultiCollection = fillerPreset.MultiCollectionId.HasValue
? _multiCollections.Find(c => c.Id == fillerPreset.MultiCollectionId.Value)
: null;
_model.SmartCollection = fillerPreset.SmartCollectionId.HasValue
? _smartCollections.Find(c => c.Id == fillerPreset.SmartCollectionId.Value)
: null;
_model.MediaItem = fillerPreset.MediaItemId.HasValue
? _televisionShows.Append(_televisionSeasons).Append(_artists).ToList().Find(vm => vm.MediaItemId == fillerPreset.MediaItemId.Value)
: null;
_model.Expression = fillerPreset.Expression;
_model.UseChaptersAsMediaItems = fillerPreset.UseChaptersAsMediaItems;
_model.Playlist = fillerPreset.Playlist;
});
}
else
{
_model.Name = "New Filler Preset";
_model.FillerKind = FillerKind.PreRoll;
_model.FillerMode = FillerMode.Duration;
}
foreach (int playlistGroupId in Optional(_model.Playlist?.PlaylistGroupId))
{
foreach (PlaylistGroupViewModel group in Optional(_playlistGroups.Find(g => g.Id == playlistGroupId)))
{
_selectedPlaylistGroup = group;
await UpdatePlaylistGroupItems(group);
}
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool IsEdit => Id != 0;
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup)
{
_selectedPlaylistGroup = playlistGroup;
_playlists.Clear();
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token));
}
private async Task HandleSubmitAsync()
{
await _form.Validate();
ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token);
if (result.IsValid)
{
// Create/Update return different result types (Update -> Unit, Create -> CreateFillerPresetResult),
// so send each on its own branch and reduce to the shared Left error sequence.
Seq<BaseError> errorMessage = IsEdit
? (await Mediator.Send(_model.ToEdit(), _cts.Token)).LeftToSeq()
: (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error saving filler preset: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("media/filler/presets"));
}
}
}
-130
View File
@@ -1,130 +0,0 @@
@page "/media/filler/presets"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Filler
@using ErsatzTV.Core.Domain.Filler
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
<MudForm 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" StartIcon="@Icons.Material.Filled.Add" Href="media/filler/presets/add">
Add Filler Preset
</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">Filler Presets</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_fillerPresetsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<FillerPresetViewModel>>>(ServerReloadFillerPresets))"
Dense="true"
@ref="_fillerPresetsTable">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Filler Kind</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Filler Kind">
@(
context.FillerKind switch
{
FillerKind.PreRoll => "Pre-Roll",
FillerKind.MidRoll => "Mid-Roll",
FillerKind.PostRoll => "Post-Roll",
FillerKind.Fallback => "Fallback",
FillerKind.Tail => "Tail",
_ => "None"
}
)
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Filler Preset">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"media/filler/presets/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Filler Preset">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteFillerPreset(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<FillerPresetViewModel> _fillerPresetsTable;
private int _fillerPresetsRowsPerPage = 10;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_fillerPresetsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.FillerPresetsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task DeleteFillerPreset(FillerPresetViewModel fillerPreset)
{
var parameters = new DialogParameters { { "EntityType", "filler preset" }, { "EntityName", fillerPreset.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Filler Preset", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteFillerPreset(fillerPreset.Id), _cts.Token);
if (_fillerPresetsTable != null)
{
await _fillerPresetsTable.ReloadServerData();
}
}
}
private async Task<TableData<FillerPresetViewModel>> ServerReloadFillerPresets(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.FillerPresetsPageSize, state.PageSize.ToString()), cancellationToken);
PagedFillerPresetsViewModel data = await Mediator.Send(new GetPagedFillerPresets(state.Page, state.PageSize), cancellationToken);
return new TableData<FillerPresetViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
}
-74
View File
@@ -1,74 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Extensions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop;
namespace ErsatzTV.Pages;
public class FragmentNavigationBase : ComponentBase, IDisposable
{
private readonly CancellationTokenSource _cts = new();
private bool _disposedValue;
protected CancellationToken CancellationToken => _cts.Token;
[Inject]
private NavigationManager NavManager { get; set; }
[Inject]
private IJSRuntime JsRuntime { get; set; }
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
NavManager.LocationChanged -= TryFragmentNavigation;
_cts?.Cancel();
_cts?.Dispose();
}
_disposedValue = true;
}
}
protected override void OnInitialized() => NavManager.LocationChanged += TryFragmentNavigation;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await NavManager.NavigateToFragmentAsync(JsRuntime);
}
catch (Exception)
{
// ignored
}
}
}
[SuppressMessage("ReSharper", "VSTHRD100")]
private async void TryFragmentNavigation(object sender, LocationChangedEventArgs args)
{
try
{
await NavManager.NavigateToFragmentAsync(JsRuntime);
}
catch (Exception)
{
// ignored
}
}
}
-110
View File
@@ -1,110 +0,0 @@
@page "/media/browser/images"
@using System.Net
@using ErsatzTV.Application.Images
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<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">Images</MudText>
<MudDivider Class="mb-6"/>
<MudCard>
<MudTreeView T="ImageTreeItemViewModel" ServerData="LoadServerData" Items="@TreeItems" Hover="true" ExpandOnClick="true">
<ItemTemplate Context="item">
<MudTreeViewItem T="ImageTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" CanExpand="@item.Value.CanExpand" Value="@item.Value">
<BodyContent>
<div style="display: flex; align-items: center; width: 100%">
<div style="justify-self: start; flex: 1">
<MudText>@item.Value.Text</MudText>
</div>
<div style="display: flex; align-items: center; justify-self: end;">
<span class="d-none d-sm-flex">@item.Value.EndText</span>
<MudTooltip Text="Edit Image Folder Duration" ShowOnHover="true" ShowOnClick="false" ShowOnFocus="false">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
OnClick="@(_ => EditImageFolderDuration(item.Value))">
</MudIconButton>
</MudTooltip>
@{
string query = GetSearchQuery(item.Value);
if (!string.IsNullOrWhiteSpace(query))
{
<MudIconButton
Class="d-none d-sm-flex"
Icon="@Icons.Material.Filled.Search"
Href="@($"search?query={query}")"/>
}
}
</div>
</div>
</BodyContent>
</MudTreeViewItem>
</ItemTemplate>
</MudTreeView>
</MudCard>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private List<TreeItemData<ImageTreeItemViewModel>> TreeItems { get; set; } = [];
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
List<ImageFolderViewModel> imageFolders = await Mediator.Send(new GetImageFolders(Option<int>.None), token);
TreeItems = imageFolders.Map(g => new TreeItemData<ImageTreeItemViewModel> { Value = new ImageTreeItemViewModel(g) }).ToList();
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task<IReadOnlyCollection<TreeItemData<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 TreeItemData<ImageTreeItemViewModel> { Value = 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 is { Canceled: false })
{
double? duration = await Mediator.Send(new UpdateImageFolderDuration(item.LibraryFolderId, result.Data as double?), _cts.Token);
item.UpdateDuration(duration);
await InvokeAsync(StateHasChanged);
}
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/images"
@page "/media/images/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<ImageList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="ImageCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/images"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private ImageCardResultsViewModel _data = new(0, new List<ImageCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:image" : $"type:image AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexImages(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/images/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/images/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is ImageCardViewModel image)
{
var parameters = new DialogParameters { { "EntityType", "image" }, { "EntityName", image.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddImageToCollection(collection.Id, image.ImageId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding image to collection: {error.Value}");
Logger.LogError("Unexpected error adding image to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-248
View File
@@ -1,248 +0,0 @@
@page "/"
@page "/system/health"
@using System.Reflection
@using ErsatzTV.Application.Health
@using ErsatzTV.Core.Health
@using ErsatzTV.Core.Interfaces.GitHub
@using Microsoft.Extensions.Caching.Memory
@implements IDisposable
@inject IGitHubApiClient GitHubApiClient
@inject IMemoryCache MemoryCache
@inject IMediator Mediator
@inject SystemStartup SystemStartup;
<MudForm Style="max-height: 100%">
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@if (!SystemStartup.IsDatabaseReady)
{
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h4">Database is initializing</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>Please wait, this may take a few minutes.</MudText>
<MudText>This page will automatically refresh when the database is ready.</MudText>
</MudCardContent>
<MudCardActions>
<MudProgressCircular Color="Color.Primary" Size="Size.Medium" Indeterminate="true"/>
</MudCardActions>
</MudCard>
}
else if (!SystemStartup.IsSearchIndexReady)
{
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h4">Search Index is initializing</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>Please wait, this may take a few minutes.</MudText>
<MudText>This page will automatically refresh when the search index is ready.</MudText>
</MudCardContent>
<MudCardActions>
<MudProgressCircular Color="Color.Primary" Size="Size.Medium" Indeterminate="true"/>
</MudCardActions>
</MudCard>
}
else
{
<MudText Typo="Typo.h5" Class="mb-2">Health Checks</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
Breakpoint="Breakpoint.None"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<HealthCheckResult>>>(ServerReload))">
<HeaderContent>
<MudTh>Check</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<div style="align-items: center; display: flex; flex-direction: row;">
@if (context.Status == HealthCheckStatus.Fail)
{
<MudIcon Color="@Color.Error" Icon="@Icons.Material.Filled.Error"/>
}
else if (context.Status == HealthCheckStatus.Warning)
{
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning"/>
}
else if (context.Status == HealthCheckStatus.Info)
{
<MudIcon Color="@Color.Info" Icon="@Icons.Material.Filled.Info"/>
}
else
{
<MudIcon Color="@Color.Success" Icon="@Icons.Material.Filled.Check"/>
}
<div class="ml-2">@context.Title</div>
</div>
</MudTd>
<MudTd>
<MudHidden Breakpoint="Breakpoint.Xs">
@if (context.Link.IsSome)
{
foreach (HealthCheckLink link in context.Link)
{
if (link.Link.StartsWith("http"))
{
<MudLink Href="@link.Link" Target="_blank">
@context.Message
</MudLink>
}
else
{
<MudLink Href="@link.Link">
@context.Message
</MudLink>
}
}
}
else
{
<MudText>
@context.Message
</MudText>
}
</MudHidden>
<MudHidden Breakpoint="Breakpoint.Xs" Invert="true">
@if (context.Link.IsSome)
{
foreach (HealthCheckLink link in context.Link)
{
<MudLink Href="@link.Link">
@context.BriefMessage
</MudLink>
}
}
else
{
<MudText>
@context.BriefMessage
</MudText>
}
</MudHidden>
</MudTd>
</RowTemplate>
</MudTable>
<MudCard Class="mt-6">
<MudCardHeader>
<MudText Class="mt-6">Full changelog is available on <MudLink Href="https://github.com/ErsatzTV/ErsatzTV/blob/main/CHANGELOG.md" Target="_blank">GitHub</MudLink></MudText>
</MudCardHeader>
<MudCardContent Class="release-notes mud-typography mud-typography-body1">
<MarkdownView Content="@_releaseNotes"/>
</MudCardContent>
</MudCard>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private string _releaseNotes;
protected override void OnInitialized()
{
SystemStartup.OnDatabaseReady += OnStartupProgress;
SystemStartup.OnSearchIndexReady += OnStartupProgress;
}
public void Dispose()
{
SystemStartup.OnDatabaseReady -= OnStartupProgress;
SystemStartup.OnSearchIndexReady -= OnStartupProgress;
_cts?.Cancel();
_cts?.Dispose();
}
private async void OnStartupProgress(object sender, EventArgs e)
{
try
{
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
// do nothing
}
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (MemoryCache.TryGetValue("Index.ReleaseNotesHtml", out string releaseNotesHtml))
{
_releaseNotes = releaseNotesHtml;
}
else
{
var assembly = Assembly.GetEntryAssembly();
if (assembly != null)
{
string version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (version != null)
{
Either<BaseError, string> maybeNotes;
if (version != "develop")
{
string gitHubVersion = version.Split("-").Head();
if (!gitHubVersion.StartsWith("v"))
{
gitHubVersion = $"v{gitHubVersion}";
}
maybeNotes = await GitHubApiClient.GetReleaseNotes(gitHubVersion, token);
foreach (string notes in maybeNotes.RightToSeq())
{
_releaseNotes = notes;
}
}
else
{
maybeNotes = await GitHubApiClient.GetLatestReleaseNotes(token);
foreach (string notes in maybeNotes.RightToSeq())
{
_releaseNotes = notes;
}
}
}
}
if (_releaseNotes != null)
{
MemoryCache.Set("Index.ReleaseNotesHtml", _releaseNotes);
}
}
}
catch (Exception)
{
// ignore
}
}
private async Task<TableData<HealthCheckResult>> ServerReload(TableState state, CancellationToken cancellationToken)
{
List<HealthCheckResult> healthCheckResults = await Mediator.Send(new GetAllHealthCheckResults(), cancellationToken);
return new TableData<HealthCheckResult>
{
TotalItems = healthCheckResults.Count,
Items = healthCheckResults
};
}
}
@@ -1,45 +0,0 @@
@page "/media/sources/jellyfin/{Id:int}/libraries"
@using ErsatzTV.Application.Jellyfin
@using ErsatzTV.Application.MediaSources
@inject IMediator Mediator
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<RemoteMediaSourceLibrariesEditor
Id="@Id"
Name="Jellyfin"
GetUpdateLibraryRequest="GetUpdateLibraryRequest"
GetLibrariesBySourceId="GetLibrariesBySourceId"
GetMediaSourceById="GetMediaSourceById"
SynchronizeLibraryByIdIfNeeded="SynchronizeLibraryByIdIfNeeded"/>
@code {
[Parameter]
public int Id { get; set; }
private IRequest<Either<BaseError, Unit>> GetUpdateLibraryRequest(List<RemoteMediaSourceLibraryEditViewModel> libraries) =>
new UpdateJellyfinLibraryPreferences(
libraries.Map(l => new JellyfinLibraryPreference(l.Id, l.ShouldSyncItems)).ToList());
private async Task<List<RemoteMediaSourceLibraryEditViewModel>> GetLibrariesBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
await Mediator.Send(new GetJellyfinLibrariesBySourceId(Id), cancellationToken)
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int mediaSourceId, CancellationToken cancellationToken) =>
await Mediator.Send(new GetJellyfinMediaSourceById(Id), cancellationToken)
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(JellyfinLibraryViewModel library) => new()
{
Id = library.Id,
Name = library.Name,
MediaKind = library.MediaKind,
ShouldSyncItems = library.ShouldSyncItems
};
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters, CancellationToken cancellationToken)
{
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(parameters.MediaSourceId), cancellationToken);
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraryByIdIfNeeded(parameters.LibraryId), cancellationToken);
return Unit.Default;
}
}
@@ -1,26 +0,0 @@
@page "/media/sources/jellyfin/edit"
@using ErsatzTV.Application.Jellyfin
@using ErsatzTV.Core.Jellyfin
@inject IMediator Mediator
<RemoteMediaSourceEditor
Name="Jellyfin"
LoadSecrets="LoadSecrets"
SaveSecrets="SaveSecrets"/>
@code {
private async Task<Unit> LoadSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
{
JellyfinSecrets secrets = await Mediator.Send(new GetJellyfinSecrets(), cancellationToken);
viewModel.Address = secrets.Address;
viewModel.ApiKey = secrets.ApiKey;
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> SaveSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
{
var secrets = new JellyfinSecrets { Address = viewModel.Address, ApiKey = viewModel.ApiKey };
return await Mediator.Send(new SaveJellyfinSecrets(secrets), cancellationToken);
}
}
-22
View File
@@ -1,22 +0,0 @@
@page "/media/sources/jellyfin"
@using ErsatzTV.Application.Jellyfin
@using ErsatzTV.Core.Interfaces.Jellyfin
@using ErsatzTV.Core.Jellyfin
@inject IJellyfinSecretStore JellyfinSecretStore
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<RemoteMediaSources
TViewModel="JellyfinMediaSourceViewModel"
TSecrets="JellyfinSecrets"
TMediaSource="JellyfinMediaSource"
Name="Jellyfin"
GetAllMediaSourcesCommand="@(new GetAllJellyfinMediaSources())"
DisconnectCommand="@(new DisconnectJellyfin())"
RefreshLibrariesCommand="@RefreshLibraries"
SecretStore="@JellyfinSecretStore"
CanEdit="true"/>
@code {
private async Task RefreshLibraries(int mediaSourceId, CancellationToken cancellationToken) =>
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSourceId), cancellationToken);
}
@@ -1,37 +0,0 @@
@page "/media/sources/jellyfin/{Id:int}/paths"
@using ErsatzTV.Application.Jellyfin
@using ErsatzTV.Application.MediaSources
@inject IMediator Mediator
<RemoteMediaSourcePathReplacementsEditor
Id="@Id"
Name="Jellyfin"
GetMediaSourceById="GetMediaSourceById"
GetUpdatePathReplacementsRequest="GetUpdatePathReplacementsRequest"
GetPathReplacementsBySourceId="GetPathReplacementsBySourceId"/>
@code {
[Parameter]
public int Id { get; set; }
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int id, CancellationToken cancellationToken) =>
await Mediator.Send(new GetJellyfinMediaSourceById(Id), cancellationToken)
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
private async Task<List<RemoteMediaSourcePathReplacementEditViewModel>> GetPathReplacementsBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
await Mediator.Send(new GetJellyfinPathReplacementsBySourceId(Id), cancellationToken)
.Map(list => list.Map(ProjectToEditViewModel).ToList());
private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(JellyfinPathReplacementViewModel item) =>
new() { Id = item.Id, RemotePath = item.JellyfinPath, LocalPath = item.LocalPath };
private IRequest<Either<BaseError, Unit>> GetUpdatePathReplacementsRequest(List<RemoteMediaSourcePathReplacementEditViewModel> pathReplacements)
{
var items = pathReplacements
.Map(item => new JellyfinPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
.ToList();
return new UpdateJellyfinPathReplacements(Id, items);
}
}
-336
View File
@@ -1,336 +0,0 @@
@page "/media/libraries"
@using ErsatzTV.Application.Emby
@using ErsatzTV.Application.Jellyfin
@using ErsatzTV.Application.Libraries
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.Plex
@using ErsatzTV.Core.Interfaces.Metadata
@using ErsatzTV.Core.Metadata
@using MediatR.Courier
@using PlexLibraryViewModel = ErsatzTV.Application.Libraries.PlexLibraryViewModel
@implements IDisposable
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
@inject ICourier Courier
@inject IScannerProxyService ScannerProxyService
<MudForm Style="max-height: 100%">
<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">Libraries</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true" Items="_libraries" Dense="true" Class="mb-5">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
@if (_showServerNames)
{
<col/>
}
<col/>
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Library Kind</MudTh>
@if (_showServerNames)
{
<MudTh>Server Name</MudTh>
}
<MudTh>Library Name</MudTh>
<MudTh>Media Kind</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Library Kind">@context.LibraryKind</MudTd>
@if (_showServerNames)
{
<MudTd DataLabel="Server Name">@context.MediaSourceName</MudTd>
}
<MudTd DataLabel="Library Name">@context.Name</MudTd>
<MudTd DataLabel="Media Kind">
@switch (context.MediaKind)
{
case LibraryMediaKind.MusicVideos:
@:Music Videos
break;
case LibraryMediaKind.OtherVideos:
@:Other Videos
break;
case LibraryMediaKind.RemoteStreams:
@:Remote Streams
break;
default:
@(context.MediaKind)
break;
}
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
@if (IsLibraryLocked(context.Id))
{
<div style="width: 48px">
@if (_progressByLibrary[context.Id] > 0)
{
<MudText Color="Color.Primary">
@($"{_progressByLibrary[context.Id]} %")
</MudText>
}
</div>
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
</div>
}
else
{
if (context is PlexLibraryViewModel or EmbyLibraryViewModel or JellyfinLibraryViewModel)
{
<MudTooltip Text="Deep Scan Library">
<MudIconButton Icon="@Icons.Material.Filled.FindReplace"
Disabled="@Locker.IsLibraryLocked(context.Id)"
OnClick="@(_ => ScanLibrary(context, true))">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="width: 48px"></div>
}
<MudTooltip Text="Scan Library">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@Locker.IsLibraryLocked(context.Id)"
OnClick="@(_ => ScanLibrary(context))">
</MudIconButton>
</MudTooltip>
}
<MudTooltip Text="Search Library">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Href="@($"search?query=library_id%3a{context.Id}")">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">External Collections</MudText>
<MudDivider Class="mb-6"/>
@if (_externalCollections.Any())
{
<MudTable Hover="true" Items="_externalCollections" Dense="true">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Library Kind</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>@context.LibraryKind</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<div style="width: 48px"></div>
@if (AreCollectionsLocked(context.LibraryKind))
{
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
</div>
}
else
{
if (context.LibraryKind is "Plex" or "Emby" or "Jellyfin")
{
<MudTooltip Text="Deep Scan Collections">
<MudIconButton Icon="@Icons.Material.Filled.FindReplace"
Disabled="@AreCollectionsLocked(context.LibraryKind)"
OnClick="@(_ => ScanExternalCollections(context, true))">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="width: 48px"></div>
}
<MudTooltip Text="Scan Collections">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@AreCollectionsLocked(context.LibraryKind)"
OnClick="@(_ => ScanExternalCollections(context))">
</MudIconButton>
</MudTooltip>
}
<div style="width: 48px"></div>
</div>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private IList<LibraryViewModel> _libraries = new List<LibraryViewModel>();
private IList<LibraryViewModel> _externalCollections = new List<LibraryViewModel>();
private Dictionary<int, int> _progressByLibrary = new();
private bool _showServerNames;
protected override void OnInitialized()
{
Locker.OnLibraryChanged += LockChanged;
Locker.OnEmbyCollectionsChanged += LockChanged;
Locker.OnJellyfinCollectionsChanged += LockChanged;
Locker.OnPlexCollectionsChanged += LockChanged;
Courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_libraries = await Mediator.Send(new GetConfiguredLibraries(), token);
_showServerNames = _libraries.Any(l => l is PlexLibraryViewModel);
token.ThrowIfCancellationRequested();
_externalCollections = await Mediator.Send(new GetExternalCollections(), token);
foreach (var library in _libraries)
{
decimal progress = ScannerProxyService.GetProgress(library.Id).IfNone(0);
_progressByLibrary[library.Id] = (int)(progress * 100);
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task ScanLibrary(LibraryViewModel library, bool deepScan = false)
{
if (Locker.LockLibrary(library.Id))
{
var token = _cts?.Token ?? CancellationToken.None;
switch (library)
{
case LocalLibraryViewModel:
await ScannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(library.Id), token);
break;
case PlexLibraryViewModel:
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(library.MediaSourceId), token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), token);
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexNetworks(library.Id, true), token);
break;
case JellyfinLibraryViewModel:
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id, deepScan), token);
break;
case EmbyLibraryViewModel:
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(library.MediaSourceId), token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id, deepScan), token);
break;
}
StateHasChanged();
}
}
private async Task ScanExternalCollections(LibraryViewModel library, bool deepScan = false)
{
var token = _cts?.Token ?? CancellationToken.None;
switch (library.LibraryKind.ToLowerInvariant())
{
case "emby":
if (Locker.LockEmbyCollections())
{
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyCollections(library.MediaSourceId, true, deepScan), token);
}
break;
case "jellyfin":
if (Locker.LockJellyfinCollections())
{
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinCollections(library.MediaSourceId, true, deepScan), token);
}
break;
case "plex":
if (Locker.LockPlexCollections())
{
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexCollections(library.MediaSourceId, true, deepScan), token);
}
break;
}
}
private void LockChanged(object sender, EventArgs e) =>
InvokeAsync(StateHasChanged);
private bool AreCollectionsLocked(string libraryKind)
{
switch (libraryKind.ToLowerInvariant())
{
case "emby":
return Locker.AreEmbyCollectionsLocked();
case "jellyfin":
return Locker.AreJellyfinCollectionsLocked();
case "plex":
return Locker.ArePlexCollectionsLocked();
}
return false;
}
private async Task HandleScanProgress(LibraryScanProgress libraryScanProgress, CancellationToken cancellationToken)
{
try
{
if (_progressByLibrary != null && _progressByLibrary.ContainsKey(libraryScanProgress.LibraryId))
{
_progressByLibrary[libraryScanProgress.LibraryId] = (int)(libraryScanProgress.Progress * 100);
await InvokeAsync(StateHasChanged);
}
}
catch (Exception)
{
// ignore
}
}
void IDisposable.Dispose()
{
Locker.OnLibraryChanged -= LockChanged;
Locker.OnEmbyCollectionsChanged -= LockChanged;
Locker.OnJellyfinCollectionsChanged -= LockChanged;
Locker.OnPlexCollectionsChanged -= LockChanged;
Courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
_cts?.Cancel();
_cts?.Dispose();
}
private bool IsLibraryLocked(int libraryId)
{
if (Locker.IsLibraryLocked(libraryId))
{
return true;
}
_progressByLibrary[libraryId] = 0;
return false;
}
}
-131
View File
@@ -1,131 +0,0 @@
@page "/media/sources/local"
@using ErsatzTV.Application.Libraries
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
@inject IEntityLocker Locker
<MudForm 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" StartIcon="@Icons.Material.Filled.Add" Href="media/sources/local/add">
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 Libraries</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true" Items="_libraries" Dense="true">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Media Kind</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Media Kind">@StringForMediaKind(context.MediaKind)</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Library">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@Locker.IsLibraryLocked(context.Id)"
Href="@($"media/sources/local/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Library">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@Locker.IsLibraryLocked(context.Id)"
OnClick="@(() => DeleteLibrary(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private IList<LocalLibraryViewModel> _libraries;
protected override void OnInitialized() => Locker.OnLibraryChanged += LockChanged;
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await LoadLibraries(token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task LoadLibraries(CancellationToken cancellationToken)
{
_libraries = await Mediator.Send(new GetAllLocalLibraries(), cancellationToken);
}
private void LockChanged(object sender, EventArgs e) =>
InvokeAsync(StateHasChanged);
private async Task DeleteLibrary(LocalLibraryViewModel library)
{
using var cts = new CancellationTokenSource();
int count = await Mediator.Send(new CountMediaItemsByLibrary(library.Id), cts.Token);
var parameters = new DialogParameters
{
{ "EntityType", "library" },
{ "EntityName", library.Name },
{ "DetailText", $"This library contains {count} media items." },
{ "DetailHighlight", count.ToString() }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Library", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteLocalLibrary(library.Id), cts.Token);
await LoadLibraries(cts.Token);
}
}
private static string StringForMediaKind(LibraryMediaKind mediaKind) =>
mediaKind switch
{
LibraryMediaKind.Movies => "Movies",
LibraryMediaKind.Shows => "Shows",
LibraryMediaKind.MusicVideos => "Music Videos",
LibraryMediaKind.OtherVideos => "Other Videos",
LibraryMediaKind.Songs => "Songs",
LibraryMediaKind.Images => "Images",
LibraryMediaKind.RemoteStreams => "Remote Streams",
_ => "Unknown"
};
void IDisposable.Dispose()
{
Locker.OnLibraryChanged -= LockChanged;
_cts?.Cancel();
_cts?.Dispose();
}
}
-271
View File
@@ -1,271 +0,0 @@
@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 @ref="_form" 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 CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly LocalLibraryEditViewModel _model = new();
private readonly LocalLibraryPathEditViewModel _newPath = new();
private MudForm _form;
private bool _success;
private bool IsEdit => Id != 0;
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (IsEdit)
{
Option<LocalLibraryViewModel> maybeLibrary = await Mediator.Send(new GetLocalLibraryById(Id), 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 = [];
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
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()
{
await _form.Validate();
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();
}
}
-140
View File
@@ -1,140 +0,0 @@
@page "/system/logs"
@using System.Globalization
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Logs
@implements IDisposable
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<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">Logs</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>Search</MudText>
</div>
<MudTextField T="string" ValueChanged="@(s => OnSearch(s))" Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium"/>
</MudStack>
<MudTable FixedHeader="true"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<LogEntryViewModel>>>(ServerReload))"
Dense="true"
Class="mt-10"
@ref="_table">
<HeaderContent>
<MudTh>
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Timestamp">
Timestamp
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Level">
Level
</MudTableSortLabel>
</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("G", _dtf)</MudTd>
<MudTd>@context.Level</MudTd>
<MudTd>@context.Message</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>No matching records found</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText>Loading...</MudText>
</LoadingContent>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
private MudTable<LogEntryViewModel> _table;
private int _rowsPerPage = 10;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.LogsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task<TableData<LogEntryViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.LogsPageSize, state.PageSize.ToString()), cancellationToken);
PagedLogEntriesViewModel data;
switch (state.SortLabel?.ToLowerInvariant())
{
case "timestamp":
data = await Mediator.Send(
new GetRecentLogEntries(state.Page, state.PageSize, _searchString)
{
SortExpression = le => le.Timestamp,
SortDescending = state.SortDirection == SortDirection.None
? Option<bool>.None
: state.SortDirection == SortDirection.Descending
},
cancellationToken);
break;
case "level":
data = await Mediator.Send(
new GetRecentLogEntries(state.Page, state.PageSize, _searchString)
{
SortExpression = le => le.Level,
SortDescending = state.SortDirection == SortDirection.None
? Option<bool>.None
: state.SortDirection == SortDirection.Descending
},
cancellationToken);
break;
default:
data = await Mediator.Send(
new GetRecentLogEntries(state.Page, state.PageSize, _searchString)
{
SortDescending = Option<bool>.None
},
cancellationToken);
break;
}
return new TableData<LogEntryViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private void OnSearch(string text)
{
_searchString = text;
_table.ReloadServerData();
}
}
-136
View File
@@ -1,136 +0,0 @@
@page "/media/collections"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
<div style="margin-right: auto" class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="media/collections/add">
Add Collection
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Collection" Href="media/collections/add"/>
</MudMenu>
</div>
</div>
</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">Manual Collections</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_collectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MediaCollectionViewModel>>>(ServerReloadCollections))"
Dense="true"
@ref="_collectionsTable">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for manual collections"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Collection">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"media/collections/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Collection">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteMediaCollection(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
<div class="mt-6"></div>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<MediaCollectionViewModel> _collectionsTable;
private int _collectionsRowsPerPage = 10;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_collectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task DeleteMediaCollection(MediaCollectionViewModel collection)
{
var parameters = new DialogParameters { { "EntityType", "collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteCollection(collection.Id), _cts.Token);
if (_collectionsTable != null)
{
await _collectionsTable.ReloadServerData();
}
}
}
private async Task<TableData<MediaCollectionViewModel>> ServerReloadCollections(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.CollectionsPageSize, state.PageSize.ToString()), cancellationToken);
PagedMediaCollectionsViewModel data = await Mediator.Send(new GetPagedCollections(_searchString, state.Page, state.PageSize), cancellationToken);
return new TableData<MediaCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private void OnSearch(string query)
{
_searchString = query;
_collectionsTable.ReloadServerData();
}
}
-348
View File
@@ -1,348 +0,0 @@
@page "/media/movies/{MovieId:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Movies
@using ErsatzTV.Extensions
@using LanguageExt.UnsafeValueAccess
@implements IDisposable
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ILogger<Movie> Logger
@inject ISnackbar Snackbar
@inject PersistentComponentState ApplicationState
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_movie?.FanArt))
{
if (_movie.FanArt.StartsWith("http://") || _movie.FanArt.StartsWith("https://"))
{
<img src="@_movie.FanArt" alt="fan art"/>
}
else
{
<img src="@($"artwork/fanart/{_movie.FanArt}")" alt="fan art"/>
}
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
@if (!string.IsNullOrWhiteSpace(_movie?.Poster))
{
<div style="display: flex; flex-direction: column; max-height: 325px; position: relative" class="z-10">
<MudImage Src="@GetPosterUrl(_movie.Poster)" Elevation="2" Class="rounded-lg" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
@if (_movie.MediaItemState == MediaItemState.FileNotFound)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (_movie.MediaItemState == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
<div style="display: flex; flex-direction: column; height: 100%" class="z-10">
<MudStack Row="false">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_movie?.Title</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_movie?.Title</MudText>
</MudHidden>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_movie?.Year</MudText>
</MudStack>
@if (!string.IsNullOrWhiteSpace(_movie?.Plot))
{
<MudCard Elevation="2" Class="mb-6">
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
<MudText Style="flex-grow: 1">@_movie.Plot</MudText>
</MudCardContent>
</MudCard>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudMenu Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Label="Add To ...">
<MudMenuItem OnClick="@AddToCollection" Icon="@Icons.Material.Filled.Add">
Add To Collection
</MudMenuItem>
<MudMenuItem OnClick="@AddToPlaylist" Icon="@Icons.Material.Filled.PlaylistAdd">
Add To Playlist
</MudMenuItem>
</MudMenu>
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Info"
OnClick="@ShowInfo">
Show Media Info
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Troubleshoot"
Href="@($"system/troubleshooting/playback?mediaItem={MovieId}")">
Troubleshoot Playback
</MudButton>
</MudStack>
</div>
</MudStack>
@if (_movie?.MediaItemState == MediaItemState.FileNotFound)
{
<MudCard Class="mb-6">
<MudCardContent>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@_movie.Path</MudText>
</div>
</MudCardContent>
</MudCard>
}
else if (_movie?.MediaItemState == MediaItemState.Unavailable)
{
<MudCard Class="mb-6">
<MudCardContent>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
<MudText>Unavailable:&nbsp;</MudText>
<MudText>@_movie.LocalPath</MudText>
</div>
</MudCardContent>
</MudCard>
}
<MudCard Class="mt-6 mb-6">
<MudCardContent>
@if (_sortedContentRatings.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Content Ratings:&nbsp;</MudText>
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
@foreach (string contentRating in _sortedContentRatings.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
}
</div>
}
@if (_sortedLanguages.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head()</MudLink>
@foreach (string language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"language:""{language.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language</MudLink>
}
</div>
}
@if (_sortedStudios.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
}
</div>
}
@if (_sortedDirectors.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Directors:&nbsp;</MudText>
<MudLink Href="@(@$"director:""{_sortedDirectors.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedDirectors.Head()</MudLink>
@foreach (string director in _sortedDirectors.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
}
</div>
}
@if (_sortedWriters.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Writers:&nbsp;</MudText>
<MudLink Href="@(@$"writer:""{_sortedWriters.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedWriters.Head()</MudLink>
@foreach (string writer in _sortedWriters.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
}
</div>
}
@if (_sortedGenres.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@if (_sortedTags.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
@if (_movie is not null && _movie.Actors.Any())
{
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Actors</MudText>
<MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (ActorCardViewModel actor in _movie.Actors)
{
<MediaCard Data="@actor"
Href="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudStack>
</MudContainer>
}
@code {
private CancellationTokenSource _cts;
private PersistingComponentStateSubscription _persistingSubscription;
[Parameter]
public int MovieId { get; set; }
private MovieViewModel _movie;
private List<string> _sortedContentRatings = new();
private List<string> _sortedLanguages = new();
private List<string> _sortedDirectors = new();
private List<string> _sortedWriters = new();
private List<string> _sortedStudios = new();
private List<string> _sortedGenres = new();
private List<string> _sortedTags = new();
public void Dispose()
{
_persistingSubscription.Dispose();
_cts?.Cancel();
_cts?.Dispose();
}
protected override Task OnInitializedAsync()
{
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
return base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (!ApplicationState.TryTakeFromJson("_movie", out MovieViewModel restored))
{
Option<MovieViewModel> maybeMovie = await Mediator.Send(new GetMovieById(MovieId), token);
foreach (var movie in maybeMovie)
{
_movie = movie;
}
if (maybeMovie.IsNone)
{
NavigationManager.NavigateTo("404");
}
}
else
{
_movie = restored;
}
_sortedContentRatings = _movie?.ContentRatings.OrderBy(cr => cr).ToList();
_sortedLanguages = _movie?.Languages.OrderBy(l => l).ToList();
_sortedStudios = _movie?.Studios.OrderBy(s => s).ToList();
_sortedGenres = _movie?.Genres.OrderBy(g => g).ToList();
_sortedTags = _movie?.Tags.OrderBy(t => t).ToList();
_sortedDirectors = _movie?.Directors.OrderBy(d => d).ToList();
_sortedWriters = _movie?.Writers.OrderBy(w => w).ToList();
}
catch (OperationCanceledException)
{
// do nothing
}
}
private Task PersistData()
{
ApplicationState.PersistAsJson("_movie", _movie);
return Task.CompletedTask;
}
private async Task AddToCollection()
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
await Mediator.Send(new AddMovieToCollection(collection.Id, MovieId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
}
}
private async Task AddToPlaylist()
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{
await Mediator.Send(new AddMovieToPlaylist(playlist.Id, MovieId), _cts.Token);
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
}
}
private async Task ShowInfo()
{
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(MovieId));
foreach (BaseError error in maybeInfo.LeftToSeq())
{
Snackbar.Add("Unexpected error loading media info");
Logger.LogError("Unexpected error loading media info: {Error}", error.Value);
}
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
{
var parameters = new DialogParameters { { "MediaItemInfo", info } };
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
IDialogReference dialog = await Dialog.ShowAsync<MediaItemInfoDialog>(_movie.Title, parameters, options);
DialogResult _ = await dialog.Result;
}
}
private static string GetPosterUrl(string poster) => poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
}
-163
View File
@@ -1,163 +0,0 @@
@page "/media/movies"
@page "/media/movies/page/{PageNumber:int}"
@inherits MultiSelectBase<MovieList>
@inject NavigationManager NavigationManager
@inject PersistentComponentState ApplicationState
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@implements IDisposable
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="MovieCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href="@($"media/movies/{context.MovieId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/movies"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private MovieCardResultsViewModel _data = new(0, new List<MovieCardViewModel>(), null);
private string _query;
private PersistingComponentStateSubscription _persistingSubscription;
protected override Task OnInitializedAsync()
{
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
_query = NavigationManager.Uri.GetSearchQuery();
return base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
if (!ApplicationState.TryTakeFromJson("_data", out MovieCardResultsViewModel restored))
{
_data = await RefreshData(CancellationToken);
}
else
{
_data = restored;
}
}
private Task PersistData()
{
ApplicationState.PersistAsJson("_data", _data);
return Task.CompletedTask;
}
void IDisposable.Dispose()
{
_persistingSubscription.Dispose();
base.Dispose();
}
protected override async Task<MovieCardResultsViewModel> RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:movie" : $"type:movie AND ({_query})";
return await Mediator.Send(new QuerySearchIndexMovies(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/movies/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/movies/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is MovieCardViewModel movie)
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMovieToCollection(collection.Id, movie.MovieId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}");
Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-268
View File
@@ -1,268 +0,0 @@
@page "/media/multi-collections/{Id:int}/edit"
@page "/media/multi-collections/add"
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<MultiCollectionEditor> Logger
<MudForm @ref="_form" @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" OnClick="@(_ => HandleSubmitAsync())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
@(IsEdit ? "Save Multi Collection" : "Add Multi Collection")
</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">Multi Collection</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="Multi-collection name is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collections</MudText>
</div>
<MudSelect @ref="_collectionSelect"
T="MediaCollectionViewModel"
@bind-value="_selectedCollection"
HelperText="Disabled collections are already present in this multi collection">
@foreach (MediaCollectionViewModel collection in _collections)
{
<MudSelectItem Disabled="@(_model.Items.Any(i => i.Collection.CollectionType == collection.CollectionType && i.Collection.Id == collection.Id))"
Value="@collection">
@collection.Name
</MudSelectItem>
}
</MudSelect>
</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="@(_ => AddCollection())" StartIcon="@Icons.Material.Filled.Add">
Add Collection
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collections</MudText>
</div>
<MudSelect @ref="_smartCollectionSelect"
T="SmartCollectionViewModel"
@bind-value="_selectedSmartCollection"
HelperText="Disabled collections are already present in this multi collection">
@foreach (SmartCollectionViewModel collection in _smartCollections)
{
<MudSelectItem Disabled="@(_model.Items.Any(i => i.Collection.CollectionType == CollectionType.SmartCollection && i.Collection.Id == collection.Id))"
Value="@collection">
@collection.Name
</MudSelectItem>
}
</MudSelect>
</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="@(_ => AddSmartCollection())" StartIcon="@Icons.Material.Filled.Add">
Add Smart Collection
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">@_model.Name Items</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true" Items="_model.Items.OrderBy(i => i.Collection.Name, StringComparer.CurrentCultureIgnoreCase)" Dense="true">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 20%"/>
<col style="width: 30%"/>
<col style="width: 60px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Collection</MudTh>
<MudTh>Schedule As Group</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>
@context.Collection.Name
</MudTd>
<MudTd>
<MudCheckBox @bind-Value="@context.ScheduleAsGroup" For="@(() => context.ScheduleAsGroup)"/>
</MudTd>
<MudTd>
@if (context.ScheduleAsGroup)
{
@(context.Collection.UseCustomPlaybackOrder ? "Custom" : "Chronological")
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveCollection(context))">
</MudIconButton>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly MultiCollectionEditViewModel _model =
new() { Items = new List<MultiCollectionItemEditViewModel>() };
private MudForm _form;
private bool _success;
private List<MediaCollectionViewModel> _collections = new();
private List<SmartCollectionViewModel> _smartCollections = new();
private MediaCollectionViewModel _selectedCollection;
private SmartCollectionViewModel _selectedSmartCollection;
private MudSelect<MediaCollectionViewModel> _collectionSelect;
private MudSelect<SmartCollectionViewModel> _smartCollectionSelect;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_collections = await Mediator.Send(new GetAllCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_smartCollections = await Mediator.Send(new GetAllSmartCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
if (IsEdit)
{
Option<MultiCollectionViewModel> maybeCollection = await Mediator.Send(new GetMultiCollectionById(Id), token);
maybeCollection.IfSome(collection =>
{
_model.Id = collection.Id;
_model.Name = collection.Name;
_model.Items = collection.Items
.Map(item =>
new MultiCollectionItemEditViewModel
{
Collection = item.Collection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder
})
.Append(
collection.SmartItems.Map(item =>
new MultiCollectionSmartItemEditViewModel
{
SmartCollection = item.SmartCollection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder
})).ToList();
});
}
else
{
_model.Name = "New Multi Collection";
_model.Items = new List<MultiCollectionItemEditViewModel>();
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool IsEdit => Id != 0;
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (_success)
{
Seq<BaseError> errorMessage = IsEdit ? (await Mediator.Send(new UpdateMultiCollection(Id, _model.Name, GetUpdateItems()), _cts.Token)).LeftToSeq() : (await Mediator.Send(new CreateMultiCollection(_model.Name, GetCreateItems()), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error saving collection: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("media/multi-collections"));
}
}
private List<UpdateMultiCollectionItem> GetUpdateItems() =>
_model.Items.Map(i =>
i switch
{
MultiCollectionSmartItemEditViewModel smartVm =>
new UpdateMultiCollectionItem(
null,
smartVm.SmartCollection.Id,
smartVm.ScheduleAsGroup,
smartVm.PlaybackOrder),
_ => new UpdateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
}).ToList();
private List<CreateMultiCollectionItem> GetCreateItems() =>
_model.Items.Map(i =>
i switch
{
MultiCollectionSmartItemEditViewModel smartVm =>
new CreateMultiCollectionItem(
null,
smartVm.SmartCollection.Id,
smartVm.ScheduleAsGroup,
smartVm.PlaybackOrder),
_ => new CreateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
}).ToList();
private void RemoveCollection(MultiCollectionItemEditViewModel item) => _model.Items.Remove(item);
private async Task AddCollection()
{
if (_selectedCollection != null && _model.Items.All(i => i.Collection != _selectedCollection))
{
_model.Items.Add(
new MultiCollectionItemEditViewModel
{
Collection = _selectedCollection,
PlaybackOrder = PlaybackOrder.Chronological
});
_selectedCollection = null;
await _collectionSelect.ResetAsync();
}
}
private async Task AddSmartCollection()
{
if (_selectedSmartCollection != null && _model.Items.OfType<MultiCollectionSmartItemEditViewModel>().All(i => i.SmartCollection != _selectedSmartCollection))
{
_model.Items.Add(
new MultiCollectionSmartItemEditViewModel
{
SmartCollection = _selectedSmartCollection,
PlaybackOrder = PlaybackOrder.Chronological
});
_selectedSmartCollection = null;
await _smartCollectionSelect.ResetAsync();
}
}
}
-136
View File
@@ -1,136 +0,0 @@
@page "/media/multi-collections"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
<div style="margin-right: auto" class="d-none d-md-flex">
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlaylistAdd" Href="media/multi-collections/add">
Add Multi Collection
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Multi Collection" Href="media/multi-collections/add"/>
</MudMenu>
</div>
</div>
</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">Multi Collections</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_multiCollectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MultiCollectionViewModel>>>(ServerReloadMultiCollections))"
Dense="true"
@ref="_multiCollectionsTable">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for multi collections"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Collection">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"media/multi-collections/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Collection">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteMultiCollection(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
<div class="mt-6"></div>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<MultiCollectionViewModel> _multiCollectionsTable;
private int _multiCollectionsRowsPerPage = 10;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_multiCollectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task DeleteMultiCollection(MultiCollectionViewModel collection)
{
var parameters = new DialogParameters { { "EntityType", "multi collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Multi Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteMultiCollection(collection.Id), _cts.Token);
if (_multiCollectionsTable != null)
{
await _multiCollectionsTable.ReloadServerData();
}
}
}
private async Task<TableData<MultiCollectionViewModel>> ServerReloadMultiCollections(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize, state.PageSize.ToString()), cancellationToken);
PagedMultiCollectionsViewModel data = await Mediator.Send(new GetPagedMultiCollections(_searchString, state.Page, state.PageSize), cancellationToken);
return new TableData<MultiCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private void OnSearch(string query)
{
_searchString = query;
_multiCollectionsTable.ReloadServerData();
}
}
-256
View File
@@ -1,256 +0,0 @@
using System.Globalization;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Shared;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using MudBlazor;
namespace ErsatzTV.Pages;
public class MultiSelectBase<T> : FragmentNavigationBase
{
private Option<MediaCardViewModel> _recentlySelected = None;
[Inject]
protected IDialogService Dialog { get; set; }
[Inject]
protected ISnackbar Snackbar { get; set; }
[Inject]
protected ILogger<T> Logger { get; set; }
[Inject]
protected IMediator Mediator { get; set; }
protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems { get; } = [];
protected bool IsSelected(MediaCardViewModel card) =>
SelectedItems.Contains(card);
protected bool IsSelectMode() =>
SelectedItems.Count != 0;
protected string SelectionLabel() =>
$"{SelectedItems.Count} {(SelectedItems.Count == 1 ? "Item" : "Items")} Selected";
protected void SelectAllPageItems(IEnumerable<MediaCardViewModel> cards)
{
ResetSelectionWithCards(SelectedItems, cards);
StateHasChanged();
}
protected void ClearSelection()
{
SelectedItems.Clear();
_recentlySelected = None;
StateHasChanged();
}
protected virtual Task RefreshData(CancellationToken cancellationToken) => Task.CompletedTask;
protected void SelectClicked(
Func<List<MediaCardViewModel>> getSortedItems,
MediaCardViewModel card,
MouseEventArgs e)
{
if (!SelectedItems.Remove(card))
{
if (e.ShiftKey && _recentlySelected.IsSome)
{
List<MediaCardViewModel> sorted = getSortedItems();
int start = sorted.IndexOf(_recentlySelected.ValueUnsafe());
int finish = sorted.IndexOf(card);
if (start > finish)
{
(start, finish) = (finish, start);
}
for (int i = start; i < finish; i++)
{
SelectedItems.Add(sorted[i]);
}
}
_recentlySelected = card;
SelectedItems.Add(card);
}
}
protected Task AddSelectionToCollection() => AddItemsToCollection(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
protected Task AddSelectionToPlaylist() => AddItemsToPlaylist(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
List<int> showIds,
List<int> seasonIds,
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
List<int> otherVideoIds,
List<int> songIds,
List<int> imageIds,
List<int> remoteStreamIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString(CultureInfo.InvariantCulture) }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog =
await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddItemsToCollection(
collection.Id,
movieIds,
showIds,
seasonIds,
episodeIds,
artistIds,
musicVideoIds,
otherVideoIds,
songIds,
imageIds,
remoteStreamIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding items to collection: {error.Value}");
Logger.LogError("Unexpected error adding items to collection: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add(
$"Added {count} items to collection {collection.Name}",
Severity.Success);
ClearSelection();
});
}
}
protected async Task RemoveSelectionFromCollection(int collectionId)
{
var parameters = new DialogParameters
{
{ "EntityType", SelectedItems.Count.ToString(CultureInfo.InvariantCulture) },
{ "EntityName", "selected items" }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>(
"Remove From Collection",
parameters,
options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
var itemIds = SelectedItems.Map(vm => vm.MediaItemId).ToList();
await Mediator.Send(
new RemoveItemsFromCollection(collectionId)
{
MediaItemIds = itemIds
},
CancellationToken);
await RefreshData(CancellationToken);
ClearSelection();
}
}
protected async Task AddItemsToPlaylist(
List<int> movieIds,
List<int> showIds,
List<int> seasonIds,
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
List<int> otherVideoIds,
List<int> songIds,
List<int> imageIds,
List<int> remoteStreamIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString(CultureInfo.InvariantCulture) }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog =
await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{
var request = new AddItemsToPlaylist(
playlist.Id,
movieIds,
showIds,
seasonIds,
episodeIds,
artistIds,
musicVideoIds,
otherVideoIds,
songIds,
imageIds,
remoteStreamIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding items to playlist: {error.Value}");
Logger.LogError("Unexpected error adding items to playlist: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add(
$"Added {count} items to playlist {playlist.Name}",
Severity.Success);
ClearSelection();
});
}
}
internal static void ResetSelectionWithCards(
ISet<MediaCardViewModel> selectedItems,
IEnumerable<MediaCardViewModel> cards)
{
selectedItems.Clear();
selectedItems.UnionWith(cards);
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/music/videos"
@page "/media/music/videos/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<MusicVideoList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="MusicVideoCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/music/videos"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private MusicVideoCardResultsViewModel _data = new(0, new List<MusicVideoCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:music_video" : $"type:music_video AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexMusicVideos(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/music/videos/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/music/videos/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/other/videos"
@page "/media/other/videos/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<OtherVideoList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="OtherVideoCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/other/videos"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private OtherVideoCardResultsViewModel _data = new(0, new List<OtherVideoCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:other_video" : $"type:other_video AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexOtherVideos(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/other/videos/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/other/videos/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is OtherVideoCardViewModel otherVideo)
{
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-606
View File
@@ -1,606 +0,0 @@
@page "/media/playlists/{Id:int}"
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Search
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<PlaylistEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@SaveChanges" StartIcon="@Icons.Material.Filled.Save" Disabled="@(_playlist.IsSystem)">
Save Playlist
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@AddPlaylistItem" StartIcon="@Icons.Material.Filled.PlaylistAdd" Disabled="@(_playlist.IsSystem)">
Add Playlist Item
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@PreviewPlayout" StartIcon="@Icons.Material.Filled.Preview">
Preview Playlist Playout
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Playlist" OnClick="@SaveChanges" Disabled="@(_playlist.IsSystem)"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Playlist Item" OnClick="@AddPlaylistItem" Disabled="@(_playlist.IsSystem)"/>
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Playlist Playout" OnClick="@PreviewPlayout"/>
</MudMenu>
</div>
</div>
</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">Playlist</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="_playlist.Name" For="@(() => _playlist.Name)" Disabled="@(_playlist.IsSystem)"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlist Items</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true" Items="_playlist.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col/>
<col/>
<col/>
<col style="width: 240px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Item Type</MudTh>
<MudTh>Item</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh>Count</MudTh>
<MudTh>Play All</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Item Type">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.ItemType
</MudText>
</MudTd>
<MudTd DataLabel="Item">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.ItemName
</MudText>
</MudTd>
<MudTd DataLabel="Playback Order">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.PlaybackOrder > 0 ? context.PlaybackOrder : string.Empty)
</MudText>
</MudTd>
<MudTd DataLabel="Count">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.Count is not null ? context.Count : (context.PlayAll ? string.Empty : "1"))
</MudText>
</MudTd>
<MudTd DataLabel="Play All">
<MudCheckBox T="bool" Value="@context.PlayAll" ValueChanged="@(e => UpdatePlayAll(context, e))" Disabled="@(_playlist.IsSystem)"/>
</MudTd>
<MudTd DataLabel="Show In EPG">
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))" Disabled="@(_playlist.IsSystem)"/>
</MudTd>
<MudTd>
<div class="d-flex">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyItem(context))"
Disabled="@(_playlist.IsSystem)">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_playlist.IsSystem || _playlist.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_playlist.IsSystem || _playlist.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemovePlaylistItem(context))"
Disabled="@(_playlist.IsSystem)">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (!_playlist.IsSystem && _selectedItem is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlist Item</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>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.Movie">Movie</MudSelectItem>
<MudSelectItem Value="CollectionType.Episode">Episode</MudSelectItem>
<MudSelectItem Value="CollectionType.MusicVideo">Music Video</MudSelectItem>
<MudSelectItem Value="CollectionType.OtherVideo">Other Video</MudSelectItem>
<MudSelectItem Value="CollectionType.Song">Song</MudSelectItem>
<MudSelectItem Value="CollectionType.Image">Image</MudSelectItem>
</MudSelect>
</MudStack>
@if (_selectedItem.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_selectedItem.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_selectedItem.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_selectedItem.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.Artist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchArtists"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="10">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.Movie)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Movie</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchMovies"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="10">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_selectedItem.PlaybackOrder"
For="@(() => _selectedItem.PlaybackOrder)"
Disabled="@(_selectedItem.CollectionType is CollectionType.Movie or CollectionType.Episode or CollectionType.MusicVideo or CollectionType.OtherVideo or CollectionType.Song or CollectionType.Image)">
@switch (_selectedItem.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem>
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem> *@
break;
case CollectionType.TelevisionSeason:
case CollectionType.Artist:
case CollectionType.FakeCollection:
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Count</MudText>
</div>
<MudTextField @bind-Value="@_selectedItem.Count" For="@(() => _selectedItem.Count)"/>
</MudStack>
}
else if (!string.IsNullOrWhiteSpace(_previewMessage))
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudAlert Severity="Severity.Warning">@_previewMessage</MudAlert>
}
else if (_previewItems is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
Items="_previewItems">
<ToolBarContent>
<MudText Typo="Typo.h6">Playlist Playout Preview</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Start</MudTh>
<MudTh>Finish</MudTh>
<MudTh>Media Item</MudTh>
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start">@context.Start.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Finish">@context.Finish.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Media Item">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private PlaylistItemsEditViewModel _playlist = new() { Items = [] };
private PlaylistItemEditViewModel _selectedItem;
private string _previewMessage;
private List<PlayoutItemPreviewViewModel> _previewItems;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<PlaylistViewModel> maybePlaylist = await Mediator.Send(new GetPlaylistById(Id), token);
if (maybePlaylist.IsNone)
{
NavigationManager.NavigateTo("media/playlists");
return;
}
foreach (PlaylistViewModel playlist in maybePlaylist)
{
_playlist = new PlaylistItemsEditViewModel
{
Name = playlist.Name,
IsSystem = playlist.IsSystem,
Items = []
};
}
Option<IEnumerable<PlaylistItemViewModel>> maybeResults = await Mediator.Send(new GetPlaylistItems(Id), token);
foreach (IEnumerable<PlaylistItemViewModel> items in maybeResults)
{
_playlist.Items.AddRange(items.Map(ProjectToEditViewModel));
if (_playlist.Items.Count == 1)
{
_selectedItem = _playlist.Items.Head();
}
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchMovies(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchMovies(value), _cts.Token);
}
private static PlaylistItemEditViewModel ProjectToEditViewModel(PlaylistItemViewModel item) =>
new()
{
Id = item.Id,
Index = item.Index,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
Count = item.Count,
PlayAll = item.PlayAll,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
private void AddPlaylistItem()
{
var item = new PlaylistItemEditViewModel
{
Index = _playlist.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
PlaybackOrder = PlaybackOrder.Chronological,
CollectionType = CollectionType.Collection
};
_playlist.Items.Add(item);
_selectedItem = item;
}
private void CopyItem(PlaylistItemEditViewModel item)
{
var newItem = new PlaylistItemEditViewModel
{
Index = item.Index + 1,
PlaybackOrder = item.PlaybackOrder,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
Count = item.Count,
PlayAll = item.PlayAll,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
foreach (PlaylistItemEditViewModel i in _playlist.Items.Filter(bi => bi.Index >= newItem.Index))
{
i.Index += 1;
}
_playlist.Items.Add(newItem);
_selectedItem = newItem;
}
private void RemovePlaylistItem(PlaylistItemEditViewModel item)
{
_selectedItem = null;
_playlist.Items.Remove(item);
}
private void MoveItemUp(PlaylistItemEditViewModel item)
{
// swap with lower index
PlaylistItemEditViewModel toSwap = _playlist.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(PlaylistItemEditViewModel item)
{
// swap with higher index
PlaylistItemEditViewModel toSwap = _playlist.Items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private async Task SaveChanges()
{
Seq<BaseError> errorMessages = await Mediator
.Send(GenerateReplaceRequest(), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving playlist: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving playlist: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("media/playlists"));
}
private ReplacePlaylistItems GenerateReplaceRequest()
{
var items = _playlist.Items.Map(item => new ReplacePlaylistItem(
item.Index,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.PlaybackOrder,
item.Count,
item.PlayAll,
item.IncludeInProgramGuide)).ToList();
return new ReplacePlaylistItems(Id, _playlist.Name, items);
}
private async Task PreviewPlayout()
{
_selectedItem = null;
_previewMessage = null;
Either<BaseError, List<PlayoutItemPreviewViewModel>> result = await Mediator.Send(new PreviewPlaylistPlayout(GenerateReplaceRequest()), _cts.Token);
foreach (var error in result.LeftToSeq())
{
_previewMessage = error.ToString();
}
foreach (List<PlayoutItemPreviewViewModel> items in result.RightToSeq())
{
_previewItems = items;
}
}
private static void UpdateEPG(PlaylistItemEditViewModel context, bool includeInProgramGuide) => context.IncludeInProgramGuide = includeInProgramGuide;
private static void UpdatePlayAll(PlaylistItemEditViewModel context, bool playAll) => context.PlayAll = playAll;
private string SelectedRowClassFunc(PlaylistItemEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
}
-235
View File
@@ -1,235 +0,0 @@
@page "/media/playlists"
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Tree
@implements IDisposable
@inject ILogger<Playlists> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudForm Style="max-height: 100%">
<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">Playlist Groups</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>Playlist Group Name</MudText>
</div>
<MudTextField @bind-Value="_playlistGroupName" For="@(() => _playlistGroupName)"/>
</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.Primary" OnClick="@(_ => AddPlaylistGroup())" StartIcon="@Icons.Material.Filled.Add">
Add Playlist Group
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlists</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>Playlist Group</MudText>
</div>
<MudSelect @bind-Value="_selectedPlaylistGroup">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
{
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist Name</MudText>
</div>
<MudTextField @bind-Value="_playlistName" For="@(() => _playlistName)"/>
</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.Primary" OnClick="@(_ => AddPlaylist())" StartIcon="@Icons.Material.Filled.Add">
Add Playlist
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudTreeView T="PlaylistTreeItemViewModel" Items="@_treeItems" Hover="true" Style="width: 100%">
<ItemTemplate Context="item">
<MudTreeViewItem T="PlaylistTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" Value="@item.Value">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudGrid Justify="Justify.FlexStart">
<MudItem xs="5">
<MudText>@item.Value.Text</MudText>
</MudItem>
@if (!string.IsNullOrWhiteSpace(item.Value.EndText))
{
<MudItem xs="6">
<MudText>@item.Value.EndText</MudText>
</MudItem>
}
</MudGrid>
<div style="justify-self: end;">
@foreach (int playlistId in Optional(item.Value.PlaylistId))
{
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"media/playlists/{playlistId}")"/>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Medium"
Color="Color.Inherit"
Disabled="@item.Value.IsSystem"
OnClick="@(_ => DeleteItem(item.Value))"/>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
</ItemTemplate>
</MudTreeView>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<TreeItemData<PlaylistTreeItemViewModel>> _treeItems = [];
private List<PlaylistGroupViewModel> _playlistGroups = [];
private PlaylistGroupViewModel _selectedPlaylistGroup;
private string _playlistGroupName;
private string _playlistName;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await ReloadPlaylistTree(token);
await InvokeAsync(StateHasChanged);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task ReloadPlaylistTree(CancellationToken cancellationToken)
{
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
_treeItems.Clear();
TreeViewModel tree = await Mediator.Send(new GetPlaylistTree(), cancellationToken);
foreach (TreeGroupViewModel group in tree.Groups)
{
_treeItems.Add(new TreeItemData<PlaylistTreeItemViewModel> { Value = new PlaylistTreeItemViewModel(group) });
}
}
private async Task AddPlaylistGroup()
{
if (!string.IsNullOrWhiteSpace(_playlistGroupName))
{
Either<BaseError, PlaylistGroupViewModel> result = await Mediator.Send(new CreatePlaylistGroup(_playlistGroupName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding playlist group: {Error}", error.Value);
}
foreach (PlaylistGroupViewModel _ in result.RightToSeq())
{
await ReloadPlaylistTree(_cts.Token);
await InvokeAsync(StateHasChanged);
}
}
}
private async Task AddPlaylist()
{
if (_selectedPlaylistGroup is not null && !string.IsNullOrWhiteSpace(_playlistName))
{
Either<BaseError, PlaylistViewModel> result = await Mediator.Send(new CreatePlaylist(_selectedPlaylistGroup.Id, _playlistName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding playlist: {Error}", error.Value);
}
foreach (PlaylistViewModel playlist in result.RightToSeq())
{
foreach (PlaylistTreeItemViewModel item in _treeItems.Map(i => i.Value).Where(item => item.PlaylistGroupId == _selectedPlaylistGroup.Id))
{
item.TreeItems.Add(new TreeItemData<PlaylistTreeItemViewModel> { Value = new PlaylistTreeItemViewModel(playlist) });
}
_playlistName = null;
await InvokeAsync(StateHasChanged);
}
}
}
private async Task DeleteItem(PlaylistTreeItemViewModel treeItem)
{
foreach (int playlistGroupId in Optional(treeItem.PlaylistGroupId))
{
var parameters = new DialogParameters { { "EntityType", "playlist group" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playlist Group", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
Option<BaseError> deleteResult = await Mediator.Send(new DeletePlaylistGroup(playlistGroupId), _cts.Token);
foreach (BaseError error in deleteResult)
{
Snackbar.Add(error.ToString(), Severity.Error);
return;
}
_treeItems.RemoveAll(i => i.Value?.PlaylistGroupId == playlistGroupId);
if (_selectedPlaylistGroup?.Id == playlistGroupId)
{
_selectedPlaylistGroup = null;
}
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
foreach (int playlistId in Optional(treeItem.PlaylistId))
{
var parameters = new DialogParameters { { "EntityType", "playlist" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
Option<BaseError> deleteResult = await Mediator.Send(new DeletePlaylist(playlistId), _cts.Token);
foreach (BaseError error in deleteResult)
{
Snackbar.Add(error.ToString(), Severity.Error);
return;
}
foreach (PlaylistTreeItemViewModel parent in _treeItems.Map(i => i.Value))
{
parent.TreeItems.RemoveAll(i => i.Value == treeItem);
}
await InvokeAsync(StateHasChanged);
}
}
}
}
@@ -1,472 +0,0 @@
@page "/playouts/{Id:int}/alternate-schedules"
@using System.Globalization
@using System.Text
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Playouts
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Core.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<ScheduleItemsEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
Save Alternate Schedules
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddAlternateSchedule())" StartIcon="@Icons.Material.Filled.PlaylistAdd">
Add Alternate Schedule
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Alternate Schedules" OnClick="@SaveChanges"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Alternate Schedule" OnClick="AddAlternateSchedule"/>
</MudMenu>
</div>
</div>
</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">@_channelName - Content</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2" Class="mb-6">In priority order from top to bottom. The bottom entry will *always* match all days and all months, as a catch-all.</MudText>
<MudTable Hover="true" Items="_items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Schedule</MudTh>
<MudTh>Days of the Week</MudTh>
<MudTh>Days of the Month</MudTh>
<MudTh>Months</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.ProgramSchedule.Name
</MudText>
</MudTd>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@ToDaysOfWeekString(context.DaysOfWeek)
</MudText>
</MudTd>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@ToDaysOfMonthString(context.DaysOfMonth)
</MudText>
</MudTd>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@ToMonthsOfYearString(context.MonthsOfYear)
</MudText>
</MudTd>
<MudTd>
<div class="d-flex">
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_items.All(x => x.Index >= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_items.All(x => x.Index <= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveAlternateSchedule(context))"
Disabled="@(_items.Count == 1)">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedItem is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Edit Content</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>Schedule</MudText>
</div>
<MudSelect @bind-Value="_selectedItem.ProgramSchedule" For="@(() => _selectedItem.ProgramSchedule)">
@foreach (ProgramScheduleViewModel schedule in _schedules)
{
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Limit to Date Range</MudText>
</div>
<MudCheckBox T="bool" @bind-Value="_selectedItem.LimitToDateRange" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Start Day</MudText>
</div>
<MudSelect T="int" @bind-Value="_selectedItem.StartMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_selectedItem.StartDay" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int day in Enumerable.Range(1, 31))
{
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int?" @bind-Value="_selectedItem.StartYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
{
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>End Day</MudText>
</div>
<MudSelect T="int" @bind-Value="_selectedItem.EndMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_selectedItem.EndDay" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int day in Enumerable.Range(1, 31))
{
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int?" @bind-Value="_selectedItem.EndYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
{
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Days of the Week</MudText>
</div>
<MudSelect T="DayOfWeek" SelectedValues="@_selectedItem.DaysOfWeek" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
<MudSelectItem Value="@DayOfWeek.Monday">@_dtf.GetDayName(DayOfWeek.Monday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Tuesday">@_dtf.GetDayName(DayOfWeek.Tuesday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Wednesday">@_dtf.GetDayName(DayOfWeek.Wednesday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Thursday">@_dtf.GetDayName(DayOfWeek.Thursday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Friday">@_dtf.GetDayName(DayOfWeek.Friday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Saturday">@_dtf.GetDayName(DayOfWeek.Saturday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Sunday">@_dtf.GetDayName(DayOfWeek.Sunday)</MudSelectItem>
</MudSelect>
</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="@(_ => SelectWeekdays())">
Weekdays
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => SelectWeekends())">
Weekends
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Days of the Month</MudText>
</div>
<MudSelect T="int" SelectedValues="@_selectedItem.DaysOfMonth" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
@foreach (int day in Enumerable.Range(1, 31))
{
<MudSelectItem Value="@day">@day</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Months</MudText>
</div>
<MudSelect T="int" SelectedValues="@_selectedItem.MonthsOfYear" SelectedValuesChanged="OnSelectedMonthsChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Months">
@foreach (int month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
}
</MudSelect>
</MudStack>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
[Parameter]
public int Id { get; set; }
private string _channelName;
private List<PlayoutAlternateScheduleEditViewModel> _items = [];
private List<ProgramScheduleViewModel> _schedules = [];
private PlayoutAlternateScheduleEditViewModel _selectedItem;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_schedules = await Mediator.Send(new GetAllProgramSchedules(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_channelName = (await Mediator.Send(new GetChannelNameByPlayoutId(Id), token)).IfNone(string.Empty);
List<PlayoutAlternateScheduleViewModel> results = await Mediator.Send(new GetPlayoutAlternateSchedules(Id), token);
_items = results.Map(ProjectToEditViewModel).ToList();
if (_items.Count == 1)
{
_selectedItem = _items.Head();
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private PlayoutAlternateScheduleEditViewModel ProjectToEditViewModel(PlayoutAlternateScheduleViewModel item) =>
new()
{
Id = item.Id,
Index = item.Index,
ProgramSchedule = _schedules.Find(vm => vm.Id == item.ProgramScheduleId),
DaysOfWeek = item.DaysOfWeek.OrderBy(x => ((int)x + 6) % 7).ToList(),
DaysOfMonth = item.DaysOfMonth.ToList(),
MonthsOfYear = item.MonthsOfYear.ToList(),
LimitToDateRange = item.LimitToDateRange,
StartMonth = item.StartMonth,
StartDay = item.StartDay,
StartYear = item.StartYear,
EndMonth = item.EndMonth,
EndDay = item.EndDay,
EndYear = item.EndYear
};
private void SelectWeekdays()
{
_selectedItem.DaysOfWeek.Clear();
_selectedItem.DaysOfWeek.AddRange(
[
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday
]);
}
private void SelectWeekends()
{
_selectedItem.DaysOfWeek.Clear();
_selectedItem.DaysOfWeek.AddRange(
[
DayOfWeek.Saturday,
DayOfWeek.Sunday
]);
}
private void AddAlternateSchedule()
{
var item = new PlayoutAlternateScheduleEditViewModel
{
Index = _items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
ProgramSchedule = _schedules.Head(),
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = false,
StartMonth = 1,
StartDay = 1,
StartYear = null,
EndMonth = 1,
EndDay = 1,
EndYear = null
};
_items.Add(item);
_selectedItem = item;
}
private void RemoveAlternateSchedule(PlayoutAlternateScheduleEditViewModel item)
{
_selectedItem = null;
_items.Remove(item);
}
private void MoveItemUp(PlayoutAlternateScheduleEditViewModel item)
{
// swap with lower index
PlayoutAlternateScheduleEditViewModel toSwap = _items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(PlayoutAlternateScheduleEditViewModel item)
{
// swap with higher index
PlayoutAlternateScheduleEditViewModel toSwap = _items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private async Task SaveChanges()
{
var items = _items.Map(item => new ReplacePlayoutAlternateSchedule(
item.Id,
item.Index,
item.ProgramSchedule.Id,
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
item.LimitToDateRange,
item.StartMonth,
item.StartDay,
item.StartYear,
item.EndMonth,
item.EndDay,
item.EndYear)).ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new ReplacePlayoutAlternateScheduleItems(Id, items), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving alternate schedules: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving alternate schedules: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("playouts"));
}
private string ToDaysOfWeekString(List<DayOfWeek> daysOfWeek)
{
if (daysOfWeek.Count is 0 or 7)
{
return "*any*";
}
daysOfWeek.Sort();
return string.Join(", ", daysOfWeek.Map(_dtf.GetAbbreviatedDayName));
}
private string ToDaysOfMonthString(List<int> daysOfMonth)
{
if (daysOfMonth.Count is 0 or 31)
{
return "*any*";
}
return ToRangeString(daysOfMonth);
}
private string ToMonthsOfYearString(List<int> monthsOfYear)
{
if (monthsOfYear.Count is 0 or 12)
{
return "*any*";
}
monthsOfYear.Sort();
return string.Join(", ", monthsOfYear.Map(_dtf.GetAbbreviatedMonthName));
}
private static string ToRangeString(List<int> list)
{
list = list.Distinct().ToList();
list.Sort();
var result = new StringBuilder();
for (var i = 0; i < list.Count; i++)
{
int temp = list[i];
//add a number
result.Append(list[i]);
//skip number(s) between a range
while (i < list.Count - 1 && list[i + 1] == list[i] + 1)
{
i++;
}
//add the range
if (temp != list[i])
{
result.Append("-").Append(list[i]);
}
//add comma
if (i != list.Count - 1)
{
result.Append(", ");
}
}
return result.ToString();
}
private string SelectedRowClassFunc(PlayoutAlternateScheduleEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
private void OnSelectedDaysChanged(IEnumerable<DayOfWeek> selectedDays)
{
_selectedItem.DaysOfWeek.Clear();
_selectedItem.DaysOfWeek.AddRange(selectedDays);
}
private void OnSelectedDaysChanged(IEnumerable<int> selectedDays)
{
_selectedItem.DaysOfMonth.Clear();
_selectedItem.DaysOfMonth.AddRange(selectedDays);
}
private void OnSelectedMonthsChanged(IEnumerable<int> selectedMonths)
{
_selectedItem.MonthsOfYear.Clear();
_selectedItem.MonthsOfYear.AddRange(selectedMonths);
}
}
-150
View File
@@ -1,150 +0,0 @@
@page "/playouts/add"
@page "/playouts/add/{kind}"
@using System.Globalization
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Validators
@using FluentValidation.Results
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<PlayoutEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm @ref="_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Class="ml-6" OnClick="@HandleSubmitAsync" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add">
Add Playout
</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">Playout</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>Channel</MudText>
</div>
<MudSelect T="ChannelViewModel" @bind-value="_model.Channel" HelperText="Disabled channels already have a playout">
@foreach (ChannelViewModel channel in _channels)
{
<MudSelectItem Disabled="@(channel.PlayoutCount > 0)" Value="@channel">
@($"{channel.Number} - {channel.Name}")
</MudSelectItem>
}
</MudSelect>
</MudStack>
@switch (Kind)
{
case PlayoutKind.ExternalJson:
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>JSON (dizqueTV) Schedule</MudText>
</div>
<MudTextField @bind-Value="_model.ScheduleFile" For="@(() => _model.ScheduleFile)"/>
</MudStack>
break;
case PlayoutKind.Sequential:
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Sequential Schedule</MudText>
</div>
<MudTextField @bind-Value="_model.ScheduleFile" For="@(() => _model.ScheduleFile)" HelperText="The full path to the sequential schedule (YAML) file" />
</MudStack>
break;
case PlayoutKind.Scripted:
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Scripted Schedule</MudText>
</div>
<MudTextField @bind-Value="_model.ScheduleFile" For="@(() => _model.ScheduleFile)"/>
</MudStack>
break;
case PlayoutKind.Block:
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudText Typo="Typo.caption" Style="font-weight: normal">Block templates are added later</MudText>
</MudStack>
break;
default:
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Classic Schedule</MudText>
</div>
<MudSelect T="ProgramScheduleViewModel" @bind-value="_model.ProgramSchedule">
@foreach (ProgramScheduleViewModel schedule in _programSchedules)
{
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
break;
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly PlayoutEditViewModel _model = new();
private readonly PlayoutEditViewModelValidator _validator = new();
private MudForm _form;
private List<ChannelViewModel> _channels = [];
private List<ProgramScheduleViewModel> _programSchedules = [];
[Parameter]
public string Kind { get; set; }
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_model.Kind = Kind;
_channels = await Mediator.Send(new GetAllChannels(), token)
.Map(list => list.OrderBy(vm => decimal.Parse(vm.Number, CultureInfo.InvariantCulture)).ToList());
if (string.IsNullOrWhiteSpace(Kind))
{
_programSchedules = await Mediator.Send(new GetAllProgramSchedules(), token)
.Map(list => list.OrderBy(vm => vm.Name).ToList());
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task HandleSubmitAsync()
{
await _form.Validate();
ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token);
if (result.IsValid)
{
Seq<BaseError> errorMessage = (await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving playout: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("playouts"));
}
}
}
-601
View File
@@ -1,601 +0,0 @@
@page "/playouts/{Id:int}/templates"
@using System.Globalization
@using System.Text
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Core.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<PlayoutTemplatesEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
Save Templates
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddTemplate())" StartIcon="@Icons.Material.Filled.PlaylistAdd">
Add Template
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => PreviewCalendar())" StartIcon="@Icons.Material.Filled.Preview">
Preview Calendar
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Templates" OnClick="@SaveChanges"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Template" OnClick="AddTemplate"/>
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Calendar" OnClick="PreviewCalendar"/>
</MudMenu>
</div>
</div>
</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">@_channelName - Templates</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2" Class="mb-6">In priority order from top to bottom.</MudText>
<MudTable T="PlayoutTemplateEditViewModel" Class="mt-4" Hover="true" Items="_items.OrderBy(i => i.Index)" Dense="true" SelectedItem="@_selectedItem" SelectedItemChanged="@(vm => SelectedItemChanged(vm))" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Template</MudTh>
<MudTh>Days of the Week</MudTh>
<MudTh>Days of the Month</MudTh>
<MudTh>Months</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.Template?.Name
</MudText>
</MudTd>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@ToDaysOfWeekString(context.DaysOfWeek)
</MudText>
</MudTd>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@ToDaysOfMonthString(context.DaysOfMonth)
</MudText>
</MudTd>
<MudTd>
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@ToMonthsOfYearString(context.MonthsOfYear)
</MudText>
</MudTd>
<MudTd>
<div class="d-flex">
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_items.All(x => x.Index >= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_items.All(x => x.Index <= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteTemplate(context))">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedItem is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Edit Template</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>Template Group</MudText>
</div>
<MudSelect T="TemplateGroupViewModel" Value="@_selectedGroup" ValueChanged="@(vm => UpdateTemplateGroupItems(vm))">
@foreach (TemplateGroupViewModel templateGroup in _templateGroups)
{
<MudSelectItem Value="@templateGroup">
@templateGroup.Name
</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Template</MudText>
</div>
<MudSelect @bind-Value="_selectedItem.Template" For="@(() => _selectedItem.Template)">
@foreach (TemplateViewModel template in _templates)
{
<MudSelectItem Value="@template">@template.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Template Group</MudText>
</div>
<MudSelect T="DecoTemplateGroupViewModel" Value="@_selectedDecoTemplateGroup" ValueChanged="@(vm => UpdateDecoTemplateGroupItems(vm))">
@foreach (DecoTemplateGroupViewModel decoTemplateGroup in _decoTemplateGroups)
{
<MudSelectItem Value="@decoTemplateGroup">
@decoTemplateGroup.Name
</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Deco Template</MudText>
</div>
<MudSelect @bind-Value="_selectedItem.DecoTemplate" For="@(() => _selectedItem.DecoTemplate)" Clearable="true">
@foreach (DecoTemplateViewModel decoTemplate in _decoTemplates)
{
<MudSelectItem Value="@decoTemplate">@decoTemplate.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Limit to Date Range</MudText>
</div>
<MudCheckBox T="bool" @bind-Value="_selectedItem.LimitToDateRange" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Start Day</MudText>
</div>
<MudSelect T="int" @bind-Value="_selectedItem.StartMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_selectedItem.StartDay" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int day in Enumerable.Range(1, 31))
{
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int?" @bind-Value="_selectedItem.StartYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
{
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>End Day</MudText>
</div>
<MudSelect T="int" @bind-Value="_selectedItem.EndMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_selectedItem.EndDay" Disabled="@(!_selectedItem.LimitToDateRange)">
@foreach (int day in Enumerable.Range(1, 31))
{
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int?" @bind-Value="_selectedItem.EndYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
{
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Days of the Week</MudText>
</div>
<MudSelect T="DayOfWeek" SelectedValues="@_selectedItem.DaysOfWeek" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
<MudSelectItem Value="@DayOfWeek.Monday">@_dtf.GetDayName(DayOfWeek.Monday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Tuesday">@_dtf.GetDayName(DayOfWeek.Tuesday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Wednesday">@_dtf.GetDayName(DayOfWeek.Wednesday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Thursday">@_dtf.GetDayName(DayOfWeek.Thursday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Friday">@_dtf.GetDayName(DayOfWeek.Friday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Saturday">@_dtf.GetDayName(DayOfWeek.Saturday)</MudSelectItem>
<MudSelectItem Value="@DayOfWeek.Sunday">@_dtf.GetDayName(DayOfWeek.Sunday)</MudSelectItem>
</MudSelect>
</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="@(_ => SelectWeekdays())">
Weekdays
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => SelectWeekends())">
Weekends
</MudButton>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Days of the Month</MudText>
</div>
<MudSelect T="int" SelectedValues="@_selectedItem.DaysOfMonth" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
@foreach (int day in Enumerable.Range(1, 31))
{
<MudSelectItem Value="@day">@day</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Months</MudText>
</div>
<MudSelect T="int" SelectedValues="@_selectedItem.MonthsOfYear" SelectedValuesChanged="OnSelectedMonthsChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Months">
@foreach (int month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
}
</MudSelect>
</MudStack>
}
else
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudCalendar ShowWeek="false"
ShowDay="false"
DateRangeChanged="@(range => DateRangeChanged(range))"
MonthCellMinHeight="115"
Items="_previewItems"/>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
[Parameter]
public int Id { get; set; }
private readonly List<TemplateGroupViewModel> _templateGroups = [];
private readonly List<TemplateViewModel> _templates = [];
private readonly List<DecoTemplateGroupViewModel> _decoTemplateGroups = [];
private readonly List<DecoTemplateViewModel> _decoTemplates = [];
private string _channelName;
private List<PlayoutTemplateEditViewModel> _items = [];
private TemplateGroupViewModel _selectedGroup;
private DecoTemplateGroupViewModel _selectedDecoTemplateGroup;
private PlayoutTemplateEditViewModel _selectedItem;
private readonly List<CalendarItem> _previewItems = [];
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_channelName = (await Mediator.Send(new GetChannelNameByPlayoutId(Id), token)).IfNone(string.Empty);
_templateGroups.Clear();
_templateGroups.AddRange(await Mediator.Send(new GetAllTemplateGroups(), token));
_decoTemplateGroups.Clear();
_decoTemplateGroups.AddRange(await Mediator.Send(new GetAllDecoTemplateGroups(), token));
List<PlayoutTemplateViewModel> results = await Mediator.Send(new GetPlayoutTemplates(Id), token);
_items = results.Map(ProjectToEditViewModel).ToList();
if (_items.Count == 1)
{
await SelectedItemChanged(_items.Head());
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private PlayoutTemplateEditViewModel ProjectToEditViewModel(PlayoutTemplateViewModel item) =>
new()
{
Id = item.Id,
Template = item.Template,
DecoTemplate = item.DecoTemplate,
Index = item.Index,
DaysOfWeek = item.DaysOfWeek.ToList(),
DaysOfMonth = item.DaysOfMonth.ToList(),
MonthsOfYear = item.MonthsOfYear.ToList(),
LimitToDateRange = item.LimitToDateRange,
StartMonth = item.StartMonth,
StartDay = item.StartDay,
StartYear = item.StartYear,
EndMonth = item.EndMonth,
EndDay = item.EndDay,
EndYear = item.EndYear
};
private async Task UpdateTemplateGroupItems(TemplateGroupViewModel templateGroup)
{
_selectedGroup = templateGroup;
_templates.Clear();
_templates.AddRange(await Mediator.Send(new GetTemplatesByTemplateGroupId(_selectedGroup.Id), _cts.Token));
}
private async Task UpdateDecoTemplateGroupItems(DecoTemplateGroupViewModel decoTemplateGroup)
{
_selectedDecoTemplateGroup = decoTemplateGroup;
_decoTemplates.Clear();
_decoTemplates.AddRange(await Mediator.Send(new GetDecoTemplatesByDecoTemplateGroupId(_selectedDecoTemplateGroup.Id), _cts.Token));
}
private async Task SelectedItemChanged(PlayoutTemplateEditViewModel template)
{
_selectedItem = template;
foreach (TemplateGroupViewModel group in Optional(_templateGroups.Find(group => group.Id == _selectedItem?.Template?.TemplateGroupId)))
{
await UpdateTemplateGroupItems(group);
}
foreach (DecoTemplateGroupViewModel group in Optional(_decoTemplateGroups.Find(group => group.Id == _selectedItem?.DecoTemplate?.DecoTemplateGroupId)))
{
await UpdateDecoTemplateGroupItems(group);
}
}
private void SelectWeekdays()
{
_selectedItem?.DaysOfWeek.Clear();
_selectedItem?.DaysOfWeek.AddRange(
[
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday
]);
}
private void SelectWeekends()
{
_selectedItem?.DaysOfWeek.Clear();
_selectedItem?.DaysOfWeek.AddRange(
[
DayOfWeek.Saturday,
DayOfWeek.Sunday
]);
}
private void AddTemplate()
{
var item = new PlayoutTemplateEditViewModel
{
Index = _items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
};
_items.Add(item);
_selectedItem = item;
}
private void DeleteTemplate(PlayoutTemplateEditViewModel item)
{
_selectedItem = null;
_items.Remove(item);
}
private void MoveItemUp(PlayoutTemplateEditViewModel item)
{
// swap with lower index
PlayoutTemplateEditViewModel toSwap = _items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(PlayoutTemplateEditViewModel item)
{
// swap with higher index
PlayoutTemplateEditViewModel toSwap = _items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private async Task SaveChanges()
{
if (_items.Any(i => i.Template is null))
{
Snackbar.Add("Unable to save; item has no template selected", Severity.Error);
return;
}
var items = _items.Map(item => new ReplacePlayoutTemplate(
item.Id,
item.Index,
item.Template.Id,
item.DecoTemplate?.Id,
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
item.LimitToDateRange,
item.StartMonth,
item.StartDay,
item.StartYear,
item.EndMonth,
item.EndDay,
item.EndYear)).ToList();
Option<BaseError> maybeError = await Mediator.Send(new ReplacePlayoutTemplateItems(Id, items), _cts.Token);
maybeError.Match(
error =>
{
Snackbar.Add($"Unexpected error saving playout templates: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving playout templates: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("playouts"));
}
private async Task PreviewCalendar()
{
_selectedItem = null;
await InvokeAsync(StateHasChanged);
}
private async Task DateRangeChanged(DateRange dateRange)
{
await Task.CompletedTask;
_previewItems.Clear();
var prioritized = _items.Filter(i => i.Template is not null).OrderBy(t => t.Index).ToList();
if (dateRange.Start.HasValue && dateRange.End.HasValue)
{
DateTime current = dateRange.Start.Value.Date;
while (current <= dateRange.End.Value.Date)
{
foreach (PlayoutTemplateEditViewModel template in prioritized)
{
if (template.AppliesToDate(current))
{
_previewItems.Add(
new CalendarItem
{
AllDay = true,
Start = current,
Text = template.Template.Name
});
break;
}
}
current = current.AddDays(1);
}
}
}
private string ToDaysOfWeekString(List<DayOfWeek> daysOfWeek)
{
if (daysOfWeek.Count is 0 or 7)
{
return "*any*";
}
daysOfWeek.Sort();
return string.Join(", ", daysOfWeek.Map(_dtf.GetAbbreviatedDayName));
}
private string ToDaysOfMonthString(List<int> daysOfMonth)
{
if (daysOfMonth.Count is 0 or 31)
{
return "*any*";
}
return ToRangeString(daysOfMonth);
}
private string ToMonthsOfYearString(List<int> monthsOfYear)
{
if (monthsOfYear.Count is 0 or 12)
{
return "*any*";
}
monthsOfYear.Sort();
return string.Join(", ", monthsOfYear.Map(_dtf.GetAbbreviatedMonthName));
}
private static string ToRangeString(List<int> list)
{
list = list.Distinct().ToList();
list.Sort();
var result = new StringBuilder();
for (var i = 0; i < list.Count; i++)
{
int temp = list[i];
//add a number
result.Append(list[i]);
//skip number(s) between a range
while (i < list.Count - 1 && list[i + 1] == list[i] + 1)
{
i++;
}
//add the range
if (temp != list[i])
{
result.Append("-").Append(list[i]);
}
//add comma
if (i != list.Count - 1)
{
result.Append(", ");
}
}
return result.ToString();
}
private string SelectedRowClassFunc(PlayoutTemplateEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
private void OnSelectedDaysChanged(IEnumerable<DayOfWeek> selectedDays)
{
_selectedItem.DaysOfWeek.Clear();
_selectedItem.DaysOfWeek.AddRange(selectedDays);
}
private void OnSelectedDaysChanged(IEnumerable<int> selectedDays)
{
_selectedItem.DaysOfMonth.Clear();
_selectedItem.DaysOfMonth.AddRange(selectedDays);
}
private void OnSelectedMonthsChanged(IEnumerable<int> selectedMonths)
{
_selectedItem.MonthsOfYear.Clear();
_selectedItem.MonthsOfYear.AddRange(selectedMonths);
}
}
-535
View File
@@ -1,535 +0,0 @@
@page "/playouts"
@using System.Globalization
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Playouts
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Core.Notifications
@using ErsatzTV.Core.Scheduling
@using MediatR.Courier
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
@inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel
@inject IEntityLocker EntityLocker;
@inject ICourier Courier;
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="flex-grow-1"></div>
<div style="margin-left: auto" class="d-none d-md-flex">
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled" Color="Color.Primary">Add Playout</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Classic Schedule" Href="playouts/add"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Block Schedule" Href="@($"playouts/add/{PlayoutKind.Block}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Sequential Schedule" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Scripted Schedule" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="JSON (dizqueTV) Schedule" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/>
</ChildContent>
</MudMenu>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@ResetAllPlayouts">
Reset All Playouts
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Classic Playout" Href="playouts/add"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Block Playout" Href="@($"playouts/add/{PlayoutKind.Block}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Sequential Playout" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Scripted Playout" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="Add JSON (dizqueTV) Playout" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Refresh" Label="Reset All Playouts" OnClick="@ResetAllPlayouts"/>
</MudMenu>
</div>
</div>
</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">Playouts</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
SelectedItemChanged="@(async (PlayoutNameViewModel x) => await PlayoutSelected(x))"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<PlayoutNameViewModel>>>(ServerReload))"
@ref="_table"
RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col style="width: 225px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for playouts"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<HeaderContent>
<MudTh>
Channel
</MudTh>
<MudTh Class="d-none d-md-table-cell">
Default Schedule
</MudTh>
<MudTh Class="d-none d-md-table-cell">Schedule Kind</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>@context.ChannelNumber - @context.ChannelName</MudTd>
<MudTd Class="d-none d-md-table-cell">
@if (!string.IsNullOrWhiteSpace(context.ScheduleName))
{
@context.ScheduleName
}
else if (!string.IsNullOrWhiteSpace(context.ScheduleFile))
{
<MudTooltip Text="@context.ScheduleFile">
@Path.GetFileName(context.ScheduleFile)
</MudTooltip>
}
</MudTd>
<MudTd Class="d-none d-md-table-cell">
@switch (context.ScheduleKind)
{
case PlayoutScheduleKind.Block:
<span>Block</span>
break;
case PlayoutScheduleKind.Sequential:
<span>Sequential</span>
break;
case PlayoutScheduleKind.Scripted:
<span>Scripted</span>
break;
case PlayoutScheduleKind.ExternalJson:
<span>JSON (dizqueTV)</span>
break;
default:
<span></span>
break;
}
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
@if (EntityLocker.IsPlayoutLocked(context.PlayoutId))
{
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
}
</div>
@if (context.ScheduleKind == PlayoutScheduleKind.Classic)
{
<MudTooltip Text="Edit Playout">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Href="@($"playouts/classic/{context.PlayoutId}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Refresh Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Schedule Reset">
<MudIconButton Icon="@Icons.Material.Filled.Update"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ScheduleReset(context))">
</MudIconButton>
</MudTooltip>
}
else if (context.ScheduleKind == PlayoutScheduleKind.ExternalJson)
{
<MudTooltip Text="Edit External Json File">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => EditExternalJsonFile(context))">
</MudIconButton>
</MudTooltip>
<div style="width: 48px"></div>
<div style="width: 48px"></div>
}
else if (context.ScheduleKind == PlayoutScheduleKind.Sequential)
{
<MudTooltip Text="Edit Playout">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Href="@($"playouts/sequential/{context.PlayoutId}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<div style="width: 48px"></div>
}
else if (context.ScheduleKind == PlayoutScheduleKind.Scripted)
{
<MudTooltip Text="Edit Playout">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Href="@($"playouts/scripted/{context.PlayoutId}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<div style="width: 48px"></div>
}
else if (context.ScheduleKind == PlayoutScheduleKind.Block)
{
<MudTooltip Text="Edit Playout">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Href="@($"playouts/block/{context.PlayoutId}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Refresh Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<div style="width: 48px"></div>
}
<MudTooltip Text="Delete Playout">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => DeletePlayout(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
@if (_selectedPlayoutId != null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playout Detail</MudText>
<MudDivider Class="mb-6"/>
@if (!string.IsNullOrWhiteSpace(_buildMessage))
{
<MudAlert Severity="Severity.Warning">@_buildMessage</MudAlert>
}
else
{
<MudTable Hover="true"
Dense="true"
@bind-RowsPerPage="@_detailRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<PlayoutItemViewModel>>>(DetailServerReload))"
@ref="_detailTable"
RowClassFunc="PlayoutItemViewRowClassFunc">
<ToolBarContent>
<MudSwitch T="bool" Class="ml-6" @bind-Value="@ShowFiller" Color="Color.Secondary" Label="Show Filler"/>
</ToolBarContent>
<HeaderContent>
<MudTh>Start</MudTh>
<MudTh>Finish</MudTh>
<MudTh>Media Item</MudTh>
<MudTh>Duration</MudTh>
<MudTh />
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start">@context.Start.ToString("G", _dtf)</MudTd>
<MudTd DataLabel="Finish">@context.Finish.ToString("G", _dtf)</MudTd>
<MudTd DataLabel="Media Item">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.SchedulingContext))
{
<div style="align-items: center; display: flex;">
<MudTooltip Text="Troubleshoot">
<MudIconButton Icon="@Icons.Material.Filled.Info"
OnClick="@(_ => ShowSchedulingContext(context))">
</MudIconButton>
</MudTooltip>
</div>
}
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
}
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
private readonly List<PlayoutNameViewModel> _currentPage = [];
private MudTable<PlayoutNameViewModel> _table;
private MudTable<PlayoutItemViewModel> _detailTable;
private int _rowsPerPage = 10;
private int _detailRowsPerPage = 10;
private string _searchString;
private int? _selectedPlayoutId;
private bool _showFiller;
private string _buildMessage;
private bool ShowFiller
{
get => _showFiller;
set
{
if (_showFiller != value)
{
_showFiller = value;
if (_detailTable != null && _selectedPlayoutId != null)
{
_detailTable.ReloadServerData();
}
}
}
}
protected override void OnInitialized() => Courier.Subscribe<PlayoutUpdatedNotification>(HandlePlayoutUpdated);
public void Dispose()
{
Courier.UnSubscribe<PlayoutUpdatedNotification>(HandlePlayoutUpdated);
_cts?.Cancel();
_cts?.Dispose();
}
public async Task HandlePlayoutUpdated(PlayoutUpdatedNotification notification, CancellationToken cancellationToken)
{
// only refresh detail table on unlock operations (after playout has been modified)
if (!notification.IsLocked)
{
int? previouslySelected = _selectedPlayoutId;
await InvokeAsync(() => _table.ReloadServerData());
_buildMessage = string.Empty;
_selectedPlayoutId = null;
if (notification.PlayoutId == previouslySelected)
{
Option<PlayoutNameViewModel> maybeItem = _currentPage.Find(i => i.PlayoutId == previouslySelected);
foreach (var item in maybeItem)
{
await InvokeAsync(() => PlayoutSelected(item));
}
}
}
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_detailRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_showFiller = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller), token)
.Map(maybeShow => maybeShow.Match(ce => bool.TryParse(ce.Value, out bool show) && show, () => false));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task PlayoutSelected(PlayoutNameViewModel playout)
{
// only show details for classic, block, sequential and scripted schedules
_selectedPlayoutId = playout.ScheduleKind is PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted
? playout.PlayoutId
: null;
_buildMessage = !(playout.BuildStatus?.Success ?? false) && !string.IsNullOrWhiteSpace(playout.BuildStatus?.Message)
? playout.BuildStatus.Message
: string.Empty;
if (_detailTable != null)
{
await _detailTable.ReloadServerData();
}
}
private async Task EditExternalJsonFile(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters { { "ScheduleFile", $"{playout.ScheduleFile}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
IDialogReference dialog = await Dialog.ShowAsync<EditExternalJsonFileDialog>("Edit External Json File", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new UpdateExternalJsonPlayout(playout.PlayoutId, result.Data as string ?? playout.ScheduleFile), _cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
_selectedPlayoutId = null;
}
}
private async Task DeletePlayout(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters { { "EntityType", "playout" }, { "EntityName", $"{playout.ScheduleName} on {playout.ChannelNumber} - {playout.ChannelName}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playout", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeletePlayout(playout.PlayoutId), _cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
if (_selectedPlayoutId == playout.PlayoutId)
{
_selectedPlayoutId = null;
}
}
}
private async Task ResetAllPlayouts()
{
_selectedPlayoutId = null;
await Mediator.Send(new ResetAllPlayouts(), _cts.Token);
}
private async Task ResetPlayout(PlayoutNameViewModel playout)
{
PlayoutBuildMode mode = playout.ScheduleKind switch
{
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
_ => PlayoutBuildMode.Reset
};
await WorkerChannel.WriteAsync(new BuildPlayout(playout.PlayoutId, mode), _cts.Token);
}
private async Task ScheduleReset(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters
{
{ "PlayoutId", playout.PlayoutId },
{ "ChannelName", playout.ChannelName },
{ "ScheduleName", playout.ScheduleName },
{ "DailyResetTime", playout.DailyRebuildTime }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<SchedulePlayoutReset>("Schedule Playout Reset", parameters, options);
await dialog.Result;
if (_table != null)
{
await _table.ReloadServerData();
}
}
private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()), cancellationToken);
PagedPlayoutsViewModel data = await Mediator.Send(new GetPagedPlayouts(_searchString, state.Page, state.PageSize), cancellationToken);
_currentPage.Clear();
_currentPage.AddRange(data.Page);
return new TableData<PlayoutNameViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()), cancellationToken);
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller, _showFiller.ToString()), cancellationToken);
if (_selectedPlayoutId.HasValue)
{
PagedPlayoutItemsViewModel data =
await Mediator.Send(new GetFuturePlayoutItemsById(_selectedPlayoutId.Value, _showFiller, state.Page, state.PageSize), cancellationToken);
return new TableData<PlayoutItemViewModel>
{
TotalItems = data.TotalCount,
Items = data.Page
};
}
return new TableData<PlayoutItemViewModel> { TotalItems = 0 };
}
private void OnSearch(string query)
{
_selectedPlayoutId = null;
_searchString = query;
_table.ReloadServerData();
}
private string PlayoutItemViewRowClassFunc(PlayoutItemViewModel item, int index)
{
return "playout-filler-" + item.FillerKind.Match(
fk => fk.ToString().ToLower(),
() => "unscheduled");
}
private string SelectedRowClassFunc(PlayoutNameViewModel element, int rowNumber)
{
if (_selectedPlayoutId != null && _selectedPlayoutId == element.PlayoutId)
{
if (element.BuildStatus is { Success: false })
{
return "playout-build-failure-selected";
}
return "selected";
}
if (element.BuildStatus is { Success: false })
{
return "playout-build-failure";
}
return string.Empty;
}
private async Task ShowSchedulingContext(PlayoutItemViewModel item)
{
Option<string> maybeResults = await Mediator.Send(new ProcessSchedulingContext(item.SchedulingContext), _cts.Token);
foreach (string result in maybeResults)
{
var parameters = new DialogParameters { { "Context", result } };
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
IDialogReference dialog = await Dialog.ShowAsync<SchedulingContextDialog>(item.Title, parameters, options);
DialogResult _ = await dialog.Result;
}
}
}
-46
View File
@@ -1,46 +0,0 @@
@page "/media/sources/plex/{Id:int}/libraries"
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.Plex
@inject IMediator Mediator
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<RemoteMediaSourceLibrariesEditor
Id="@Id"
Name="Plex"
GetUpdateLibraryRequest="GetUpdateLibraryRequest"
GetLibrariesBySourceId="GetLibrariesBySourceId"
GetMediaSourceById="GetMediaSourceById"
SynchronizeLibraryByIdIfNeeded="SynchronizeLibraryByIdIfNeeded"/>
@code {
[Parameter]
public int Id { get; set; }
private IRequest<Either<BaseError, Unit>> GetUpdateLibraryRequest(List<RemoteMediaSourceLibraryEditViewModel> libraries) =>
new UpdatePlexLibraryPreferences(
libraries.Map(l => new PlexLibraryPreference(l.Id, l.ShouldSyncItems)).ToList());
private async Task<List<RemoteMediaSourceLibraryEditViewModel>> GetLibrariesBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
await Mediator.Send(new GetPlexLibrariesBySourceId(Id), cancellationToken)
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int mediaSourceId, CancellationToken cancellationToken) =>
await Mediator.Send(new GetPlexMediaSourceById(Id), cancellationToken)
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(PlexLibraryViewModel library) => new()
{
Id = library.Id,
Name = library.Name,
MediaKind = library.MediaKind,
ShouldSyncItems = library.ShouldSyncItems
};
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters, CancellationToken cancellationToken)
{
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(parameters.LibraryId), cancellationToken);
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexNetworks(parameters.LibraryId, false), cancellationToken);
return Unit.Default;
}
}
-170
View File
@@ -1,170 +0,0 @@
@page "/media/sources/plex"
@using ErsatzTV.Application.Plex
@using ErsatzTV.Core.Interfaces.Plex
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ISnackbar Snackbar
@inject ILogger<PlexMediaSources> Logger
@inject IJSRuntime JsRuntime
@inject IPlexSecretStore PlexSecretStore
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
@if (_mediaSources.Any())
{
<MudButton Variant="Variant.Filled"
Color="Color.Error"
OnClick="@(_ => SignOutOfPlex())"
Disabled="@Locker.IsPlexLocked()"
Class="ml-8">
Sign out of plex
</MudButton>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="@(_ => AddPlexMediaSource())"
Disabled="@Locker.IsPlexLocked()"
Class="ml-8">
Sign in to plex
</MudButton>
}
@if (_mediaSources.Any() && !_isAuthorized)
{
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
OnClick="@(_ => AddPlexMediaSource())"
Disabled="@Locker.IsPlexLocked()"
Class="ml-4">
Fix Plex Credentials
</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">Plex Media Sources</MudText>
<MudDivider Class="mb-6"/>
<MudTable T="PlexMediaSourceViewModel" Hover="true" Dense="true" Items="_mediaSources">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Address</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd Style="overflow-wrap: anywhere;">@context.Address</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Refresh Libraries">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@(Locker.IsPlexLocked())"
OnClick="@(_ => RefreshLibraries(context.Id))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Edit Libraries">
<MudIconButton Icon="@Icons.Material.Filled.VideoLibrary"
Href="@($"media/sources/plex/{context.Id}/libraries")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Edit Path Replacements">
<MudIconButton Icon="@Icons.Material.Filled.Folder"
Href="@($"media/sources/plex/{context.Id}/paths")">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private List<PlexMediaSourceViewModel> _mediaSources = new();
private bool _isAuthorized;
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
protected override void OnInitialized() =>
Locker.OnPlexChanged += PlexChanged;
private async Task LoadMediaSources()
{
_isAuthorized = await PlexSecretStore.GetUserAuthTokens().Map(list => Optional(list).Flatten().Any());
_mediaSources = await Mediator.Send(new GetAllPlexMediaSources());
}
private async Task SignOutOfPlex()
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = await Dialog.ShowAsync<SignOutOfPlexDialog>("Sign out of Plex", options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
if (Locker.LockPlex())
{
await Mediator.Send(new SignOutOfPlex());
await LoadMediaSources();
}
}
}
private async Task AddPlexMediaSource()
{
if (Locker.LockPlex())
{
Either<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
await maybeUrl.Match(
async url =>
{
try
{
await JsRuntime.InvokeAsync<object>("open", [url, "_blank"]);
}
catch (Exception)
{
// ignored
}
},
error =>
{
Locker.UnlockPlex();
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error generating plex auth app url: {Error}", error.Value);
return Task.CompletedTask;
});
}
}
private async void PlexChanged(object sender, EventArgs e)
{
try
{
await InvokeAsync(LoadMediaSources);
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
// do nothing
}
}
private async Task RefreshLibraries(int mediaSourceId) =>
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(mediaSourceId));
void IDisposable.Dispose() => Locker.OnPlexChanged -= PlexChanged;
}
@@ -1,37 +0,0 @@
@page "/media/sources/plex/{Id:int}/paths"
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.Plex
@inject IMediator Mediator
<RemoteMediaSourcePathReplacementsEditor
Id="@Id"
Name="Plex"
GetMediaSourceById="GetMediaSourceById"
GetUpdatePathReplacementsRequest="GetUpdatePathReplacementsRequest"
GetPathReplacementsBySourceId="GetPathReplacementsBySourceId"/>
@code {
[Parameter]
public int Id { get; set; }
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int id, CancellationToken cancellationToken) =>
await Mediator.Send(new GetPlexMediaSourceById(Id), cancellationToken)
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
private async Task<List<RemoteMediaSourcePathReplacementEditViewModel>> GetPathReplacementsBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id), cancellationToken)
.Map(list => list.Map(ProjectToEditViewModel).ToList());
private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) =>
new() { Id = item.Id, RemotePath = item.PlexPath, LocalPath = item.LocalPath };
private IRequest<Either<BaseError, Unit>> GetUpdatePathReplacementsRequest(List<RemoteMediaSourcePathReplacementEditViewModel> pathReplacements)
{
var items = pathReplacements
.Map(item => new PlexPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
.ToList();
return new UpdatePlexPathReplacements(Id, items);
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/remote/streams"
@page "/media/remote/streams/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<RemoteStreamList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="RemoteStreamCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/remote/streams"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private RemoteStreamCardResultsViewModel _data = new(0, new List<RemoteStreamCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:remote_stream" : $"type:remote_stream AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexRemoteStreams(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/remote/streams/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/remote/streams/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is RemoteStreamCardViewModel remoteStream)
{
var parameters = new DialogParameters { { "EntityType", "remote stream" }, { "EntityName", remoteStream.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMediaItemToCollection(collection.Id, remoteStream.RemoteStreamId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding remote stream to collection: {error.Value}");
Logger.LogError("Unexpected error adding remote stream to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {remoteStream.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-347
View File
@@ -1,347 +0,0 @@
@page "/media/rerun-collections/{Id:int}/edit"
@page "/media/rerun-collections/add"
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Search
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject ILogger<RerunCollectionEditor> Logger
<MudForm @ref="_form" @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" OnClick="@(_ => HandleSubmitAsync())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
@(IsEdit ? "Save Rerun Collection" : "Add Rerun Collection")
</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">Rerun Collection</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="Rerun collection name is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_model.CollectionType" For="@(() => _model.CollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_model.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_model.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_model.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_model.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_model.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.Artist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_model.MediaItem" SearchFunc="@SearchArtists"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="10">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>First Run Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_model.FirstRunPlaybackOrder" For="@(() => _model.FirstRunPlaybackOrder)">
@switch (_model.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Rerun Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_model.RerunPlaybackOrder" For="@(() => _model.RerunPlaybackOrder)">
@switch (_model.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly RerunCollectionEditViewModel _model = new();
private MudForm _form;
private bool _success;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (IsEdit)
{
Option<RerunCollectionViewModel> maybeCollection = await Mediator.Send(new GetRerunCollectionById(Id), token);
maybeCollection.IfSome(collection =>
{
_model.Id = collection.Id;
_model.Name = collection.Name;
_model.CollectionType = collection.CollectionType;
_model.Collection = collection.Collection;
_model.MultiCollection = collection.MultiCollection;
_model.SmartCollection = collection.SmartCollection;
_model.MediaItem = collection.MediaItem;
_model.FirstRunPlaybackOrder = collection.FirstRunPlaybackOrder;
_model.RerunPlaybackOrder = collection.RerunPlaybackOrder;
});
}
else
{
_model.Name = "New Rerun Collection";
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool IsEdit => Id != 0;
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (_success)
{
Seq<BaseError> errorMessage = IsEdit
? (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq()
: (await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error saving collection: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("media/rerun-collections"));
}
}
}
-136
View File
@@ -1,136 +0,0 @@
@page "/media/rerun-collections"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
<div style="margin-right: auto" class="d-none d-md-flex">
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="media/rerun-collections/add">
Add Rerun Collection
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Rerun Collection" Href="media/rerun-collections/add"/>
</MudMenu>
</div>
</div>
</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">Rerun Collections</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_rerunCollectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<RerunCollectionViewModel>>>(ServerReloadRerunCollections))"
Dense="true"
@ref="_rerunCollectionsTable">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 120px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for rerun collections"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Rerun Collection">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"/media/rerun-collections/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Rerun Collection">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteRerunCollection(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
<div class="mt-6"></div>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<RerunCollectionViewModel> _rerunCollectionsTable;
private int _rerunCollectionsRowsPerPage = 10;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_rerunCollectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.RerunCollectionsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task DeleteRerunCollection(RerunCollectionViewModel collection)
{
var parameters = new DialogParameters { { "EntityType", "rerun collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Rerun Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteRerunCollection(collection.Id), _cts.Token);
if (_rerunCollectionsTable != null)
{
await _rerunCollectionsTable.ReloadServerData();
}
}
}
private async Task<TableData<RerunCollectionViewModel>> ServerReloadRerunCollections(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.RerunCollectionsPageSize, state.PageSize.ToString()), cancellationToken);
PagedRerunCollectionsViewModel data = await Mediator.Send(new GetPagedRerunCollections(_searchString, state.Page, state.PageSize), cancellationToken);
return new TableData<RerunCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private void OnSearch(string query)
{
_searchString = query;
_rerunCollectionsTable.ReloadServerData();
}
}
-147
View File
@@ -1,147 +0,0 @@
@page "/schedules/{Id:int}"
@page "/schedules/add"
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Core.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<ScheduleEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm @ref="_form" @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="HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Schedule" : "Add Schedule")</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">Schedule</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="Schedule name is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Keep Multi-Part Episodes Together</MudText>
</div>
<MudCheckBox @bind-Value="@_model.KeepMultiPartEpisodesTogether" For="@(() => _model.KeepMultiPartEpisodesTogether)" Dense="true">
<MudText Typo="Typo.caption" Style="font-weight: normal">Group multi-part episodes when shuffling</MudText>
</MudCheckBox>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Treat Collections As Shows</MudText>
</div>
<MudCheckBox @bind-Value="@_model.TreatCollectionsAsShows"
Disabled="@(_model.KeepMultiPartEpisodesTogether == false)"
For="@(() => _model.TreatCollectionsAsShows)" Dense="true">
<MudText Typo="Typo.caption" Style="font-weight: normal">This is useful for multi-part crossover episodes</MudText>
</MudCheckBox>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Shuffle Schedule Items</MudText>
</div>
<MudCheckBox @bind-Value="@_model.ShuffleScheduleItems" For="@(() => _model.ShuffleScheduleItems)" Dense="true">
<MudText Typo="Typo.caption" Style="font-weight: normal">Note: this disables fixed start times and flood mode</MudText>
</MudCheckBox>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Random Start Point</MudText>
</div>
<MudCheckBox @bind-Value="@_model.RandomStartPoint" For="@(() => _model.RandomStartPoint)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Fixed Start Time Behavior</MudText>
</div>
<MudSelect @bind-Value="@_model.FixedStartTimeBehavior" For="@(() => _model.FixedStartTimeBehavior)">
<MudSelectItem Value="FixedStartTimeBehavior.Strict">Strict - Wait For Exact Start Time</MudSelectItem>
<MudSelectItem Value="FixedStartTimeBehavior.Flexible">Flexible - Start As Soon As Possible</MudSelectItem>
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly ProgramScheduleEditViewModel _model = new();
private MudForm _form;
private bool _success;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (IsEdit)
{
Option<ProgramScheduleViewModel> maybeProgramSchedule = await Mediator.Send(new GetProgramScheduleById(Id), token);
maybeProgramSchedule.Match(
viewModel =>
{
_model.Id = viewModel.Id;
_model.Name = viewModel.Name;
_model.ShuffleScheduleItems = viewModel.ShuffleScheduleItems;
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
_model.RandomStartPoint = viewModel.RandomStartPoint;
_model.FixedStartTimeBehavior = viewModel.FixedStartTimeBehavior;
},
() => NavigationManager.NavigateTo("404"));
}
else
{
_model.Name = "New Schedule";
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool IsEdit => Id > 0;
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (_success)
{
Either<BaseError, EntityIdResult> result = IsEdit
? await Mediator.Send(_model.ToUpdate(), _cts.Token).MapT(r => r as EntityIdResult)
: await Mediator.Send(_model.ToCreate(), _cts.Token).MapT(r => r as EntityIdResult);
result.Match(
programSchedule =>
{
string destination = IsEdit ? "/schedules" : $"/schedules/{programSchedule.Id}/items";
NavigationManager.NavigateTo(destination);
},
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving schedule: {Error}", error.Value);
});
}
}
}
File diff suppressed because it is too large Load Diff
-235
View File
@@ -1,235 +0,0 @@
@page "/schedules"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.ProgramSchedules
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
@inject NavigationManager NavigationManager
<MudForm 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" StartIcon="@Icons.Material.Filled.Add" Href="schedules/add">
Add Schedule
</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">Schedules</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
Breakpoint="Breakpoint.None"
SelectedItemChanged="@(async (ProgramScheduleViewModel x) => await ScheduleSelected(x))"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ProgramScheduleViewModel>>>(ServerReload))"
@ref="_table"
RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 300px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for schedules"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Properties">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"schedules/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Edit Schedule Items">
<MudIconButton Icon="@Icons.Material.Filled.FormatListNumbered"
Href="@($"schedules/{context.Id}/items")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Copy Schedule">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopySchedule(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Troubleshoot">
<MudIconButton Icon="@Icons.Material.Filled.Info"
OnClick="@(_ => Troubleshoot(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Schedule">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteSchedule(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
@if (_selectedSchedule != null)
{
<MudHidden Breakpoint="Breakpoint.SmAndDown">
<MudTable Hover="true"
Class="mt-8"
@bind-RowsPerPage="@_detailRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ProgramScheduleItemViewModel>>>(DetailServerReload))"
@ref="_detailTable">
<ToolBarContent>
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Start Time</MudTh>
<MudTh>Collection</MudTh>
<MudTh>Playout Mode</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
@(context.StartType == StartType.Fixed ? context.StartTime == null ? string.Empty : DateTime.Today.Add(context.StartTime.Value).ToShortTimeString() : "Dynamic")
</MudTd>
<MudTd DataLabel="Collection">@context.Name</MudTd>
<MudTd DataLabel="Playout Mode">@context.PlayoutMode</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
</MudHidden>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<ProgramScheduleViewModel> _table;
private MudTable<ProgramScheduleItemViewModel> _detailTable;
private int _rowsPerPage = 10;
private int _detailRowsPerPage = 10;
private string _searchString;
private ProgramScheduleViewModel _selectedSchedule;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_detailRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task ScheduleSelected(ProgramScheduleViewModel schedule)
{
_selectedSchedule = schedule;
if (_selectedSchedule != null && _detailTable != null)
{
await _detailTable.ReloadServerData();
}
}
private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule)
{
var parameters = new DialogParameters { { "EntityType", "schedule" }, { "EntityName", programSchedule.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteProgramSchedule(programSchedule.Id), _cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
if (_selectedSchedule == programSchedule)
{
_selectedSchedule = null;
}
}
}
private async Task CopySchedule(ProgramScheduleViewModel programSchedule)
{
var parameters = new DialogParameters { { "ProgramScheduleId", programSchedule.Id } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<CopyScheduleDialog>("Copy Schedule", parameters, options);
DialogResult dialogResult = await dialog.Result;
if (dialogResult is { Canceled: false, Data: ProgramScheduleViewModel data })
{
NavigationManager.NavigateTo($"schedules/{data.Id}/items");
}
}
private async Task<TableData<ProgramScheduleViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()), cancellationToken);
PagedProgramSchedulesViewModel data = await Mediator.Send(new GetPagedProgramSchedules(_searchString, state.Page, state.PageSize), cancellationToken);
return new TableData<ProgramScheduleViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private async Task<TableData<ProgramScheduleItemViewModel>> DetailServerReload(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString()), cancellationToken);
List<ProgramScheduleItemViewModel> scheduleItems = await Mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id), cancellationToken);
IOrderedEnumerable<ProgramScheduleItemViewModel> sorted = scheduleItems.OrderBy(s => s.Index);
// TODO: properly page this data
return new TableData<ProgramScheduleItemViewModel>
{
TotalItems = scheduleItems.Count,
Items = sorted.Skip(state.Page * state.PageSize).Take(state.PageSize)
};
}
private void OnSearch(string query)
{
_selectedSchedule = null;
_searchString = query;
_table.ReloadServerData();
}
private async Task Troubleshoot(ProgramScheduleViewModel schedule)
{
Option<IEnumerable<ProgramScheduleItemViewModel>> maybeResults = await Mediator.Send(new GetProgramScheduleItems(schedule.Id), _cts.Token);
foreach (IEnumerable<ProgramScheduleItemViewModel> results in maybeResults)
{
var sorted = results.OrderBy(i => i.Index).ToList();
var parameters = new DialogParameters { { "Schedule", schedule }, { "Items", sorted } };
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
IDialogReference dialog = await Dialog.ShowAsync<ScheduleItemsDialog>(schedule.Name, parameters, options);
DialogResult _ = await dialog.Result;
}
}
private string SelectedRowClassFunc(ProgramScheduleViewModel element, int rowNumber) => _selectedSchedule != null && _selectedSchedule == element ? "selected" : string.Empty;
}
-121
View File
@@ -1,121 +0,0 @@
@page "/playouts/scripted/{Id:int}"
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Playouts
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker EntityLocker;
@inject ILogger<ScriptedPlayoutEditor> Logger
<MudForm 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="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
Save Scripted Schedule
</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">@_channelName - Scripted Schedule</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>Scripted Schedule</MudText>
</div>
@if (_playout is not null)
{
<MudTextField @bind-Value="@_playout.ScheduleFile" For="@(() => _playout.ScheduleFile)"/>
}
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
</div>
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItems(eraseHistory: true))" StartIcon="@Icons.Material.Filled.Delete">
Erase Items and History
</MudButton>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private PlayoutNameViewModel _playout;
[Parameter]
public int Id { get; set; }
private string _channelName;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<string> maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), token);
if (maybeName.IsNone)
{
NavigationManager.NavigateTo("playouts");
return;
}
foreach (string name in maybeName)
{
_channelName = name;
}
Option<PlayoutNameViewModel> maybePlayout = await Mediator.Send(new GetPlayoutById(Id), token);
foreach (PlayoutNameViewModel playout in maybePlayout)
{
_playout = playout;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task EraseItems(bool eraseHistory)
{
IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id);
await Mediator.Send(request, _cts.Token);
string message = eraseHistory ? "Erased playout items and history" : "Erased playout items";
Snackbar.Add(message, Severity.Info);
}
private async Task SaveChanges()
{
if (_playout is null)
{
return;
}
Either<BaseError, PlayoutNameViewModel> result =
await Mediator.Send(new UpdateScriptedPlayout(_playout.PlayoutId, _playout.ScheduleFile), _cts.Token);
result.Match(
_ => { Snackbar.Add($"Saved scripted schedule for playout {_channelName}", Severity.Success); },
error =>
{
Snackbar.Add($"Unexpected error saving scripted schedule: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving scripted schedule: {Error}", error.Value);
});
}
}
-974
View File
@@ -1,974 +0,0 @@
@page "/search"
@inherits MultiSelectBase<Search>
@inject NavigationManager NavigationManager
@inject PersistentComponentState ApplicationState
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@implements IDisposable
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
@if (IsSelectMode())
{
<div class="flex-grow-1">
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
</div>
<div style="margin-left: auto" class="d-none d-md-flex">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@(_ => AddSelectionToPlaylist())">
Add To Playlist
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add To Collection" OnClick="@AddSelectionToCollection"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add To Playlist" OnClick="AddSelectionToPlaylist"/>
<MudMenuItem Icon="@Icons.Material.Filled.Check" Label="Clear Selection" OnClick="ClearSelection"/>
</MudMenu>
</div>
}
else
{
<div style="align-items: center; display: flex; width: 100%" class="d-none d-md-flex">
<MudText>@_query</MudText>
@if (_movies?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
}
@if (_shows?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
@if (_seasons?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")" Style="margin-bottom: auto; margin-top: auto">@_seasons.Count Seasons</MudLink>
}
@if (_episodes?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
}
@if (_artists?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
}
@if (_musicVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
@if (_otherVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#other_videos")" Style="margin-bottom: auto; margin-top: auto">@_otherVideos.Count Other Videos</MudLink>
}
@if (_songs?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#songs")" Style="margin-bottom: auto; margin-top: auto">@_songs.Count Songs</MudLink>
}
@if (_images?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink>
}
@if (_remoteStreams?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")" Style="margin-bottom: auto; margin-top: auto">@_remoteStreams.Count Remote Streams</MudLink>
}
<div class="flex-grow-1 d-none d-md-flex"></div>
<div>
<MudTooltip Text="Add All To Collection">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddAllToCollection">
Add All
</MudButton>
</MudTooltip>
<MudTooltip Text="Add All To Playlist">
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@AddAllToPlaylist">
Add All
</MudButton>
</MudTooltip>
<MudTooltip Text="Save As Smart Collection">
<MudButton Class="ml-3" Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsSmartCollection">
Save As
</MudButton>
</MudTooltip>
</div>
</div>
<div style="align-items: center; display: flex; width: 100%" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add All To Collection" OnClick="@AddAllToCollection"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add All To Playlist" OnClick="AddAllToPlaylist"/>
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save As Smart Collection" OnClick="SaveAsSmartCollection"/>
</MudMenu>
</div>
}
</div>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@if (_movies?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
@if (_movies.Count > 50)
{
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_shows?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_seasons?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons
</MudText>
@if (_seasons.Count > 50)
{
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_episodes?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
@if (_episodes.Count > 50)
{
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
AddToCollectionClicked="@AddToCollection"
Href="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_artists?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href="@($"media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_images?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Images
</MudText>
@if (_images.Count > 50)
{
<MudLink Href="@GetImagesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (ImageCardViewModel card in _images.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
@if (_remoteStreams?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
Remote Streams
</MudText>
@if (_remoteStreams.Count > 50)
{
<MudLink Href="@GetRemoteStreamsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (RemoteStreamCardViewModel card in _remoteStreams.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
</MudContainer>
</div>
</MudForm>
@code {
private string _query;
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private TelevisionSeasonCardResultsViewModel _seasons;
private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images;
private RemoteStreamCardResultsViewModel _remoteStreams;
private ArtistCardResultsViewModel _artists;
private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync()
{
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
_query = NavigationManager.Uri.GetSearchQuery();
if (!string.IsNullOrWhiteSpace(_query))
{
if (!ApplicationState.TryTakeFromJson("_movies", out MovieCardResultsViewModel restoredMovies))
{
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50), CancellationToken);
}
else
{
_movies = restoredMovies;
}
if (!ApplicationState.TryTakeFromJson("_shows", out TelevisionShowCardResultsViewModel restoredShows))
{
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50), CancellationToken);
}
else
{
_shows = restoredShows;
}
if (!ApplicationState.TryTakeFromJson("_seasons", out TelevisionSeasonCardResultsViewModel restoredSeasons))
{
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50), CancellationToken);
}
else
{
_seasons = restoredSeasons;
}
if (!ApplicationState.TryTakeFromJson("_episodes", out TelevisionEpisodeCardResultsViewModel restoredEpisodes))
{
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50), CancellationToken);
}
else
{
_episodes = restoredEpisodes;
}
if (!ApplicationState.TryTakeFromJson("_musicVideos", out MusicVideoCardResultsViewModel restoredMusicVideos))
{
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50), CancellationToken);
}
else
{
_musicVideos = restoredMusicVideos;
}
if (!ApplicationState.TryTakeFromJson("_otherVideos", out OtherVideoCardResultsViewModel restoredOtherVideos))
{
_otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50), CancellationToken);
}
else
{
_otherVideos = restoredOtherVideos;
}
if (!ApplicationState.TryTakeFromJson("_songs", out SongCardResultsViewModel restoredSongs))
{
_songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50), CancellationToken);
}
else
{
_songs = restoredSongs;
}
if (!ApplicationState.TryTakeFromJson("_images", out ImageCardResultsViewModel restoredImages))
{
_images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), CancellationToken);
}
else
{
_images = restoredImages;
}
if (!ApplicationState.TryTakeFromJson("_remoteStreams", out RemoteStreamCardResultsViewModel restoredRemoteStreams))
{
_remoteStreams = await Mediator.Send(new QuerySearchIndexRemoteStreams($"type:remote_stream AND ({_query})", 1, 50), CancellationToken);
}
else
{
_remoteStreams = restoredRemoteStreams;
}
if (!ApplicationState.TryTakeFromJson("_artists", out ArtistCardResultsViewModel restoredArtists))
{
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken);
}
else
{
_artists = restoredArtists;
}
}
}
private Task PersistData()
{
ApplicationState.PersistAsJson("_movies", _movies);
ApplicationState.PersistAsJson("_shows", _shows);
ApplicationState.PersistAsJson("_seasons", _seasons);
ApplicationState.PersistAsJson("_episodes", _episodes);
ApplicationState.PersistAsJson("_musicVideos", _musicVideos);
ApplicationState.PersistAsJson("_otherVideos", _otherVideos);
ApplicationState.PersistAsJson("_songs", _songs);
ApplicationState.PersistAsJson("_images", _images);
ApplicationState.PersistAsJson("_remoteStreams", _remoteStreams);
ApplicationState.PersistAsJson("_artists", _artists);
return Task.CompletedTask;
}
void IDisposable.Dispose()
{
_persistingSubscription.Dispose();
base.Dispose();
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_seasons.Cards.OrderBy(s => s.SortTitle))
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.Append(_images.Cards.OrderBy(ov => ov.SortTitle))
.Append(_remoteStreams.Cards.OrderBy(ov => ov.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is MovieCardViewModel movie)
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMovieToCollection(collection.Id, movie.MovieId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}");
Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is TelevisionShowCardViewModel show)
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddShowToCollection(collection.Id, show.TelevisionShowId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding show to collection: {error.Value}");
Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{season.Title} Season {season.TelevisionSeasonNumber}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}");
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} Season {season.TelevisionSeasonNumber} to collection {collection.Name}", Severity.Success));
}
}
if (card is ArtistCardViewModel artist)
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddArtistToCollection(collection.Id, artist.ArtistId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}");
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is OtherVideoCardViewModel otherVideo)
{
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is SongCardViewModel song)
{
var parameters = new DialogParameters { { "EntityType", "song" }, { "EntityName", song.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSongToCollection(collection.Id, song.SongId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding song to collection: {error.Value}");
Logger.LogError("Unexpected error adding song to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {song.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is ImageCardViewModel image)
{
var parameters = new DialogParameters { { "EntityType", "image" }, { "EntityName", image.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddImageToCollection(collection.Id, image.ImageId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding image to collection: {error.Value}");
Logger.LogError("Unexpected error adding image to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is RemoteStreamCardViewModel remoteStream)
{
var parameters = new DialogParameters { { "EntityType", "remote stream" }, { "EntityName", remoteStream.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMediaItemToCollection(collection.Id, remoteStream.RemoteStreamId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding remote stream to collection: {error.Value}");
Logger.LogError("Unexpected error adding remote stream to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {remoteStream.Title} to collection {collection.Name}", Severity.Success));
}
}
}
private string GetMoviesLink()
{
var uri = "media/movies/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetShowsLink()
{
var uri = "media/tv/shows/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetSeasonsLink()
{
var uri = "media/tv/seasons/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetEpisodesLink()
{
var uri = "media/tv/episodes/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetArtistsLink()
{
var uri = "media/music/artists/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetMusicVideosLink()
{
var uri = "media/music/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetOtherVideosLink()
{
var uri = "media/other/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetSongsLink()
{
var uri = "media/music/songs/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
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 string GetRemoteStreamsLink()
{
var uri = "media/remote/streams/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private async Task AddAllToCollection(MouseEventArgs _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
await AddItemsToCollection(
results.MovieIds,
results.ShowIds,
results.SeasonIds,
results.EpisodeIds,
results.ArtistIds,
results.MusicVideoIds,
results.OtherVideoIds,
results.SongIds,
results.ImageIds,
results.RemoteStreamIds,
"search results");
}
private async Task AddAllToPlaylist(MouseEventArgs _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
await AddItemsToPlaylist(
results.MovieIds,
results.ShowIds,
results.SeasonIds,
results.EpisodeIds,
results.ArtistIds,
results.MusicVideoIds,
results.OtherVideoIds,
results.SongIds,
results.ImageIds,
results.RemoteStreamIds,
"search results");
}
private async Task SaveAsSmartCollection(MouseEventArgs _)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
var parameters = new DialogParameters<SaveAsSmartCollectionDialog>
{
{ x => x.Query, _query }
};
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("Save As Smart Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: Tuple<SmartCollectionViewModel, bool> collectionResult })
{
// Item2 => update is needed
if (collectionResult.Item2)
{
var request = new UpdateSmartCollection(
collectionResult.Item1.Id,
collectionResult.Item1.Name,
_query);
Either<BaseError, UpdateSmartCollectionResult> updateResult = await Mediator.Send(request, CancellationToken);
updateResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error saving smart collection: {error.Value}");
Logger.LogError("Unexpected error saving smart collection: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add(
$"Saved smart collection {collectionResult.Item1.Name}",
Severity.Success);
ClearSelection();
});
}
}
}
}
@@ -1,121 +0,0 @@
@page "/playouts/sequential/{Id:int}"
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Playouts
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker EntityLocker;
@inject ILogger<SequentialPlayoutEditor> Logger
<MudForm 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="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
Save Sequential Schedule
</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">@_channelName - Sequential Schedule</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>Sequential Schedule</MudText>
</div>
@if (_playout is not null)
{
<MudTextField @bind-Value="@_playout.ScheduleFile" For="@(() => _playout.ScheduleFile)"/>
}
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
</div>
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItems(eraseHistory: true))" StartIcon="@Icons.Material.Filled.Delete">
Erase Items and History
</MudButton>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private PlayoutNameViewModel _playout;
[Parameter]
public int Id { get; set; }
private string _channelName;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<string> maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), token);
if (maybeName.IsNone)
{
NavigationManager.NavigateTo("playouts");
return;
}
foreach (string name in maybeName)
{
_channelName = name;
}
Option<PlayoutNameViewModel> maybePlayout = await Mediator.Send(new GetPlayoutById(Id), token);
foreach (PlayoutNameViewModel playout in maybePlayout)
{
_playout = playout;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task EraseItems(bool eraseHistory)
{
IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id);
await Mediator.Send(request, _cts.Token);
string message = eraseHistory ? "Erased playout items and history" : "Erased playout items";
Snackbar.Add(message, Severity.Info);
}
private async Task SaveChanges()
{
if (_playout is null)
{
return;
}
Either<BaseError, PlayoutNameViewModel> result =
await Mediator.Send(new UpdateSequentialPlayout(_playout.PlayoutId, _playout.ScheduleFile), _cts.Token);
result.Match(
_ => { Snackbar.Add($"Saved sequential schedule for playout {_channelName}", Severity.Success); },
error =>
{
Snackbar.Add($"Unexpected error saving sequential schedule: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving sequential schedule: {Error}", error.Value);
});
}
}
@@ -1,293 +0,0 @@
@page "/settings/ffmpeg"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.Core.FFmpeg
@using ErsatzTV.FFmpeg.OutputFormat
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<FFmpegSettings> Logger
@inject IDialogService Dialog
<MudForm @ref="_form" @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" OnClick="@(_ => SaveFFmpegSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">FFmpeg</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>FFmpeg Path</MudText>
</div>
<MudTextField @bind-Value="_ffmpegSettings.FFmpegPath" HelperText="The full path to the ffmpeg executable file" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>FFprobe Path</MudText>
</div>
<MudTextField @bind-Value="_ffmpegSettings.FFprobePath" HelperText="The full path to the ffprobe executable file" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Default FFmpeg Profile</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" HelperText="The FFmpeg Profile to use when creating new channels">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preferred Audio Language</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.PreferredAudioLanguageCode" Required="true" RequiredError="Preferred Language Code is required!">
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Use Embedded Subtitles</MudText>
</div>
<MudCheckBox @bind-Value="_ffmpegSettings.UseEmbeddedSubtitles" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Extract Embedded (Text) Subtitles</MudText>
</div>
<MudCheckBox @bind-Value="_ffmpegSettings.ExtractEmbeddedSubtitles" Disabled="@(_ffmpegSettings.UseEmbeddedSubtitles == false)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Probe For Interlaced Frames</MudText>
</div>
<MudCheckBox @bind-Value="_ffmpegSettings.ProbeForInterlacedFrames" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Save Troubleshooting Reports To Disk</MudText>
</div>
<MudCheckBox @bind-Value="_ffmpegSettings.SaveReports" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Global Watermark</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.GlobalWatermarkId" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Global Fallback Filler</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.GlobalFallbackFillerId" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
{
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>HLS Segmenter Idle Timeout</MudText>
</div>
<MudTextField @bind-Value="_ffmpegSettings.HlsSegmenterIdleTimeout" Validation="@(new Func<int, string>(ValidateHlsSegmenterIdleTimeout))" Required="true" RequiredError="HLS Segmenter idle timeout is required!" Adornment="Adornment.End" AdornmentText="seconds" HelperText="The number of seconds to continue transcoding a channel while no requests have been received from any client"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Work-Ahead HLS Segmenter Limit</MudText>
</div>
<MudTextField @bind-Value="_ffmpegSettings.WorkAheadSegmenterLimit" Validation="@(new Func<int, string>(ValidateWorkAheadSegmenterLimit))" Required="true" RequiredError="Work-ahead HLS Segmenter limit is required!" HelperText="The number of segmenters (channels) that will work-ahead (transcode at maximum speed) simultaneously, if multiple channels are being watched"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>HLS Segmenter Initial Segment Count</MudText>
</div>
<MudTextField @bind-Value="_ffmpegSettings.InitialSegmentCount" Validation="@(new Func<int, string>(ValidateInitialSegmentCount))" Required="true" RequiredError="HLS Segmenter initial segment count is required!" HelperText="Delays stream start until the specified number of (4-second) segments have been transcoded; a larger number will mean slower initial playback, but potentially less buffering"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>HLS Direct Output Format</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.HlsDirectOutputFormat" Clearable="true">
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.MpegTs">MPEG-TS</MudSelectItem>
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mp4">MP4</MudSelectItem>
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mkv">MKV</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Default MPEG-TS Script</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.DefaultMpegTsScript" HelperText="The MPEG-TS script to use when streaming">
@foreach (MpegTsScript script in _mpegTsScripts)
{
<MudSelectItem Value="@script.Id">@script.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudText Typo="Typo.h5" Class="mb-2 mt-10">Custom Resolutions</MudText>
<MudDivider Class="mb-6"/>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" StartIcon="@Icons.Material.Filled.Add">
Add Custom Resolution
</MudButton>
<MudTable Hover="true" Items="_customResolutions" Dense="true" Class="mt-6 mb-12" Style="max-width: 800px">
<ColGroup>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Resolution">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteCustomResolution(context))">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudForm _form;
private bool _success;
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private FFmpegSettingsViewModel _ffmpegSettings = new();
private List<LanguageCodeViewModel> _availableCultures = [];
private List<WatermarkViewModel> _watermarks = [];
private List<FillerPresetViewModel> _fillerPresets = [];
private List<ResolutionViewModel> _customResolutions = [];
private readonly List<MpegTsScript> _mpegTsScripts = [];
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), token);
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), token);
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), token);
_watermarks = await Mediator.Send(new GetAllWatermarks(), token);
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), token)
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList());
_mpegTsScripts.Clear();
_mpegTsScripts.AddRange(await Mediator.Send(new GetMpegTsScripts(), token));
await RefreshCustomResolutions(token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
private static string ValidateHlsSegmenterIdleTimeout(int idleTimeout) => idleTimeout < 30 ? "HLS Segmenter idle timeout must be greater than or equal to 30" : null;
private static string ValidateWorkAheadSegmenterLimit(int limit) => limit < 0 ? "Work-Ahead HLS Segmenter limit must be greater than or equal to 0" : null;
private static string ValidateInitialSegmentCount(int count) => count < 1 ? "HLS Segmenter initial segment count must be greater than or equal to 1" : null;
private async Task SaveFFmpegSettings()
{
await _form.Validate();
if (_success)
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings), _cts.Token);
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success);
_success = false;
});
}
}
private async Task RefreshCustomResolutions(CancellationToken cancellationToken) =>
_customResolutions = await Mediator.Send(new GetAllResolutions(), cancellationToken)
.Map(list => list.Filter(r => r.IsCustom).ToList());
private async Task AddCustomResolution()
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = await Dialog.ShowAsync<AddCustomResolutionDialog>("Add Custom Resolution", options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ResolutionEditViewModel resolution })
{
Option<BaseError> saveResult = await Mediator.Send(
new CreateCustomResolution(resolution.Width, resolution.Height),
_cts.Token);
foreach (BaseError error in saveResult)
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding custom resolution: {Error}", error.Value);
}
if (saveResult.IsNone)
{
await RefreshCustomResolutions(_cts.Token);
}
}
}
private async Task DeleteCustomResolution(ResolutionViewModel resolution)
{
Option<BaseError> result = await Mediator.Send(new DeleteCustomResolution(resolution.Id), _cts.Token);
foreach (BaseError error in result)
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error deleting custom resolution: {Error}", error.Value);
}
if (result.IsNone)
{
await RefreshCustomResolutions(_cts.Token);
}
}
}
@@ -1,86 +0,0 @@
@page "/settings/hdhr"
@using ErsatzTV.Annotations
@using ErsatzTV.Application.HDHR
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<HDHRSettings> Logger
<MudForm @ref="_form" @bind-IsValid="@_hdhrSuccess" 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" OnClick="@(_ => SaveHDHRSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">HDHomeRun</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>UUID</MudText>
</div>
<MudTextField @bind-Value="_uuid" Disabled="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Tuner Count</MudText>
</div>
<MudTextField @bind-Value="_tunerCount" Validation="@(new Func<int, string>(ValidateTunerCount))" Required="true" RequiredError="Tuner count is required!"/>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudForm _form;
private bool _hdhrSuccess;
private int _tunerCount;
[UsedImplicitly]
private Guid _uuid;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_tunerCount = await Mediator.Send(new GetHDHRTunerCount(), token);
_uuid = await Mediator.Send(new GetHDHRUUID(), token);
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private static string ValidateTunerCount(int tunerCount) => tunerCount <= 0 ? "Tuner count must be greater than zero" : null;
private async Task SaveHDHRSettings()
{
await _form.Validate();
if (_hdhrSuccess)
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateHDHRTunerCount(_tunerCount), _cts.Token);
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved HDHomeRun settings", Severity.Success));
}
}
}
@@ -1,127 +0,0 @@
@page "/settings/logging"
@using ErsatzTV.Application.Configuration
@using Serilog.Events
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<LoggingSettings> Logger
<MudForm 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" OnClick="@(_ => SaveLoggingSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">Logging</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>Default Minimum Log Level</MudText>
</div>
<MudSelect @bind-Value="_loggingSettings.DefaultMinimumLogLevel">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Scanning Minimum Log Level</MudText>
</div>
<MudSelect @bind-Value="_loggingSettings.ScanningMinimumLogLevel">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Scheduling Minimum Log Level</MudText>
</div>
<MudSelect @bind-Value="_loggingSettings.SchedulingMinimumLogLevel">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Searching Minimum Log Level</MudText>
</div>
<MudSelect @bind-Value="_loggingSettings.SearchingMinimumLogLevel">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Streaming Minimum Log Level</MudText>
</div>
<MudSelect @bind-Value="_loggingSettings.StreamingMinimumLogLevel">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Request Logging Minimum Log Level</MudText>
</div>
<MudSelect @bind-Value="_loggingSettings.HttpMinimumLogLevel">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private LoggingSettingsViewModel _loggingSettings = new();
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_loggingSettings = await Mediator.Send(new GetLoggingSettings(), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task SaveLoggingSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateLoggingSettings(_loggingSettings), _cts.Token);
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving logging settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved logging settings", Severity.Success));
}
}
@@ -1,91 +0,0 @@
@page "/settings/playout"
@using ErsatzTV.Application.Configuration
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<PlayoutSettings> Logger
<MudForm @ref="_form" @bind-IsValid="@_playoutSuccess" 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" OnClick="@(_ => SavePlayoutSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">Playout</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>Days To Build</MudText>
</div>
<MudTextField @bind-Value="_playoutSettings.DaysToBuild" Validation="@(new Func<int, string>(ValidatePlayoutDaysToBuild))" Required="true" RequiredError="Playout days to build is required!" Adornment="Adornment.End" AdornmentText="days"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Skip Missing Items</MudText>
</div>
<MudCheckBox @bind-Value="_playoutSettings.SkipMissingItems" Dense="true">
<MudText Typo="Typo.caption" Style="font-weight: normal">Controls whether file-not-found or unavailable items should be included in playouts</MudText>
</MudCheckBox>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Scripted Schedule Timeout</MudText>
</div>
<MudTextField @bind-Value="_playoutSettings.ScriptedScheduleTimeoutSeconds" Validation="@(new Func<int, string>(ValidateScriptedScheduleTimeoutSeconds))" Required="true" RequiredError="Scripted schedule timeout is required!" Adornment="Adornment.End" AdornmentText="seconds"/>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudForm _form;
private bool _playoutSuccess;
private PlayoutSettingsViewModel _playoutSettings = new();
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_playoutSettings = await Mediator.Send(new GetPlayoutSettings(), token);
_playoutSuccess = _playoutSettings.DaysToBuild > 0 && _playoutSettings.ScriptedScheduleTimeoutSeconds >= 10;
}
catch (OperationCanceledException)
{
// do nothing
}
}
private static string ValidatePlayoutDaysToBuild(int daysToBuild) => daysToBuild <= 0 ? "Playout days to build must be greater than zero" : null;
private static string ValidateScriptedScheduleTimeoutSeconds(int seconds) => seconds < 10 ? "Scripted schedule timeout seconds must be at least 10" : null;
private async Task SavePlayoutSettings()
{
await _form.Validate();
if (_playoutSuccess)
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdatePlayoutSettings(_playoutSettings), _cts.Token);
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving playout settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved playout settings", Severity.Success));
}
}
}
@@ -1,80 +0,0 @@
@page "/settings/scanner"
@using ErsatzTV.Application.Configuration
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<ScannerSettings> Logger
<MudForm @ref="_form" @bind-IsValid="@_scannerSuccess" 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" OnClick="@(_ => SaveScannerSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">Scanner</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>Library Refresh Interval</MudText>
</div>
<MudTextField @bind-Value="_libraryRefreshInterval" Validation="@(new Func<int, string>(ValidateLibraryRefreshInterval))" Required="true" RequiredError="Library refresh interval is required!" Adornment="Adornment.End" AdornmentText="hours"/>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudForm _form;
private bool _scannerSuccess;
private int _libraryRefreshInterval;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_libraryRefreshInterval = await Mediator.Send(new GetLibraryRefreshInterval(), token);
_scannerSuccess = _libraryRefreshInterval is >= 0 and < 1_000_000;
}
catch (OperationCanceledException)
{
// do nothing
}
}
private static string ValidateLibraryRefreshInterval(int libraryRefreshInterval) => libraryRefreshInterval switch
{
<= -1 => "Library refresh interval must be 0 (do not refresh) or greater than zero",
>= 1_000_000 => "Library refresh interval must be less than 1,000,000. Use 0 to disable automatic refresh",
_ => null
};
private async Task SaveScannerSettings()
{
await _form.Validate();
if (_scannerSuccess)
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateLibraryRefreshInterval(_libraryRefreshInterval), _cts.Token);
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving scanner settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved scanner settings", Severity.Success));
}
}
}
-86
View File
@@ -1,86 +0,0 @@
@page "/settings/ui"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Core.Notifications
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@inject ILogger<UISettings> Logger
<MudForm 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" OnClick="@(_ => SaveUiSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">UI</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>Theme</MudText>
</div>
<MudSelect @bind-Value="_uiSettings.IsDarkMode">
<MudSelectItem Value="@true">Dark</MudSelectItem>
<MudSelectItem Value="@false">Light</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Language</MudText>
</div>
<MudSelect @bind-Value="_uiSettings.Language">
@foreach (Localization.CultureLanguage cultureLanguage in Localization.SupportedLanguages)
{
<MudSelectItem Value="@cultureLanguage.Culture">@cultureLanguage.Language</MudSelectItem>
}
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private UiSettingsViewModel _uiSettings = new();
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_uiSettings = await Mediator.Send(new GetUiSettings(), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task SaveUiSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateUiSettings(_uiSettings), _cts.Token);
foreach (var error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving ui settings: {Error}", error.Value);
}
if (result.IsRight)
{
NavigationManager.Refresh(true);
}
}
}
@@ -1,86 +0,0 @@
@page "/settings/xmltv"
@using ErsatzTV.Application.Configuration
@implements IDisposable
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<XMLTVSettings> Logger
<MudForm 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" OnClick="@(_ => SaveXmltvSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</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">XMLTV</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>Days To Build</MudText>
</div>
<MudTextField @bind-Value="_xmltvSettings.DaysToBuild" Validation="@(new Func<int, string>(ValidateXmltvDaysToBuild))" Required="true" RequiredError="XMLTV days to build is required!" Adornment="Adornment.End" AdornmentText="days"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>XMLTV Time Zone</MudText>
</div>
<MudSelect @bind-Value="_xmltvSettings.TimeZone">
<MudSelectItem Value="@XmltvTimeZone.Local">Local</MudSelectItem>
<MudSelectItem Value="@XmltvTimeZone.Utc">UTC</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>XMLTV Block Behavior</MudText>
</div>
<MudSelect @bind-Value="_xmltvSettings.BlockBehavior">
<MudSelectItem Value="@XmltvBlockBehavior.SplitTimeEvenly">Split Time Evenly</MudSelectItem>
<MudSelectItem Value="@XmltvBlockBehavior.UseActualTimes">Use Actual Times</MudSelectItem>
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private XmltvSettingsViewModel _xmltvSettings = new();
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_xmltvSettings = await Mediator.Send(new GetXmltvSettings(), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private static string ValidateXmltvDaysToBuild(int daysToBuild) => daysToBuild <= 0 ? "XMLTV days to build must be greater than zero" : null;
private async Task SaveXmltvSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateXmltvSettings(_xmltvSettings), _cts.Token);
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving xmltv settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved xmltv settings", Severity.Success));
}
}
@@ -1,84 +0,0 @@
@page "/media/smart-collections/{Id:int}/edit"
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<CollectionEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm @ref="_form" @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="@HandleSubmitAsync" StartIcon="@Icons.Material.Filled.Save">
Save Smart Collection
</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">Smart Collection</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="Smart collection name is required!"/>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly SmartCollectionEditViewModel _model = new();
private MudForm _form;
private bool _success;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<SmartCollectionViewModel> maybeSmartCollection = await Mediator.Send(new GetSmartCollectionById(Id), token);
foreach (SmartCollectionViewModel smartCollection in maybeSmartCollection)
{
_model.Id = smartCollection.Id;
_model.Name = smartCollection.Name;
_model.Query = smartCollection.Query;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (_success)
{
Either<BaseError, UpdateSmartCollectionResult> result = await Mediator.Send(new UpdateSmartCollection(Id, _model.Name, _model.Query), _cts.Token);
result.Match(
_ => NavigationManager.NavigateTo("media/smart-collections"),
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error saving smart collection: {Error}", error.Value);
});
}
}
}
-127
View File
@@ -1,127 +0,0 @@
@page "/media/smart-collections"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Extensions
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<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">Smart Collections</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_smartCollectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<SmartCollectionViewModel>>>(ServerReloadSmartCollections))"
Dense="true"
@ref="_smartCollectionsTable">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for smart collections"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Smart Collection">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"/media/smart-collections/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Search Smart Collection">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Href="@context.Query.GetRelativeSearchQuery()">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Smart Collection">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteSmartCollection(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
<div class="mt-6"></div>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private MudTable<SmartCollectionViewModel> _smartCollectionsTable;
private int _smartCollectionsRowsPerPage = 10;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_smartCollectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SmartCollectionsPageSize), token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task DeleteSmartCollection(SmartCollectionViewModel collection)
{
var parameters = new DialogParameters { { "EntityType", "smart collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Smart Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
await Mediator.Send(new DeleteSmartCollection(collection.Id), _cts.Token);
if (_smartCollectionsTable != null)
{
await _smartCollectionsTable.ReloadServerData();
}
}
}
private async Task<TableData<SmartCollectionViewModel>> ServerReloadSmartCollections(TableState state, CancellationToken cancellationToken)
{
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SmartCollectionsPageSize, state.PageSize.ToString()), cancellationToken);
PagedSmartCollectionsViewModel data = await Mediator.Send(new GetPagedSmartCollections(_searchString, state.Page, state.PageSize), cancellationToken);
return new TableData<SmartCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private void OnSearch(string query)
{
_searchString = query;
_smartCollectionsTable.ReloadServerData();
}
}
-134
View File
@@ -1,134 +0,0 @@
@page "/media/music/songs"
@page "/media/music/songs/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<SongList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="SongCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/music/songs"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private SongCardResultsViewModel _data = new(0, new List<SongCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:song" : $"type:song AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexSongs(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/music/songs/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/music/songs/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is SongCardViewModel song)
{
var parameters = new DialogParameters { { "EntityType", "song" }, { "EntityName", song.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSongToCollection(collection.Id, song.SongId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding song to collection: {error.Value}");
Logger.LogError("Unexpected error adding song to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {song.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-345
View File
@@ -1,345 +0,0 @@
@page "/media/tv/seasons/{SeasonId:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.Television
@using ErsatzTV.Extensions
@implements IDisposable
@inject IMediator Mediator
@inject ILogger<TelevisionEpisodeList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_season?.FanArt))
{
if (_season.FanArt.StartsWith("http://") || _season.FanArt.StartsWith("https://"))
{
<img src="@_season.FanArt" alt="fan art"/>
}
else
{
<img src="@($"artwork/fanart/{_season.FanArt}")" alt="fan art"/>
}
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
@if (!string.IsNullOrWhiteSpace(_season?.Poster))
{
<MudImage Src="@GetPosterUrl(_season.Poster)" Elevation="2" Class="rounded-lg z-10" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
}
<MudStack Row="false" Style="flex: 1" Class="z-10">
<MudStack Row="false">
<MudLink Href="@($"media/tv/shows/{_season?.ShowId}")">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_season?.Title</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_season?.Title</MudText>
</MudHidden>
</MudLink>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_season?.Name</MudText>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@AddToPlaylist">
Add To Playlist
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
</MudStack>
</MudStack>
</MudStack>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="false" Spacing="6">
@foreach (TelevisionEpisodeCardViewModel episode in _data.Cards)
{
<MudCard>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" id="@($"episode-{episode.EpisodeId}")">
@if (!string.IsNullOrWhiteSpace(episode.Poster))
{
<div style="display: flex; flex-direction: column; position: relative">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudImage Src="@GetThumbnailUrl(episode.Poster)" Style="max-height: 220px; max-width: 265px; margin-left: auto; margin-right: auto;"/>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudImage Src="@GetThumbnailUrl(episode.Poster)" Style="max-height: 220px; max-width: 392px; margin-left: auto; margin-right: auto;"/>
</MudHidden>
@if (episode.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (episode.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4" Class="mb-6">@episode.Episode. @episode.Title</MudText>
<MudText Class="d-none d-md-flex" Style="flex-grow: 1">@episode.Plot</MudText>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown">
<MudMenu Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Label="Add To ...">
<MudMenuItem OnClick="@(_ => AddEpisodeToCollection(episode))" Icon="@Icons.Material.Filled.Add">
Add To Collection
</MudMenuItem>
<MudMenuItem OnClick="@(_ => AddEpisodeToPlaylist(episode))" Icon="@Icons.Material.Filled.PlaylistAdd">
Add To Playlist
</MudMenuItem>
</MudMenu>
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Info"
OnClick="@(_ => ShowInfo(episode))">
Show Media Info
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Troubleshoot"
Href="@($"system/troubleshooting/playback?mediaItem={episode.EpisodeId}")">
Troubleshoot Playback
</MudButton>
</MudStack>
</div>
</MudCardContent>
</MudStack>
<div class="pl-3 pt-3">
@if (episode.State == MediaItemState.FileNotFound)
{
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@episode.Path</MudText>
</div>
}
else if (episode.State == MediaItemState.Unavailable)
{
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
<MudText>Unavailable:&nbsp;</MudText>
<MudText>@episode.LocalPath</MudText>
</div>
}
<div class="d-none d-md-flex" style="flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
</div>
@if (episode.Directors.Any())
{
var sorted = episode.Directors.OrderBy(w => w).ToList();
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Directors:&nbsp;</MudText>
<MudLink Href="@(@$"director:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
@foreach (string director in sorted.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
}
</div>
}
@if (episode.Writers.Any())
{
var sorted = episode.Writers.OrderBy(w => w).ToList();
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Writers:&nbsp;</MudText>
<MudLink Href="@(@$"writer:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
@foreach (string writer in sorted.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
}
</div>
}
</div>
</MudCard>
}
</MudStack>
</MudContainer>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int SeasonId { get; set; }
private TelevisionSeasonViewModel _season;
private static int PageSize => 100;
private const int PageNumber = 1;
private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), null);
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await NavigationManager.NavigateToFragmentAsync(JsRuntime);
}
catch (Exception)
{
// ignored
}
}
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await Mediator.Send(new GetTelevisionSeasonById(SeasonId), token).IfSomeAsync(vm => _season = vm);
_data = await Mediator.Send(new GetTelevisionEpisodeCards(SeasonId, PageNumber, PageSize), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task AddToCollection()
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
await Mediator.Send(new AddSeasonToCollection(collection.Id, SeasonId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
}
}
private async Task AddToPlaylist()
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{
await Mediator.Send(new AddSeasonToPlaylist(playlist.Id, SeasonId), _cts.Token);
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
}
}
private async Task AddToSchedule()
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.TelevisionSeason, SeasonId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}
private async Task AddEpisodeToCollection(TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
private async Task AddEpisodeToPlaylist(TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{
var request = new AddEpisodeToPlaylist(playlist.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to playlist: {error.Value}");
Logger.LogError("Unexpected error adding episode to playlist: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to playlist {playlist.Name}", Severity.Success));
}
}
private async Task ShowInfo(TelevisionEpisodeCardViewModel episode)
{
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(episode.EpisodeId));
foreach (BaseError error in maybeInfo.LeftToSeq())
{
Snackbar.Add("Unexpected error loading media info");
Logger.LogError("Unexpected error loading media info: {Error}", error.Value);
}
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
{
var parameters = new DialogParameters { { "MediaItemInfo", info } };
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
IDialogReference dialog = await Dialog.ShowAsync<MediaItemInfoDialog>(
$"{episode.ShowTitle} - s{episode.Season:00}e{episode.Episode:00} - {episode.Title}",
parameters,
options);
DialogResult _ = await dialog.Result;
}
}
private static string GetPosterUrl(string poster) => poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
private static string GetThumbnailUrl(string poster) => poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/thumbnails/{poster}";
}
-323
View File
@@ -1,323 +0,0 @@
@page "/media/tv/shows/{ShowId:int}"
@using System.Globalization
@using ErsatzTV.Application.Libraries
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.Television
@using ErsatzTV.Extensions
@implements IDisposable
@inject IMediator Mediator
@inject ILogger<TelevisionSeasonList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_show?.FanArt))
{
if (_show.FanArt.StartsWith("http://") || _show.FanArt.StartsWith("https://"))
{
<img src="@_show.FanArt" alt="fan art"/>
}
else
{
<img src="@($"artwork/fanart/{_show.FanArt}")" alt="fan art"/>
}
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
<MudImage Src="@GetPosterUrl(_show?.Poster)" Elevation="2" Class="rounded-lg z-10" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
<MudStack Row="false" Style="flex: 1" Class="z-10">
<MudStack Row="false">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_show?.Title</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_show?.Title</MudText>
</MudHidden>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_show?.Year</MudText>
</MudStack>
@if (!string.IsNullOrWhiteSpace(_show?.Plot))
{
<MudCard Elevation="2" Class="mb-6">
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
</MudCardContent>
</MudCard>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled" Color="Color.Primary">Add To</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Label="Collection" Icon="@Icons.Material.Filled.Add" OnClick="@AddToCollection"/>
<MudMenuItem Label="Playlist" Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="@AddToPlaylist"/>
<MudMenuItem Label="Schedule" Icon="@Icons.Material.Filled.Schedule" OnClick="@AddToSchedule"/>
</ChildContent>
</MudMenu>
@if (_show?.MediaSourceKind is MediaSourceKind.Plex or MediaSourceKind.Jellyfin or MediaSourceKind.Emby)
{
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Color="Color.Primary">Scan</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Label="Quick Scan" Icon="@Icons.Material.Filled.Search" OnClick="@(_ => ScanShow(false))"/>
<MudMenuItem Label="Deep Scan" Icon="@Icons.Material.Filled.ManageSearch" OnClick="@(_ => ScanShow(true))"/>
</ChildContent>
</MudMenu>
}
</MudStack>
</MudStack>
</MudStack>
<MudCard Class="mt-6 mb-6">
<MudCardContent>
@if (_sortedContentRatings.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Content Ratings:&nbsp;</MudText>
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
@foreach (string contentRating in _sortedContentRatings.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
}
</div>
}
@if (_sortedLanguages.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
}
</div>
}
@if (_sortedStudios.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
}
</div>
}
@if (_sortedNetworks.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Networks:&nbsp;</MudText>
<MudLink Href="@(@$"network:""{_sortedNetworks.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedNetworks.Head()</MudLink>
@foreach (string network in _sortedNetworks.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"network:""{network.ToLowerInvariant()}""".GetRelativeSearchQuery())">@network</MudLink>
}
</div>
}
@if (_sortedGenres.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@if (_sortedTags.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Seasons</MudText>
<MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
{
<MediaCard Data="@card" Placeholder="@card.Placeholder"
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
AddToCollectionClicked="@AddSeasonToCollection"/>
}
</MudStack>
@if ((_show?.Actors?.Count ?? 0) > 0)
{
<MudText Class="mb-4">Actors</MudText>
<MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (ActorCardViewModel actor in _show.Actors)
{
<MediaCard Data="@actor"
Href="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudStack>
}
</MudContainer>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int ShowId { get; set; }
private TelevisionShowViewModel _show;
private List<string> _sortedContentRatings = [];
private List<CultureInfo> _sortedLanguages = [];
private List<string> _sortedStudios = [];
private List<string> _sortedNetworks = [];
private List<string> _sortedGenres = [];
private List<string> _sortedTags = [];
private static int PageSize => 100;
private const int PageNumber = 1;
private TelevisionSeasonCardResultsViewModel _data = new(0, [], null);
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<TelevisionShowViewModel> maybeShow = await Mediator.Send(new GetTelevisionShowById(ShowId), token);
foreach (TelevisionShowViewModel show in maybeShow)
{
_show = show;
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
_sortedNetworks = _show.Networks.OrderBy(n => n).ToList();
}
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, PageNumber, PageSize), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task AddToCollection()
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
await Mediator.Send(new AddShowToCollection(collection.Id, ShowId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
}
}
private async Task AddToPlaylist()
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{
await Mediator.Send(new AddShowToPlaylist(playlist.Id, ShowId), _cts.Token);
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
}
}
private async Task AddToSchedule()
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.TelevisionShow, ShowId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}
private async Task AddSeasonToCollection(MediaCardViewModel card)
{
if (card is TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
}
}
}
private static string GetPosterUrl(string poster)
{
if (string.IsNullOrWhiteSpace(poster))
{
return poster;
}
return poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
}
private async Task ScanShow(bool deepScan)
{
QueueShowScanResult result =
await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
if (result != QueueShowScanResult.Queued)
{
Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error);
}
else
{
Snackbar.Add($"Done scanning show {_show.Title}", Severity.Success);
StateHasChanged();
}
}
}
@@ -1,156 +0,0 @@
@page "/media/tv/seasons"
@page "/media/tv/seasons/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<TelevisionShowList>
@inject NavigationManager NavigationManager
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<FragmentLetterAnchor TCard="TelevisionSeasonCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href="@($"media/tv/seasons/{context.TelevisionSeasonId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/tv/seasons"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private TelevisionSeasonCardResultsViewModel _data = new(0, new List<TelevisionSeasonCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData(CancellationToken);
}
protected override async Task RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:season" : $"type:season AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexSeasons(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/tv/seasons/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/tv/seasons/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}");
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-169
View File
@@ -1,169 +0,0 @@
@page "/media/tv/shows"
@page "/media/tv/shows/page/{PageNumber:int}"
@inherits MultiSelectBase<TelevisionShowList>
@inject NavigationManager NavigationManager
@inject PersistentComponentState ApplicationState
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@implements IDisposable
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="TelevisionShowCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href="@($"media/tv/shows/{context.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/tv/shows"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private TelevisionShowCardResultsViewModel _data = new(0, new List<TelevisionShowCardViewModel>(), null);
private string _query;
private PersistingComponentStateSubscription _persistingSubscription;
protected override Task OnInitializedAsync()
{
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
_query = NavigationManager.Uri.GetSearchQuery();
return base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
try
{
if (!ApplicationState.TryTakeFromJson("_data", out TelevisionShowCardResultsViewModel restored))
{
_data = await RefreshData(CancellationToken);
}
else
{
_data = restored;
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private Task PersistData()
{
ApplicationState.PersistAsJson("_data", _data);
return Task.CompletedTask;
}
void IDisposable.Dispose()
{
_persistingSubscription.Dispose();
base.Dispose();
}
protected override async Task<TelevisionShowCardResultsViewModel> RefreshData(CancellationToken cancellationToken)
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:show" : $"type:show AND ({_query})";
return await Mediator.Send(new QuerySearchIndexShows(searchQuery, PageNumber, PageSize), cancellationToken);
}
private void PrevPage()
{
var uri = $"media/tv/shows/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/tv/shows/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is TelevisionShowCardViewModel show)
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddShowToCollection(collection.Id, show.TelevisionShowId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding show to collection: {error.Value}");
Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
-326
View File
@@ -1,326 +0,0 @@
@page "/templates/{Id:int}"
@using System.Globalization
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<TemplateEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm 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="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
Save Template
</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">General</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>Template Group Name</MudText>
</div>
<MudTextField @bind-Value="_template.GroupName" Disabled="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Template Name</MudText>
</div>
<MudTextField @bind-Value="_template.Name" For="@(() => _template.Name)"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Add Content</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>Block Group</MudText>
</div>
<MudSelect T="string" ValueChanged="@(name => UpdateBlockGroupItems(name))">
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
{
<MudSelectItem Value="@blockGroup.Name">@blockGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Block</MudText>
</div>
<MudSelect T="string" ValueChanged="@(name => UpdateSelectedBlock(name))">
@foreach (BlockViewModel block in _blocks)
{
<MudSelectItem Value="@block.Name">@block.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Start Time On Or After</MudText>
</div>
<MudSelect T="DateTime" @bind-value="_selectedBlockStart">
@foreach (DateTime startTime in _startTimes)
{
<MudSelectItem Value="@startTime">
@startTime.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern)
</MudSelectItem>
}
</MudSelect>
</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.Primary" OnClick="@(_ => AddBlockToTemplate())" Disabled="@(_selectedBlock is null)" StartIcon="@Icons.Material.Filled.Add">
Add Block To Template
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Remove Content</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>Block To Remove</MudText>
</div>
<MudSelect T="TemplateItemEditViewModel" @bind-Value="_blockToRemove">
<MudSelectItem Value="@((TemplateItemEditViewModel)null)">(none)</MudSelectItem>
@foreach (TemplateItemEditViewModel item in _template.Items.OrderBy(i => i.Start))
{
<MudSelectItem Value="@item">@item.Start.ToShortTimeString() - @item.Text</MudSelectItem>
}
</MudSelect>
</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.Primary" OnClick="@(_ => RemoveBlockFromTemplate())" Disabled="@(_blockToRemove is null)" StartIcon="@Icons.Material.Filled.Remove">
Remove Block From Template
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Content</MudText>
<MudDivider Class="mb-6"/>
<MudCalendar @ref="_calendar"
T="CalendarItem"
Class="mb-6"
Items="@_template.Items"
ShowMonth="false"
ShowWeek="false"
ShowPrevNextButtons="false"
ShowDatePicker="false"
ShowTodayButton="false"
DayTimeInterval="CalendarTimeInterval.Minutes10"
Use24HourClock="@(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains("H"))"
EnableDragItems="true"
EnableResizeItems="false"
ItemChanged="@(ci => CalendarItemChanged(ci))"/>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<BlockGroupViewModel> _blockGroups = [];
private readonly List<BlockViewModel> _blocks = [];
private readonly List<DateTime> _startTimes = [];
[Parameter]
public int Id { get; set; }
private TemplateItemsEditViewModel _template = new();
private TemplateItemEditViewModel _blockToRemove;
private BlockGroupViewModel _selectedBlockGroup;
private BlockViewModel _selectedBlock;
private DateTime _selectedBlockStart;
private MudCalendar<CalendarItem> _calendar;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await LoadTemplateItems(token);
DateTime start = DateTime.Today;
_selectedBlockStart = start;
while (start.Date == DateTime.Today.Date)
{
_startTimes.Add(start);
start = start.AddMinutes(5);
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task LoadTemplateItems(CancellationToken cancellationToken)
{
Option<TemplateViewModel> maybeTemplate = await Mediator.Send(new GetTemplateById(Id), cancellationToken);
if (maybeTemplate.IsNone)
{
NavigationManager.NavigateTo("templates");
return;
}
foreach (TemplateViewModel template in maybeTemplate)
{
_template = new TemplateItemsEditViewModel
{
GroupId = template.TemplateGroupId,
GroupName = template.GroupName,
Name = template.Name
};
}
Option<IEnumerable<TemplateItemViewModel>> maybeResults = await Mediator.Send(new GetTemplateItems(Id), cancellationToken);
foreach (IEnumerable<TemplateItemViewModel> items in maybeResults)
{
_template.Items.AddRange(items.Map(ProjectToEditViewModel));
}
_blockGroups.AddRange(await Mediator.Send(new GetAllBlockGroups(), cancellationToken));
}
private static TemplateItemEditViewModel ProjectToEditViewModel(TemplateItemViewModel item) =>
new()
{
BlockId = item.BlockId,
BlockName = item.BlockName,
Start = item.StartTime,
End = item.EndTime
};
private async Task UpdateBlockGroupItems(string blockGroupName)
{
Option<BlockGroupViewModel> maybeBlockGroup = Optional(_blockGroups.Find(bg => bg.Name == blockGroupName));
foreach (var blockGroup in maybeBlockGroup)
{
_selectedBlockGroup = blockGroup;
_blocks.Clear();
_blocks.AddRange(await Mediator.Send(new GetBlocksByBlockGroupId(_selectedBlockGroup.Id), _cts.Token));
}
}
private void UpdateSelectedBlock(string blockName)
{
_selectedBlock = _blocks.Find(bg => bg.Name == blockName);
InvokeAsync(StateHasChanged);
}
private void AddBlockToTemplate()
{
// find first time where this block will fit
DateTime maybeStart = _selectedBlockStart;
while (maybeStart.Date == DateTime.Today)
{
DateTime maybeEnd = maybeStart.AddMinutes(_selectedBlock.Minutes);
if (!IntersectsOthers(null, maybeStart, maybeEnd))
{
var item = new TemplateItemEditViewModel
{
BlockId = _selectedBlock.Id,
BlockName = _selectedBlock.Name,
Start = maybeStart,
End = maybeEnd,
LastStart = maybeStart,
LastEnd = maybeEnd
};
_template.Items.Add(item);
InvokeAsync(() => { _calendar.ScrollToTime(TimeOnly.FromDateTime(item.Start)); });
break;
}
maybeStart = maybeStart.AddMinutes(5);
}
}
private async Task RemoveBlockFromTemplate()
{
if (_blockToRemove is not null)
{
_template.Items.Remove(_blockToRemove);
await InvokeAsync(() => { _calendar.ScrollToTime(TimeOnly.FromDateTime(_blockToRemove.Start)); });
_blockToRemove = null;
await InvokeAsync(StateHasChanged);
}
}
private void CalendarItemChanged(CalendarItem calendarItem)
{
// don't allow any overlap
if (calendarItem is TemplateItemEditViewModel item)
{
bool intersects = item.End.HasValue && IntersectsOthers(item, item.Start, item.End.Value);
bool crossesMidnight = item.End.HasValue && item.End.Value.TimeOfDay > TimeSpan.Zero && item.End.Value.TimeOfDay < item.Start.TimeOfDay;
if (intersects || crossesMidnight)
{
// roll back
item.Start = item.LastStart;
item.End = item.LastEnd;
}
else
{
// commit
item.LastStart = item.Start;
item.LastEnd = item.End;
}
}
}
private bool IntersectsOthers(TemplateItemEditViewModel item, DateTime start, DateTime end)
{
var willFit = true;
foreach (TemplateItemEditViewModel existing in _template.Items)
{
if (existing == item)
{
continue;
}
if (start < existing.End && existing.Start < end)
{
willFit = false;
break;
}
}
return !willFit;
}
// private void RemoveTemplateItem(TemplateItemEditViewModel item)
// {
// _selectedItem = null;
// _template.Items.Remove(item);
// }
private async Task SaveChanges()
{
var items = _template.Items.Map(item => new ReplaceTemplateItem(item.BlockId, item.Start.TimeOfDay)).ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new ReplaceTemplateItems(_template.GroupId, Id, _template.Name, items), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving template: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving template: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("templates"));
}
}
-309
View File
@@ -1,309 +0,0 @@
@page "/templates"
@using ErsatzTV.Application.Scheduling
@implements IDisposable
@inject ILogger<Templates> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<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">Template Groups</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>Template Group Name</MudText>
</div>
<MudTextField @bind-Value="_templateGroupName" For="@(() => _templateGroupName)"/>
</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.Primary" OnClick="@(_ => AddTemplateGroup())" StartIcon="@Icons.Material.Filled.Add">
Add Template Group
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Templates</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>Template Group</MudText>
</div>
<MudSelect T="string" ValueChanged="@UpdateSelectedTemplateGroup">
@foreach (TemplateGroupViewModel templateGroup in _templateGroups)
{
<MudSelectItem Value="@templateGroup.Name">@templateGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Template Name</MudText>
</div>
<MudTextField @bind-Value="_templateName" For="@(() => _templateName)"/>
</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.Primary" OnClick="@(_ => AddTemplate())" StartIcon="@Icons.Material.Filled.Add">
Add Template
</MudButton>
</MudStack>
<MudTable Hover="true"
Dense="true"
Class="mt-8"
Items="@_templates"
GroupBy="@_groupDefinition"
GroupHeaderStyle="background-color:var(--mud-palette-appbarbackground)"
RowStyle="background-color:var(--mud-palette-background-gray)"
Filter="new Func<TemplateViewModel,bool>(FilterTemplates)">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col style="width: 60px;"/>
<col/>
<col style="width: 180px;"/>
</MudHidden>
</ColGroup>
<ToolBarContent>
<MudTextField T="string"
ValueChanged="@(s => OnSearch(s))"
Placeholder="Search for templates"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterList"
Clearable="true">
</MudTextField>
</ToolBarContent>
<GroupHeaderTemplate>
<MudTd Class="mud-table-cell-custom-group">
@($"{context.Key}")
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<div style="width: 48px;"></div>
<div style="width: 48px;"></div>
<MudTooltip Text="Delete Template Group">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteTemplateGroup(context.Items?.FirstOrDefault()))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</GroupHeaderTemplate>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>
<div class="d-flex">
@if (context.Id >= 0)
{
<MudTooltip Text="Edit Template">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"templates/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Copy Template">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyTemplate(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Template">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteTemplate(context))">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="height: 48px; width: 48px"></div>
}
</div>
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
private readonly List<TemplateViewModel> _templates = [];
private List<TemplateGroupViewModel> _templateGroups = [];
private TemplateGroupViewModel _selectedTemplateGroup;
private string _templateGroupName;
private string _templateName;
private string _searchString;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), token);
_templates.Clear();
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), token));
}
catch (OperationCanceledException)
{
// do nothing
}
}
private readonly TableGroupDefinition<TemplateViewModel> _groupDefinition = new()
{
GroupName = "Group",
Indentation = false,
Expandable = true,
Selector = (e) => e.GroupName
};
private async Task AddTemplateGroup()
{
if (!string.IsNullOrWhiteSpace(_templateGroupName))
{
Either<BaseError, TemplateGroupViewModel> result = await Mediator.Send(new CreateTemplateGroup(_templateGroupName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding template group: {Error}", error.Value);
}
foreach (TemplateGroupViewModel templateGroup in result.RightToSeq())
{
_templateGroupName = null;
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), _cts.Token);
_selectedTemplateGroup = _templateGroups.Find(tg => tg.Id == templateGroup.Id);
_templates.Clear();
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
await InvokeAsync(StateHasChanged);
}
}
}
private void UpdateSelectedTemplateGroup(string templateGroupName)
{
_selectedTemplateGroup = _templateGroups.Find(tg => tg.Name == templateGroupName);
InvokeAsync(StateHasChanged);
}
private async Task AddTemplate()
{
if (_selectedTemplateGroup is not null && !string.IsNullOrWhiteSpace(_templateName))
{
Either<BaseError, TemplateViewModel> result = await Mediator.Send(new CreateTemplate(_selectedTemplateGroup.Id, _templateName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding template: {Error}", error.Value);
}
if (result.IsRight)
{
_templates.Clear();
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
_templateName = null;
await InvokeAsync(StateHasChanged);
}
}
}
private void OnSearch(string query)
{
_searchString = query;
}
private bool FilterTemplates(TemplateViewModel template) => FilterTemplates(template, _searchString);
private bool FilterTemplates(TemplateViewModel template, string searchString)
{
if (string.IsNullOrWhiteSpace(searchString))
{
return true;
}
if (template.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private async Task DeleteTemplateGroup(TemplateViewModel template)
{
if (template is null)
{
return;
}
var parameters = new DialogParameters { { "EntityType", "template group" }, { "EntityName", template.GroupName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Template Group", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteTemplateGroup(template.TemplateGroupId), _cts.Token);
if (_selectedTemplateGroup?.Id == template.TemplateGroupId)
{
_selectedTemplateGroup = null;
}
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), _cts.Token);
_templates.Clear();
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
await InvokeAsync(StateHasChanged);
}
}
private async Task CopyTemplate(TemplateViewModel template)
{
var parameters = new DialogParameters { { "TemplateId", template.Id } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<CopyTemplateDialog>("Copy Template", parameters, options);
DialogResult dialogResult = await dialog.Result;
if (dialogResult is { Canceled: false, Data: TemplateViewModel data })
{
NavigationManager.NavigateTo($"templates/{data.Id}");
}
}
private async Task DeleteTemplate(TemplateViewModel template)
{
if (template is null)
{
return;
}
var parameters = new DialogParameters { { "EntityType", "template" }, { "EntityName", template.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Template", parameters, options);
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeleteTemplate(template.Id), _cts.Token);
_templates.Clear();
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
await InvokeAsync(StateHasChanged);
}
}
}

Some files were not shown because too many files have changed in this diff Show More