musicbrainz-provider #2
77
.gitea/workflows/docker-build.yml
Normal file
77
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
env:
|
||||
# Docker image configuration - using your Gitea registry
|
||||
REGISTRY: gitea.mrixs.me
|
||||
IMAGE_NAME: naviwatcher
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Needed for writing to GitHub Packages registry
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
go mod tidy
|
||||
go mod verify
|
||||
|
||||
- name: Run unit tests
|
||||
run: go test ./... -v -coverprofile=coverage.out
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.out
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-buildcache
|
||||
cache-to: type=inline,mode=max
|
||||
70
CI-CD-GUIDE.md
Normal file
70
CI-CD-GUIDE.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# CI/CD with Gitea Actions for NaviWatcher
|
||||
|
||||
This repository uses Gitea Actions to automatically build and publish Docker images.
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
The workflow (`.gitea/workflows/docker-build.yml`) performs the following steps:
|
||||
|
||||
1. **Trigger Conditions**:
|
||||
- Pushes to `main` or `master` branches
|
||||
- Pull requests targeting `main` or `master`
|
||||
- Pushes of version tags (e.g., `v1.0.0`, `v2.1.0`)
|
||||
|
||||
2. **Job Steps**:
|
||||
- Checkout repository code
|
||||
- Set up Go environment (version 1.25)
|
||||
- Run `go mod tidy` and `go mod verify`
|
||||
- Execute unit tests with coverage
|
||||
- Set up Docker Buildx for multi-platform builds
|
||||
- Authenticate with container registry
|
||||
- Extract metadata for image tagging
|
||||
- Build and push Docker image to registry
|
||||
|
||||
## Required Secrets
|
||||
|
||||
To use this workflow, you need to configure the following secrets in your Gitea repository:
|
||||
|
||||
1. **REGISTRY_USERNAME** - Username for your container registry
|
||||
2. **REGISTRY_PASSWORD** - Password or access token for your container registry
|
||||
3. **REGISTRY** - The registry URL (e.g., `docker.io`, `ghcr.io`, or your private registry)
|
||||
4. **IMAGE_NAME** - The name for your Docker image (e.g., `naviwatcher`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The workflow uses these environment variables (can be configured in the workflow or repository settings):
|
||||
|
||||
- `REGISTRY`: Container registry URL
|
||||
- `IMAGE_NAME`: Name of the Docker image
|
||||
|
||||
## Customization
|
||||
|
||||
To customize the workflow:
|
||||
|
||||
1. **Change trigger branches**: Modify the `branches` filter in the `on` section
|
||||
2. **Adjust Go version**: Update the `go-version` in the setup-go step
|
||||
3. **Modify build arguments**: Add build-args to the docker/build-push-action if needed
|
||||
4. **Change registry**: Update the REGISTRY environment variable and corresponding secrets
|
||||
|
||||
## Example Configuration
|
||||
|
||||
For Docker Hub:
|
||||
- REGISTRY: `docker.io`
|
||||
- IMAGE_NAME: `yourusername/naviwatcher`
|
||||
|
||||
For GitHub Container Registry:
|
||||
- REGISTRY: `ghcr.io`
|
||||
- IMAGE_NAME: `username/naviwatcher`
|
||||
|
||||
For GitLab Container Registry:
|
||||
- REGISTRY: `registry.gitlab.com`
|
||||
- IMAGE_NAME: `group/project/naviwatcher`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If builds fail:
|
||||
|
||||
1. Check that all required secrets are set correctly
|
||||
2. Verify you have push permissions to the target registry
|
||||
3. Ensure Dockerfile is valid and builds locally
|
||||
4. Check the Actions tab in Gitea for detailed logs
|
||||
@@ -7,24 +7,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DBer is the minimal query interface satisfied by both *sql.DB and *sql.Tx,
|
||||
// so callers can run statements inside or outside a transaction.
|
||||
type DBer interface {
|
||||
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||
}
|
||||
|
||||
// GetArtistSettings retrieves an artist_settings row by ID.
|
||||
// Returns sql.ErrNoRows if the artist is not found.
|
||||
func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) {
|
||||
var (
|
||||
s ArtistSettings
|
||||
mbid sql.NullString
|
||||
s ArtistSettings
|
||||
mbid sql.NullString
|
||||
lastSynced sql.NullTime
|
||||
)
|
||||
err := db.Conn().QueryRow(
|
||||
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings WHERE id = ?",
|
||||
"SELECT id, name, mbid, ignore_singles, ignore_compilations, ignore_live, ignore_remix, monitored, last_synced FROM artist_settings WHERE id = ?",
|
||||
id,
|
||||
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced)
|
||||
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.IgnoreLive, &s.IgnoreRemix, &s.Monitored, &lastSynced)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrArtistNotFound
|
||||
@@ -60,17 +54,19 @@ func TouchArtistSynced(db DBer, artistID string, syncedAt time.Time) error {
|
||||
// every periodic artist sync.
|
||||
func SaveArtistSettings(db *DB, settings *ArtistSettings) error {
|
||||
_, err := db.Conn().Exec(`
|
||||
INSERT INTO artist_settings (id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO artist_settings (id, name, mbid, ignore_singles, ignore_compilations, ignore_live, ignore_remix, monitored, last_synced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
mbid = COALESCE(excluded.mbid, artist_settings.mbid),
|
||||
ignore_singles = excluded.ignore_singles,
|
||||
ignore_compilations = excluded.ignore_compilations,
|
||||
ignore_live = excluded.ignore_live,
|
||||
ignore_remix = excluded.ignore_remix,
|
||||
monitored = excluded.monitored,
|
||||
last_synced = COALESCE(excluded.last_synced, artist_settings.last_synced)
|
||||
`,
|
||||
settings.ID, settings.Name, nullIfEmpty(settings.MBID), settings.IgnoreSingles, settings.IgnoreCompilations, settings.Monitored, nullIfEmptyTime(settings.LastSynced),
|
||||
settings.ID, settings.Name, nullIfEmpty(settings.MBID), settings.IgnoreSingles, settings.IgnoreCompilations, settings.IgnoreLive, settings.IgnoreRemix, settings.Monitored, nullIfEmptyTime(settings.LastSynced),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save artist settings: %w", err)
|
||||
@@ -99,7 +95,7 @@ func nullIfEmptyTime(t time.Time) interface{} {
|
||||
// GetAllArtistSettings returns all rows from artist_settings.
|
||||
func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings",
|
||||
"SELECT id, name, mbid, ignore_singles, ignore_compilations, ignore_live, ignore_remix, monitored, last_synced FROM artist_settings",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query all artist settings: %w", err)
|
||||
@@ -111,7 +107,7 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||
var s ArtistSettings
|
||||
var mbid sql.NullString
|
||||
var lastSynced sql.NullTime
|
||||
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced); err != nil {
|
||||
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.IgnoreLive, &s.IgnoreRemix, &s.Monitored, &lastSynced); err != nil {
|
||||
return nil, fmt.Errorf("scan artist settings: %w", err)
|
||||
}
|
||||
s.MBID = mbid.String
|
||||
@@ -127,7 +123,7 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||
}
|
||||
|
||||
// UpdateArtistSettings updates specific fields of an artist_settings row by ID.
|
||||
// The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored".
|
||||
// The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored", "ignore_live", "ignore_remix".
|
||||
func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return fmt.Errorf("no updates provided")
|
||||
@@ -164,6 +160,18 @@ func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) err
|
||||
}
|
||||
setClause += "ignore_compilations = ?"
|
||||
args = append(args, val)
|
||||
case "ignore_live":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += "ignore_live = ?"
|
||||
args = append(args, val)
|
||||
case "ignore_remix":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += "ignore_remix = ?"
|
||||
args = append(args, val)
|
||||
case "monitored":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
|
||||
@@ -15,6 +15,12 @@ type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// DBer is the minimal query interface satisfied by both *sql.DB and *sql.Tx,
|
||||
// so callers can run statements inside or outside a transaction.
|
||||
type DBer interface {
|
||||
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||
}
|
||||
|
||||
// ErrArtistNotFound is returned by artist lookups when no row matches the given
|
||||
// ID. It is a sentinel so callers (e.g. the web UI) can distinguish "missing"
|
||||
// from other errors.
|
||||
@@ -164,6 +170,11 @@ func (db *DB) migrate() error {
|
||||
name: "009_add_last_synced_to_artist_settings",
|
||||
sql: `ALTER TABLE artist_settings ADD COLUMN last_synced DATETIME;`,
|
||||
},
|
||||
{
|
||||
name: "010_add_ignore_live_ignore_remix_to_artist_settings",
|
||||
sql: `ALTER TABLE artist_settings ADD COLUMN ignore_live BOOLEAN DEFAULT 0;
|
||||
ALTER TABLE artist_settings ADD COLUMN ignore_remix BOOLEAN DEFAULT 0;`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
@@ -215,6 +226,8 @@ type ArtistSettings struct {
|
||||
MBID string `json:"mbid"`
|
||||
IgnoreSingles bool `json:"ignore_singles"`
|
||||
IgnoreCompilations bool `json:"ignore_compilations"`
|
||||
IgnoreLive bool `json:"ignore_live"`
|
||||
IgnoreRemix bool `json:"ignore_remix"`
|
||||
Monitored bool `json:"monitored"`
|
||||
LastSynced time.Time `json:"last_synced"`
|
||||
}
|
||||
|
||||
@@ -395,9 +395,9 @@ func TestSecondaryTypesRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{"empty", nil, nil},
|
||||
{"single", []string{"Compilation"}, []string{"Compilation"}},
|
||||
@@ -406,10 +406,10 @@ func TestSecondaryTypesRoundTrip(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &ExternalRelease{
|
||||
RGID: "rgid-" + c.name,
|
||||
ArtistID: "artist-1",
|
||||
Title: "Title " + c.name,
|
||||
Type: "Album",
|
||||
RGID: "rgid-" + c.name,
|
||||
ArtistID: "artist-1",
|
||||
Title: "Title " + c.name,
|
||||
Type: "Album",
|
||||
SecondaryTypes: c.in,
|
||||
}
|
||||
if err := SaveExternalRelease(db, r); err != nil {
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
// fields we need for MBID resolution are decoded.
|
||||
type mbArtistSearchResult struct {
|
||||
Artists []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Score int `json:"score"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Score int `json:"score"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
|
||||
@@ -235,9 +235,9 @@ func SyncArtistDiscography(
|
||||
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) FROM artist_settings WHERE id = ?",
|
||||
"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)
|
||||
).Scan(&opts.IgnoreSingles, &opts.IgnoreCompilations, &opts.IgnoreLive, &opts.IgnoreRemix)
|
||||
if err == sql.ErrNoRows {
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
@@ -16,49 +16,16 @@ type MissingRelease struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
}
|
||||
|
||||
// TypeFilter carries the per-artist type toggles that suppress whole release
|
||||
// categories from the missing set. It mirrors the ignore_singles /
|
||||
// ignore_compilations columns on artist_settings.
|
||||
//
|
||||
// These toggles are applied at scan/read time (not only when the MusicBrainz
|
||||
// discography is synced) so a user flipping a toggle takes effect immediately on
|
||||
// the dashboard, artist page, and Telegram digest — rather than waiting for the
|
||||
// artist's MusicBrainz cache to expire and the rows to be pruned on the next
|
||||
// cache-miss re-sync.
|
||||
//
|
||||
// The scanner applies filtering at at scan/read time (not only when the MusicBrainz
|
||||
// discography is synced) so a user flipping a toggle takes effect immediately on
|
||||
// the dashboard, artist page, and Telegram digest — rather than waiting for the
|
||||
// artist's MusicBrainz cache to expire and the rows to be pruned on the next
|
||||
// cache-miss re-sync.
|
||||
//
|
||||
// The scanner path applies filtering at read-time, while the MusicBrainz sync
|
||||
// path applies filtering at store-time. This dual-path approach ensures:
|
||||
// 1. Storage efficiency: filtered results are stored during MusicBrainz sync
|
||||
// 2. Real-time responsiveness: changes to ignore_singles/ignore_compilations
|
||||
// take effect immediately in scan results
|
||||
// 3. Consistency: both paths use the same filtering logic via
|
||||
// musicbrainz.ApplyTypeToggles
|
||||
type TypeFilter struct {
|
||||
IgnoreSingles bool
|
||||
IgnoreCompilations bool
|
||||
}
|
||||
|
||||
// suppressed reports whether an external release is dropped by the type toggles.
|
||||
// FilterIsSuppressed reports whether an external release is dropped by the type toggles.
|
||||
// A release counts as a Single/Compilation via either its primary Type or its
|
||||
// secondary types, matching musicbrainz.FilterReleaseGroups so both the
|
||||
// cache-miss (store-time) and read-time paths agree.
|
||||
//
|
||||
// This method reuses the centralized filtering logic from the musicbrainz
|
||||
// This function reuses the centralized filtering logic from the musicbrainz
|
||||
// package to ensure consistency between the scanner's read-time filtering
|
||||
// and the MusicBrainz sync's store-time filtering.
|
||||
func (f TypeFilter) suppressed(ext database.ExternalRelease) bool {
|
||||
// Use the centralized filtering logic from musicbrainz package
|
||||
opts := musicbrainz.FilterOptions{
|
||||
IgnoreSingles: f.IgnoreSingles,
|
||||
IgnoreCompilations: f.IgnoreCompilations,
|
||||
}
|
||||
filtered := musicbrainz.ApplyTypeToggles([]database.ExternalRelease{ext}, opts)
|
||||
func FilterIsSuppressed(filter musicbrainz.FilterOptions, ext database.ExternalRelease) bool {
|
||||
filtered := musicbrainz.ApplyTypeToggles([]database.ExternalRelease{ext}, filter)
|
||||
return len(filtered) == 0
|
||||
}
|
||||
|
||||
@@ -77,7 +44,7 @@ func (f TypeFilter) suppressed(ext database.ExternalRelease) bool {
|
||||
// The filter.suppressed() check applies the same IgnoreSingles/IgnoreCompilations
|
||||
// filtering logic as used in the MusicBrainz sync path, ensuring consistent
|
||||
// behavior between cache-hit (read-time) and cache-miss (store-time) paths.
|
||||
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter TypeFilter) []MissingRelease {
|
||||
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter musicbrainz.FilterOptions) []MissingRelease {
|
||||
// Resolve the threshold exactly as ScanArtist/ScanAll do, so the exported
|
||||
// primitive honors the same zero-means-default contract rather than treating
|
||||
// 0 as "always match" (which would report nothing as missing).
|
||||
@@ -94,7 +61,7 @@ func FindMissingReleases(local []database.LocalAlbum, external []database.Extern
|
||||
if ext.IsIgnored {
|
||||
continue
|
||||
}
|
||||
if filter.suppressed(ext) {
|
||||
if FilterIsSuppressed(filter, ext) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"naviwatcher/internal/musicbrainz"
|
||||
)
|
||||
|
||||
func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
|
||||
func TestFilterIsSuppressedMatchesMusicbrainzFilter(t *testing.T) {
|
||||
// Test cases covering various combinations of types and secondary types
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -66,11 +66,11 @@ func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test scanner filter
|
||||
scannerFilter := TypeFilter{
|
||||
scannerFilter := musicbrainz.FilterOptions{
|
||||
IgnoreSingles: tc.ignoreSingles,
|
||||
IgnoreCompilations: tc.ignoreCompilations,
|
||||
}
|
||||
scannerSuppressed := scannerFilter.suppressed(release)
|
||||
scannerSuppressed := FilterIsSuppressed(scannerFilter, release)
|
||||
|
||||
// Test musicbrainz filter
|
||||
mbFilter := musicbrainz.FilterOptions{
|
||||
@@ -86,7 +86,7 @@ func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
|
||||
tc, scannerSuppressed, mbSuppressed)
|
||||
}
|
||||
|
||||
// Check against expected value
|
||||
// Check against expected value
|
||||
if scannerSuppressed != tc.expectedSuppressed {
|
||||
t.Errorf("Scanner filter returned %v, expected %v for case %v",
|
||||
scannerSuppressed, tc.expectedSuppressed, tc.name)
|
||||
@@ -96,7 +96,7 @@ func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test that verifies the specific case mentioned in the issue: EP in SecondaryTypes counts as Single
|
||||
func TestTypeFilterTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
|
||||
func TestFilterIsSuppressedTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
releaseType string
|
||||
@@ -121,11 +121,11 @@ func TestTypeFilterTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test scanner filter
|
||||
scannerFilter := TypeFilter{
|
||||
scannerFilter := musicbrainz.FilterOptions{
|
||||
IgnoreSingles: tc.ignoreSingles,
|
||||
IgnoreCompilations: tc.ignoreCompilations,
|
||||
}
|
||||
scannerSuppressed := scannerFilter.suppressed(release)
|
||||
scannerSuppressed := FilterIsSuppressed(scannerFilter, release)
|
||||
|
||||
// Test musicbrainz filter
|
||||
mbFilter := musicbrainz.FilterOptions{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"naviwatcher/internal/database"
|
||||
"naviwatcher/internal/musicbrainz"
|
||||
)
|
||||
|
||||
// ScanArtist loads the local albums and external releases for a single artist
|
||||
@@ -38,7 +39,7 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold
|
||||
if err != nil {
|
||||
// If artist settings don't exist, use empty filter (no filtering)
|
||||
if err == database.ErrArtistNotFound {
|
||||
filter := TypeFilter{
|
||||
filter := musicbrainz.FilterOptions{
|
||||
IgnoreSingles: false,
|
||||
IgnoreCompilations: false,
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
filter := TypeFilter{
|
||||
filter := musicbrainz.FilterOptions{
|
||||
IgnoreSingles: settings.IgnoreSingles,
|
||||
IgnoreCompilations: settings.IgnoreCompilations,
|
||||
}
|
||||
|
||||
@@ -364,10 +364,10 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
|
||||
|
||||
// Seed artist settings with IgnoreSingles enabled
|
||||
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
||||
ID: "artist-1",
|
||||
Name: "Test Artist",
|
||||
Monitored: true,
|
||||
IgnoreSingles: true, // This is the key toggle we're testing
|
||||
ID: "artist-1",
|
||||
Name: "Test Artist",
|
||||
Monitored: true,
|
||||
IgnoreSingles: true, // This is the key toggle we're testing
|
||||
IgnoreCompilations: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveArtistSettings error: %v", err)
|
||||
@@ -427,7 +427,7 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistSettings error: %v", err)
|
||||
}
|
||||
filter := TypeFilter{
|
||||
filter := musicbrainz.FilterOptions{
|
||||
IgnoreSingles: settings.IgnoreSingles,
|
||||
IgnoreCompilations: settings.IgnoreCompilations,
|
||||
}
|
||||
@@ -436,7 +436,7 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
|
||||
var isSuppressed bool
|
||||
for _, ext := range externalReleases {
|
||||
if ext.RGID == "rg-ep-release" {
|
||||
isSuppressed = filter.suppressed(ext)
|
||||
isSuppressed = FilterIsSuppressed(filter, ext)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"naviwatcher/internal/database"
|
||||
"naviwatcher/internal/musicbrainz"
|
||||
)
|
||||
|
||||
func TestSimilarity(t *testing.T) {
|
||||
@@ -202,7 +203,7 @@ func TestFindMissingReleases(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FindMissingReleases(tt.local, tt.external, threshold, TypeFilter{})
|
||||
got := FindMissingReleases(tt.local, tt.external, threshold, musicbrainz.FilterOptions{})
|
||||
|
||||
gotRGIDs := make([]string, 0, len(got))
|
||||
for _, m := range got {
|
||||
@@ -225,7 +226,7 @@ func TestFindMissingReleases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMissingReleases_TypeFilter(t *testing.T) {
|
||||
func TestFindMissingReleases_FilterOptions(t *testing.T) {
|
||||
const threshold = 0.85
|
||||
artist := "artist-a"
|
||||
|
||||
@@ -238,27 +239,27 @@ func TestFindMissingReleases_TypeFilter(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter TypeFilter
|
||||
filter musicbrainz.FilterOptions
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no filter reports all",
|
||||
filter: TypeFilter{},
|
||||
filter: musicbrainz.FilterOptions{},
|
||||
want: []string{"rg-album", "rg-single", "rg-comp", "rg-comp-sec"},
|
||||
},
|
||||
{
|
||||
name: "ignore singles drops Single primary type",
|
||||
filter: TypeFilter{IgnoreSingles: true},
|
||||
filter: musicbrainz.FilterOptions{IgnoreSingles: true},
|
||||
want: []string{"rg-album", "rg-comp", "rg-comp-sec"},
|
||||
},
|
||||
{
|
||||
name: "ignore compilations drops Compilation primary and secondary type",
|
||||
filter: TypeFilter{IgnoreCompilations: true},
|
||||
filter: musicbrainz.FilterOptions{IgnoreCompilations: true},
|
||||
want: []string{"rg-album", "rg-single"},
|
||||
},
|
||||
{
|
||||
name: "both toggles drop singles and compilations",
|
||||
filter: TypeFilter{IgnoreSingles: true, IgnoreCompilations: true},
|
||||
filter: musicbrainz.FilterOptions{IgnoreSingles: true, IgnoreCompilations: true},
|
||||
want: []string{"rg-album"},
|
||||
},
|
||||
}
|
||||
@@ -301,7 +302,7 @@ func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) {
|
||||
}
|
||||
// With default threshold 0.85, "The Wall Live" does not match "The Wall";
|
||||
// at a low threshold it would. Confirms threshold is honoured.
|
||||
if len(FindMissingReleases(local, external, 0.85, TypeFilter{})) != 1 {
|
||||
if len(FindMissingReleases(local, external, 0.85, musicbrainz.FilterOptions{})) != 1 {
|
||||
t.Errorf("expected 1 missing at 0.85 threshold")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,9 +257,9 @@ func TestArchive_RendersIgnoredReleases(t *testing.T) {
|
||||
seedArtist(t, db, "a1", "Radiohead", "", true)
|
||||
// An ignored external release.
|
||||
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
|
||||
RGID: "r-ignored",
|
||||
ArtistID: "a1",
|
||||
Title: "Ignored Album",
|
||||
RGID: "r-ignored",
|
||||
ArtistID: "a1",
|
||||
Title: "Ignored Album",
|
||||
IsIgnored: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed ignored release: %v", err)
|
||||
@@ -490,10 +490,10 @@ func TestStateChangingEnforcesSameOrigin(t *testing.T) {
|
||||
served := "http://0.0.0.0:8080" // matches the server's Addr()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
route string
|
||||
origin string
|
||||
wantCode int
|
||||
name string
|
||||
route string
|
||||
origin string
|
||||
wantCode int
|
||||
}{
|
||||
{"same-origin Origin allowed", "/artist/a1/ignore", served, http.StatusSeeOther},
|
||||
{"no Origin header allowed (same-origin form post)", "/artist/a1/ignore", "", http.StatusSeeOther},
|
||||
|
||||
Reference in New Issue
Block a user