Foundation: config, schema/migrations, auth, stable IDs, metadata probing

This commit is contained in:
2026-08-22 23:29:16 -05:00
commit d4ea7a4ca9
40 changed files with 3874 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type MediaFile struct {
ID string
MusicFolderID string
RelativePath string
SizeBytes int64
MtimeUnix int64
ContentHash string
Format string
Kind string
DurationSeconds float64
BitrateKbps int
SampleRateHz int
Channels int
Missing bool
FirstSeenAt time.Time
LastScannedAt time.Time
}
// FindMediaFileByPath looks up the stable natural key (folder, relative
// path) used to detect unchanged/changed files across rescans.
func FindMediaFileByPath(db *sql.DB, folderID, relativePath string) (*MediaFile, error) {
return scanMediaFile(db.QueryRow(mediaFileSelect+` WHERE music_folder_id = ? AND relative_path = ?`, folderID, relativePath))
}
// FindMediaFileByHash looks up a file by content fingerprint within a
// folder, used for rename detection: same hash, different path.
func FindMediaFileByHash(db *sql.DB, folderID, contentHash string) (*MediaFile, error) {
return scanMediaFile(db.QueryRow(mediaFileSelect+` WHERE music_folder_id = ? AND content_hash = ? AND content_hash != '' ORDER BY last_scanned_at DESC LIMIT 1`, folderID, contentHash))
}
func GetMediaFile(db *sql.DB, id string) (*MediaFile, error) {
return scanMediaFile(db.QueryRow(mediaFileSelect+` WHERE id = ?`, id))
}
const mediaFileSelect = `SELECT id, music_folder_id, relative_path, size_bytes, mtime_unix, content_hash, format, kind, duration_seconds, bitrate_kbps, sample_rate_hz, channels, missing, first_seen_at, last_scanned_at FROM media_files`
func scanMediaFile(row *sql.Row) (*MediaFile, error) {
var m MediaFile
var missing int
var firstSeen, lastScanned string
err := row.Scan(&m.ID, &m.MusicFolderID, &m.RelativePath, &m.SizeBytes, &m.MtimeUnix, &m.ContentHash, &m.Format, &m.Kind,
&m.DurationSeconds, &m.BitrateKbps, &m.SampleRateHz, &m.Channels, &missing, &firstSeen, &lastScanned)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
m.Missing = missing != 0
m.FirstSeenAt, _ = parseTime(firstSeen)
m.LastScannedAt, _ = parseTime(lastScanned)
return &m, nil
}
// CreateMediaFile inserts a new media file row for a path never seen
// before in this folder.
func CreateMediaFile(db *sql.DB, folderID, relativePath string, size, mtime int64, hash, format, kind string, duration float64, bitrate, sampleRate, channels int) (*MediaFile, error) {
id := idgen.New("mfl")
_, err := db.Exec(`INSERT INTO media_files (id, music_folder_id, relative_path, size_bytes, mtime_unix, content_hash, format, kind, duration_seconds, bitrate_kbps, sample_rate_hz, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, folderID, relativePath, size, mtime, hash, format, kind, duration, bitrate, sampleRate, channels)
if err != nil {
return nil, err
}
return GetMediaFile(db, id)
}
// UpdateMediaFileScanned refreshes a media file's disk-derived fields
// after a rescan finds it changed, and clears any missing flag.
func UpdateMediaFileScanned(db *sql.DB, id string, size, mtime int64, hash, format, kind string, duration float64, bitrate, sampleRate, channels int) error {
_, err := db.Exec(`UPDATE media_files SET size_bytes=?, mtime_unix=?, content_hash=?, format=?, kind=?, duration_seconds=?, bitrate_kbps=?, sample_rate_hz=?, channels=?, missing=0, last_scanned_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
size, mtime, hash, format, kind, duration, bitrate, sampleRate, channels, id)
return err
}
// TouchMediaFileScanned marks a file as seen in the current scan pass
// without changing its metadata (fast path for unchanged files).
func TouchMediaFileScanned(db *sql.DB, id string) error {
_, err := db.Exec(`UPDATE media_files SET missing=0, last_scanned_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, id)
return err
}
// RenameMediaFile updates the relative path of a file matched by content
// hash to a new location (rename/move detection).
func RenameMediaFile(db *sql.DB, id, newRelativePath string) error {
_, err := db.Exec(`UPDATE media_files SET relative_path=?, missing=0, last_scanned_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, newRelativePath, id)
return err
}
// MarkFolderFilesMissingExcept flags every media file in a folder that
// was not touched (last_scanned_at older than the given scan start) as
// missing, without deleting it — playlists, stars, and history stay
// intact if the file reappears later.
func MarkFolderFilesMissingExcept(db *sql.DB, folderID string, scanStartedAt time.Time) (int64, error) {
res, err := db.Exec(`UPDATE media_files SET missing=1 WHERE music_folder_id=? AND missing=0 AND last_scanned_at < ?`,
folderID, scanStartedAt.UTC().Format(timeLayout))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func ListMediaFilesForFolder(db *sql.DB, folderID string) ([]*MediaFile, error) {
rows, err := db.Query(mediaFileSelect+` WHERE music_folder_id = ? ORDER BY relative_path`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMediaFiles(rows)
}
func scanMediaFiles(rows *sql.Rows) ([]*MediaFile, error) {
var out []*MediaFile
for rows.Next() {
var m MediaFile
var missing int
var firstSeen, lastScanned string
if err := rows.Scan(&m.ID, &m.MusicFolderID, &m.RelativePath, &m.SizeBytes, &m.MtimeUnix, &m.ContentHash, &m.Format, &m.Kind,
&m.DurationSeconds, &m.BitrateKbps, &m.SampleRateHz, &m.Channels, &missing, &firstSeen, &lastScanned); err != nil {
return nil, err
}
m.Missing = missing != 0
m.FirstSeenAt, _ = parseTime(firstSeen)
m.LastScannedAt, _ = parseTime(lastScanned)
out = append(out, &m)
}
return out, rows.Err()
}