Some checks failed
Build and Push Docker Image / build (pull_request) Failing after 38s
This commit includes: 1. Live/Remix Filtering Feature: - Added ignore_live and ignore_remix columns to artist_settings table (migration 010) - Updated ArtistSettings struct with IgnoreLive and IgnoreRemix fields - Modified SaveArtistSettings and UpdateArtistSettings to handle new fields - Extended FilterOptions struct with IgnoreLive and IgnoreRemix - Updated ApplyTypeToggles and ApplyTypeTogglesToReleaseGroups to filter Live/Remix types - Added toggleIgnoreLive and toggleIgnoreRemix handlers in web layer - Updated ArtistData view model and artist.html template with new toggle UI - Comprehensive test coverage for all new functionality 2. CI/CD Pipeline with Gitea Actions: - Added .gitea/workflows/docker-build.yml for automated Docker builds - Workflow triggers on pushes to main/master and tags, plus PRs - Runs Go tests before building - Builds and pushes multi-architecture Docker images to gitea.mrixs.me - Includes caching for faster subsequent builds - Proper tagging strategy (branch, semver, SHA) - CI-CD-GUIDE.md documentation 3. Cleanup: - Removed temporary build artifacts and coverage files
249 lines
9.5 KiB
Go
249 lines
9.5 KiB
Go
package musicbrainz
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"naviwatcher/internal/database"
|
|
)
|
|
|
|
// SyncArtistDiscography synchronizes an artist's discography from MusicBrainz
|
|
// into the local external_releases table. It follows this flow:
|
|
// 1. Check if cached data exists and is within TTL.
|
|
// 2. If cache hit, return the cached releases immediately.
|
|
// 3. If cache miss or expired, fetch release groups from MusicBrainz API.
|
|
// 4. Apply status and type filtering.
|
|
// 5. Within a transaction: delete old entries, then upsert each filtered release group.
|
|
// 6. Return the list of external releases.
|
|
//
|
|
// artistID is the canonical artist key from artist_settings (the Navidrome
|
|
// artist ID). It is stored as external_releases.artist_id so that the foreign
|
|
// key to artist_settings and the scanner's join on ArtistID resolve correctly.
|
|
// artistMBID is the MusicBrainz ID used only to query the MusicBrainz API.
|
|
//
|
|
// Context cancellation is checked before the API call and between each upsert
|
|
// to allow graceful interruption.
|
|
func SyncArtistDiscography(
|
|
ctx context.Context,
|
|
client *MusicBrainzClient,
|
|
db *database.DB,
|
|
artistID string,
|
|
artistMBID string,
|
|
ttl time.Duration,
|
|
) ([]database.ExternalRelease, error) {
|
|
// Check context before starting.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: %w", err)
|
|
}
|
|
|
|
// Step 1: Check cache freshness. A genuine hit means the artist was synced
|
|
// within the TTL — even when it has zero release groups. We must not gate on
|
|
// row count, or artists with an empty MusicBrainz discography would be
|
|
// re-fetched on every sync (defeating the TTL and wasting the 1 req/s budget).
|
|
cachedReleases, err := database.GetExternalReleasesByArtistWithCache(db, artistID, ttl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err)
|
|
}
|
|
fresh, err := database.ArtistCacheFresh(db, artistID, ttl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: cache freshness check failed: %w", err)
|
|
}
|
|
|
|
// Step 2: If we have a fresh cache, return the cached data. Re-apply the
|
|
// per-artist type toggles even on a cache hit so user changes to
|
|
// ignore_singles / ignore_compilations take effect without waiting for cache
|
|
// expiry. (Status/type inclusion was already applied when the rows were first
|
|
// synced and stored, so only the toggles can change.)
|
|
//
|
|
// The MusicBrainz sync path applies filtering at store-time (when caching
|
|
// release groups from the API), while the scanner path applies filtering at
|
|
// read-time (when retrieving cached data). This dual-path approach ensures:
|
|
// 1. Storage efficiency: filtered results are stored, reducing database size
|
|
// 2. Real-time responsiveness: changes to ignore_singles/ignore_compilations
|
|
// take effect immediately without waiting for cache expiry
|
|
// 3. Consistency: both paths use the same filtering logic via
|
|
// musicbrainz.ApplyTypeToggles
|
|
if fresh {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: %w", err)
|
|
}
|
|
opts, err := getArtistFilterOptions(db, artistID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: read artist filter options: %w", err)
|
|
}
|
|
filtered := ApplyTypeToggles(cachedReleases, opts)
|
|
return filtered, nil
|
|
}
|
|
|
|
// Step 3: Cache miss — fetch from MusicBrainz API.
|
|
groups, err := client.GetArtistReleaseGroups(ctx, artistMBID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: fetch release groups for artist %s: %w", artistMBID, err)
|
|
}
|
|
|
|
// Step 4: Apply filtering with per-artist type preferences.
|
|
opts, err := getArtistFilterOptions(db, artistID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: read artist filter options: %w", err)
|
|
}
|
|
filtered := FilterReleaseGroups(groups, opts)
|
|
|
|
// Step 5: Upsert within a transaction — delete old entries first, then insert new ones.
|
|
now := time.Now().UTC()
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Read existing ignore states before deleting to preserve user-set flags.
|
|
ignoredMap := map[string]bool{}
|
|
rows, err := tx.Query("SELECT rgid, is_ignored FROM external_releases WHERE artist_id = ?", artistID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: query existing releases: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var rgid string
|
|
var ignored bool
|
|
if err := rows.Scan(&rgid, &ignored); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("sync artist discography: scan existing release: %w", err)
|
|
}
|
|
ignoredMap[rgid] = ignored
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("sync artist discography: iterate existing releases: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
// Build the set of RGIDs present in this sync so we can drop only the rows
|
|
// that disappeared, leaving the rest (and their notification markers) intact.
|
|
synced := make([]string, 0, len(filtered))
|
|
for _, rg := range filtered {
|
|
synced = append(synced, rg.ID)
|
|
}
|
|
|
|
// Drop notification markers for releases that are gone. This runs before the
|
|
// external_releases delete so the FK on notifications_sent.rgid stays valid
|
|
// (we only ever delete from notifications_sent here).
|
|
// Process in chunks to avoid SQLite parameter limits (default limit is 999).
|
|
if len(synced) > 0 {
|
|
const chunkSize = 500
|
|
for i := 0; i < len(synced); i += chunkSize {
|
|
end := i + chunkSize
|
|
if end > len(synced) {
|
|
end = len(synced)
|
|
}
|
|
chunk := synced[i:end]
|
|
|
|
placeholders := strings.Repeat("?,", len(chunk))
|
|
placeholders = placeholders[:len(placeholders)-1]
|
|
query := fmt.Sprintf(
|
|
"DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ? AND rgid NOT IN (%s))",
|
|
placeholders,
|
|
)
|
|
args := make([]any, 1+len(chunk))
|
|
args[0] = artistID
|
|
for i, v := range chunk {
|
|
args[i+1] = v
|
|
}
|
|
if _, err := tx.Exec(query, args...); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: prune stale notifications (chunk %d-%d): %w", i, end, err)
|
|
}
|
|
}
|
|
|
|
// Remove external_release rows that are no longer part of the discography.
|
|
// Process in chunks to avoid SQLite parameter limits.
|
|
for i := 0; i < len(synced); i += chunkSize {
|
|
end := i + chunkSize
|
|
if end > len(synced) {
|
|
end = len(synced)
|
|
}
|
|
chunk := synced[i:end]
|
|
|
|
placeholders := strings.Repeat("?,", len(chunk))
|
|
placeholders = placeholders[:len(placeholders)-1]
|
|
delQuery := fmt.Sprintf(
|
|
"DELETE FROM external_releases WHERE artist_id = ? AND rgid NOT IN (%s)",
|
|
placeholders,
|
|
)
|
|
args := make([]any, 1+len(chunk))
|
|
args[0] = artistID
|
|
for i, v := range chunk {
|
|
args[i+1] = v
|
|
}
|
|
if _, err := tx.Exec(delQuery, args...); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: delete stale releases (chunk %d-%d): %w", i, end, err)
|
|
}
|
|
}
|
|
} else {
|
|
// No releases this sync: the artist may have an empty discography. Drop
|
|
// everything we previously cached for them.
|
|
if _, err := tx.Exec("DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)", artistID); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: delete notifications: %w", err)
|
|
}
|
|
if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistID); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: delete old releases: %w", err)
|
|
}
|
|
}
|
|
|
|
var releases []database.ExternalRelease
|
|
for _, rg := range filtered {
|
|
// Check context cancellation between each upsert.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: %w", err)
|
|
}
|
|
|
|
ext := rg.ToExternalRelease(artistID)
|
|
ext.CachedAt = now
|
|
// Preserve user-set ignore flag from previous sync.
|
|
if ignored, ok := ignoredMap[ext.RGID]; ok {
|
|
ext.IsIgnored = ignored
|
|
}
|
|
|
|
if _, err := tx.Exec(
|
|
"INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
ext.RGID, ext.ArtistID, ext.Title, ext.Type, ext.ReleaseDate, ext.IsIgnored, database.FormatCachedAt(ext.CachedAt), database.JoinSecondaryTypes(ext.SecondaryTypes),
|
|
); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: insert release %s: %w", rg.ID, err)
|
|
}
|
|
|
|
releases = append(releases, *ext)
|
|
}
|
|
|
|
// Mark the artist as synced (even when it has zero release groups) so the
|
|
// cache TTL honours empty discographies and they are not re-fetched every
|
|
// cycle.
|
|
if err := database.TouchArtistSynced(tx, artistID, now); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: touch last_synced: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("sync artist discography: commit transaction: %w", err)
|
|
}
|
|
|
|
return releases, nil
|
|
}
|
|
|
|
// getArtistFilterOptions reads per-artist type filtering preferences.
|
|
// Defaults to no filtering if artist_settings row doesn't exist.
|
|
// artistID is the Navidrome artist ID (artist_settings.id), not the MusicBrainz ID.
|
|
func getArtistFilterOptions(db *database.DB, artistID string) (FilterOptions, error) {
|
|
var opts FilterOptions
|
|
err := db.Conn().QueryRow(
|
|
"SELECT COALESCE(ignore_singles, 0), COALESCE(ignore_compilations, 0), COALESCE(ignore_live, 0), COALESCE(ignore_remix, 0) FROM artist_settings WHERE id = ?",
|
|
artistID,
|
|
).Scan(&opts.IgnoreSingles, &opts.IgnoreCompilations, &opts.IgnoreLive, &opts.IgnoreRemix)
|
|
if err == sql.ErrNoRows {
|
|
return opts, nil
|
|
}
|
|
if err != nil {
|
|
return opts, fmt.Errorf("query artist filter options: %w", err)
|
|
}
|
|
return opts, nil
|
|
}
|