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() }