Files
playlink/internal/store/models.go
T
cdnutterandClaude Opus 5.5 5fb195f0d6 Redo file naming in the style of Lidarr and add "Rename now"
Naming: separate artist folder, album folder, track and multi-disc track
formats with presets, Lidarr-style tokens ({Artist Name}, {Artist
NameThe}, {Artist CleanName}, {Artist NameFirstCharacter}, {Album
Title}, {Album TitleThe}, {Album CleanTitle}, {Release Year},
{track:00}, {medium:00}, {Medium Format}, {Track Title}, {Track
CleanTitle}, {Track ArtistName}, {Genre}) with Lidarr's word-separator
and case variants, colon replacement modes and replace/remove for other
illegal characters. Empty brackets and separators left by missing values
are cleaned up. Defaults to Artist/Album/01 Title with Disc 01 folders
for multi-disc albums; earlier pattern/disc settings are converted.

Examples are now rendered by the server with the same code as real
renames, fixing the settings page showing paths that differed from what
would happen (e.g. disc 1 of a multi-disc album outside its Disc folder).

"Rename now..." checks one or both libraries, then lists every change
grouped by destination folder with per-file and per-folder selection,
skipped files with reasons and a Fix tags button, and warnings; applying
re-checks each file, renames, and rescans. The pre-action backup runs
first when enabled. Migration 9 stores the naming as JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:58:36 -07:00

