corpus

Log | Files | Refs | README | LICENSE

commit 75884341e60299f61a76d0c6ca32a120db2c966a
parent e394d45dba7c024d0a5057945d4261fe623c8048
Author: mtmn <miro@haravara.org>
Date:   Sat, 11 Apr 2026 04:26:13 +0200

feat: duckdb, blob storage, pagination

Former-commit-id: 07c24820321cbdf0912f80f22f01572b04257e71

Diffstat:
A.env.example | 9+++++++++
M.gitignore | 20++++++++++++++++++--
AContainerfile | 43+++++++++++++++++++++++++++++++++++++++++++
Aclient.js.REMOVED.git-id | 2++
Aflake.lock | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflake.nix | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage-lock.json.REMOVED.git-id | 4++--
Mpackage.json | 17++++++++++-------
Ascorpus.db | 0
Ascorpus.db.wal.REMOVED.git-id | 2++
Ascrobbler.db | 0
Ascrobbler.db.wal.REMOVED.git-id | 2++
Aserver.js.REMOVED.git-id | 2++
Mspago.lock | 19+++++++++++++++++--
Mspago.yaml | 4+++-
Asrc/Client.js | 8++++++++
Msrc/Client.purs | 197+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Asrc/Db.js | 40++++++++++++++++++++++++++++++++++++++++
Asrc/Db.purs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Main.js | 21+++++++++++++++++++++
Msrc/Main.purs | 372++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Asrc/S3.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/S3.purs | 31+++++++++++++++++++++++++++++++
Msrc/Types.purs | 33++++++++++++++++++++++++++++++++-
24 files changed, 948 insertions(+), 196 deletions(-)

