mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 22:07:30 +02:00
internal/download The ~40-line progress-bar decorator stack was copy-pasted into three download paths and had started to drift; it is now newDownloadBar, which returns a nil bar when progress is off (so callers use it unconditionally) plus a finish func to defer. The three hand-rolled read/write loops that differed only in whether they touched a bar become copyWithProgress. FileDeezerEncrypted and FileYandexEncrypted were ~90% identical: mkdir, GET, status check, create output, bar, cleanup-on-failure, Content-Length check, fsync. That scaffolding is now streamEncrypted, and each function supplies only its decryption step. Also switches the io.EOF/io.ErrUnexpectedEOF comparisons to errors.Is. internal/app Three copies of "coerce a JSON array that may be []any or []map[string]any" become anySlice, which also reports whether the value was an array at all so callers can still distinguish missing from empty. The worker-pool dispatch existed three times and the copies disagreed: two counted failures under a mutex and logged inline with the track id, while the third collected errors in a channel and logged them all after wg.Wait() without the id. runTrackWorkers unifies them on the inline form, which reports failures as they happen and names the track, and keeps the mutex held across the log so failure lines cannot interleave. Tests The progress-bar paths had no coverage at all, because progress is disabled when not attached to a terminal; four new tests force it on via the existing STREAMRIP_GO_FORCE_PROGRESS escape hatch and cover the determinate bar, the unknown-length spinner, Deezer decryption with a bar attached, and the failure path still removing the partial file. internal/download coverage 53% -> 63%. Three cancellation tests pin the Ctrl-C fix. Verified as genuine regression tests by reverting the guards: without them the rip walks all 40 tracks of a cancelled album, logs a failure for each, and Rip() returns nil. Adds store.SQLite.IsFailed, the read complement to MarkFailed, used by the bookkeeping test to confirm failure records still land under a cancelled context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
8.4 KiB
Go
328 lines
8.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type SQLite struct {
|
|
db *sql.DB
|
|
path string
|
|
mu sync.Mutex
|
|
|
|
// hasLegacyRows is set when the database was migrated from the pre-source
|
|
// schema. Those rows have no source, so IsDownloaded has to consider them
|
|
// separately or the entire pre-migration library looks undownloaded.
|
|
hasLegacyRows bool
|
|
legacyWarned bool
|
|
}
|
|
|
|
func NewSQLite(path string) (*SQLite, error) {
|
|
db, err := sql.Open("sqlite", sqliteDSN(path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// One writer at a time. SQLite allows a single writer anyway, and the
|
|
// mutex below already serializes this process; capping the pool keeps the
|
|
// driver from opening connections that can only contend with each other.
|
|
db.SetMaxOpenConns(1)
|
|
|
|
s := &SQLite{db: db, path: path}
|
|
if err = s.init(); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// sqliteDSN builds a file: URI with the pragmas this app needs.
|
|
//
|
|
// WAL lets a concurrent reader (for example `rip db browse`) run while a rip
|
|
// is writing, and busy_timeout makes a contended write wait instead of failing
|
|
// immediately with SQLITE_BUSY. The path is passed through url.URL so that a
|
|
// config path containing '?' or '#' is not parsed as query parameters.
|
|
func sqliteDSN(path string) string {
|
|
u := url.URL{Scheme: "file", Opaque: (&url.URL{Path: path}).EscapedPath()}
|
|
q := url.Values{}
|
|
q.Add("_pragma", "busy_timeout(10000)")
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
q.Add("_pragma", "foreign_keys(on)")
|
|
return u.String() + "?" + q.Encode()
|
|
}
|
|
|
|
// schemaVersion is stored in PRAGMA user_version so later migrations can be
|
|
// ordered explicitly instead of being inferred from PRAGMA table_info.
|
|
const schemaVersion = 1
|
|
|
|
func (s *SQLite) userVersion() (int, error) {
|
|
var v int
|
|
if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
|
|
return 0, err
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (s *SQLite) setUserVersion(v int) error {
|
|
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, v))
|
|
return err
|
|
}
|
|
|
|
// backupFile copies the database aside before a destructive migration. A
|
|
// migration that loses the download history silently costs the user a full
|
|
// re-download, so keep a copy they can fall back to.
|
|
func (s *SQLite) backupFile() (string, error) {
|
|
if strings.TrimSpace(s.path) == "" {
|
|
return "", nil
|
|
}
|
|
src, err := os.Open(s.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
defer func() { _ = src.Close() }()
|
|
|
|
dstPath := fmt.Sprintf("%s.bak-%d", s.path, time.Now().Unix())
|
|
dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err = io.Copy(dst, src); err != nil {
|
|
_ = dst.Close()
|
|
_ = os.Remove(dstPath)
|
|
return "", err
|
|
}
|
|
if err = dst.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
return dstPath, nil
|
|
}
|
|
|
|
func (s *SQLite) init() error {
|
|
if err := s.ensureDownloadsSchema(); err != nil {
|
|
return err
|
|
}
|
|
|
|
queries := []string{
|
|
`CREATE TABLE IF NOT EXISTS failed_downloads (
|
|
source TEXT NOT NULL,
|
|
media_type TEXT NOT NULL,
|
|
id TEXT NOT NULL,
|
|
PRIMARY KEY (source, media_type, id)
|
|
)`,
|
|
}
|
|
|
|
for _, q := range queries {
|
|
if _, err := s.db.Exec(q); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// A database migrated by an earlier version has sourceless rows but no
|
|
// in-memory flag, so detect them here rather than only at migration time.
|
|
if !s.hasLegacyRows {
|
|
if n, err := s.legacyRowCount(context.Background()); err == nil && n > 0 {
|
|
s.hasLegacyRows = true
|
|
}
|
|
}
|
|
|
|
if v, err := s.userVersion(); err == nil && v < schemaVersion {
|
|
if err = s.setUserVersion(schemaVersion); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLite) ensureDownloadsSchema() error {
|
|
rows, err := s.db.Query(`PRAGMA table_info(downloads)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
hasTable := false
|
|
hasSource := false
|
|
for rows.Next() {
|
|
hasTable = true
|
|
var (
|
|
cid int
|
|
name string
|
|
colType string
|
|
notNull int
|
|
defaultV sql.NullString
|
|
pkOrdinal int
|
|
)
|
|
if err = rows.Scan(&cid, &name, &colType, ¬Null, &defaultV, &pkOrdinal); err != nil {
|
|
return err
|
|
}
|
|
if name == "source" {
|
|
hasSource = true
|
|
}
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !hasTable {
|
|
_, err = s.db.Exec(`CREATE TABLE downloads (
|
|
source TEXT NOT NULL,
|
|
id TEXT NOT NULL,
|
|
PRIMARY KEY (source, id)
|
|
)`)
|
|
return err
|
|
}
|
|
|
|
if hasSource {
|
|
_, err = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_downloads_source_id ON downloads(source, id)`)
|
|
return err
|
|
}
|
|
|
|
// Legacy schema: a downloads table with no source column. Back the file up
|
|
// first, because the rows below can only be carried over without a source
|
|
// and that is not fully reversible.
|
|
backupPath, backupErr := s.backupFile()
|
|
if backupErr != nil {
|
|
return fmt.Errorf("could not back up %s before migrating: %w", s.path, backupErr)
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
if _, err = tx.Exec(`DROP TABLE IF EXISTS downloads_legacy`); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Exec(`ALTER TABLE downloads RENAME TO downloads_legacy`); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Exec(`CREATE TABLE downloads (
|
|
source TEXT NOT NULL,
|
|
id TEXT NOT NULL,
|
|
PRIMARY KEY (source, id)
|
|
)`); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Exec(`INSERT OR IGNORE INTO downloads(source, id) SELECT '', id FROM downloads_legacy`); err != nil {
|
|
return err
|
|
}
|
|
// downloads_legacy is deliberately kept. Dropping it used to make the
|
|
// migration unrecoverable, and IsDownloaded needs the sourceless rows to
|
|
// avoid re-downloading everything the user already has.
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.hasLegacyRows = true
|
|
if backupPath != "" {
|
|
fmt.Fprintf(os.Stderr, "download database migrated to the source-aware schema; previous copy kept at %s\n", backupPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// legacyRowCount reports how many pre-migration rows exist. Rows carried over
|
|
// from the old schema have an empty source.
|
|
func (s *SQLite) legacyRowCount(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE source = ''`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func (s *SQLite) IsDownloaded(ctx context.Context, source, id string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var count int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE source = ? AND id = ?`, source, id).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if count > 0 {
|
|
return true, nil
|
|
}
|
|
|
|
// Rows migrated from the pre-source schema have an empty source, so an
|
|
// exact match on (source, id) misses them and the whole pre-migration
|
|
// library would look undownloaded. Matching on id alone can only ever
|
|
// over-match across sources, which costs a skipped re-download rather than
|
|
// a duplicated library.
|
|
if !s.hasLegacyRows {
|
|
return false, nil
|
|
}
|
|
err = s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE source = '' AND id = ?`, id).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if count > 0 && !s.legacyWarned {
|
|
s.legacyWarned = true
|
|
fmt.Fprintf(os.Stderr, "note: matching downloads recorded before sources were tracked; pass --force to re-download them\n")
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (s *SQLite) MarkDownloaded(ctx context.Context, source, id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO downloads(source, id) VALUES (?, ?)`, source, id)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLite) MarkFailed(ctx context.Context, source, mediaType, id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
_, err := s.db.ExecContext(
|
|
ctx,
|
|
`INSERT OR IGNORE INTO failed_downloads(source, media_type, id) VALUES (?, ?, ?)`,
|
|
source,
|
|
mediaType,
|
|
id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// IsFailed reports whether a previous attempt at this item was recorded as
|
|
// failed. It complements MarkFailed and is what the retry/repair workflows and
|
|
// `rip database browse failed` are built on.
|
|
func (s *SQLite) IsFailed(ctx context.Context, source, mediaType, id string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var count int
|
|
err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT COUNT(1) FROM failed_downloads WHERE source = ? AND media_type = ? AND id = ?`,
|
|
source, mediaType, id,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (s *SQLite) Close() error {
|
|
if s.db == nil {
|
|
return nil
|
|
}
|
|
return s.db.Close()
|
|
}
|