mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-08-19 12:32:34 -04:00
fix(playlists): queue durable rematching and project legacy tracks
This commit is contained in:
@@ -152,6 +152,16 @@ public sealed class InjectedPlaylistTrackActionsContractTests
|
||||
Assert.Contains("await API.refreshPlaylists();", script, StringComparison.Ordinal);
|
||||
Assert.Contains("await API.matchAllPlaylists();", script, StringComparison.Ordinal);
|
||||
Assert.Contains("Refreshed and rematched", script, StringComparison.Ordinal);
|
||||
Assert.Contains("Playlist rematching queued. Progress appears in the operation center.", script, StringComparison.Ordinal);
|
||||
|
||||
var controller = File.ReadAllText(FindRepositoryFile("allstarr", "Controllers", "PlaylistController.cs"));
|
||||
Assert.Contains("DurableJobQueue jobs", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("\"playlist.match-all\"", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("return Accepted", controller, StringComparison.Ordinal);
|
||||
|
||||
var matcher = File.ReadAllText(FindRepositoryFile("allstarr", "Services", "Spotify", "SpotifyTrackMatchingService.cs"));
|
||||
Assert.Contains("LegacyPlaylistMatchAllJobHandler", matcher, StringComparison.Ordinal);
|
||||
Assert.Contains("IDurableJobHandler", matcher, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -172,10 +182,11 @@ public sealed class InjectedPlaylistTrackActionsContractTests
|
||||
Assert.Contains("backendItemId", script, StringComparison.Ordinal);
|
||||
Assert.Contains("Open mapping details for", script, StringComparison.Ordinal);
|
||||
Assert.Contains("data-testid=\"track-details-dialog\"", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Playback</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Identifiers</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Match history</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Activity</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<small>Playback</small>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Current route</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Known services</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("Technical history", script, StringComparison.Ordinal);
|
||||
Assert.Contains("<h4>Recent activity</h4>", script, StringComparison.Ordinal);
|
||||
Assert.Contains("compact-track-details", script, StringComparison.Ordinal);
|
||||
Assert.Contains("track-details-dialog redesigned-dialog", script, StringComparison.Ordinal);
|
||||
Assert.Contains("track-details-scroll", script, StringComparison.Ordinal);
|
||||
|
||||
@@ -47,6 +47,8 @@ public sealed class LegacyMappingReadinessContractTests
|
||||
Assert.Contains("LibraryTracks", projector, StringComparison.Ordinal);
|
||||
Assert.Contains("ProjectAllAsync", projector, StringComparison.Ordinal);
|
||||
Assert.Contains("ProjectSourceTracksAsync", projector, StringComparison.Ordinal);
|
||||
Assert.Contains("ProjectConfiguredSourceTracksAsync", projector, StringComparison.Ordinal);
|
||||
Assert.Contains("playlistFetcher.GetPlaylistTracksAsync", projector, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -11,6 +11,7 @@ using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Core.Jobs;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -2462,7 +2463,9 @@ public class PlaylistController : ControllerBase
|
||||
/// Trigger track matching for all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/match-all")]
|
||||
public async Task<IActionResult> MatchAllPlaylistTracks()
|
||||
public async Task<IActionResult> MatchAllPlaylistTracks(
|
||||
[FromServices] DurableJobQueue jobs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
|
||||
@@ -2471,16 +2474,41 @@ public class PlaylistController : ControllerBase
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
if (!TrySession(out var session, out var error)) return error!;
|
||||
var generation = DateTimeOffset.UtcNow.UtcTicks;
|
||||
var receipt = await jobs.EnqueueAsync(new DurableJobEnqueueRequest<LegacyPlaylistMatchAllJobPayload>(
|
||||
"playlist.match-all",
|
||||
$"playlist-match-all:{session!.TenantId:N}:{generation / TimeSpan.TicksPerMinute}",
|
||||
new(generation),
|
||||
session.TenantId,
|
||||
session.AllstarrUserId,
|
||||
CorrelationId: HttpContext.TraceIdentifier), cancellationToken);
|
||||
return Accepted(new
|
||||
{
|
||||
await _matchingService.TriggerMatchingAsync();
|
||||
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
message = receipt.Created ? "Playlist rematching queued" : "Playlist rematching is already queued",
|
||||
jobId = receipt.JobId,
|
||||
created = receipt.Created,
|
||||
generation
|
||||
});
|
||||
}
|
||||
|
||||
private bool TrySession(out AdminAuthSession? session, out IActionResult? error)
|
||||
{
|
||||
session = null;
|
||||
error = null;
|
||||
if (!HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var value) ||
|
||||
value is not AdminAuthSession authenticated)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching" });
|
||||
error = Unauthorized(new { error = "Authentication required" });
|
||||
return false;
|
||||
}
|
||||
if (!authenticated.TenantId.HasValue || !authenticated.AllstarrUserId.HasValue)
|
||||
{
|
||||
error = StatusCode(403, new { error = "The backend identity is not linked to an Allstarr user" });
|
||||
return false;
|
||||
}
|
||||
session = authenticated;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeKnownExternalProvider(string? provider)
|
||||
|
||||
@@ -577,6 +577,7 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
|
||||
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||
builder.Services.AddSingleton<allstarr.Core.Jobs.IDurableJobHandler, allstarr.Services.Spotify.LegacyPlaylistMatchAllJobHandler>();
|
||||
|
||||
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||
// DISABLED - No need to prefetch since Jellyfin and Spotify lyrics are fast
|
||||
|
||||
@@ -2,8 +2,10 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -14,6 +16,8 @@ namespace allstarr.Services.Spotify;
|
||||
/// </summary>
|
||||
public sealed class LegacySpotifyMappingProjector(
|
||||
SpotifyMappingService mappings,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
IOptions<SpotifyImportSettings> importSettings,
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState,
|
||||
ILogger<LegacySpotifyMappingProjector> logger) : BackgroundService
|
||||
@@ -29,6 +33,7 @@ public sealed class LegacySpotifyMappingProjector(
|
||||
if (storageState.GetSnapshot().Readiness == DurableStorageReadiness.Ready)
|
||||
{
|
||||
await ProjectAllAsync(stoppingToken);
|
||||
await ProjectConfiguredSourceTracksAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
@@ -65,6 +70,30 @@ public sealed class LegacySpotifyMappingProjector(
|
||||
return projected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects every configured legacy playlist source entry, including tracks
|
||||
/// which have never matched. This keeps the durable identity graph complete
|
||||
/// without requiring a manual rematch first.
|
||||
/// </summary>
|
||||
internal async Task<int> ProjectConfiguredSourceTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var projected = 0;
|
||||
foreach (var playlist in importSettings.Value.Playlists
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name)))
|
||||
{
|
||||
var tracks = await playlistFetcher.GetPlaylistTracksAsync(playlist.Name);
|
||||
projected += await ProjectSourceTracksAsync(tracks, cancellationToken);
|
||||
}
|
||||
|
||||
if (projected > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Projected {Count} legacy playlist source tracks into Postgres",
|
||||
projected);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every source playlist entry is represented in the durable identity
|
||||
/// graph, including entries which do not have a playable route yet.
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Core.Jobs;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -453,10 +454,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
|
||||
/// Use this when only the local library has changed.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
public async Task TriggerMatchingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
await MatchAllPlaylistsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2437,3 +2438,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record LegacyPlaylistMatchAllJobPayload(long Generation);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the compatibility playlist matcher outside the admin request so the UI
|
||||
/// can report durable progress and the work survives a browser disconnect.
|
||||
/// </summary>
|
||||
public sealed class LegacyPlaylistMatchAllJobHandler(SpotifyTrackMatchingService matchingService)
|
||||
: IDurableJobHandler
|
||||
{
|
||||
public string JobType => "playlist.match-all";
|
||||
|
||||
public async Task<DurableJobCompletion> ExecuteAsync(
|
||||
DurableJobExecutionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LegacyPlaylistMatchAllJobPayload? payload;
|
||||
try { payload = context.Claim.Payload.Deserialize<LegacyPlaylistMatchAllJobPayload>(); }
|
||||
catch (JsonException) { payload = null; }
|
||||
if (payload == null || payload.Generation <= 0)
|
||||
return DurableJobCompletion.Failure("playlist_match_payload_invalid", "The playlist match request is invalid.");
|
||||
|
||||
await matchingService.TriggerMatchingAsync(cancellationToken);
|
||||
return DurableJobCompletion.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +423,22 @@
|
||||
padding: 8px 20px 20px;
|
||||
}
|
||||
|
||||
.track-technical-history {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--space-4) var(--space-5) var(--space-5);
|
||||
}
|
||||
|
||||
.track-technical-history > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.track-technical-history[open] > summary {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.track-detail-section {
|
||||
min-width: 0;
|
||||
padding: 18px 16px;
|
||||
|
||||
@@ -2801,12 +2801,14 @@ class AllstarrApp extends LitElement {
|
||||
await API.refreshPlaylist(name);
|
||||
await API.matchPlaylist(name);
|
||||
}));
|
||||
await this.loadPlaylists(true);
|
||||
this.toast(`Refreshed and rematched ${names.length} ${names.length === 1 ? "playlist" : "playlists"}`);
|
||||
} else {
|
||||
await API.refreshPlaylists();
|
||||
await API.matchAllPlaylists();
|
||||
await this.loadPlaylists(true);
|
||||
this.toast("Playlist rematching queued. Progress appears in the operation center.");
|
||||
}
|
||||
await this.loadPlaylists(true);
|
||||
this.toast(`Refreshed and rematched ${names.length} ${names.length === 1 ? "playlist" : "playlists"}`);
|
||||
}}>${icon("refresh")}<span>Sync ${selected.size ? `${selected.size} selected` : "all now"}</span></button></div>
|
||||
</div>
|
||||
<div class="playlist-toolbar">
|
||||
@@ -3020,27 +3022,28 @@ class AllstarrApp extends LitElement {
|
||||
<div><small>Cached</small><strong>${lastCached ? formatRelativeTime(lastCached) : "No"}</strong></div>
|
||||
</div>
|
||||
<div class="track-details-grid compact-track-details">
|
||||
<section class="track-detail-section"><div class="section-heading"><div><h4>Playback</h4></div></div>
|
||||
<section class="track-detail-section"><div class="section-heading"><div><h4>Current route</h4><p>Where Allstarr will play this entry.</p></div></div>
|
||||
<div class="track-identity-list">
|
||||
${context.isLocal === true && materializedBackendItemId ? html`<div><span class="provider-badge configured">${icon("library", 14)} ${titleCase(this.status?.backendType || "Jellyfin")}</span><strong>Local library</strong><small class="mono">${materializedBackendItemId}</small></div>` : nothing}
|
||||
${context.isLocal === false ? html`<div><span class="provider-badge configured">${this.renderProviderLogo(context.externalProvider, "tiny")} ${providerDisplayName(context.externalProvider, this.schema?.providers)}</span><strong>External playback</strong><small>${legacy?.lastValidatedAt ? `Checked ${formatRelativeTime(legacy.lastValidatedAt)}` : "Ready for the next validation"}</small></div>` : nothing}
|
||||
${context.isLocal == null ? html`<div class="empty compact"><strong>Not matched</strong><span>Choose Rematch from the track menu.</span></div>` : nothing}
|
||||
${context.isLocal == null ? html`<div class="empty compact"><strong>No playable route yet</strong><span>Allstarr checked the local library first, then the enabled providers in your configured order. Use Rematch after changing the library or provider order.</span></div>` : nothing}
|
||||
</div>
|
||||
</section>
|
||||
<section class="track-detail-section"><div class="section-heading"><div><h4>Identifiers</h4></div></div>
|
||||
<section class="track-detail-section"><div class="section-heading"><div><h4>Known services</h4><p>Other identities associated with this recording.</p></div></div>
|
||||
<div class="track-identity-list">
|
||||
<div><span class="provider-badge configured">${this.renderProviderLogo("spotify", "tiny")} Spotify</span><strong class="mono">${display(context.spotifyId)}</strong></div>
|
||||
${identities.filter((identity) => identity.providerId !== "spotify").map((identity) => html`<div><span class="provider-badge configured">${this.renderProviderLogo(identity.providerId, "tiny")} ${providerDisplayName(identity.providerId, this.schema?.providers)}</span><strong class="mono">${display(identity.externalId)}</strong></div>`)}
|
||||
${localTracks.filter((track) => track.backendItemId !== materializedBackendItemId).map((track) => html`<div><span class="provider-badge configured">${icon("library", 14)} ${titleCase(this.status?.backendType || "Jellyfin")}</span><strong class="mono">${display(track.backendItemId)}</strong></div>`)}
|
||||
<div><span class="provider-badge configured">${this.renderProviderLogo("spotify", "tiny")} Spotify</span><strong>Source track</strong></div>
|
||||
${identities.filter((identity) => identity.providerId !== "spotify").map((identity) => html`<div><span class="provider-badge configured">${this.renderProviderLogo(identity.providerId, "tiny")} ${providerDisplayName(identity.providerId, this.schema?.providers)}</span><strong>Linked</strong></div>`)}
|
||||
${localTracks.filter((track) => track.backendItemId !== materializedBackendItemId).map(() => html`<div><span class="provider-badge configured">${icon("library", 14)} ${titleCase(this.status?.backendType || "Jellyfin")}</span><strong>Another local copy</strong></div>`)}
|
||||
</div>
|
||||
</section>
|
||||
<section class="track-detail-section"><div class="section-heading"><div><h4>Match history</h4></div></div>
|
||||
<div class="track-history-list">${history.length ? history.slice(0, 3).map((item) => html`<div><span class="status-chip ${item.state}">${titleCase(item.state)}</span><strong>${item.confidence != null && Number.isFinite(Number(item.confidence)) ? `${(Number(item.confidence) * 100).toFixed(0)}% match` : titleCase(item.source || "automatic")}</strong><small>${item.decidedAt ? formatRelativeTime(item.decidedAt) : "Current playlist"}</small></div>`) : html`<div class="empty compact">No durable decision recorded yet.</div>`}</div>
|
||||
</section>
|
||||
<section class="track-detail-section"><div class="section-heading"><div><h4>Activity</h4></div></div>
|
||||
<div class="track-activity-list">${activity.length ? activity.slice(0, 5).map((item) => html`<div><span>${icon(item.kind === "download" ? "download" : item.kind === "cache" ? "metadata" : item.kind === "validation" ? "check" : "link", 16)}</span><div><strong>${display(item.title)}</strong><small>${formatRelativeTime(item.at)}</small></div></div>`) : html`<div class="empty compact">No activity recorded yet.</div>`}</div>
|
||||
</section>
|
||||
</div>
|
||||
<details class="track-technical-history">
|
||||
<summary>Technical history</summary>
|
||||
<div class="track-details-grid compact-track-details">
|
||||
<section class="track-detail-section"><h4>Match decisions</h4><div class="track-history-list">${history.length ? history.slice(0, 3).map((item) => html`<div><span class="status-chip ${item.state}">${titleCase(item.state)}</span><strong>${item.confidence != null && Number.isFinite(Number(item.confidence)) ? `${(Number(item.confidence) * 100).toFixed(0)}% match` : titleCase(item.source || "automatic")}</strong><small>${item.decidedAt ? formatRelativeTime(item.decidedAt) : "Current playlist"}</small></div>`) : html`<div class="empty compact">No saved decision yet.</div>`}</div></section>
|
||||
<section class="track-detail-section"><h4>Recent activity</h4><div class="track-activity-list">${activity.length ? activity.slice(0, 5).map((item) => html`<div><span>${icon(item.kind === "download" ? "download" : item.kind === "cache" ? "metadata" : item.kind === "validation" ? "check" : "link", 16)}</span><div><strong>${display(item.title)}</strong><small>${formatRelativeTime(item.at)}</small></div></div>`) : html`<div class="empty compact">No activity recorded yet.</div>`}</div></section>
|
||||
</div>
|
||||
</details>
|
||||
` : html`<div class="empty">Mapping history could not be loaded.</div>`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user