- Honor ignore_singles/ignore_compilations at scanner read time so toggles take effect immediately on the dashboard, artist page, and digest instead of waiting for the MusicBrainz cache to expire and prune rows. - Run notifier notify synchronously in the scheduler loop to avoid overlapping read-send-mark runs double-sending the digest. - Show artist name (with ID fallback) on the archive page instead of raw IDs. - Select last_synced in GetAllArtistSettings for contract consistency. - Fix stale startPeriodicSync comment and remove redundant error var. - Remove dead ignored-branch from the artist template (never rendered). - Add tests: CSRF sameOrigin, ArtistCacheFresh, secondary_types round-trip, and scanner type-toggle filtering. - Update Specification.md schema/config to reflect mbid, last_synced, secondary_types, sync.interval, and server.public_url.
112 lines
3.6 KiB
Go
112 lines
3.6 KiB
Go
package scanner
|
|
|
|
import (
|
|
"naviwatcher/internal/database"
|
|
)
|
|
|
|
// MissingRelease describes an external release that has no sufficiently similar
|
|
// local album. It is a flattened, consumer-friendly projection of a
|
|
// database.ExternalRelease.
|
|
type MissingRelease struct {
|
|
RGID string `json:"rgid"`
|
|
ArtistID string `json:"artist_id"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
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.
|
|
type TypeFilter struct {
|
|
IgnoreSingles bool
|
|
IgnoreCompilations bool
|
|
}
|
|
|
|
// suppressed 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.
|
|
func (f TypeFilter) suppressed(ext database.ExternalRelease) bool {
|
|
if f.IgnoreSingles && (ext.Type == "Single" || hasType(ext.SecondaryTypes, "Single")) {
|
|
return true
|
|
}
|
|
if f.IgnoreCompilations && (ext.Type == "Compilation" || hasType(ext.SecondaryTypes, "Compilation")) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasType reports whether types contains want.
|
|
func hasType(types []string, want string) bool {
|
|
for _, t := range types {
|
|
if t == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FindMissingReleases compares an artist's external discography against the
|
|
// user's local albums and returns the releases that are present externally but
|
|
// have no sufficiently similar local album.
|
|
//
|
|
// Rules:
|
|
// - External releases flagged IsIgnored are never reported.
|
|
// - External releases suppressed by the per-artist type toggles (filter) are
|
|
// never reported.
|
|
// - A local album only matches an external release for the same ArtistID.
|
|
// - An external release is "missing" when none of the local albums (same
|
|
// ArtistID) IsMatch at the given threshold.
|
|
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter TypeFilter) []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).
|
|
threshold = resolveThreshold(threshold)
|
|
|
|
// Group local albums by artist for O(1) lookup per external release.
|
|
localByArtist := make(map[string][]database.LocalAlbum)
|
|
for _, a := range local {
|
|
localByArtist[a.ArtistID] = append(localByArtist[a.ArtistID], a)
|
|
}
|
|
|
|
var missing []MissingRelease
|
|
for _, ext := range external {
|
|
if ext.IsIgnored {
|
|
continue
|
|
}
|
|
if filter.suppressed(ext) {
|
|
continue
|
|
}
|
|
|
|
albums := localByArtist[ext.ArtistID]
|
|
matched := false
|
|
for _, a := range albums {
|
|
if IsMatch(a.Title, ext.Title, threshold) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if matched {
|
|
continue
|
|
}
|
|
|
|
missing = append(missing, MissingRelease{
|
|
RGID: ext.RGID,
|
|
ArtistID: ext.ArtistID,
|
|
Title: ext.Title,
|
|
Type: ext.Type,
|
|
ReleaseDate: ext.ReleaseDate,
|
|
})
|
|
}
|
|
|
|
return missing
|
|
}
|