corpus

Log | Files | Refs | README | LICENSE

commit de66d01dd48fb8106822a79165d26c0c82ccf535
Author: mtmn <miro@haravara.org>
Date:   Fri, 10 Apr 2026 23:49:22 +0200

init

Former-commit-id: e1309e5cb74da3f0c493a268f080fa1b927ba155

Diffstat:
Aindex.html | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajustfile | 45+++++++++++++++++++++++++++++++++++++++++++++
Apackage-lock.json.REMOVED.git-id | 2++
Apackage.json | 21+++++++++++++++++++++
Apackages.dhall | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspago.dhall | 18++++++++++++++++++
Asrc/Main.purs | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Proxy.purs | 32++++++++++++++++++++++++++++++++
8 files changed, 862 insertions(+), 0 deletions(-)

diff --git a/index.html b/index.html @@ -0,0 +1,250 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>scrobbler.mtmn.name</title> + <script src="https://unpkg.com/htmx.org@2.0.7"></script> + <style> + body { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background: #332d38; + color: #ffffff; + margin: 0; + padding: 20px; + line-height: 1.6; + } + + .container { + max-width: 800px; + margin: 0 auto; + } + + h1 { + color: #ffffff; + margin-bottom: 20px; + font-size: 24px; + } + + ul { + list-style: none; + padding: 0; + margin: 0 0 20px 0; + } + + li { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(80, 68, 127, 0.5); + border-radius: 4px; + padding: 15px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + } + + li.success { + background: rgba(185, 208, 170, 0.2); + border-color: rgba(185, 208, 170, 0.3); + } + + .track-info { + flex: 1; + } + + .track-name { + font-weight: bold; + font-size: 16px; + color: #ffffff; + } + + .track-artist { + font-size: 14px; + color: #a0c0d0; + margin-top: 4px; + } + + .track-time { + font-size: 12px; + color: #9fbfe7; + margin-top: 2px; + } + + .status { + color: #b9d0aa; + font-weight: bold; + } + + .loading { + padding: 20px; + color: #9fbfe7; + text-align: center; + } + + .error { + padding: 20px; + color: #eca28f; + text-align: center; + } + + .small { + font-size: 12px; + color: #9fbfe7; + margin-top: 20px; + } + + .small a { + color: #a0c0d0; + text-decoration: none; + } + + .small a:hover { + color: #ffffff; + text-decoration: underline; + } + + .refresh-btn { + background: none; + border: none; + color: #a0c0d0; + cursor: pointer; + font-size: 12px; + text-decoration: underline; + } + + .refresh-btn:hover { + color: #ffffff; + } + + .playing-indicator { + display: inline-block; + width: 8px; + height: 8px; + background: #b9d0aa; + border-radius: 50%; + margin-right: 8px; + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.3; } + 100% { opacity: 1; } + } + </style> +</head> +<body> + <div class="container"> + <ul> + <li class="success">Now Playing</li> + </ul> + + <h1>Recent Tracks</h1> + <ul id="tracks-container" + hx-get="/api/1/user/mtmn/listens" + hx-trigger="load, every 30s" + hx-target="#tracks-container" + hx-ext="listenbrainz"> + <li class="loading">Loading recent tracks...</li> + </ul> + + <p class="small"> + <button class="refresh-btn" hx-get="/api/1/user/mtmn/listens" hx-target="#tracks-container">Refresh</button> • + <a href="https://listenbrainz.org/user/mtmn/">ListenBrainz</a> • + </p> + + <p class="small" id="last-updated"></p> + </div> + + <script> + // Update last updated time + function updateLastUpdated() { + const now = new Date(); + document.getElementById('last-updated').textContent = `Last check: ${now.toISOString()}`; + } + + // Handle ListenBrainz API response + htmx.defineExtension('listenbrainz', { + onEvent: function(name, evt) { + if (name === 'htmx:afterRequest') { + const xhr = evt.detail.xhr; + if (xhr.status === 200) { + try { + const data = JSON.parse(xhr.responseText); + if (data.payload && data.payload.listens) { + const html = generateTracksHTML(data.payload.listens); + document.getElementById('tracks-container').innerHTML = html; + } else { + document.getElementById('tracks-container').innerHTML = '<li class="error">No tracks found</li>'; + } + } catch (e) { + document.getElementById('tracks-container').innerHTML = '<li class="error">Error parsing response</li>'; + } + } else { + document.getElementById('tracks-container').innerHTML = '<li class="error">Error loading tracks</li>'; + } + updateLastUpdated(); + } + } + }); + + function generateTracksHTML(listens) { + let html = ''; + listens.forEach((listen, index) => { + const track = listen.track_metadata; + const listenedAt = formatTimeAgo(listen.listened_at); + + const trackName = track.track_name || 'Unknown Track'; + const artistName = track.artist_name || 'Unknown Artist'; + const releaseName = track.release_name || 'Unknown Album'; + + const playingIndicator = index === 0 ? '<span class="playing-indicator"></span>' : ''; + + html += ` + <li class="success"> + <div class="track-info"> + <div class="track-name">${playingIndicator}${escapeHtml(trackName)}</div> + <div class="track-artist">${escapeHtml(artistName)}</div> + <div class="track-time">${escapeHtml(releaseName)} • ${listenedAt}</div> + </div> + <span class="status">Played</span> + </li> + `; + }); + return html; + } + + function formatTimeAgo(timestamp) { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) { + return 'just now'; + } else if (diff < 3600) { + const minutes = Math.floor(diff / 60); + return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + } else if (diff < 86400) { + const hours = Math.floor(diff / 3600); + return `${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(diff / 86400); + return `${days} day${days > 1 ? 's' : ''} ago`; + } + } + + function escapeHtml(text) { + const map = { + '&': '&amp;', + '<': '&lt;', + '>': '&gt;', + '"': '&quot;', + "'": '&#39;' + }; + return text.replace(/[&<>"']/g, m => map[m]); + } + + // Initial update + updateLastUpdated(); + </script> +</body> +</html> diff --git a/justfile b/justfile @@ -0,0 +1,45 @@ +# Justfile for PureScript scrobbler + +# Default recipe +default: help + +# Show help +help: + @just --list + +# Install dependencies +install: + npx spago install + +# Build the project +build: + npx spago build + +# Bundle to JavaScript +bundle: + npx spago bundle-app --main Main --to index.js --platform node + +# Run the application +run: + node index.js + +# Build and run +dev: build run + +# Start fresh installation +setup: install build + @echo "Setup complete! Run 'just run' to start the server" + @echo "Then visit: http://localhost:8000" + +# Clean build artifacts +clean: + rm -f index.js + npx spago clean + +# Production build +prod: clean bundle + @echo "Production build complete!" + +# Run tests +test: + npx spago test diff --git a/package-lock.json.REMOVED.git-id b/package-lock.json.REMOVED.git-id @@ -0,0 +1 @@ +cb42bac2fd6f70dc9423a238f450ac6dc2ad8a2d +\ No newline at end of file diff --git a/package.json b/package.json @@ -0,0 +1,21 @@ +{ + "name": "scrobbler-purescript", + "version": "1.0.0", + "description": "ListenBrainz scrobbler in PureScript", + "main": "index.js", + "scripts": { + "build": "npx spago build", + "test": "npx spago test", + "tidy": "purs-tidy format-in-place src/**/*.purs", + "serve": "npx spago build && node -e \"require('spago').run({cmd: 'bundle-app', main: 'Main', to: './index.js', platform: 'node'})\" && node ./index.js", + "start": "npx spago build && node -e \"require('spago').run({cmd: 'bundle-app', main: 'Main', to: './index.js', platform: 'node'})\" && node ./index.js" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "purescript": "^0.15.15", + "purescript-language-server": "^0.18.0", + "purescript-psa": "^0.9.0", + "purs-tidy": "^0.11.1", + "spago": "^0.21.0" + } +} diff --git a/packages.dhall b/packages.dhall @@ -0,0 +1,105 @@ +{- +Welcome to your new Dhall package-set! + +Below are instructions for how to edit this file for most use +cases, so that you don't need to know Dhall to use it. + +## Use Cases + +Most will want to do one or both of these options: +1. Override/Patch a package's dependency +2. Add a package not already in the default package set + +This file will continue to work whether you use one or both options. +Instructions for each option are explained below. + +### Overriding/Patching a package + +Purpose: +- Change a package's dependency to a newer/older release than the + default package set's release +- Use your own modified version of some dependency that may + include new API, changed API, removed API by + using your custom git repo of the library rather than + the package set's repo + +Syntax: +where `entityName` is one of the following: +- dependencies +- repo +- version +------------------------------- +let upstream = -- +in upstream + with packageName.entityName = "new value" +------------------------------- + +Example: +------------------------------- +let upstream = -- +in upstream + with halogen.version = "master" + with halogen.repo = "https://example.com/path/to/git/repo.git" + + with halogen-vdom.version = "v4.0.0" + with halogen-vdom.dependencies = [ "extra-dependency" ] # halogen-vdom.dependencies +------------------------------- + +### Additions + +Purpose: +- Add packages that aren't already included in the default package set + +Syntax: +where `<version>` is: +- a tag (i.e. "v4.0.0") +- a branch (i.e. "master") +- commit hash (i.e. "701f3e44aafb1a6459281714858fadf2c4c2a977") +------------------------------- +let upstream = -- +in upstream + with new-package-name = + { dependencies = + [ "dependency1" + , "dependency2" + ] + , repo = + "https://example.com/path/to/git/repo.git" + , version = + "<version>" + } +------------------------------- + +Example: +------------------------------- +let upstream = -- +in upstream + with benchotron = + { dependencies = + [ "arrays" + , "exists" + , "profunctor" + , "strings" + , "quickcheck" + , "lcg" + , "transformers" + , "foldable-traversable" + , "exceptions" + , "node-fs" + , "node-buffer" + , "node-readline" + , "datetime" + , "now" + ] + , repo = + "https://github.com/hdgarrood/purescript-benchotron.git" + , version = + "v7.0.0" + } +------------------------------- +-} +let upstream = + https://github.com/purescript/package-sets/releases/download/psc-0.15.15-20260410/packages.dhall + sha256:790bcce8148c65d74e13a149145e5a26869d4d625c29093cb4ac0883d0cd3e05 + +in upstream +\ No newline at end of file diff --git a/spago.dhall b/spago.dhall @@ -0,0 +1,18 @@ +{ name = "scrobbler.purs" +, dependencies = + [ "aff" + , "console" + , "effect" + , "either" + , "exceptions" + , "node-buffer" + , "node-event-emitter" + , "node-http" + , "node-net" + , "node-streams" + , "prelude" + , "unsafe-coerce" + ] +, packages = ./packages.dhall +, sources = [ "src/**/*.purs" ] +} diff --git a/src/Main.purs b/src/Main.purs @@ -0,0 +1,388 @@ +module Main where + +import Prelude + +import Effect (Effect) +import Effect.Class (liftEffect) +import Effect.Console as Console +import Node.HTTP (createServer) +import Node.HTTPS as HTTPS +import Node.HTTP.Server as Server +import Node.EventEmitter (on_, EventHandle(..)) +import Effect.Uncurried (mkEffectFn1) +import Node.HTTP.ClientRequest as Client +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) +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 Unsafe.Coerce (unsafeCoerce) + +-- Types +type Request = IncomingMessage IMServer +type Response = ServerResponse + +type ProxyConfig = + { port :: Int + , listenBrainzUrl :: String + } + +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 + + req # on_ Client.responseH \res -> do + let sc = IM.statusCode res + Console.log $ "ListenBrainz response status: " <> show sc + launchAff_ do + body <- readableToStringUtf8 (IM.toReadable res) + liftEffect $ callback (Right body) + + let errorH = EventHandle "error" mkEffectFn1 + on_ errorH + ( \err -> do + Console.log $ "ListenBrainz fetch error: " <> Exception.message err + callback (Left err) + ) + (unsafeCoerce req) + + 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\"}" + +indexHtml :: String +indexHtml = + """<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>scrobbler.mtmn.name</title> + <script src="https://unpkg.com/htmx.org@2.0.7"></script> + <style> + body { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background: #332d38; + color: #ffffff; + margin: 0; + padding: 20px; + line-height: 1.6; + } + + .container { + max-width: 800px; + margin: 0 auto; + } + + h1 { + color: #ffffff; + margin-bottom: 20px; + font-size: 24px; + } + + ul { + list-style: none; + padding: 0; + margin: 0 0 20px 0; + } + + li { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(80, 68, 127, 0.5); + border-radius: 4px; + padding: 15px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + } + + li.success { + background: rgba(185, 208, 170, 0.2); + border-color: rgba(185, 208, 170, 0.3); + } + + .track-info { + flex: 1; + } + + .track-name { + font-weight: bold; + font-size: 16px; + color: #ffffff; + } + + .track-artist { + font-size: 14px; + color: #a0c0d0; + margin-top: 4px; + } + + .track-time { + font-size: 12px; + color: #9fbfe7; + margin-top: 2px; + } + + .status { + color: #b9d0aa; + font-weight: bold; + } + + .loading { + padding: 20px; + color: #9fbfe7; + text-align: center; + } + + .error { + padding: 20px; + color: #eca28f; + text-align: center; + } + + .small { + font-size: 12px; + color: #9fbfe7; + margin-top: 20px; + } + + .small a { + color: #a0c0d0; + text-decoration: none; + } + + .small a:hover { + color: #ffffff; + text-decoration: underline; + } + + .refresh-btn { + background: none; + border: none; + color: #a0c0d0; + cursor: pointer; + font-size: 12px; + text-decoration: underline; + } + + .refresh-btn:hover { + color: #ffffff; + } + + .playing-indicator { + display: inline-block; + width: 8px; + height: 8px; + background: #b9d0aa; + border-radius: 50%; + margin-right: 8px; + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.3; } + 100% { opacity: 1; } + } + </style> +</head> +<body> + <div class="container"> + <h1>History</h1> + <ul id="tracks-container" + hx-get="/proxy" + hx-trigger="load, every 30s" + hx-target="#tracks-container" + hx-ext="listenbrainz"> + <li class="loading">Loading recent tracks...</li> + </ul> + + <p class="small"> + <a href="https://listenbrainz.org/user/mtmn/">ListenBrainz</a> + </p> + + <p class="small" id="last-updated"></p> + </div> + + <script> + // Update last updated time + function updateLastUpdated() { + const now = new Date(); + document.getElementById('last-updated').textContent = `${now.toISOString()}`; + } + + // Handle ListenBrainz API response + htmx.defineExtension('listenbrainz', { + onEvent: function(name, evt) { + if (name === 'htmx:afterRequest') { + const xhr = evt.detail.xhr; + if (xhr.status === 200) { + try { + const data = JSON.parse(xhr.responseText); + if (data.payload && data.payload.listens) { + const html = generateTracksHTML(data.payload.listens); + document.getElementById('tracks-container').innerHTML = html; + } else { + document.getElementById('tracks-container').innerHTML = '<li class="error">No tracks found</li>'; + } + } catch (e) { + document.getElementById('tracks-container').innerHTML = '<li class="error">Error parsing response</li>'; + } + } else { + document.getElementById('tracks-container').innerHTML = '<li class="error">Error loading tracks</li>'; + } + updateLastUpdated(); + } + } + }); + + function generateTracksHTML(listens) { + let html = ''; + listens.forEach((listen, index) => { + const track = listen.track_metadata; + const listenedAt = formatTimeAgo(listen.listened_at); + + const trackName = track.track_name || 'Unknown Track'; + const artistName = track.artist_name || 'Unknown Artist'; + const releaseName = track.release_name || 'Unknown Album'; + + const playingIndicator = index === 0 ? '<span class="playing-indicator"></span>' : ''; + + html += ` + <li class="success"> + <div class="track-info"> + <div class="track-name">${playingIndicator}${escapeHtml(trackName)}</div> + <div class="track-artist">${escapeHtml(artistName)}</div> + <div class="track-time">${escapeHtml(releaseName)} • ${listenedAt}</div> + </div> + <span class="status">Played</span> + </li> + `; + }); + return html; + } + + function formatTimeAgo(timestamp) { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) { + return 'just now'; + } else if (diff < 3600) { + const minutes = Math.floor(diff / 60); + return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + } else if (diff < 86400) { + const hours = Math.floor(diff / 3600); + return `${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(diff / 86400); + return `${days} day${days > 1 ? 's' : ''} ago`; + } + } + + function escapeHtml(text) { + const map = { + '&': '&amp;', + '<': '&lt;', + '>': '&gt;', + '"': '&quot;', + "'": '&#39;' + }; + return text.replace(/[&<>"']/g, m => map[m]); + } + + // Initial update + updateLastUpdated(); + </script> +</body> +</html>""" + +-- Request handler +handleRequest :: Request -> Response -> Effect Unit +handleRequest req res = do + let path = IM.url req + Console.log $ "Request received: " <> path + + case path of + "/" -> serveIndex res + "/proxy" -> serveProxy res + "/favicon.ico" -> serveFavicon res + _ -> serveNotFound res + +serveIndex :: Response -> Effect Unit +serveIndex res = do + setHeader "Content-Type" "text/html" (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) + setStatusCode 200 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 indexHtml + end w + +serveProxy :: Response -> Effect Unit +serveProxy 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 + liftEffect $ do + setStatusCode 200 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 body + end w + +serveFavicon :: Response -> Effect Unit +serveFavicon res = do + setHeader "Content-Type" "image/x-icon" (toOutgoingMessage res) + setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) + setStatusCode 200 res + end (toWriteable (toOutgoingMessage res)) + +serveNotFound :: Response -> Effect Unit +serveNotFound res = do + setHeader "Content-Type" "text/plain" (toOutgoingMessage res) + setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) + setStatusCode 404 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 "Not Found" + end w + +startServer :: Int -> Effect Unit +startServer port = do + server <- createServer + server # on_ Server.requestH handleRequest + let netServer = Server.toNetServer server + + netServer # on_ listeningH do + Console.log $ "Server is running on port " <> show port + + listenTcp netServer { host: "127.0.0.1", port, backlog: 128 } + +main :: Effect Unit +main = do + startServer 8000 diff --git a/src/Proxy.purs b/src/Proxy.purs @@ -0,0 +1,32 @@ +module Proxy where + +import Prelude + +import Effect (Effect) +import Effect.Console as Console + +-- Simple proxy that would work with a proper HTTP server implementation +-- For now, this is a placeholder that shows the structure + +type ProxyConfig = + { port :: Int + , listenBrainzUrl :: String + } + +startProxy :: ProxyConfig -> Effect Unit +startProxy config = do + Console.log $ "Proxy server would start on port " <> show config.port + Console.log $ "Proxying requests to: " <> config.listenBrainzUrl + Console.log "Note: This requires a proper HTTP server implementation in PureScript" + +-- For a complete implementation, you would need: +-- 1. HTTP server library (like node-http or similar) +-- 2. HTTP client for making requests to ListenBrainz +-- 3. CORS handling +-- 4. Static file serving + +defaultConfig :: ProxyConfig +defaultConfig = + { port: 8000 + , listenBrainzUrl: "https://api.listenbrainz.org/1/user/mtmn/listens" + }