commit de66d01dd48fb8106822a79165d26c0c82ccf535
Author: mtmn <miro@haravara.org>
Date: Fri, 10 Apr 2026 23:49:22 +0200
init
Former-commit-id: e1309e5cb74da3f0c493a268f080fa1b927ba155
Diffstat:
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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ 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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ 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"
+ }