Files
NaviWatcher/internal/database/external_releases.go
Vladimir Zagainov b21bf07208 fix: address code review findings
- Store cached_at in canonical UTC layout so the cache TTL cutoff comparison is a valid time ordering (previously go-sqlite3 serialized time.Time as RFC3339, making the space-separated cutoff match only by ASCII accident; same-day expired entries were falsely served as fresh).

- Remove stale no-op scanner config keys (ignore_bootlegs, include_compilations) from config.yaml.example and docs; these fields were removed from ScannerConfig but left in configs, silently doing nothing.
2026-07-19 20:01:26 +03:00

215 lines
7.0 KiB
Go

package database
import (
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
// utcLayout is the canonical layout for the cached_at column. go-sqlite3
// serializes a time.Time as RFC3339, which does not compare correctly against
// the space-separated cutoff used by the cache query. Storing this layout keeps
// the lexicographic comparison in GetExternalReleasesByArtistWithCache valid.
const utcLayout = "2006-01-02 15:04:05"
// FormatCachedAt renders a timestamp in the canonical UTC layout for storage.
// A zero time yields nil so the column is left NULL.
func FormatCachedAt(t time.Time) interface{} {
if t.IsZero() {
return nil
}
return t.UTC().Format(utcLayout)
}
// joinSecondaryTypes renders a slice of secondary types as a comma-separated
// string for storage in the secondary_types TEXT column (empty when none).
func joinSecondaryTypes(types []string) string {
return strings.Join(types, ",")
}
// splitSecondaryTypes parses the comma-separated secondary_types column back
// into a slice. A NULL/empty column yields an empty slice.
func splitSecondaryTypes(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return out
}
// GetExternalRelease retrieves an external_release row by RGID.
func GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error) {
var r ExternalRelease
var cachedAt sql.NullTime
var secondaryTypes sql.NullString
err := db.Conn().QueryRow(
"SELECT rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types FROM external_releases WHERE rgid = ?",
rgid,
).Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored, &cachedAt, &secondaryTypes)
if err != nil {
return nil, fmt.Errorf("get external release: %w", err)
}
if cachedAt.Valid {
r.CachedAt = cachedAt.Time
}
if secondaryTypes.Valid {
r.SecondaryTypes = splitSecondaryTypes(secondaryTypes.String)
}
return &r, nil
}
// SaveExternalRelease inserts or replaces an external_release row.
func SaveExternalRelease(db *DB, release *ExternalRelease) error {
var cachedAt interface{} = FormatCachedAt(release.CachedAt)
_, err := db.Conn().Exec(
"INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
release.RGID, release.ArtistID, release.Title, release.Type, release.ReleaseDate, release.IsIgnored, cachedAt, joinSecondaryTypes(release.SecondaryTypes),
)
if err != nil {
return fmt.Errorf("save external release: %w", err)
}
return nil
}
// GetExternalReleasesByArtist returns all external_release rows for a given artist_id.
func GetExternalReleasesByArtist(db *DB, artistID string) ([]ExternalRelease, error) {
rows, err := db.Conn().Query(
"SELECT rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types FROM external_releases WHERE artist_id = ?",
artistID,
)
if err != nil {
return nil, fmt.Errorf("query external releases by artist: %w", err)
}
defer rows.Close()
var results []ExternalRelease
for rows.Next() {
var r ExternalRelease
var cachedAt sql.NullTime
var secondaryTypes sql.NullString
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored, &cachedAt, &secondaryTypes); err != nil {
return nil, fmt.Errorf("scan external release: %w", err)
}
if cachedAt.Valid {
r.CachedAt = cachedAt.Time
}
if secondaryTypes.Valid {
r.SecondaryTypes = splitSecondaryTypes(secondaryTypes.String)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external releases: %w", err)
}
return results, nil
}
// GetIgnoredReleases returns all external_release rows where is_ignored = 1.
func GetIgnoredReleases(db *DB) ([]ExternalRelease, error) {
rows, err := db.Conn().Query(
"SELECT rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types FROM external_releases WHERE is_ignored = 1",
)
if err != nil {
return nil, fmt.Errorf("query ignored releases: %w", err)
}
defer rows.Close()
var results []ExternalRelease
for rows.Next() {
var r ExternalRelease
var cachedAt sql.NullTime
var secondaryTypes sql.NullString
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored, &cachedAt, &secondaryTypes); err != nil {
return nil, fmt.Errorf("scan ignored release: %w", err)
}
if cachedAt.Valid {
r.CachedAt = cachedAt.Time
}
if secondaryTypes.Valid {
r.SecondaryTypes = splitSecondaryTypes(secondaryTypes.String)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate ignored releases: %w", err)
}
return results, nil
}
// SetReleaseIgnored updates the is_ignored flag for a given RGID.
func SetReleaseIgnored(db *DB, rgid string, ignored bool) error {
result, err := db.Conn().Exec(
"UPDATE external_releases SET is_ignored = ? WHERE rgid = ?",
ignored, rgid,
)
if err != nil {
return fmt.Errorf("set release ignored: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("release not found: %s", rgid)
}
return nil
}
// GetExternalReleasesByArtistWithCache returns cached external_release rows for a given artist_id
// that are within the specified TTL.
func GetExternalReleasesByArtistWithCache(db *DB, artistID string, ttl time.Duration) ([]ExternalRelease, error) {
// cached_at is stored in the "2006-01-02 15:04:05" UTC layout via
// FormatCachedAt. Compare against an explicitly formatted cutoff string in
// the same layout so the lexicographic comparison is a valid time ordering.
const layout = "2006-01-02 15:04:05"
cutoff := time.Now().UTC().Add(-ttl).Format(layout)
rows, err := db.Conn().Query(
"SELECT rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types FROM external_releases WHERE artist_id = ? AND cached_at >= ?",
artistID, cutoff,
)
if err != nil {
return nil, fmt.Errorf("query cached external releases: %w", err)
}
defer rows.Close()
var results []ExternalRelease
for rows.Next() {
var r ExternalRelease
var releaseType sql.NullString
var releaseDate sql.NullString
var cachedAt sql.NullTime
var secondaryTypes sql.NullString
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &releaseType, &releaseDate, &r.IsIgnored, &cachedAt, &secondaryTypes); err != nil {
return nil, fmt.Errorf("scan cached external release: %w", err)
}
if releaseType.Valid {
r.Type = releaseType.String
}
if releaseDate.Valid {
r.ReleaseDate = releaseDate.String
}
if cachedAt.Valid {
r.CachedAt = cachedAt.Time
}
if secondaryTypes.Valid {
r.SecondaryTypes = splitSecondaryTypes(secondaryTypes.String)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate cached external releases: %w", err)
}
return results, nil
}