corpus

Log | Files | Refs | README | LICENSE

commit cada497f6a566dc3bce7c714f58ec74cb52bc6a2
parent a3ac50ec5adc0313bce55736a32195371e891191
Author: mtmn <miro@haravara.org>
Date:   Mon, 13 Apr 2026 11:41:38 +0200

feat: add backups, enabled params

Diffstat:
M.env.example | 3+++
MREADME.md | 25+++++++++++++++++++++----
Msrc/Db.js | 6++++++
Msrc/Db.purs | 49+++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/Main.purs | 41++++++++++++++++++++++++-----------------
Mtest/Main.purs | 30+++++++++++++++++++++++++++++-
6 files changed, 130 insertions(+), 24 deletions(-)

diff --git a/.env.example b/.env.example @@ -9,3 +9,6 @@ AWS_S3_ADDRESSING_STYLE=path AWS_ENDPOINT_URL=http://localhost:8333 INITIAL_SYNC=true DATABASE_FILE=/tmp/scorpus.db +COVER_CACHE_ENABLED=true +BACKUP_ENABLED=true +BACKUP_INTERVAL_HOURS=1 diff --git a/README.md b/README.md @@ -43,7 +43,24 @@ npx spago bundle --module Client --outfile client.js --platform browser ``` -Required environment variables: -- `LISTENBRAINZ_USER`: Your ListenBrainz username -- `DATABASE_FILE`: Path to the DuckDB file (defaults to `scorpus.db`) -- `S3_BUCKET`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc. (for cover art caching) +### Environment variables + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LISTENBRAINZ_USER` | — | Your ListenBrainz username (required for syncing) | +| `DATABASE_FILE` | `scorpus.db` | Path to the DuckDB database file | +| `PORT` | `8000` | HTTP port to listen on | +| `INITIAL_SYNC` | — | Set to `true` to perform a full historical sync on startup | +| `COVER_CACHE_ENABLED` | `true` | Set to `false` to disable S3 cover art caching | +| `S3_BUCKET` | — | S3 bucket name for cover art caching | +| `S3_REGION` | `us-east-1` | S3 region | +| `AWS_ACCESS_KEY_ID` | — | S3 credentials | +| `AWS_SECRET_ACCESS_KEY` | — | S3 credentials | +| `AWS_ENDPOINT_URL` | — | S3-compatible endpoint (e.g. for MinIO) | +| `AWS_S3_ADDRESSING_STYLE` | — | Set to `path` for path-style S3 URLs | +| `LASTFM_API_KEY` | — | Last.fm API key for cover art fallback | +| `DISCOGS_TOKEN` | — | Discogs token for cover art fallback | +| `BACKUP_ENABLED` | — | Set to `true` to enable periodic local database backups | +| `BACKUP_INTERVAL_HOURS` | `1` | How often to back up the database (in hours) | + +Backups are written to a `backup/` directory alongside `DATABASE_FILE`, named `scorpus-<timestamp>.db`. diff --git a/src/Db.js b/src/Db.js @@ -21,6 +21,12 @@ export const runImpl = (conn) => (sql) => (params) => (cb) => () => { }); }; +export const checkpointImpl = (conn) => (cb) => () => { + conn.run("CHECKPOINT", (err) => { + cb(err)(); + }); +}; + export const allImpl = (conn) => (sql) => (params) => (cb) => () => { conn.all(sql, ...params, (err, rows) => { if (rows) { diff --git a/src/Db.purs b/src/Db.purs @@ -5,9 +5,13 @@ import Prelude import Data.Argonaut.Core (Json, toObject, toString) import Data.Either (Either(..)) import Data.Maybe (Maybe(..), fromMaybe) +import Data.Time.Duration (Milliseconds(..)) import Effect (Effect) -import Effect.Aff (Aff, makeAff, nonCanceler, try) -import Effect.Exception (Error, error) +import Effect.Aff (Aff, delay, makeAff, nonCanceler, try) +import Effect.Class (liftEffect) +import Effect.Exception (Error, error, message) +import Effect.Now (nowDateTime) +import Data.Formatter.DateTime (formatDateTime) import Foreign (Foreign) import Unsafe.Coerce (unsafeCoerce) import Types (Listen(..), TrackMetadata(..), MbidMapping(..), Stats(..), StatsEntry(..)) @@ -15,12 +19,18 @@ import Data.Traversable (traverse) import Foreign.Object as Object import Data.Nullable (Nullable, toMaybe, toNullable) import Data.Array (mapMaybe, uncons) +import Data.String (lastIndexOf, Pattern(..), take) +import Control.Monad.Rec.Class (forever) +import Node.FS.Aff as FSA +import Node.FS.Perms (mkPerms, all, read) as Perms +import Log as Log foreign import data Connection :: Type foreign import connectImpl :: String -> (Nullable Error -> Nullable Connection -> Effect Unit) -> Effect Unit foreign import runImpl :: Connection -> String -> Array Foreign -> (Nullable Error -> Effect Unit) -> Effect Unit foreign import allImpl :: Connection -> String -> Array Foreign -> (Nullable Error -> Nullable (Array Json) -> Effect Unit) -> Effect Unit +foreign import checkpointImpl :: Connection -> (Nullable Error -> Effect Unit) -> Effect Unit connect :: String -> Aff Connection connect path = makeAff \cb -> do @@ -40,6 +50,41 @@ run conn sql params = makeAff \cb -> do Nothing -> cb (Right unit) pure nonCanceler +checkpoint :: Connection -> Aff Unit +checkpoint conn = makeAff \cb -> do + checkpointImpl conn \err -> + case toMaybe err of + Just e -> cb (Left e) + Nothing -> cb (Right unit) + pure nonCanceler + +dirName :: String -> String +dirName path = case lastIndexOf (Pattern "/") path of + Just i -> take (i + 1) path + Nothing -> "./" + +performBackup :: Connection -> String -> Aff Unit +performBackup conn dbFile = do + checkpoint conn + dt <- liftEffect nowDateTime + let + ts = case formatDateTime "YYYY-MM-DDTHH:mm:ss" dt of + Right s -> s + Left _ -> "unknown" + let dir = dirName dbFile <> "backup/" + void $ try $ FSA.mkdir' dir { recursive: true, mode: Perms.mkPerms Perms.all Perms.all Perms.read } + let dest = dir <> "scorpus-" <> ts <> ".db" + FSA.copyFile dbFile dest + Log.info $ "Backup saved locally: " <> dest + +backupDb :: Connection -> String -> Number -> Aff Unit +backupDb conn dbFile intervalMs = forever do + delay (Milliseconds intervalMs) + result <- try $ performBackup conn dbFile + case result of + Left err -> Log.error $ "Backup failed: " <> message err + Right _ -> pure unit + queryAll :: Connection -> String -> Array Foreign -> Aff (Array Json) queryAll conn sql params = makeAff \cb -> do allImpl conn sql params \err rows -> diff --git a/src/Main.purs b/src/Main.purs @@ -38,11 +38,11 @@ import Data.Argonaut.Core (toObject, toArray, toString, stringify) import Data.Array ((!!), length, uncons) import Data.Tuple (Tuple(..)) import Data.Foldable (for_) -import Db (Connection, connect, initDb, upsertScrobble, getScrobbles, checkExists, run, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleaseByMbid, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping) +import Db (Connection, connect, initDb, upsertScrobble, getScrobbles, checkExists, run, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleaseByMbid, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping, backupDb) import Types (Listen(..), ListenBrainzResponse(..), Payload(..), TrackMetadata(..)) import Control.Monad.Rec.Class (forever) import Data.Time.Duration (Milliseconds(..)) -import Data.Int (fromString) +import Data.Int (fromString, toNumber) import Data.String (Pattern(..)) import Data.String.Common (split) as String import Data.String.Regex (replace, parseFlags) @@ -567,8 +567,8 @@ indexHtml = </html>""" -- Request handler -handleRequest :: Connection -> Ref Boolean -> Request -> Response -> Effect Unit -handleRequest db isSyncing req res = do +handleRequest :: Connection -> Ref Boolean -> Boolean -> Request -> Response -> Effect Unit +handleRequest db isSyncing coverCacheEnabled req res = do let method = IM.method req let rawUrl = IM.url req case URL.fromRelative rawUrl "http://localhost" of @@ -581,7 +581,7 @@ handleRequest db isSyncing req res = do "/" -> serveIndex res "/healthz" -> serveHealthz db isSyncing res "/proxy" -> serveProxy db isSyncing url res - "/cover" -> serveCover isSyncing url res + "/cover" -> serveCover coverCacheEnabled isSyncing url res "/stats" -> serveStats db isSyncing res "/client.js" -> serveClientJs res "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res @@ -625,8 +625,8 @@ sanitizeKey str = in replace re2 "_" (replace re1 "_" str) -serveCover :: Ref Boolean -> URL -> Response -> Effect Unit -serveCover isSyncing url res = do +serveCover :: Boolean -> Ref Boolean -> URL -> Response -> Effect Unit +serveCover coverCacheEnabled isSyncing url res = do launchAff_ do yieldToSync isSyncing let mbid = fromMaybe "" (getQueryParam "mbid" url) @@ -657,11 +657,13 @@ serveCover isSyncing url res = do tryLastfm artistStr releaseStr res where - checkS3 s3Key = do - result <- try $ existsInS3 s3Key - pure $ case result of - Right b -> b - Left _ -> false + checkS3 s3Key + | not coverCacheEnabled = pure false + | otherwise = do + result <- try $ existsInS3 s3Key + pure $ case result of + Right b -> b + Left _ -> false serveS3 s3Key response = liftEffect $ do setStatusCode 302 response @@ -685,7 +687,7 @@ serveCover isSyncing url res = do end writer -- Cache to S3 in background - void $ forkAff $ do + when coverCacheEnabled $ void $ forkAff $ do uploadResult <- try $ uploadToS3 s3Key (unsafeCoerce buf) contentType case uploadResult of Right _ -> Log.info $ "Cached to S3: " <> s3Key @@ -1034,18 +1036,19 @@ enrichMetadata conn isSyncing = forever do -- Genre found in MusicBrainz, just save as usual upsertReleaseMetadata conn mbid mbdata.genre mbdata.label mbdata.year -startServer :: Int -> String -> String -> Effect Unit -startServer port dbFile username = launchAff_ do +startServer :: Int -> String -> String -> Boolean -> Number -> Boolean -> Effect Unit +startServer port dbFile username backupEnabled backupIntervalMs coverCacheEnabled = launchAff_ do conn <- connect dbFile initDb conn initReleaseMetadata conn isSyncing <- liftEffect $ Ref.new false void $ forkAff $ syncData conn username isSyncing void $ forkAff $ enrichMetadata conn isSyncing + when backupEnabled $ void $ forkAff $ backupDb conn dbFile backupIntervalMs liftEffect $ do server <- createServer - server # on_ Server.requestH (handleRequest conn isSyncing) + server # on_ Server.requestH (handleRequest conn isSyncing coverCacheEnabled) let netServer = Server.toNetServer server netServer # on_ listeningH do @@ -1062,5 +1065,9 @@ main = do let port = fromMaybe 8000 (Object.lookup "PORT" env >>= fromString) let dbFile = fromMaybe "scorpus.db" (Object.lookup "DATABASE_FILE" env) let username = fromMaybe "" (Object.lookup "LISTENBRAINZ_USER" env) + let backupEnabled = Object.lookup "BACKUP_ENABLED" env == Just "true" + let backupIntervalHours = fromMaybe 1 (Object.lookup "BACKUP_INTERVAL_HOURS" env >>= fromString) + let backupIntervalMs = toNumber backupIntervalHours * 3600000.0 + let coverCacheEnabled = Object.lookup "COVER_CACHE_ENABLED" env /= Just "false" when (username == "") $ Log.warn "LISTENBRAINZ_USER is not set — syncing will be disabled" - startServer port dbFile username + startServer port dbFile username backupEnabled backupIntervalMs coverCacheEnabled diff --git a/test/Main.purs b/test/Main.purs @@ -14,9 +14,12 @@ import Test.Spec.Assertions (shouldEqual, fail) import Test.Spec.Reporter.Console (consoleReporter) import Test.Spec.Runner.Node (runSpecAndExitProcess) import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), Stats(..), StatsEntry(..), TrackMetadata(..)) -import Db (connect, initDb, checkExists, upsertScrobble, getScrobbles, initReleaseMetadata, upsertReleaseMetadata, getStats) +import Db (connect, initDb, checkExists, upsertScrobble, getScrobbles, initReleaseMetadata, upsertReleaseMetadata, getStats, dirName, performBackup) import Main (sanitizeKey, listenBrainzUrl) import S3 (getS3Url) +import Node.FS.Aff as FSA +import Node.FS.Perms (mkPerms, all, read) as Perms +import Effect.Aff (try) main :: Effect Unit main = do @@ -153,6 +156,31 @@ main = do listensEmpty <- getScrobbles conn 10 0 (Just { field: "genre", value: "Jazz" }) length listensEmpty `shouldEqual` 0 + describe "Scorpus Backup" do + describe "dirName" do + it "extracts directory from an absolute path" do + dirName "/app/data/scorpus.db" `shouldEqual` "/app/data/" + it "extracts directory from a nested path" do + dirName "/tmp/test/scorpus.db" `shouldEqual` "/tmp/test/" + it "returns ./ for a bare filename" do + dirName "scorpus.db" `shouldEqual` "./" + + it "local backup creates a file in backup/ alongside the db" do + let testDir = "/tmp/scorpus-backup-test" + let dbPath = testDir <> "/scorpus.db" + let backupDir = testDir <> "/backup" + -- clean up any previous run + void $ try $ FSA.rm' testDir { force: true, recursive: true, maxRetries: 0, retryDelay: 100 } + FSA.mkdir' testDir { recursive: true, mode: Perms.mkPerms Perms.all Perms.all Perms.read } + conn <- connect dbPath + initDb conn + initReleaseMetadata conn + performBackup conn dbPath + files <- FSA.readdir backupDir + length files `shouldEqual` 1 + -- cleanup + void $ try $ FSA.rm' testDir { force: true, recursive: true, maxRetries: 0, retryDelay: 100 } + describe "Scorpus S3" do it "should generate virtual-host style S3 URLs" do liftEffect $ Process.setEnv "AWS_S3_ADDRESSING_STYLE" "virtual"