fix(playlists): queue durable rematching and project legacy tracks
CI / build-and-test (push) Canceled after 0s
CI / provider-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s

This commit is contained in:
2026-07-20 18:10:45 -04:00
parent b3a5544b7f
commit 9fffb89741
8 changed files with 145 additions and 28 deletions
@@ -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]
+36 -8
View File
@@ -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)
+1
View File
@@ -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();
}
}
+16
View File
@@ -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;
+17 -14
View File
@@ -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>