Merge Phase 18.6 Track B (CMS Opus status: backfill badge + Post-Processing) into streaming-overhaul
This commit is contained in:
@@ -249,6 +249,40 @@ public class TrackController : ControllerBase
|
|||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET api/track/opus-status ([ApiKeyAuthorize])
|
||||||
|
// Admin Post-Processing view (18.6): returns every track with a flag for whether it carries a COMPLETE
|
||||||
|
// Opus artifact — both the Opus audio AND the seek/setup sidecar present (TrackFormatResolver.HasOpusAsync,
|
||||||
|
// the same completeness rule the 18.5 Backfill-Opus pass enqueues against; a half-derived track counts as
|
||||||
|
// missing). Mirrors GET waveform-status exactly: same ApiKey auth, same unpaged whole-catalogue shape, same
|
||||||
|
// literal-route placement before "{trackId}". The CMS reads it to show the Backfill-Opus "missing N" badge
|
||||||
|
// and to poll per-track Post-Processing status after an upload.
|
||||||
|
[ApiKeyAuthorize]
|
||||||
|
[HttpGet("opus-status")]
|
||||||
|
public async Task<ActionResult> GetOpusStatus()
|
||||||
|
{
|
||||||
|
var tracks = await _sqlTrackService.GetAll();
|
||||||
|
if (!tracks.Success || tracks.Value is null)
|
||||||
|
{
|
||||||
|
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
_logger.LogError("GetOpusStatus failed to load tracks: {Error}", error);
|
||||||
|
return StatusCode(500, "Failed to load tracks");
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = new List<OpusStatusDto>(tracks.Value.Count);
|
||||||
|
foreach (var track in tracks.Value)
|
||||||
|
{
|
||||||
|
status.Add(new OpusStatusDto
|
||||||
|
{
|
||||||
|
TrackId = track.Id,
|
||||||
|
EntryKey = track.EntryKey,
|
||||||
|
TrackName = track.TrackName,
|
||||||
|
HasOpus = await _formatResolver.HasOpusAsync(track.EntryKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
|
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
|
||||||
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
|
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
|
||||||
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
|
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
|
||||||
|
|||||||
@@ -518,7 +518,10 @@
|
|||||||
{
|
{
|
||||||
var row = _tracks[i];
|
var row = _tracks[i];
|
||||||
|
|
||||||
if (row.Status == BatchRowStatus.Done)
|
// Skip rows already processed in a prior submit attempt. PostProcessing counts as processed:
|
||||||
|
// the track persisted successfully (only its background Opus derive is still settling), so a
|
||||||
|
// re-submit after a partial failure must NOT re-upload it and mint a duplicate.
|
||||||
|
if (row.Status is BatchRowStatus.Done or BatchRowStatus.PostProcessing)
|
||||||
{
|
{
|
||||||
_processedCount++;
|
_processedCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -638,7 +641,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = BatchRowStatus.Done;
|
// §3.1a: a new-track upload persists the track (live + lossless) and the server
|
||||||
|
// derives Opus in the background, so the row enters the visible Post-Processing
|
||||||
|
// phase — same as BatchUpload. (The metadata-only update path above stays Done: it
|
||||||
|
// changes no audio, so it triggers no transcode.) Non-blocking; the Releases view
|
||||||
|
// polls the durable Opus status to settle it.
|
||||||
|
row.Status = BatchRowStatus.PostProcessing;
|
||||||
succeeded++;
|
succeeded++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,9 @@ public class BatchRowModel
|
|||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
|
// Done is the terminal success state (track persisted + playable losslessly). PostProcessing is the
|
||||||
|
// visible third upload phase (§3.1a): the byte transfer and server persist are finished and the track is
|
||||||
|
// live, but the server-side background Opus transcode is still running. It is NOT a failure and never
|
||||||
|
// blocks completion — the form may navigate away while a row sits in PostProcessing; the Releases browse
|
||||||
|
// view polls the durable Opus status from there.
|
||||||
|
public enum BatchRowStatus { Queued, Uploading, PostProcessing, Done, Failed }
|
||||||
|
|||||||
@@ -126,6 +126,8 @@
|
|||||||
{
|
{
|
||||||
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
||||||
|
BatchRowStatus.PostProcessing => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||||
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Post-Processing</MudChip>,
|
||||||
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
||||||
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
||||||
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
||||||
|
|||||||
@@ -69,6 +69,15 @@
|
|||||||
Value="@_tracks[0].UploadPercent"
|
Value="@_tracks[0].UploadPercent"
|
||||||
aria-label="Uploading track" />
|
aria-label="Uploading track" />
|
||||||
}
|
}
|
||||||
|
else if (_tracks[0].Status == BatchRowStatus.PostProcessing)
|
||||||
|
{
|
||||||
|
@* §3.1a: track is live + plays lossless; the Opus transcode runs in the background.
|
||||||
|
Indeterminate (no client-side progress for a server-side job) and non-blocking. *@
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||||
|
<MudText Typo="Typo.caption">Post-Processing (deriving Opus)…</MudText>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -519,7 +528,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = BatchRowStatus.Done;
|
// §3.1a: the byte transfer + server persist are done and the track is live and plays
|
||||||
|
// losslessly — the upload is successful HERE. The server then derives Opus on a
|
||||||
|
// background worker, so the row enters the visible Post-Processing phase rather than
|
||||||
|
// jumping straight to Done. This never blocks: the loop continues and the form may
|
||||||
|
// navigate away while rows sit in Post-Processing; the Releases view polls the durable
|
||||||
|
// Opus status to settle each one.
|
||||||
|
row.Status = BatchRowStatus.PostProcessing;
|
||||||
succeeded++;
|
succeeded++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject ILogger<Releases> Logger
|
@inject ILogger<Releases> Logger
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@implements IDisposable
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>Releases — Deep DRFT Management</PageTitle>
|
<PageTitle>Releases — Deep DRFT Management</PageTitle>
|
||||||
@@ -51,11 +52,13 @@
|
|||||||
<span>Backfill High-res (@MissingHighResCount)</span>
|
<span>Backfill High-res (@MissingHighResCount)</span>
|
||||||
}
|
}
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@* Backfill-Opus (Phase 18.5). Unlike the two waveform buttons, the Opus derive runs on a
|
@* Backfill-Opus (Phase 18.5 + 18.6 badge). The Opus derive runs on a server-side background
|
||||||
server-side background worker: the API decides which tracks lack Opus and enqueues them, so
|
worker: pressing the button enqueues every track lacking a complete Opus artifact and reports
|
||||||
there is no client-side "missing N" count to gate on and no per-track progress to render — the
|
the (enqueued / skipped) outcome. The "missing N" badge (18.6) reads the same opus-status map
|
||||||
action schedules the work and reports the (enqueued / skipped) outcome. Re-runnable: a second
|
the page polls, giving visual parity with the two waveform backfill buttons — but unlike them,
|
||||||
press only enqueues tracks still missing Opus. Disabled while a press is in flight. *@
|
the count is informational, not a per-track client loop (the work is scheduled, not driven from
|
||||||
|
here). The button stays pressable at N=0 (a no-op re-run is harmless); it only disables while a
|
||||||
|
press is in flight or another bulk run holds the page. *@
|
||||||
<MudButton Variant="Variant.Outlined"
|
<MudButton Variant="Variant.Outlined"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||||
@@ -68,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Backfill Opus</span>
|
<span>Backfill Opus (@MissingOpusCount)</span>
|
||||||
}
|
}
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
@@ -160,6 +163,22 @@
|
|||||||
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
|
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
|
||||||
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
|
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
|
||||||
|
|
||||||
|
// EntryKey → HasOpus (a complete audio+sidecar derive). Loaded alongside the waveform status on init and
|
||||||
|
// re-read after a Backfill-Opus run so the "missing N" badge settles. Also the source the Post-Processing
|
||||||
|
// poll watches: a freshly uploaded track lands here with HasOpus=false and flips to true once the
|
||||||
|
// server-side background transcode finishes — the durable surface for the upload meter's Post-Processing
|
||||||
|
// phase after the form returns the admin to this view (§3.1a).
|
||||||
|
private IReadOnlyList<OpusStatusDto> _opusStatus = Array.Empty<OpusStatusDto>();
|
||||||
|
|
||||||
|
private int MissingOpusCount => _opusStatus.Count(s => !s.HasOpus);
|
||||||
|
|
||||||
|
// Post-Processing poll: while any track is still missing Opus (a transcode in flight or not yet
|
||||||
|
// backfilled), re-read the opus-status map on an interval so the "missing N" badge and any per-track
|
||||||
|
// Post-Processing indicator settle without a manual refresh. Stops itself once nothing is missing, and is
|
||||||
|
// torn down on dispose. Non-blocking — it never gates an upload or a button; it only refreshes a count.
|
||||||
|
private const int OpusPollIntervalMs = 4000;
|
||||||
|
private CancellationTokenSource? _opusPollCts;
|
||||||
|
|
||||||
// Local state for the parent-owned "Generate All Profiles" bulk run.
|
// Local state for the parent-owned "Generate All Profiles" bulk run.
|
||||||
private bool _bulkRunning;
|
private bool _bulkRunning;
|
||||||
private int _bulkTotal;
|
private int _bulkTotal;
|
||||||
@@ -195,9 +214,81 @@
|
|||||||
_waveformStatus = result.Success && result.Value is not null
|
_waveformStatus = result.Success && result.Value is not null
|
||||||
? result.Value
|
? result.Value
|
||||||
: Array.Empty<WaveformStatusDto>();
|
: Array.Empty<WaveformStatusDto>();
|
||||||
|
|
||||||
|
await RefreshOpusStatusAsync();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-read the per-track Opus derive status and (re)arm the Post-Processing poll when work is still
|
||||||
|
/// pending. Called on init, after a Backfill-Opus run, and from the poll itself. Best-effort: a failed
|
||||||
|
/// fetch leaves the previous map in place rather than zeroing the badge on a transient API blip. Does not
|
||||||
|
/// call StateHasChanged itself — callers batch it with their own render (the poll path renders explicitly).
|
||||||
|
/// </summary>
|
||||||
|
private async Task RefreshOpusStatusAsync()
|
||||||
|
{
|
||||||
|
var opusResult = await CmsTrackService.GetOpusStatusAsync();
|
||||||
|
if (opusResult.Success && opusResult.Value is not null)
|
||||||
|
{
|
||||||
|
_opusStatus = opusResult.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MissingOpusCount > 0)
|
||||||
|
{
|
||||||
|
EnsureOpusPollRunning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the Post-Processing poll if it is not already running. The loop re-reads opus-status every
|
||||||
|
// OpusPollIntervalMs and renders; it exits as soon as nothing is missing (or on cancel/dispose). Guarded
|
||||||
|
// by a non-null CTS so overlapping callers (init + backfill) cannot start two loops.
|
||||||
|
private void EnsureOpusPollRunning()
|
||||||
|
{
|
||||||
|
if (_opusPollCts is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_opusPollCts = new CancellationTokenSource();
|
||||||
|
_ = PollOpusStatusAsync(_opusPollCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollOpusStatusAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested && MissingOpusCount > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(OpusPollIntervalMs, ct);
|
||||||
|
|
||||||
|
var result = await CmsTrackService.GetOpusStatusAsync();
|
||||||
|
if (result.Success && result.Value is not null)
|
||||||
|
{
|
||||||
|
_opusStatus = result.Value;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on dispose / navigation away — nothing to do.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Opus Post-Processing poll stopped on an unexpected error.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_opusPollCts?.Dispose();
|
||||||
|
_opusPollCts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_opusPollCts?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidates the cached per-track waveform status on all embedded grids so the next row expand
|
// Invalidates the cached per-track waveform status on all embedded grids so the next row expand
|
||||||
// re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows
|
// re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows
|
||||||
// reflect the new waveform state on the next expand interaction.
|
// reflect the new waveform state on the next expand interaction.
|
||||||
@@ -338,6 +429,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-read the status map so the "missing N" badge reflects the just-enqueued work and the
|
||||||
|
// Post-Processing poll arms to watch the transcodes settle from N→0 as each finishes.
|
||||||
|
await RefreshOpusStatusAsync();
|
||||||
|
|
||||||
var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped);
|
var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped);
|
||||||
if (enqueued == 0)
|
if (enqueued == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -804,6 +804,50 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ResultContainer<OpusStatusDto[]>> GetOpusStatusAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await client.GetAsync("api/track/opus-status", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Content API call failed for Opus status");
|
||||||
|
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Content API is unreachable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Content API Opus status failed: {Status}", (int)response.StatusCode);
|
||||||
|
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Failed to load Opus status.");
|
||||||
|
}
|
||||||
|
|
||||||
|
OpusStatusDto[]? status;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status = await response.Content.ReadFromJsonAsync<OpusStatusDto[]>(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deserialize Opus status from Content API response");
|
||||||
|
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Content API returned a null Opus status list");
|
||||||
|
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Content API returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResultContainer<OpusStatusDto[]>.CreatePassResult(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ public interface ICmsTrackService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<ResultContainer<OpusBackfillResult>> BackfillOpusAsync(CancellationToken ct = default);
|
Task<ResultContainer<OpusBackfillResult>> BackfillOpusAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch per-track Opus derive status from <c>GET api/track/opus-status</c> (Phase 18.6) for the CMS
|
||||||
|
/// Post-Processing surfaces. Unpaged — the admin catalogue is small. Each row's <c>HasOpus</c> is true only
|
||||||
|
/// when the track carries a complete Opus artifact (audio + sidecar). Drives the Backfill-Opus "missing N"
|
||||||
|
/// badge and the post-upload Post-Processing poll. Idempotent read — safe to poll on an interval.
|
||||||
|
/// </summary>
|
||||||
|
Task<ResultContainer<OpusStatusDto[]>> GetOpusStatusAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace DeepDrftModels.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-track Opus derive status for the CMS Post-Processing surfaces (Phase 18.6). Mirrors
|
||||||
|
/// <see cref="WaveformStatusDto"/>: one row per track, flagging whether the track already carries a
|
||||||
|
/// <strong>complete</strong> Opus artifact. "Complete" means BOTH the Opus audio bytes AND the seek/setup
|
||||||
|
/// sidecar are present in the <c>track-opus</c> vault — a half-derived track (audio without sidecar) is
|
||||||
|
/// unseekable and counts as missing, so the Backfill-Opus pass re-derives it. <see cref="EntryKey"/> is the
|
||||||
|
/// vault key the per-track enqueue trigger and the polling Post-Processing affordance key on.
|
||||||
|
/// </summary>
|
||||||
|
public class OpusStatusDto
|
||||||
|
{
|
||||||
|
public long TrackId { get; set; }
|
||||||
|
public string EntryKey { get; set; } = string.Empty;
|
||||||
|
public string TrackName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>True only when both the Opus audio and the seek/setup sidecar are stored (a complete derive).</summary>
|
||||||
|
public bool HasOpus { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user