Files
NaviWatcher/internal/scanner/diff.go

125 lines
4.7 KiB
Go

package scanner
import (
"naviwatcher/internal/database"
"naviwatcher/internal/musicbrainz"
)
// 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.
//
// 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.
// 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
// 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)
return len(filtered) == 0
}
// 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.
//
// 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 {
// 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
}