Foundation: config, schema/migrations, auth, stable IDs, metadata probing
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user