Initial commit: playlink music library hardlink deduplicator
Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds songs present in both a host and a slave music library and replaces the slave copy with a hardlink to the host copy. - Path-first matching with format/title/album-folder fallbacks - Auto-linking of byte-identical files, re-verified with cmp before linking - Manual review with ffprobe quality comparison, downgrade guard and promote-to-host override - Build mode to populate an empty slave library from the host - Dry run by default, atomic stage+rename replacement, quarantine with retention, undo, path confinement and authenticated UI - Dockerfile and compose file for Unraid Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ac47e61b08
42 files changed
+8892
No files matched your search
@@ -0,0 +1,651 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ---- users & sessions ----
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateFirstUser inserts a user only if none exist (atomic setup).
|
||||
func (s *Store) CreateFirstUser(ctx context.Context, username, hash string) error {
|
||||
res, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, created_at)
|
||||
SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM users)`, username, hash, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errors.New("setup already completed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UserByName(ctx context.Context, name string) (User, error) {
|
||||
var u User
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE username = ?`, name).
|
||||
Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) UserByID(ctx context.Context, id int64) (User, error) {
|
||||
var u User
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE id = ?`, id).
|
||||
Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(ctx context.Context, id int64, hash string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID, expires int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`,
|
||||
tokenHash, userID, expires, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (int64, error) {
|
||||
var uid int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT user_id FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now()).Scan(&uid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return uid, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, except string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, except)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PruneSessions(ctx context.Context) {
|
||||
_, _ = s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, now())
|
||||
}
|
||||
|
||||
// ---- pairs ----
|
||||
|
||||
type Pair struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HostRoot string `json:"host_root"`
|
||||
SlaveRoot string `json:"slave_root"`
|
||||
QuarantineDir string `json:"quarantine_dir"`
|
||||
Extensions []string `json:"extensions"`
|
||||
AutoLinkIdentical bool `json:"auto_link_identical"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
SettleMinutes int `json:"settle_minutes"`
|
||||
LastScanAt *int64 `json:"last_scan_at"`
|
||||
LastScanError string `json:"last_scan_error"`
|
||||
LastScanWarnings []string `json:"last_scan_warnings"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
const pairCols = `id, name, host_root, slave_root, quarantine_dir, extensions, auto_link_identical,
|
||||
scan_interval_minutes, retention_days, settle_minutes, last_scan_at, last_scan_error, last_scan_warnings, created_at`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanPair(r scanner) (Pair, error) {
|
||||
var p Pair
|
||||
var exts, warns string
|
||||
var last sql.NullInt64
|
||||
err := r.Scan(&p.ID, &p.Name, &p.HostRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
|
||||
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &last, &p.LastScanError, &warns, &p.CreatedAt)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if last.Valid {
|
||||
p.LastScanAt = &last.Int64
|
||||
}
|
||||
p.Extensions = strings.Split(exts, ",")
|
||||
_ = json.Unmarshal([]byte(warns), &p.LastScanWarnings)
|
||||
if p.LastScanWarnings == nil {
|
||||
p.LastScanWarnings = []string{}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) Pairs(ctx context.Context) ([]Pair, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+pairCols+` FROM pairs ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Pair{}
|
||||
for rows.Next() {
|
||||
p, err := scanPair(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Pair(ctx context.Context, id int64) (Pair, error) {
|
||||
p, err := scanPair(s.DB.QueryRowContext(ctx, `SELECT `+pairCols+` FROM pairs WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return p, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) SavePair(ctx context.Context, p *Pair) error {
|
||||
exts := strings.Join(p.Extensions, ",")
|
||||
if p.ID == 0 {
|
||||
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, host_root, slave_root, quarantine_dir, extensions,
|
||||
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.Name, p.HostRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, host_root = ?, slave_root = ?, quarantine_dir = ?,
|
||||
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?
|
||||
WHERE id = ?`,
|
||||
p.Name, p.HostRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeletePair(ctx context.Context, id int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM pairs WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearMatches(ctx context.Context, pairID int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM matches WHERE pair_id = ?`, pairID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) FinishScan(ctx context.Context, pairID, at int64, scanErr string, warnings []string) error {
|
||||
if warnings == nil {
|
||||
warnings = []string{}
|
||||
}
|
||||
w, _ := json.Marshal(warnings)
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET last_scan_at = ?, last_scan_error = ?, last_scan_warnings = ? WHERE id = ?`,
|
||||
at, scanErr, string(w), pairID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- matches ----
|
||||
|
||||
// Match statuses.
|
||||
const (
|
||||
StatusLinked = "linked" // already the same inode
|
||||
StatusIdentical = "identical" // byte-identical, safe to auto-link
|
||||
StatusDifferent = "different" // same path, different content: manual review
|
||||
StatusSimilar = "similar" // similar name (other extension / normalized title): manual review
|
||||
StatusUnmatched = "unmatched" // exists only in the slave library
|
||||
StatusError = "error" // cannot be linked (e.g. other filesystem)
|
||||
)
|
||||
|
||||
// Match kinds (how the host candidate was found).
|
||||
const (
|
||||
KindPath = "path" // identical relative path
|
||||
KindPathCase = "path_case" // relative path differs only by case
|
||||
KindExtension = "extension" // same directory and title, different extension
|
||||
KindFuzzy = "fuzzy" // same directory, normalized title matches
|
||||
KindElsewhere = "elsewhere" // same artist, different album folder
|
||||
KindNone = "none"
|
||||
)
|
||||
|
||||
type Match struct {
|
||||
ID int64 `json:"id"`
|
||||
PairID int64 `json:"pair_id"`
|
||||
RelSlave string `json:"rel_slave"`
|
||||
RelHost string `json:"rel_host"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
HostIno uint64 `json:"-"`
|
||||
HostSize int64 `json:"host_size"`
|
||||
HostMtime int64 `json:"host_mtime"`
|
||||
SlaveIno uint64 `json:"-"`
|
||||
SlaveSize int64 `json:"slave_size"`
|
||||
SlaveMtime int64 `json:"slave_mtime"`
|
||||
HostProbe json.RawMessage `json:"host_probe,omitempty"`
|
||||
SlaveProbe json.RawMessage `json:"slave_probe,omitempty"`
|
||||
Verdict json.RawMessage `json:"verdict,omitempty"`
|
||||
Note string `json:"note"`
|
||||
Ignored bool `json:"ignored"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
const matchCols = `id, pair_id, rel_slave, rel_host, kind, status, host_ino, host_size, host_mtime,
|
||||
slave_ino, slave_size, slave_mtime, host_probe, slave_probe, verdict, note, ignored, updated_at`
|
||||
|
||||
func scanMatch(r scanner) (Match, error) {
|
||||
var m Match
|
||||
var hp, sp, v string
|
||||
err := r.Scan(&m.ID, &m.PairID, &m.RelSlave, &m.RelHost, &m.Kind, &m.Status, &m.HostIno, &m.HostSize, &m.HostMtime,
|
||||
&m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &hp, &sp, &v, &m.Note, &m.Ignored, &m.UpdatedAt)
|
||||
if hp != "" {
|
||||
m.HostProbe = json.RawMessage(hp)
|
||||
}
|
||||
if sp != "" {
|
||||
m.SlaveProbe = json.RawMessage(sp)
|
||||
}
|
||||
if v != "" {
|
||||
m.Verdict = json.RawMessage(v)
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
func (s *Store) Match(ctx context.Context, id int64) (Match, error) {
|
||||
m, err := scanMatch(s.DB.QueryRowContext(ctx, `SELECT `+matchCols+` FROM matches WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return m, ErrNotFound
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
func (s *Store) MatchBySlave(ctx context.Context, pairID int64, relSlave string) (Match, error) {
|
||||
m, err := scanMatch(s.DB.QueryRowContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ? AND rel_slave = ?`, pairID, relSlave))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return m, ErrNotFound
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// AllMatches loads every match of a pair keyed by slave path (used by scans
|
||||
// to reuse comparison and probe results for unchanged files).
|
||||
func (s *Store) AllMatches(ctx context.Context, pairID int64) (map[string]Match, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ?`, pairID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]Match{}
|
||||
for rows.Next() {
|
||||
m, err := scanMatch(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[m.RelSlave] = m
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type MatchFilter struct {
|
||||
Status string // a status, "review" (different+similar), "ignored" or ""
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Match, int, error) {
|
||||
where := []string{"pair_id = ?"}
|
||||
args := []any{pairID}
|
||||
switch f.Status {
|
||||
case "":
|
||||
case "review":
|
||||
where = append(where, "status IN ('different','similar') AND ignored = 0")
|
||||
case "ignored":
|
||||
where = append(where, "ignored = 1")
|
||||
default:
|
||||
where = append(where, "status = ? AND ignored = 0")
|
||||
args = append(args, f.Status)
|
||||
}
|
||||
if f.Query != "" {
|
||||
where = append(where, "(rel_slave LIKE ? ESCAPE '\\' OR rel_host LIKE ? ESCAPE '\\')")
|
||||
q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
|
||||
args = append(args, q, q)
|
||||
}
|
||||
w := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM matches WHERE `+w, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
f.Limit = 100
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+
|
||||
` ORDER BY rel_slave LIMIT ? OFFSET ?`, append(args, f.Limit, f.Offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Match{}
|
||||
for rows.Next() {
|
||||
m, err := scanMatch(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// IdenticalIDs lists linkable identical matches (not ignored).
|
||||
func (s *Store) IdenticalIDs(ctx context.Context, pairID int64) ([]int64, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id FROM matches WHERE pair_id = ? AND status = 'identical' AND ignored = 0 ORDER BY rel_slave`, pairID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertMatch(ctx context.Context, m *Match) error {
|
||||
nullable := func(r json.RawMessage) string { return string(r) }
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO matches (pair_id, rel_slave, rel_host, kind, status, host_ino, host_size,
|
||||
host_mtime, slave_ino, slave_size, slave_mtime, host_probe, slave_probe, verdict, note, ignored, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(pair_id, rel_slave) DO UPDATE SET rel_host = excluded.rel_host, kind = excluded.kind,
|
||||
status = excluded.status, host_ino = excluded.host_ino, host_size = excluded.host_size,
|
||||
host_mtime = excluded.host_mtime, slave_ino = excluded.slave_ino, slave_size = excluded.slave_size,
|
||||
slave_mtime = excluded.slave_mtime, host_probe = excluded.host_probe, slave_probe = excluded.slave_probe,
|
||||
verdict = excluded.verdict, note = excluded.note, ignored = excluded.ignored, updated_at = excluded.updated_at`,
|
||||
m.PairID, m.RelSlave, m.RelHost, m.Kind, m.Status, m.HostIno, m.HostSize, m.HostMtime, m.SlaveIno, m.SlaveSize,
|
||||
m.SlaveMtime, nullable(m.HostProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Ignored, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteMatch(ctx context.Context, pairID int64, relSlave string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM matches WHERE pair_id = ? AND rel_slave = ?`, pairID, relSlave)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMatchesExcept removes rows for slave files that disappeared.
|
||||
func (s *Store) DeleteMatchesExcept(ctx context.Context, pairID int64, keep map[string]bool) error {
|
||||
existing, err := s.AllMatches(ctx, pairID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rel := range existing {
|
||||
if !keep[rel] {
|
||||
if err := s.DeleteMatch(ctx, pairID, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetIgnored(ctx context.Context, id int64, ignored bool) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE matches SET ignored = ? WHERE id = ?`, ignored, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// LinkedHostPaths returns host paths already linked into the slave under prefix.
|
||||
func (s *Store) LinkedHostPaths(ctx context.Context, pairID int64, prefix string) ([]string, error) {
|
||||
q := `SELECT rel_host FROM matches WHERE pair_id = ? AND status = 'linked'`
|
||||
args := []any{pairID}
|
||||
if prefix != "" {
|
||||
q += ` AND rel_host LIKE ? ESCAPE '\'`
|
||||
args = append(args, strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)+"/%")
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var r string
|
||||
if err := rows.Scan(&r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Counts map[string]int `json:"counts"`
|
||||
Review int `json:"review"`
|
||||
Ignored int `json:"ignored"`
|
||||
ReclaimableSize int64 `json:"reclaimable_bytes"`
|
||||
LinkedSize int64 `json:"linked_bytes"`
|
||||
}
|
||||
|
||||
func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) {
|
||||
sum := Summary{Counts: map[string]int{}}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT status, ignored, COUNT(*), COALESCE(SUM(slave_size), 0) FROM matches WHERE pair_id = ? GROUP BY status, ignored`, pairID)
|
||||
if err != nil {
|
||||
return sum, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var st string
|
||||
var ign bool
|
||||
var n int
|
||||
var size int64
|
||||
if err := rows.Scan(&st, &ign, &n, &size); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
if ign {
|
||||
sum.Ignored += n
|
||||
continue
|
||||
}
|
||||
sum.Counts[st] += n
|
||||
switch st {
|
||||
case StatusIdentical:
|
||||
sum.ReclaimableSize += size
|
||||
case StatusLinked:
|
||||
sum.LinkedSize += size
|
||||
case StatusDifferent, StatusSimilar:
|
||||
sum.Review += n
|
||||
}
|
||||
}
|
||||
return sum, rows.Err()
|
||||
}
|
||||
|
||||
// ---- operations ----
|
||||
|
||||
type Operation struct {
|
||||
ID int64 `json:"id"`
|
||||
PairID int64 `json:"pair_id"`
|
||||
Action string `json:"action"`
|
||||
RelHost string `json:"rel_host"`
|
||||
RelSlave string `json:"rel_slave"`
|
||||
RelNew string `json:"rel_new"`
|
||||
QuarantinePath string `json:"quarantine_path"`
|
||||
QuarantineSide string `json:"quarantine_side"`
|
||||
TmpPath string `json:"-"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
FinishedAt *int64 `json:"finished_at"`
|
||||
UndoneAt *int64 `json:"undone_at"`
|
||||
PurgedAt *int64 `json:"purged_at"`
|
||||
}
|
||||
|
||||
// Operation actions.
|
||||
const (
|
||||
ActLink = "link" // slave replaced by hardlink of host
|
||||
ActPromote = "promote" // host replaced by slave's file, then linked
|
||||
ActBuild = "build" // host file linked into the slave (fresh library)
|
||||
ActRemove = "remove" // slave hardlink removed (host copy kept)
|
||||
)
|
||||
|
||||
// Operation statuses.
|
||||
const (
|
||||
OpRunning = "running"
|
||||
OpDone = "done"
|
||||
OpFailed = "failed"
|
||||
OpPlanned = "planned" // dry run
|
||||
OpInterrupted = "interrupted"
|
||||
)
|
||||
|
||||
const opCols = `id, pair_id, action, rel_host, rel_slave, rel_new, quarantine_path, quarantine_side, tmp_path, bytes,
|
||||
dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at`
|
||||
|
||||
func scanOp(r scanner) (Operation, error) {
|
||||
var o Operation
|
||||
var fin, und, pur sql.NullInt64
|
||||
err := r.Scan(&o.ID, &o.PairID, &o.Action, &o.RelHost, &o.RelSlave, &o.RelNew, &o.QuarantinePath, &o.QuarantineSide,
|
||||
&o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur)
|
||||
if fin.Valid {
|
||||
o.FinishedAt = &fin.Int64
|
||||
}
|
||||
if und.Valid {
|
||||
o.UndoneAt = &und.Int64
|
||||
}
|
||||
if pur.Valid {
|
||||
o.PurgedAt = &pur.Int64
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateOp(ctx context.Context, o *Operation) error {
|
||||
o.CreatedAt = now()
|
||||
res, err := s.DB.ExecContext(ctx, `INSERT INTO operations (pair_id, action, rel_host, rel_slave, rel_new, quarantine_path,
|
||||
quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
o.PairID, o.Action, o.RelHost, o.RelSlave, o.RelNew, o.QuarantinePath, o.QuarantineSide, o.TmpPath, o.Bytes,
|
||||
o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) FinishOp(ctx context.Context, id int64, status, errMsg string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE operations SET status = ?, error = ?, tmp_path = '', finished_at = ? WHERE id = ?`,
|
||||
status, errMsg, now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkUndone(ctx context.Context, id int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE operations SET undone_at = ? WHERE id = ?`, now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkPurged(ctx context.Context, id int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE operations SET purged_at = ? WHERE id = ?`, now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Op(ctx context.Context, id int64) (Operation, error) {
|
||||
o, err := scanOp(s.DB.QueryRowContext(ctx, `SELECT `+opCols+` FROM operations WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return o, ErrNotFound
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
type OpFilter struct {
|
||||
PairID int64
|
||||
Quarantine bool // only ops holding a live quarantined file
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (s *Store) Ops(ctx context.Context, f OpFilter) ([]Operation, int, error) {
|
||||
where := []string{"1=1"}
|
||||
var args []any
|
||||
if f.PairID != 0 {
|
||||
where = append(where, "pair_id = ?")
|
||||
args = append(args, f.PairID)
|
||||
}
|
||||
if f.Quarantine {
|
||||
where = append(where, "quarantine_path != '' AND purged_at IS NULL AND undone_at IS NULL AND status IN ('done','interrupted')")
|
||||
}
|
||||
w := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM operations WHERE `+w, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
f.Limit = 100
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+opCols+` FROM operations WHERE `+w+` ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
append(args, f.Limit, f.Offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Operation{}
|
||||
for rows.Next() {
|
||||
o, err := scanOp(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// RunningOps returns operations left in the running state (after a crash).
|
||||
func (s *Store) RunningOps(ctx context.Context) ([]Operation, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+opCols+` FROM operations WHERE status = 'running'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Operation
|
||||
for rows.Next() {
|
||||
o, err := scanOp(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ExpiredQuarantine returns quarantine-holding ops older than cutoff.
|
||||
func (s *Store) ExpiredQuarantine(ctx context.Context, pairID, cutoff int64) ([]Operation, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+opCols+` FROM operations WHERE pair_id = ? AND quarantine_path != ''
|
||||
AND purged_at IS NULL AND undone_at IS NULL AND status IN ('done','interrupted') AND created_at < ?`, pairID, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Operation
|
||||
for rows.Next() {
|
||||
o, err := scanOp(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package store persists playlink state in SQLite.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
var migrations = []string{
|
||||
`CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE pairs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
host_root TEXT NOT NULL,
|
||||
slave_root TEXT NOT NULL,
|
||||
quarantine_dir TEXT NOT NULL,
|
||||
extensions TEXT NOT NULL,
|
||||
auto_link_identical INTEGER NOT NULL DEFAULT 0,
|
||||
scan_interval_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
settle_minutes INTEGER NOT NULL DEFAULT 10,
|
||||
last_scan_at INTEGER,
|
||||
last_scan_error TEXT NOT NULL DEFAULT '',
|
||||
last_scan_warnings TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE matches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
pair_id INTEGER NOT NULL REFERENCES pairs(id) ON DELETE CASCADE,
|
||||
rel_slave TEXT NOT NULL,
|
||||
rel_host TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
host_ino INTEGER NOT NULL DEFAULT 0,
|
||||
host_size INTEGER NOT NULL DEFAULT 0,
|
||||
host_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
slave_ino INTEGER NOT NULL DEFAULT 0,
|
||||
slave_size INTEGER NOT NULL DEFAULT 0,
|
||||
slave_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
host_probe TEXT NOT NULL DEFAULT '',
|
||||
slave_probe TEXT NOT NULL DEFAULT '',
|
||||
verdict TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
ignored INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(pair_id, rel_slave)
|
||||
);
|
||||
CREATE INDEX matches_pair_status ON matches(pair_id, status);
|
||||
CREATE TABLE operations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
pair_id INTEGER NOT NULL REFERENCES pairs(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
rel_host TEXT NOT NULL DEFAULT '',
|
||||
rel_slave TEXT NOT NULL DEFAULT '',
|
||||
rel_new TEXT NOT NULL DEFAULT '',
|
||||
quarantine_path TEXT NOT NULL DEFAULT '',
|
||||
quarantine_side TEXT NOT NULL DEFAULT '',
|
||||
tmp_path TEXT NOT NULL DEFAULT '',
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
status TEXT NOT NULL,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
undone_at INTEGER,
|
||||
purged_at INTEGER
|
||||
);
|
||||
CREATE INDEX operations_pair ON operations(pair_id, created_at DESC);`,
|
||||
}
|
||||
|
||||
// Open opens (and migrates) the database in dataDir.
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
dsn := "file:" + filepath.Join(dataDir, "playlink.db") +
|
||||
"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: one writer; keeps things simple and safe.
|
||||
s := &Store{DB: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
if _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS schema_version (v INTEGER NOT NULL)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var v int
|
||||
if err := s.DB.QueryRow(`SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := v; i < len(migrations); i++ {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_version (v) VALUES (?)`, i+1); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func now() int64 { return time.Now().Unix() }
|
||||
|
||||
// ---- settings ----
|
||||
|
||||
func (s *Store) Setting(ctx context.Context, key, def string) string {
|
||||
var v string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v); err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// DryRun is the global safety switch. It defaults to on for new installs.
|
||||
func (s *Store) DryRun(ctx context.Context) bool {
|
||||
return s.Setting(ctx, "dry_run", "1") == "1"
|
||||
}
|
||||
Reference in new issue
Block a user