843 lines
29 KiB
Go

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())
}
// ---- warnings ----
// Warning codes.
const WarnRenamedFolders = "renamed_folders"
// Warning is a scan or validation notice, structured so the UI can lay it
// out (a heading, an explanation and a table of affected folders) rather
// than print one long sentence.
type Warning struct {
// Code identifies warnings the UI offers a fix for.
Code string `json:"code,omitempty"`
Title string `json:"title"`
Detail string `json:"detail,omitempty"`
Items []WarningItem `json:"items,omitempty"`
// Total is the full number of items when Items was truncated.
Total int `json:"total,omitempty"`
// Fixable is how many items the offered fix can handle automatically.
Fixable int `json:"fixable,omitempty"`
}
// WarningItem pairs a slave path with the master path it corresponds to.
type WarningItem struct {
Slave string `json:"slave"`
Master string `json:"master"`
Count int `json:"count,omitempty"` // songs affected, when grouped
}
// decodeWarnings reads stored warnings; scans before structured warnings
// stored plain strings, which become title-only warnings.
func decodeWarnings(raw string) []Warning {
var ws []Warning
if json.Unmarshal([]byte(raw), &ws) == nil && ws != nil {
return ws
}
var old []string
_ = json.Unmarshal([]byte(raw), &old)
ws = make([]Warning, 0, len(old))
for _, t := range old {
ws = append(ws, Warning{Title: t})
}
return ws
}
// ---- pairs ----
type Pair struct {
ID int64 `json:"id"`
Name string `json:"name"`
MasterRoot string `json:"master_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"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` // percent
OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
AutoOrganize string `json:"auto_organize"` // off, slave, master or both
OrganizeDiscDigits int `json:"-"` // superseded by OrganizeConfig; read to convert old settings
OrganizeConfig json.RawMessage `json:"-"` // naming settings (engine.NamingConfig)
LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"`
CreatedAt int64 `json:"created_at"`
}
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, organize_template,
organize_multidisc, auto_organize, organize_disc_digits, organize_config, 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, orgCfg string
var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &p.OrganizeTemplate,
&p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &orgCfg, &last, &p.LastScanError,
&warns, &p.CreatedAt)
if err != nil {
return p, err
}
if last.Valid {
p.LastScanAt = &last.Int64
}
p.Extensions = strings.Split(exts, ",")
p.LastScanWarnings = decodeWarnings(warns)
if orgCfg != "" {
p.OrganizeConfig = json.RawMessage(orgCfg)
}
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, master_root, slave_root, quarantine_dir, extensions,
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), now())
if err != nil {
return err
}
p.ID, _ = res.LastInsertId()
return nil
}
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ?,
organize_disc_digits = ?, organize_config = ? WHERE id = ?`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), 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 []Warning) error {
if warnings == nil {
warnings = []Warning{}
}
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 master 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 top-level folder and title, different subfolder
KindApprox = "approximate" // opt-in: similar title, disc folders ignored
KindInode = "inode" // the same file (hardlink) at another path
KindNone = "none"
)
type Match struct {
ID int64 `json:"id"`
PairID int64 `json:"pair_id"`
RelSlave string `json:"rel_slave"`
RelMaster string `json:"rel_master"`
Kind string `json:"kind"`
Status string `json:"status"`
MasterIno uint64 `json:"-"`
MasterSize int64 `json:"master_size"`
MasterMtime int64 `json:"master_mtime"`
SlaveIno uint64 `json:"-"`
SlaveSize int64 `json:"slave_size"`
SlaveMtime int64 `json:"slave_mtime"`
MasterProbe json.RawMessage `json:"master_probe,omitempty"`
SlaveProbe json.RawMessage `json:"slave_probe,omitempty"`
Verdict json.RawMessage `json:"verdict,omitempty"`
Note string `json:"note"`
Reason string `json:"reason"` // why an approximate match was chosen
LinkTarget string `json:"link_target"` // where linking puts the slave file (filled by the API)
Ignored bool `json:"ignored"`
UpdatedAt int64 `json:"updated_at"`
}
const matchCols = `id, pair_id, rel_slave, rel_master, kind, status, master_ino, master_size, master_mtime,
slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, ignored, updated_at`
func scanMatch(r scanner) (Match, error) {
var m Match
var mp, sp, v string
err := r.Scan(&m.ID, &m.PairID, &m.RelSlave, &m.RelMaster, &m.Kind, &m.Status, &m.MasterIno, &m.MasterSize, &m.MasterMtime,
&m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &mp, &sp, &v, &m.Note, &m.Reason, &m.Ignored, &m.UpdatedAt)
if mp != "" {
m.MasterProbe = json.RawMessage(mp)
}
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
IDs []int64 // restrict to these matches (at most maxIDFilter)
Limit int
Offset int
}
// matchWhere builds the WHERE clause shared by listing and export.
func matchWhere(pairID int64, f MatchFilter) (string, []any) {
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")
case "upgrade":
where = append(where, "status IN ('different','similar') AND ignored = 0 AND "+upgradeCond)
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_master LIKE ? ESCAPE '\\')")
q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
args = append(args, q, q)
}
if len(f.IDs) > 0 {
ids := f.IDs
if len(ids) > maxIDFilter {
ids = ids[:maxIDFilter]
}
where = append(where, "id IN ("+strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")+")")
for _, id := range ids {
args = append(args, id)
}
}
return strings.Join(where, " AND "), args
}
const maxIDFilter = 500
// MatchesByIDs loads matches by id (any pair; callers check ownership).
func (s *Store) MatchesByIDs(ctx context.Context, ids []int64) ([]Match, error) {
out := make([]Match, 0, len(ids))
for start := 0; start < len(ids); start += maxIDFilter {
chunk := ids[start:min(start+maxIDFilter, len(ids))]
args := make([]any, len(chunk))
for i, id := range chunk {
args[i] = id
}
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE id IN (`+
strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
rows.Close()
return nil, err
}
out = append(out, m)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return out, nil
}
func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Match, int, error) {
w, args := matchWhere(pairID, f)
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()
}
// ExportMatches calls fn for every match of the filter, ordered by slave
// path. Rows are read in pages so a slow download never holds the database
// connection (scans need it) for longer than one page.
func (s *Store) ExportMatches(ctx context.Context, pairID int64, f MatchFilter, fn func(Match) error) error {
const page = 1000
w, args := matchWhere(pairID, f)
after := ""
for {
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+
` AND rel_slave > ? ORDER BY rel_slave LIMIT ?`, append(append([]any{}, args...), after, page)...)
if err != nil {
return err
}
batch := make([]Match, 0, page)
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
rows.Close()
return err
}
batch = append(batch, m)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, m := range batch {
if err := fn(m); err != nil {
return err
}
}
if len(batch) < page {
return nil
}
after = batch[len(batch)-1].RelSlave
}
}
// 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_master, kind, status, master_ino, master_size,
master_mtime, slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, ignored, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pair_id, rel_slave) DO UPDATE SET rel_master = excluded.rel_master, kind = excluded.kind,
status = excluded.status, master_ino = excluded.master_ino, master_size = excluded.master_size,
master_mtime = excluded.master_mtime, slave_ino = excluded.slave_ino, slave_size = excluded.slave_size,
slave_mtime = excluded.slave_mtime, master_probe = excluded.master_probe, slave_probe = excluded.slave_probe,
verdict = excluded.verdict, note = excluded.note, reason = excluded.reason, ignored = excluded.ignored, updated_at = excluded.updated_at`,
m.PairID, m.RelSlave, m.RelMaster, m.Kind, m.Status, m.MasterIno, m.MasterSize, m.MasterMtime, m.SlaveIno, m.SlaveSize,
m.SlaveMtime, nullable(m.MasterProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Reason, 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
}
// LinkedMasterPaths returns master paths already linked into the slave under prefix.
func (s *Store) LinkedMasterPaths(ctx context.Context, pairID int64, prefix string) ([]string, error) {
q := `SELECT rel_master FROM matches WHERE pair_id = ? AND status = 'linked'`
args := []any{pairID}
if prefix != "" {
q += ` AND rel_master 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()
}
// upgradeCond selects review matches where the master copy scored better, so
// linking upgrades the slave.
const upgradeCond = `verdict LIKE '%"better":"master"%'`
type Summary struct {
Counts map[string]int `json:"counts"`
Review int `json:"review"`
Upgrades int `json:"upgrades"`
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
}
}
if err := rows.Err(); err != nil {
return sum, err
}
err = s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM matches WHERE pair_id = ? AND ignored = 0
AND status IN ('different','similar') AND `+upgradeCond, pairID).Scan(&sum.Upgrades)
return sum, err
}
// ---- operations ----
type Operation struct {
ID int64 `json:"id"`
PairID int64 `json:"pair_id"`
Action string `json:"action"`
RelMaster string `json:"rel_master"`
RelSlave string `json:"rel_slave"`
RelNew string `json:"rel_new"`
MatchKind string `json:"match_kind"`
MatchReason string `json:"match_reason"`
LinkPreexisted bool `json:"link_preexisted"`
Detail json.RawMessage `json:"detail,omitempty"`
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 master
ActPromote = "promote" // master replaced by slave's file, then linked
ActBuild = "build" // master file linked into the slave (fresh library)
ActRemove = "remove" // slave hardlink removed (master copy kept)
ActRename = "rename" // slave folder renamed to the master's spelling
ActTags = "tags" // tags of a file edited
ActMove = "move" // file renamed/moved by the organizer
)
// Operation statuses.
const (
OpRunning = "running"
OpDone = "done"
OpFailed = "failed"
OpPlanned = "planned" // dry run
OpInterrupted = "interrupted"
)
const opCols = `id, pair_id, action, rel_master, rel_slave, rel_new, quarantine_path, quarantine_side, tmp_path, bytes,
dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted, detail`
func scanOp(r scanner) (Operation, error) {
var o Operation
var fin, und, pur sql.NullInt64
var detail string
err := r.Scan(&o.ID, &o.PairID, &o.Action, &o.RelMaster, &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, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted, &detail)
if detail != "" {
o.Detail = json.RawMessage(detail)
}
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_master, rel_slave, rel_new, quarantine_path,
quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
o.PairID, o.Action, o.RelMaster, o.RelSlave, o.RelNew, o.QuarantinePath, o.QuarantineSide, o.TmpPath, o.Bytes,
o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted, string(o.Detail))
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()
}
// MatchesByMaster lists the matches of a pair that point at a master file.
func (s *Store) MatchesByMaster(ctx context.Context, pairID int64, relMaster string) ([]Match, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ? AND rel_master = ?`, pairID, relMaster)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Match
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}