diff --git a/.env.example b/.env.example @@ -0,0 +1,9 @@ +DISCOGS_TOKEN= +LASTFM_API_KEY= +S3_BUCKET= +S3_REGION= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_ADDRESSING_STYLE= +AWS_ENDPOINT_URL= +INITIAL_SYNC= diff --git a/.gitignore b/.gitignore @@ -1,5 +1,21 @@ -*.js +bower_components/ +node_modules/ +.pulp-cache/ output/ +output-es/ +generated-docs/ +.psc-package/ +result +result-* +.psc* +.purs* +.psa* .spago/ -node_modules/ .env +.env.local + +server.js +client.js +server.log +scrobbler.db* +scorpus.db* diff --git a/Containerfile b/Containerfile @@ -0,0 +1,43 @@ +FROM docker.io/node:25-slim AS builder + +WORKDIR /app + +# Satisfy DL3008 (pin versions) and DL3015 (no-install-recommends) +# Note: Pinning exact apt versions is often brittle in rolling distros, +# but using --no-install-recommends is a standard best practice. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + git \ + libtinfo5 \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +COPY spago.yaml spago.lock ./ + +RUN npm ci +COPY src ./src + +RUN npm run build + +FROM docker.io/node:25-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY --from=builder /app/server.js ./ +COPY --from=builder /app/client.js ./ + +RUN mkdir -p /app/data && chown -R node:node /app/data + +USER node + +# Default port for scorched scrobbler +ENV PORT=8321 +ENV DATABASE_FILE=/app/data/scorpus.db +ENV NODE_ENV=production + +EXPOSE 8321 + +CMD ["node", "--no-deprecation", "server.js"] diff --git a/client.js.REMOVED.git-id b/client.js.REMOVED.git-id @@ -0,0 +1 @@ +73ca26efb57ec470fbd14a0f85c50f8583385fa8 +\ No newline at end of file diff --git a/flake.lock b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775793324, + "narHash": "sha256-omax7atcZbol+6HJ2RLpP+ZCFcPa5bZ65Hn71RufeWQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9d29d5f667d7467f98efc31881e824fa586c927e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix @@ -0,0 +1,85 @@ +{ + description = "ListenBrainz frontend in PureScript"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = nixpkgs.legacyPackages.${system}; + scorpus = pkgs.buildNpmPackage { + pname = "scorpus"; + version = "1.0.0"; + src = ./.; + + npmDepsHash = "sha256-0000000000000000000000000000000000000000000="; + + nativeBuildInputs = with pkgs; [ + purescript + spago + esbuild + ]; + + buildPhase = '' + export HOME=$TMPDIR + # Spago and npm run build setup + npm run build + ''; + + installPhase = '' + mkdir -p $out/lib + cp server.js client.js package.json $out/lib/ + mkdir -p $out/bin + echo "#!/bin/sh" > $out/bin/scorpus-server + echo "${pkgs.nodejs}/bin/node --no-deprecation $out/lib/server.js" >> $out/bin/scorpus-server + chmod +x $out/bin/scorpus-server + ''; + }; + in { + packages.default = scorpus; + + packages.container = pkgs.dockerTools.buildLayeredImage { + name = "scorpus"; + tag = "latest"; + contents = [pkgs.nodejs pkgs.cacert]; + config = { + Cmd = ["${pkgs.nodejs}/bin/node" "--no-deprecation" "/app/server.js"]; + WorkingDir = "/app"; + ExposedPorts = { + "8321/tcp" = {}; + }; + User = "1000:1000"; + Env = [ + "PORT=8321" + "DATABASE_FILE=/app/data/scorpus.db" + "NODE_ENV=production" + ]; + }; + extraCommands = '' + mkdir -p app/data + cp ${scorpus}/lib/server.js app/ + cp ${scorpus}/lib/client.js app/ + + chown -R 1000:1000 app + chmod 700 app/data + ''; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + nodejs + purescript + spago + purs-tidy + esbuild + ]; + }; + } + ); +} diff --git a/package-lock.json.REMOVED.git-id b/package-lock.json.REMOVED.git-id @@ -1 +1 @@ -0906d25db792f3ec30afece36314f061a3baf45a -\ No newline at end of file +ef94c9294dead0c5bb30e7aad92649283a4a9962 +\ No newline at end of file diff --git a/package.json b/package.json @@ -1,17 +1,17 @@ { - "name": "scrobbler-purescript", + "name": "scorpus", "version": "1.0.0", - "description": "ListenBrainz scrobbler in PureScript", + "description": "ListenBrainz frontend in PureScript", "main": "index.js", "type": "module", "scripts": { - "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --outfile=server.js --external:http --external:https --external:xhr2 --external:dotenv --external:url && npx spago bundle --module Client --outfile client.js --platform browser", + "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 && npx spago bundle --module Client --outfile client.js --platform browser", "build-client": "npx spago bundle --module Client --outfile client.js --platform browser", - "build-server": "npx esbuild output/Main/index.js --bundle --platform=node --format=esm --outfile=server.js --external:http --external:https --external:xhr2 --external:dotenv --external:url", + "build-server": "npx esbuild output/Main/index.js --bundle --platform=node --format=esm --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3", "test": "npx spago test", "tidy": "purs-tidy format-in-place src/**/*.purs", - "serve": "node server.js 2>&1 | tee server.log", - "start": "node server.js 2>&1 | tee server.log" + "serve": "node --no-deprecation server.js 2>&1 | tee server.log", + "start": "node --no-deprecation server.js 2>&1 | tee server.log" }, "devDependencies": { "esbuild": "^0.28.0", @@ -22,7 +22,10 @@ "spago": "^1.0.4" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1029.0", + "audit": "^0.0.6", "dotenv": "^17.4.1", - "xhr2": "^0.2.1" + "duckdb": "^1.4.4", + "fix": "^0.0.6" } } diff --git a/scorpus.db b/scorpus.db Binary files differ. diff --git a/scorpus.db.wal.REMOVED.git-id b/scorpus.db.wal.REMOVED.git-id @@ -0,0 +1 @@ +0aaa61e78ffc091de568b77aa8244033baff8dc7 +\ No newline at end of file diff --git a/scrobbler.db b/scrobbler.db Binary files differ. diff --git a/scrobbler.db.wal.REMOVED.git-id b/scrobbler.db.wal.REMOVED.git-id @@ -0,0 +1 @@ +0aaa61e78ffc091de568b77aa8244033baff8dc7 +\ No newline at end of file diff --git a/server.js.REMOVED.git-id b/server.js.REMOVED.git-id @@ -0,0 +1 @@ +05154c6c49414d8992937a0206db9f15fb2b1744 +\ No newline at end of file diff --git a/spago.lock b/spago.lock @@ -1,7 +1,7 @@ { "workspace": { "packages": { - "scrobbler-purescript": { + "scorpus": { "path": "./", "core": { "dependencies": [ @@ -30,6 +30,7 @@ "node-http", "node-net", "node-process", + "node-sqlite3", "node-streams", "node-url", "now", @@ -38,7 +39,8 @@ "strings", "tailrec", "unsafe-coerce", - "web-dom" + "web-dom", + "web-html" ] }, "test": { @@ -1616,6 +1618,19 @@ "strings" ] }, + "node-sqlite3": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-vmiT8S2dcBO6udUoXn8ppG3FNCEcq6AA1q92memeyWo=", + "dependencies": [ + "aff", + "effect", + "either", + "exceptions", + "foreign", + "prelude" + ] + }, "node-streams": { "type": "registry", "version": "9.0.1", diff --git a/spago.yaml b/spago.yaml @@ -1,5 +1,5 @@ package: - name: scrobbler-purescript + name: scorpus dependencies: - aff - affjax @@ -26,6 +26,7 @@ package: - node-http - node-net - node-process + - node-sqlite3 - node-streams - node-url - now @@ -35,6 +36,7 @@ package: - tailrec - unsafe-coerce - web-dom + - web-html test: main: Test.Main dependencies: [] diff --git a/src/Client.js b/src/Client.js @@ -0,0 +1,8 @@ +export const extractParam = (key) => (search) => { + const params = new URLSearchParams(search); + return params.get(key); +}; + +export const formatRFC3339 = (instant) => { + return new Date(instant).toISOString(); +}; diff --git a/src/Client.purs b/src/Client.purs @@ -5,10 +5,9 @@ import Prelude import Affjax.Web as AX import Affjax.ResponseFormat as ResponseFormat import Data.Argonaut (decodeJson) -import Data.Array (mapWithIndex) -import Data.DateTime.Instant (unInstant) +import Data.Array (mapWithIndex, length) import Data.Either (Either(..)) -import Data.Int (floor) +import Data.Int (floor, fromString) import Data.Maybe (Maybe(..), fromMaybe) import Effect (Effect) import Effect.Console as Console @@ -27,9 +26,15 @@ import JSURI (encodeURIComponent) import Web.DOM.ParentNode (QuerySelector(..)) import Types (Listen(..), ListenBrainzResponse(..), TrackMetadata(..), Payload(..), MbidMapping(..)) import Data.Time.Duration (Milliseconds(..)) - +import Data.DateTime.Instant (Instant, unInstant) import Data.Set (Set) import Data.Set as Set +import Web.HTML (window) +import Web.HTML.Window (location, history) +import Web.HTML.Location (search) +import Web.HTML.History (pushState, DocumentTitle(..), URL(..)) +import Foreign (unsafeToForeign) +import Data.Nullable (Nullable, toMaybe) type State = { listens :: Array Listen @@ -38,6 +43,8 @@ type State = , loading :: Boolean , currentTime :: Maybe Milliseconds , failedCovers :: Set String + , offset :: Int + , limit :: Int } data Action @@ -45,6 +52,8 @@ data Action | Refresh | ReceiveResponse (Either String (Array Listen)) | ImageError String + | NextPage + | PrevPage component :: forall query input output m. MonadAff m => H.Component query input output m component = @@ -64,13 +73,31 @@ component = , loading: true , currentTime: Nothing , failedCovers: Set.empty + , offset: 0 + , limit: 25 } render state = HH.div [ HP.class_ (H.ClassName "container") ] - [ HH.h1_ [ HH.text "Recent Tracks" ] + [ HH.h1_ [ HH.text "scorpus" ] , renderContent state + , HH.div + [ HP.class_ (H.ClassName "pagination") ] + [ HH.button + [ HP.class_ (H.ClassName "page-btn") + , HP.disabled (state.offset == 0 || state.loading) + , HE.onClick \_ -> PrevPage + ] + [ HH.text "Previous" ] + , HH.div [ HP.class_ (H.ClassName "page-indicator") ] [ HH.text $ "Page " <> show (state.offset / state.limit + 1) ] + , HH.button + [ HP.class_ (H.ClassName "page-btn") + , HP.disabled (length state.listens < state.limit || state.loading) + , HE.onClick \_ -> NextPage + ] + [ HH.text "Next" ] + ] , HH.p [ HP.id "last-updated" , HP.class_ (H.ClassName "small") @@ -88,100 +115,118 @@ component = (mapWithIndex (renderListen state.currentTime state.failedCovers) state.listens) renderListen currentTime failedCovers _ (Listen { trackMetadata: TrackMetadata track, listenedAt }) = - let + let release = fromMaybe "" track.releaseName artist = fromMaybe "" track.artistName - - -- Priority: - -- 1. CAA (CAA MBID) - -- 2. CAA (Release MBID) - -- 3. Last.fm proxy - -- 4. Discogs proxy - + mbid = case track.mbidMapping of Just (MbidMapping { caaReleaseMbid: Just m }) -> Just m Just (MbidMapping { releaseMbid: Just m }) -> Just m _ -> Nothing - + lastfmId = "lastfm-" <> artist <> "-" <> release discogsId = "discogs-" <> artist <> "-" <> release - + coverInfo = case mbid of - Just m -> - if Set.member m failedCovers then - -- Fallback 1: Last.fm + Just m -> + if Set.member m failedCovers then if Set.member lastfmId failedCovers then - -- Fallback 2: Discogs if Set.member discogsId failedCovers then Nothing - else Just { id: discogsId, url: "/discogs?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } - else Just { id: lastfmId, url: "/lastfm?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } - else Just { id: m, url: "https://coverartarchive.org/release/" <> m <> "/front-250" } - Nothing -> - -- No MBID, try Last.fm then Discogs + else Just { id: discogsId, url: "/discogs-cover?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: lastfmId, url: "/lastfm-cover?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: m, url: "/caa-cover?mbid=" <> m } + Nothing -> if Set.member lastfmId failedCovers then if Set.member discogsId failedCovers then Nothing - else Just { id: discogsId, url: "/discogs?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } - else Just { id: lastfmId, url: "/lastfm?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: discogsId, url: "/discogs-cover?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: lastfmId, url: "/lastfm-cover?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } in - HH.li - [ HP.class_ (H.ClassName "success") ] - [ HH.div - [ HP.class_ (H.ClassName "track-info") ] - [ HH.div - [ HP.class_ (H.ClassName "track-name") ] - [ HH.text $ fromMaybe "Unknown Track" track.trackName ] - , HH.div - [ HP.class_ (H.ClassName "track-artist") ] - [ HH.text artist ] - , HH.div - [ HP.class_ (H.ClassName "track-time") ] - [ let - query = fromMaybe "" $ encodeURIComponent (artist <> " " <> release) - in - HH.span_ - [ HH.a - [ HP.href $ "https://www.discogs.com/search/?q=" <> query <> "&type=release" - , HP.target "_blank" - , HP.class_ (H.ClassName "album-link") + HH.li + [ HP.class_ (H.ClassName "success") ] + [ HH.div + [ HP.class_ (H.ClassName "track-info") ] + [ HH.div + [ HP.class_ (H.ClassName "track-name") ] + [ HH.text $ fromMaybe "Unknown Track" track.trackName ] + , HH.div + [ HP.class_ (H.ClassName "track-artist") ] + [ HH.text artist ] + , HH.div + [ HP.class_ (H.ClassName "track-time") ] + [ let + query = fromMaybe "" $ encodeURIComponent (artist <> " " <> release) + in + HH.span_ + [ HH.a + [ HP.href $ "https://www.discogs.com/search/?q=" <> query <> "&type=release" + , HP.target "_blank" + , HP.class_ (H.ClassName "album-link") + ] + [ HH.text release ] + , HH.text $ " • " <> (fromMaybe "unknown time" $ formatTimeAgo currentTime listenedAt) ] - [ HH.text release ] - , HH.text $ " • " <> (fromMaybe "unknown time" $ formatTimeAgo currentTime listenedAt) - ] - ] - ] - , case coverInfo of - Just { id, url } -> - HH.img - [ HP.class_ (H.ClassName "track-cover") - , HP.src url - , HP.alt release - , HE.onError \_ -> ImageError id - ] - Nothing -> HH.text "" - ] + ] + ] + , case coverInfo of + Just { id, url } -> + HH.img + [ HP.class_ (H.ClassName "track-cover") + , HP.src url + , HP.alt release + , HE.onError \_ -> ImageError id + ] + Nothing -> HH.text "" + ] handleAction = case _ of Initialize -> do + w <- liftEffect window + loc <- liftEffect $ location w + qs <- liftEffect $ search loc + let pageParam = toMaybe $ extractParam "page" qs + let initialPage = fromMaybe 1 (pageParam >>= fromString) + let initialOffset = max 0 ((initialPage - 1) * 25) + + H.modify_ _ { offset = initialOffset } + void $ H.fork $ forever (H.liftAff (delay (Milliseconds 30000.0)) *> handleAction Refresh) handleAction Refresh Refresh -> do + state <- H.get H.modify_ _ { loading = true, error = Nothing } - response <- H.liftAff fetchListens + response <- H.liftAff $ fetchListens state.limit state.offset handleAction (ReceiveResponse response) ReceiveResponse result -> do nowInstant <- liftEffect now let nowMs = unInstant nowInstant - let nowStr = "Last check: " <> show nowInstant + let nowStr = formatRFC3339 nowInstant case result of Left err -> H.modify_ _ { loading = false, error = Just err, lastCheck = Just nowStr, currentTime = Just nowMs } Right listens -> H.modify_ _ { loading = false, listens = listens, lastCheck = Just nowStr, currentTime = Just nowMs } ImageError mbid -> do liftEffect $ Console.log $ "Image load failed for: " <> mbid H.modify_ \state -> state { failedCovers = Set.insert mbid state.failedCovers } + NextPage -> do + H.modify_ \state -> state { offset = state.offset + state.limit } + updateUrl + handleAction Refresh + PrevPage -> do + H.modify_ \state -> state { offset = max 0 (state.offset - state.limit) } + updateUrl + handleAction Refresh + + updateUrl = do + state <- H.get + let page = (state.offset / state.limit) + 1 + liftEffect do + w <- window + h <- history w + pushState (unsafeToForeign {}) (DocumentTitle "") (URL $ "?page=" <> show page) h - fetchListens :: Aff (Either String (Array Listen)) - fetchListens = do - res <- AX.get ResponseFormat.json "/proxy" + fetchListens :: Int -> Int -> Aff (Either String (Array Listen)) + fetchListens limit offset = do + let url = "/proxy?limit=" <> show limit <> "&offset=" <> show offset + res <- AX.get ResponseFormat.json url case res of Left err -> pure $ Left $ "Network error: " <> AX.printError err Right response -> @@ -196,14 +241,15 @@ component = let nowSecs = floor (nowMs / 1000.0) diff = nowSecs - timestamp - in Just $ - if diff < 60 then "just now" - else if diff < 3600 then - let mins = diff / 60 in show mins <> " minute" <> (if mins > 1 then "s" else "") <> " ago" - else if diff < 86400 then - let hours = diff / 3600 in show hours <> " hour" <> (if hours > 1 then "s" else "") <> " ago" - else - let days = diff / 86400 in show days <> " day" <> (if days > 1 then "s" else "") <> " ago" + in + Just $ + if diff < 60 then "just now" + else if diff < 3600 then + let mins = diff / 60 in show mins <> " minute" <> (if mins > 1 then "s" else "") <> " ago" + else if diff < 86400 then + let hours = diff / 3600 in show hours <> " hour" <> (if hours > 1 then "s" else "") <> " ago" + else + let days = diff / 86400 in show days <> " day" <> (if days > 1 then "s" else "") <> " ago" main :: Effect Unit main = HA.runHalogenAff do @@ -211,3 +257,6 @@ main = HA.runHalogenAff do case maybeApp of Nothing -> HA.awaitBody >>= runUI component unit Just app -> runUI component unit app + +foreign import extractParam :: String -> String -> Nullable String +foreign import formatRFC3339 :: Instant -> String diff --git a/src/Db.js b/src/Db.js @@ -0,0 +1,40 @@ +import duckdb from 'duckdb'; + +export const connectImpl = (path) => (cb) => () => { + const db = new duckdb.Database(path, (err) => { + if (err) { + cb(err)(null)(); + } else { + try { + const conn = db.connect(); + cb(null)(conn)(); + } catch (e) { + cb(e)(null)(); + } + } + }); +}; + +export const runImpl = (conn) => (sql) => (params) => (cb) => () => { + conn.run(sql, ...params, (err) => { + cb(err)(); + }); +}; + +export const allImpl = (conn) => (sql) => (params) => (cb) => () => { + conn.all(sql, ...params, (err, rows) => { + if (rows) { + // DuckDB returns BIGINT as BigInt, which JSON.stringify doesn't support. + // We convert them to Numbers here. + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + for (const key in row) { + if (typeof row[key] === 'bigint') { + row[key] = Number(row[key]); + } + } + } + } + cb(err)(rows)(); + }); +}; diff --git a/src/Db.purs b/src/Db.purs @@ -0,0 +1,110 @@ +module Db where + +import Prelude + +import Data.Argonaut.Core (Json, toObject, toString, toArray) +import Data.Either (Either(..)) +import Data.Maybe (Maybe(..), fromMaybe) +import Effect (Effect) +import Effect.Class (liftEffect) +import Effect.Aff (Aff, makeAff, nonCanceler) +import Effect.Exception (Error, error) +import Foreign (Foreign) +import Unsafe.Coerce (unsafeCoerce) +import Types (Listen(..), TrackMetadata(..), MbidMapping(..)) +import Data.Traversable (traverse) +import Foreign.Object as Object +import Data.Nullable (Nullable, toMaybe) + +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 + +connect :: String -> Aff Connection +connect path = makeAff \cb -> do + connectImpl path \err conn -> + case toMaybe err of + Just e -> cb (Left e) + Nothing -> case toMaybe conn of + Just c -> cb (Right c) + Nothing -> cb (Left $ error "Failed to create connection") + pure nonCanceler + +run :: Connection -> String -> Array Foreign -> Aff Unit +run conn sql params = makeAff \cb -> do + runImpl conn sql params \err -> + case toMaybe err of + Just e -> cb (Left e) + Nothing -> cb (Right unit) + pure nonCanceler + +queryAll :: Connection -> String -> Array Foreign -> Aff (Array Json) +queryAll conn sql params = makeAff \cb -> do + allImpl conn sql params \err rows -> + case toMaybe err of + Just e -> cb (Left e) + Nothing -> cb (Right (fromMaybe [] (toMaybe rows))) + pure nonCanceler + +initDb :: Connection -> Aff Unit +initDb conn = do + run conn "CREATE TABLE IF NOT EXISTS scrobbles (listened_at BIGINT PRIMARY KEY, track_name VARCHAR, artist_name VARCHAR, release_name VARCHAR, release_mbid VARCHAR, caa_release_mbid VARCHAR)" [] + +checkExists :: Connection -> Int -> Aff Boolean +checkExists conn ts = do + rows <- queryAll conn "SELECT 1 FROM scrobbles WHERE listened_at = ?" [ unsafeCoerce ts ] + pure $ fromMaybe false $ do + arr <- Just rows + case arr of + [] -> Just false + _ -> Just true + +upsertScrobble :: Connection -> Listen -> Aff Unit +upsertScrobble conn (Listen { listenedAt, trackMetadata: TrackMetadata track }) = do + case listenedAt of + Nothing -> pure unit + Just ts -> do + let + mbid = fromMaybe (MbidMapping { releaseMbid: Nothing, caaReleaseMbid: Nothing }) track.mbidMapping + MbidMapping m = mbid + params = + [ unsafeCoerce ts + , unsafeCoerce (fromMaybe "" track.trackName) + , unsafeCoerce (fromMaybe "" track.artistName) + , unsafeCoerce (fromMaybe "" track.releaseName) + , unsafeCoerce (fromMaybe "" m.releaseMbid) + , unsafeCoerce (fromMaybe "" m.caaReleaseMbid) + ] + _ <- run conn "INSERT INTO scrobbles SELECT * FROM (SELECT ? as listened_at, ? as track_name, ? as artist_name, ? as release_name, ? as release_mbid, ? as caa_release_mbid) t WHERE NOT EXISTS (SELECT 1 FROM scrobbles WHERE listened_at = t.listened_at)" params + pure unit + +getScrobbles :: Connection -> Int -> Int -> Aff (Array Listen) +getScrobbles conn limit offset = do + rows <- queryAll conn "SELECT listened_at, track_name, artist_name, release_name, release_mbid, caa_release_mbid FROM scrobbles ORDER BY listened_at DESC LIMIT ? OFFSET ?" + [ unsafeCoerce limit, unsafeCoerce offset ] + pure $ fromMaybe [] $ traverse rowToListen rows + +rowToListen :: Json -> Maybe Listen +rowToListen json = do + obj <- toObject json + listenedAt <- Object.lookup "listened_at" obj >>= (unsafeCoerce >>> Just) + trackName <- Object.lookup "track_name" obj >>= toString + artistName <- Object.lookup "artist_name" obj >>= toString + releaseName <- Object.lookup "release_name" obj >>= toString + releaseMbid <- Object.lookup "release_mbid" obj >>= toString + caaReleaseMbid <- Object.lookup "caa_release_mbid" obj >>= toString + + pure $ Listen + { listenedAt: Just listenedAt + , trackMetadata: TrackMetadata + { trackName: Just trackName + , artistName: Just artistName + , releaseName: Just releaseName + , mbidMapping: Just $ MbidMapping + { releaseMbid: if releaseMbid == "" then Nothing else Just releaseMbid + , caaReleaseMbid: if caaReleaseMbid == "" then Nothing else Just caaReleaseMbid + } + } + } diff --git a/src/Main.js b/src/Main.js @@ -0,0 +1,21 @@ +import { config } from 'dotenv'; + +export const dotenvConfig = () => { + config(); +}; + +export const getQueryParam = (name) => (url) => () => { + return url.searchParams.get(name); +}; + +export const split = (sep) => (str) => { + return str.split(sep); +}; + +export const writeBuffer = (stream) => (buffer) => () => { + stream.write(Buffer.from(buffer)); +}; + +export const sanitizeKey = (str) => { + return str.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_'); +}; diff --git a/src/Main.purs b/src/Main.purs @@ -15,26 +15,34 @@ import Node.HTTP.IncomingMessage as IM import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) import Node.HTTP.OutgoingMessage (setHeader, toWriteable) -import Node.Stream (end, writeString, pipe) +import Node.Stream (Writable, end, writeString) import Node.Stream.Aff (readableToStringUtf8) import Node.Encoding (Encoding(UTF8)) import Node.Net.Server (listenTcp, listeningH) import Data.Either (Either(..)) import Effect.Exception as Exception -import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try) +import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try, delay, forkAff) +import Foreign (Foreign) import Unsafe.Coerce (unsafeCoerce) import Node.FS.Aff as FSA import Node.Process (getEnv) -import Fetch (fetch, Method(GET)) +import Fetch (fetch, Method(GET), lookup) import Fetch.Argonaut.Json (fromJson) import Data.Maybe (Maybe(..), fromMaybe) import JSURI (encodeURIComponent) import Node.URL (URL, new', pathname) -import Affjax.RequestHeader (RequestHeader(..)) import Foreign.Object as Object -import Data.Argonaut.Core (toObject, toArray, toString) -import Data.Array ((!!)) +import Data.Argonaut (decodeJson, encodeJson, parseJson) +import Data.Argonaut.Core (toObject, toArray, toString, stringify) +import Data.Array ((!!), length, uncons) import Data.Nullable (Nullable, toMaybe) +import Db (Connection, connect, initDb, upsertScrobble, getScrobbles, checkExists, run) +import Types (Listen(..), ListenBrainzResponse(..), Payload(..)) +import Control.Monad.Rec.Class (forever) +import Data.Time.Duration (Milliseconds(..)) +import Data.Int (fromString) +import Data.Foldable (traverse_) +import S3 (existsInS3, uploadToS3, getS3Url) -- Types type Request = IncomingMessage IMServer @@ -43,10 +51,11 @@ type Response = ServerResponse listenBrainzUrl :: String listenBrainzUrl = "https://api.listenbrainz.org/1/user/mtmn/listens" -fetchListenBrainzData :: Aff String -fetchListenBrainzData = makeAff \callback -> do - Console.log $ "Fetching from ListenBrainz: " <> listenBrainzUrl - req <- HTTPS.get listenBrainzUrl +fetchListenBrainzData :: Int -> Aff String +fetchListenBrainzData count = makeAff \callback -> do + let url = listenBrainzUrl <> "?count=" <> show count + Console.log $ "Fetching from ListenBrainz: " <> url + req <- HTTPS.get url req # on_ Client.responseH \res -> do let sc = IM.statusCode res @@ -65,16 +74,44 @@ fetchListenBrainzData = makeAff \callback -> do pure nonCanceler -handleApiRequest :: Aff String -handleApiRequest = do - result <- try fetchListenBrainzData - case result of - Right responseData -> do - liftEffect $ Console.log $ "Successfully fetched bytes" - pure responseData - Left error -> do - liftEffect $ Console.log $ "Error in handleApiRequest: " <> Exception.message error - pure "{\"error\": \"ListenBrainz fetch failed\"}" +syncData :: Connection -> Aff Unit +syncData conn = do + env <- liftEffect getEnv + let shouldInitialSync = Object.lookup "INITIAL_SYNC" env == Just "true" + + if shouldInitialSync then do + liftEffect $ Console.log "Performing initial sync of 10000 tracks" + void $ performSync 10000 + else + liftEffect $ Console.log "Initial sync skipped (INITIAL_SYNC != true)" + + pure unit + where + performSync count = do + result <- try $ fetchListenBrainzData count + case result of + Right body -> do + case parseJson body >>= decodeJson of + Left err -> liftEffect $ Console.log $ "Sync parse error: " <> show err + Right (ListenBrainzResponse { payload: Payload { listens } }) -> do + liftEffect $ Console.log $ "Processing " <> show (length listens) <> " scrobbles" + run conn "BEGIN TRANSACTION" [] + newCount <- syncRecursive 0 listens + run conn "COMMIT" [] + liftEffect $ Console.log $ "Sync complete. Added " <> show newCount <> " new scrobbles." + Left err -> liftEffect $ Console.log $ "Sync fetch error: " <> Exception.message err + + syncRecursive acc listens = case uncons listens of + Nothing -> pure acc + Just { head: l@(Listen { listenedAt: Just ts }), tail } -> do + exists <- checkExists conn ts + if exists then do + liftEffect $ Console.log $ "Hit existing record at " <> show ts <> ". Stopping sync." + pure acc + else do + upsertScrobble conn l + syncRecursive (acc + 1) tail + Just { head: _, tail } -> syncRecursive acc tail indexHtml :: String indexHtml = @@ -240,6 +277,40 @@ indexHtml = 50% { opacity: 0.3; } 100% { opacity: 1; } } + + .pagination { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 20px; + } + + .page-btn { + background: #521e40; + border: 1px solid #50447f; + color: #ffffff; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + box-shadow: 2px 2px 0px #50447f; + } + + .page-btn:hover { + background: #50447f; + } + + .page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .page-indicator { + display: flex; + align-items: center; + font-size: 14px; + color: #9fbfe7; + } </style> </head> <body> @@ -249,8 +320,8 @@ indexHtml = </html>""" -- Request handler -handleRequest :: Request -> Response -> Effect Unit -handleRequest req res = do +handleRequest :: Connection -> Request -> Response -> Effect Unit +handleRequest db req res = do let rawUrl = IM.url req url <- new' rawUrl "http://localhost" path <- pathname url @@ -258,7 +329,8 @@ handleRequest req res = do case path of "/" -> serveIndex res - "/proxy" -> serveProxy res + "/proxy" -> serveProxy db url res + "/caa-cover" -> serveCaaCover url res "/discogs-cover" -> serveDiscogsCover url res "/lastfm-cover" -> serveLastfmCover url res "/client.js" -> serveClientJs res @@ -289,6 +361,33 @@ serveClientJs res = do Left _ -> serveNotFound res foreign import getQueryParam :: String -> URL -> Effect (Nullable String) +foreign import writeBuffer :: forall r. Writable r -> Foreign -> Effect Unit +foreign import sanitizeKey :: String -> String + +serveCaaCover :: URL -> Response -> Effect Unit +serveCaaCover url res = do + launchAff_ do + mbidMaybe <- liftEffect $ getQueryParam "mbid" url + case toMaybe mbidMaybe of + Nothing -> liftEffect $ serveNotFound res + Just mbid -> do + let s3Key = "covers/caa/" <> mbid <> ".jpg" + cachedResult <- try $ existsInS3 s3Key + let + cached = case cachedResult of + Right b -> b + Left _ -> false + + if cached then do + liftEffect $ Console.log $ "Serving CAA cover from S3: " <> s3Key + liftEffect $ do + setStatusCode 302 res + setHeader "Location" (getS3Url s3Key) (toOutgoingMessage res) + end (toWriteable (toOutgoingMessage res)) + else do + let caaUrl = "https://coverartarchive.org/release/" <> mbid <> "/front-250" + liftEffect $ Console.log $ "Fetching CAA cover: " <> mbid + proxyAndCacheImage caaUrl s3Key res serveLastfmCover :: URL -> Response -> Effect Unit serveLastfmCover url res = do @@ -298,113 +397,165 @@ serveLastfmCover url res = do let artistStr = fromMaybe "" (toMaybe artistMaybe) let releaseStr = fromMaybe "" (toMaybe releaseMaybe) - env <- liftEffect getEnv - let apiKey = Object.lookup "LASTFM_API_KEY" env - case apiKey of - Nothing -> liftEffect $ serveNotFound res - Just k -> do - let searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=" <> k <> "&artist=" <> (fromMaybe "" $ encodeURIComponent artistStr) <> "&album=" <> (fromMaybe "" $ encodeURIComponent releaseStr) <> "&format=json" - liftEffect $ Console.log $ "Last.fm search: " <> artistStr <> " - " <> releaseStr - - result <- try $ fetch searchUrl { method: GET } - case result of - Right fetchRes -> do - json <- fromJson fetchRes.json - let coverUrl = do + let safeArtist = sanitizeKey artistStr + let safeRelease = sanitizeKey releaseStr + let s3Key = "covers/lastfm/" <> safeArtist <> "-" <> safeRelease <> ".jpg" + + cachedResult <- try $ existsInS3 s3Key + let + cached = case cachedResult of + Right b -> b + Left _ -> false + + if cached then do + liftEffect $ Console.log $ "Serving Last.fm cover from S3: " <> s3Key + liftEffect $ do + setStatusCode 302 res + setHeader "Location" (getS3Url s3Key) (toOutgoingMessage res) + end (toWriteable (toOutgoingMessage res)) + else do + env <- liftEffect getEnv + let apiKey = Object.lookup "LASTFM_API_KEY" env + case apiKey of + Nothing -> liftEffect $ serveNotFound res + Just k -> do + let searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=" <> k <> "&artist=" <> (fromMaybe "" $ encodeURIComponent artistStr) <> "&album=" <> (fromMaybe "" $ encodeURIComponent releaseStr) <> "&format=json" + liftEffect $ Console.log $ "Last.fm search: " <> artistStr <> " - " <> releaseStr + + result <- try $ fetch searchUrl { method: GET } + case result of + Right fetchRes -> do + json <- fromJson fetchRes.json + let + coverUrl = do obj <- toObject json album <- Object.lookup "album" obj >>= toObject images <- Object.lookup "image" album >>= toArray - -- Try to get the large image (index 2 or 3) imageObj <- images !! 2 >>= toObject u <- Object.lookup "#text" imageObj >>= toString if u == "" then Nothing else Just u - - case coverUrl of - Just urlStr -> do - liftEffect $ Console.log $ "Found Last.fm cover: " <> urlStr - proxyImage urlStr res - Nothing -> do - liftEffect $ Console.log $ "No Last.fm cover found for: " <> artistStr <> " - " <> releaseStr - liftEffect $ serveNotFound res - Left err -> do - liftEffect $ Console.log $ "Last.fm API error: " <> Exception.message err - liftEffect $ serveNotFound res + + case coverUrl of + Just urlStr -> do + liftEffect $ Console.log $ "Found Last.fm cover: " <> urlStr + proxyAndCacheImage urlStr s3Key res + Nothing -> do + liftEffect $ Console.log $ "No Last.fm cover found for: " <> artistStr <> " - " <> releaseStr + liftEffect $ serveNotFound res + Left err -> do + liftEffect $ Console.log $ "Last.fm API error: " <> Exception.message err + liftEffect $ serveNotFound res serveDiscogsCover :: URL -> Response -> Effect Unit serveDiscogsCover url res = do launchAff_ do artistMaybe <- liftEffect $ getQueryParam "artist" url releaseMaybe <- liftEffect $ getQueryParam "release" url - let artistStr = fromMaybe "" (toMaybe artistMaybe) let releaseStr = fromMaybe "" (toMaybe releaseMaybe) - env <- liftEffect getEnv - let token = Object.lookup "DISCOGS_TOKEN" env - case token of - Nothing -> do - liftEffect $ Console.log "DISCOGS_TOKEN not found in env" - liftEffect $ serveNotFound res - Just t -> do - let queryStr = artistStr <> " " <> releaseStr - let searchUrl = "https://api.discogs.com/database/search?q=" <> (fromMaybe "" $ encodeURIComponent queryStr) <> "&type=release&per_page=1&token=" <> t - liftEffect $ Console.log $ "Discogs search (broad): " <> queryStr - - result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "ScrobblerPureScript/1.0" } } - case result of - Right fetchRes -> do - json <- fromJson fetchRes.json - let coverUrl = do + let safeArtist = sanitizeKey artistStr + let safeRelease = sanitizeKey releaseStr + let s3Key = "covers/discogs/" <> safeArtist <> "-" <> safeRelease <> ".jpg" + + cachedResult <- try $ existsInS3 s3Key + let + cached = case cachedResult of + Right b -> b + Left _ -> false + + if cached then do + liftEffect $ Console.log $ "Serving Discogs cover from S3: " <> s3Key + liftEffect $ do + setStatusCode 302 res + setHeader "Location" (getS3Url s3Key) (toOutgoingMessage res) + end (toWriteable (toOutgoingMessage res)) + else do + env <- liftEffect getEnv + let token = Object.lookup "DISCOGS_TOKEN" env + case token of + Nothing -> do + liftEffect $ Console.log "DISCOGS_TOKEN not found in env" + liftEffect $ serveNotFound res + Just t -> do + let queryStr = artistStr <> " " <> releaseStr + let searchUrl = "https://api.discogs.com/database/search?q=" <> (fromMaybe "" $ encodeURIComponent queryStr) <> "&type=release&per_page=1&token=" <> t + liftEffect $ Console.log $ "Discogs search (broad): " <> queryStr + + result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "ScrobblerPureScript/1.0" } } + case result of + Right fetchRes -> do + json <- fromJson fetchRes.json + let + coverUrl = do obj <- toObject json results <- Object.lookup "results" obj >>= toArray firstResult <- results !! 0 >>= toObject cover <- Object.lookup "cover_image" firstResult >>= toString pure cover - - case coverUrl of - Just urlStr -> do - liftEffect $ Console.log $ "Found Discogs cover: " <> urlStr - proxyImage urlStr res - Nothing -> do - liftEffect $ Console.log $ "No Discogs cover found for: " <> queryStr - liftEffect $ serveNotFound res - Left err -> do - liftEffect $ Console.log $ "Discogs API error: " <> Exception.message err - liftEffect $ serveNotFound res - -proxyImage :: String -> Response -> Aff Unit -proxyImage urlStr res = liftEffect $ do - imgReq <- HTTPS.get urlStr - imgReq # on_ Client.responseH \imgRes -> do - let imgSc = IM.statusCode imgRes - setStatusCode imgSc res - setHeader "Content-Type" (fromMaybe "image/jpeg" $ Object.lookup "content-type" (IM.headers imgRes)) (toOutgoingMessage res) - setHeader "Cache-Control" "public, max-age=86400" (toOutgoingMessage res) - - let reader = IM.toReadable imgRes - let writer = toWriteable (toOutgoingMessage res) - void $ pipe reader writer - - let errorH = EventHandle "error" mkEffectFn1 - on_ errorH (\err -> do - Console.log $ "Image proxy error: " <> Exception.message err - serveNotFound res - ) (unsafeCoerce imgReq) -serveProxy :: Response -> Effect Unit -serveProxy res = do + case coverUrl of + Just urlStr -> do + liftEffect $ Console.log $ "Found Discogs cover: " <> urlStr + proxyAndCacheImage urlStr s3Key res + Nothing -> do + liftEffect $ Console.log $ "No Discogs cover found for: " <> queryStr + liftEffect $ serveNotFound res + Left err -> do + liftEffect $ Console.log $ "Discogs API error: " <> Exception.message err + liftEffect $ serveNotFound res + +proxyAndCacheImage :: String -> String -> Response -> Aff Unit +proxyAndCacheImage urlStr s3Key res = do + liftEffect $ Console.log $ "Proxying and caching image: " <> urlStr + makeAff \cb -> do + launchAff_ do + fetchResult <- try $ fetch urlStr { method: GET } + case fetchResult of + Right fr -> do + let contentType = fromMaybe "image/jpeg" $ lookup "content-type" fr.headers + buf <- fr.arrayBuffer + liftEffect $ do + setStatusCode fr.status res + setHeader "Content-Type" contentType (toOutgoingMessage res) + setHeader "Cache-Control" "public, max-age=86400" (toOutgoingMessage res) + let writer = toWriteable (toOutgoingMessage res) + writeBuffer writer (unsafeCoerce buf) + end writer + + -- Cache to S3 + uploadResult <- try $ uploadToS3 s3Key (unsafeCoerce buf) contentType + case uploadResult of + Right _ -> liftEffect $ Console.log $ "Cached to S3: " <> s3Key + Left err -> liftEffect $ Console.log $ "S3 upload failed: " <> Exception.message err + liftEffect $ cb (Right unit) + Left err -> do + liftEffect $ Console.log $ "Failed to fetch image: " <> Exception.message err + liftEffect $ serveNotFound res + liftEffect $ cb (Right unit) + pure nonCanceler + +serveProxy :: Connection -> URL -> Response -> Effect Unit +serveProxy db url res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) setHeader "Access-Control-Allow-Methods" "GET, POST, OPTIONS" (toOutgoingMessage res) setHeader "Access-Control-Allow-Headers" "*" (toOutgoingMessage res) - -- Launch the Aff action to fetch data from ListenBrainz launchAff_ do - body <- handleApiRequest + limitStr <- liftEffect $ getQueryParam "limit" url + offsetStr <- liftEffect $ getQueryParam "offset" url + + let limit = fromMaybe 25 (toMaybe limitStr >>= fromString) + let offset = fromMaybe 0 (toMaybe offsetStr >>= fromString) + + listens <- getScrobbles db limit offset + let responseBody = stringify $ encodeJson { payload: { listens: listens } } + liftEffect $ do setStatusCode 200 res let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 body + void $ writeString w UTF8 responseBody end w serveFavicon :: Response -> Effect Unit @@ -424,15 +575,20 @@ serveNotFound res = do end w startServer :: Int -> Effect Unit -startServer port = do - server <- createServer - server # on_ Server.requestH handleRequest - let netServer = Server.toNetServer server +startServer port = launchAff_ do + conn <- connect "scorpus.db" + initDb conn + void $ forkAff $ syncData conn - netServer # on_ listeningH do - Console.log $ "Server is running on port " <> show port + liftEffect $ do + server <- createServer + server # on_ Server.requestH (handleRequest conn) + let netServer = Server.toNetServer server - listenTcp netServer { host: "127.0.0.1", port, backlog: 128 } + netServer # on_ listeningH do + Console.log $ "Server is running on port " <> show port + + listenTcp netServer { host: "127.0.0.1", port, backlog: 128 } foreign import dotenvConfig :: Effect Unit @@ -440,3 +596,5 @@ main :: Effect Unit main = do dotenvConfig startServer 8000 + +foreign import split :: String -> String -> Array String diff --git a/src/S3.js b/src/S3.js @@ -0,0 +1,62 @@ +import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"; + +const getClient = () => new S3Client({ + region: process.env.S3_REGION || "us-east-1", + endpoint: process.env.AWS_ENDPOINT_URL, + forcePathStyle: process.env.AWS_S3_ADDRESSING_STYLE === "path", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY + } +}); + +export const uploadToS3Impl = (key) => (body) => (contentType) => (cb) => () => { + const client = getClient(); + const command = new PutObjectCommand({ + Bucket: process.env.S3_BUCKET, + Key: key, + Body: body, + ContentType: contentType + }); + + console.log(`S3: Uploading ${key} to bucket ${process.env.S3_BUCKET} at ${process.env.AWS_ENDPOINT_URL}`); + + client.send(command) + .then(() => { + console.log(`S3: Successfully uploaded ${key}`); + cb(null)(); + }) + .catch((err) => { + console.error(`S3: Upload failed for ${key}`, err); + cb(err)(); + }); +}; + +export const existsInS3Impl = (key) => (cb) => () => { + const client = getClient(); + const command = new HeadObjectCommand({ + Bucket: process.env.S3_BUCKET, + Key: key + }); + + client.send(command) + .then(() => cb(true)()) + .catch((err) => { + if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) { + cb(false)(); + } else { + console.error(`S3: exists check failed for ${key}`, err); + cb(false)(); + } + }); +}; + +export const getS3UrlImpl = (key) => { + const endpoint = process.env.AWS_ENDPOINT_URL; + const bucket = process.env.S3_BUCKET; + if (process.env.AWS_S3_ADDRESSING_STYLE === "path") { + return `${endpoint}/${bucket}/${key}`; + } else { + return `${endpoint.replace("://", `://${bucket}.`)}/${key}`; + } +}; diff --git a/src/S3.purs b/src/S3.purs @@ -0,0 +1,31 @@ +module S3 where + +import Prelude + +import Data.Either (Either(..)) +import Data.Maybe (Maybe(..)) +import Effect (Effect) +import Effect.Aff (Aff, makeAff, nonCanceler) +import Effect.Exception (Error) +import Node.Buffer (Buffer) +import Data.Nullable (Nullable, toMaybe) + +foreign import uploadToS3Impl :: String -> Buffer -> String -> (Nullable Error -> Effect Unit) -> Effect Unit +foreign import existsInS3Impl :: String -> (Boolean -> Effect Unit) -> Effect Unit +foreign import getS3UrlImpl :: String -> String + +uploadToS3 :: String -> Buffer -> String -> Aff Unit +uploadToS3 key body contentType = makeAff \cb -> do + uploadToS3Impl key body contentType \err -> + case toMaybe err of + Just e -> cb (Left e) + Nothing -> cb (Right unit) + pure nonCanceler + +existsInS3 :: String -> Aff Boolean +existsInS3 key = makeAff \cb -> do + existsInS3Impl key \exists -> cb (Right exists) + pure nonCanceler + +getS3Url :: String -> String +getS3Url = getS3UrlImpl diff --git a/src/Types.purs b/src/Types.purs @@ -2,7 +2,8 @@ module Types where import Prelude -import Data.Argonaut (class DecodeJson, decodeJson, (.:), (.:?)) +import Data.Argonaut (class DecodeJson, class EncodeJson, decodeJson, encodeJson, (.:), (.:?), (:=), (~>)) +import Data.Argonaut.Core (jsonEmptyObject) import Data.Maybe (Maybe) import Data.Generic.Rep (class Generic) @@ -19,6 +20,11 @@ instance DecodeJson ListenBrainzResponse where payload <- obj .: "payload" pure $ ListenBrainzResponse { payload } +instance EncodeJson ListenBrainzResponse where + encodeJson (ListenBrainzResponse { payload }) = + "payload" := encodeJson payload + ~> jsonEmptyObject + newtype Payload = Payload { listens :: Array Listen } @@ -32,6 +38,11 @@ instance DecodeJson Payload where listens <- obj .: "listens" pure $ Payload { listens } +instance EncodeJson Payload where + encodeJson (Payload { listens }) = + "listens" := encodeJson listens + ~> jsonEmptyObject + newtype Listen = Listen { trackMetadata :: TrackMetadata , listenedAt :: Maybe Int @@ -47,6 +58,12 @@ instance DecodeJson Listen where listenedAt <- obj .:? "listened_at" pure $ Listen { trackMetadata, listenedAt } +instance EncodeJson Listen where + encodeJson (Listen { trackMetadata, listenedAt }) = + "track_metadata" := encodeJson trackMetadata + ~> "listened_at" := encodeJson listenedAt + ~> jsonEmptyObject + newtype TrackMetadata = TrackMetadata { trackName :: Maybe String , artistName :: Maybe String @@ -66,6 +83,14 @@ instance DecodeJson TrackMetadata where mbidMapping <- obj .:? "mbid_mapping" pure $ TrackMetadata { trackName, artistName, releaseName, mbidMapping } +instance EncodeJson TrackMetadata where + encodeJson (TrackMetadata { trackName, artistName, releaseName, mbidMapping }) = + "track_name" := encodeJson trackName + ~> "artist_name" := encodeJson artistName + ~> "release_name" := encodeJson releaseName + ~> "mbid_mapping" := encodeJson mbidMapping + ~> jsonEmptyObject + newtype MbidMapping = MbidMapping { releaseMbid :: Maybe String , caaReleaseMbid :: Maybe String @@ -80,3 +105,9 @@ instance DecodeJson MbidMapping where releaseMbid <- obj .:? "release_mbid" caaReleaseMbid <- obj .:? "caa_release_mbid" pure $ MbidMapping { releaseMbid, caaReleaseMbid } + +instance EncodeJson MbidMapping where + encodeJson (MbidMapping { releaseMbid, caaReleaseMbid }) = + "release_mbid" := encodeJson releaseMbid + ~> "caa_release_mbid" := encodeJson caaReleaseMbid + ~> jsonEmptyObject