diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4a6a52c..0117566 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "context": "..", "dockerfile": "./Dockerfile" }, - "postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install", + "postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && bun install", "customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "anthropic.claude-code"] diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 0000000..bd07360 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,62 @@ +name: 'Bug Report' +description: Create a bug report. +title: '[BUG]
A minimalist music streaming application
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/donate/index.js b/functions/donate/index.js new file mode 100644 index 0000000..93e334b --- /dev/null +++ b/functions/donate/index.js @@ -0,0 +1,34 @@ +export async function onRequest(context) { + const { request } = context; + const pageUrl = request.url; + + const metaHtml = ` + + + + +A minimalist music streaming application
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/library/index.js b/functions/library/index.js new file mode 100644 index 0000000..5ed5c78 --- /dev/null +++ b/functions/library/index.js @@ -0,0 +1,34 @@ +export async function onRequest(context) { + const { request } = context; + const pageUrl = request.url; + + const metaHtml = ` + + + + +A minimalist music streaming application
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/parties/index.js b/functions/parties/index.js new file mode 100644 index 0000000..db05542 --- /dev/null +++ b/functions/parties/index.js @@ -0,0 +1,34 @@ +export async function onRequest(context) { + const { request } = context; + const pageUrl = request.url; + + const metaHtml = ` + + + + +Listen to music with your friends
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/podcasts/[id].js b/functions/podcasts/[id].js index 25586fe..40fbe98 100644 --- a/functions/podcasts/[id].js +++ b/functions/podcasts/[id].js @@ -50,7 +50,7 @@ export async function onRequest(context) { const title = feed.title; const author = feed.author || feed.ownerName || ''; const episodeCount = feed.episodeCount || 0; - const rawDescription = feed.description || ''; + const _rawDescription = feed.description || ''; const description = author ? `Podcast by ${author} • ${episodeCount} Episodes\nListen on Monochrome` : `Podcast • ${episodeCount} Episodes\nListen on Monochrome`; diff --git a/functions/recent/index.js b/functions/recent/index.js new file mode 100644 index 0000000..48797cc --- /dev/null +++ b/functions/recent/index.js @@ -0,0 +1,34 @@ +export async function onRequest(context) { + const { request } = context; + const pageUrl = request.url; + + const metaHtml = ` + + + + +A minimalist music streaming application
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/settings/index.js b/functions/settings/index.js new file mode 100644 index 0000000..80a81b3 --- /dev/null +++ b/functions/settings/index.js @@ -0,0 +1,34 @@ +export async function onRequest(context) { + const { request } = context; + const pageUrl = request.url; + + const metaHtml = ` + + + + +A minimalist music streaming application
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/unreleased/[sheetId].js b/functions/unreleased/[sheetId].js new file mode 100644 index 0000000..1488b53 --- /dev/null +++ b/functions/unreleased/[sheetId].js @@ -0,0 +1,101 @@ +// functions/unreleased/[sheetId].js + +const ARTISTS_NDJSON_URL = 'https://assets.artistgrid.cx/artists.ndjson'; +const ASSETS_BASE_URL = 'https://assets.artistgrid.cx'; + +function getSheetId(url) { + if (!url) return null; + const match = url.match(/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); + return match ? match[1] : null; +} + +function normalizeArtistName(name) { + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +async function loadArtistsData() { + try { + const response = await fetch(ARTISTS_NDJSON_URL); + if (!response.ok) throw new Error('Network response was not ok'); + const text = await response.text(); + return text + .trim() + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((item) => item !== null); + } catch (e) { + console.error('Failed to load Artists List:', e); + return []; + } +} + +export async function onRequest(context) { + const { request, params, env } = context; + const userAgent = request.headers.get('User-Agent') || ''; + const isBot = + /discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test( + userAgent + ); + const sheetId = params.sheetId; + + if (isBot && sheetId) { + try { + const artists = await loadArtistsData(); + const artist = artists.find((a) => getSheetId(a.url) === sheetId); + + if (artist && artist.name) { + const normalizedName = normalizeArtistName(artist.name); + const imageUrl = `${ASSETS_BASE_URL}/${normalizedName}.webp`; + const pageUrl = new URL(request.url).href; + const title = `${artist.name} | Unreleased`; + const description = `Stream unreleased music by ${artist.name} on Monochrome`; + + const metaHtml = ` + + + + +${description}
+${description}
+Stream unreleased music on Monochrome. Provided by Artistgrid.
+ + + `; + + return new Response(metaHtml, { + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }); +} diff --git a/functions/userplaylist/[id].js b/functions/userplaylist/[id].js index 631b4c9..17bc79d 100644 --- a/functions/userplaylist/[id].js +++ b/functions/userplaylist/[id].js @@ -3,6 +3,54 @@ const POCKETBASE_URL = 'https://data.samidy.xyz'; const PUBLIC_COLLECTION = 'public_playlists'; +function safeParseTracks(tracksData) { + if (!tracksData) return []; + if (Array.isArray(tracksData)) return tracksData; + if (typeof tracksData === 'string') { + try { + return JSON.parse(tracksData); + } catch { + return []; + } + } + return []; +} + +function parseDuration(durationStr) { + if (!durationStr || durationStr === 'N/A' || typeof durationStr !== 'string') return 0; + const parts = durationStr.split(':'); + if (parts.length === 2) { + return parseInt(parts[0]) * 60 + parseInt(parts[1]); + } + if (parts.length === 3) { + return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]); + } + return 0; +} + +function calculatePlaylistDuration(tracks) { + let totalSeconds = 0; + for (const track of tracks) { + const duration = track.duration || track.durationSeconds || 0; + if (typeof duration === 'number') { + totalSeconds += duration; + } else if (typeof duration === 'string') { + totalSeconds += parseDuration(duration); + } + } + return totalSeconds; +} + +function formatDuration(seconds) { + if (!seconds || seconds <= 0) return '0 min'; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) { + return `${hours} hr ${minutes} min`; + } + return `${minutes} min`; +} + export async function onRequest(context) { const { request, params, env } = context; const userAgent = request.headers.get('User-Agent') || ''; @@ -37,14 +85,10 @@ export async function onRequest(context) { (extraData && (extraData.title || extraData.name)) || 'Untitled Playlist'; - let tracks = []; - try { - tracks = record.tracks ? JSON.parse(record.tracks) : []; - } catch { - tracks = []; - } - + let tracks = safeParseTracks(record.tracks); const trackCount = tracks.length; + const totalDuration = calculatePlaylistDuration(tracks); + const durationStr = formatDuration(totalDuration); let rawCover = record.image || record.cover || record.playlist_cover || ''; if (!rawCover && extraData && typeof extraData === 'object') { @@ -70,7 +114,7 @@ export async function onRequest(context) { imageUrl = 'https://monochrome.tf/assets/appicon.png'; } - const description = `Playlist • ${trackCount} Tracks\nListen on Monochrome`; + const description = `Playlist • ${trackCount} Tracks • ${durationStr}\nListen on Monochrome`; const pageUrl = new URL(request.url).href; const metaHtml = ` diff --git a/gen-editors-picks.py b/gen-editors-picks.py new file mode 100644 index 0000000..95e102e --- /dev/null +++ b/gen-editors-picks.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 + +import urllib.request +import urllib.parse +import json +import re +import sys +import hashlib +import time + +INPUT_FILE = "editors-picks-input.txt" +COUNTRY = "US" + +# Tidal internal token — replace when expired +TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MjQ2NzQ2LCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.ksUE4yhQ39IG7oHWk8DyJ91dwIoDVWGzvTAnpeDJ5p-_Gp0F_yO858xDO11AINBaahQCq0jlbqWqIaTqCTOjqg" + +TIDAL_HEADERS = { + "accept": "*/*", + "authorization": f"Bearer {TIDAL_TOKEN}", +} + +# PodcastIndex credentials +PODCAST_API_KEY = "YU5HMSDYBQQVYDF6QN4P" +PODCAST_API_SECRET = "8hCvpjSL7T$S7^5ftnf5MhqQwYUYVjM^fmUL3Ld$" +PODCASTINDEX_BASE = "https://api.podcastindex.org/api/1.0" + + +# ── Tidal helpers ───────────────────────────────────────────────────────────── + +def tidal_get(path, params=None): + if params is None: + params = {} + params.setdefault("countryCode", COUNTRY) + url = f"https://api.tidal.com/v1/{path}?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(url, headers=TIDAL_HEADERS) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + print(f"Error fetching {url}: {e}", file=sys.stderr) + return None + + +def fetch_album(album_id): + return tidal_get(f"albums/{album_id}") + + +def fetch_artist(artist_id): + return tidal_get(f"artists/{artist_id}") + + +def fetch_track(track_id): + return tidal_get(f"tracks/{track_id}") + + +def fetch_playlist(uuid): + return tidal_get(f"playlists/{uuid}") + + +# ── PodcastIndex helper ─────────────────────────────────────────────────────── + +def podcast_get(endpoint): + api_time = str(int(time.time())) + raw = PODCAST_API_KEY + PODCAST_API_SECRET + api_time + auth_hash = hashlib.sha1(raw.encode()).hexdigest() + headers = { + "User-Agent": "MonochromeMusic/1.0", + "X-Auth-Key": PODCAST_API_KEY, + "X-Auth-Date": api_time, + "Authorization": auth_hash, + } + url = f"{PODCASTINDEX_BASE}{endpoint}" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + print(f"Error fetching {url}: {e}", file=sys.stderr) + return None + + +def fetch_podcast(feed_id): + return podcast_get(f"/podcasts/byfeedid?id={feed_id}&pretty") + + +# ── Transformers ────────────────────────────────────────────────────────────── + +def transform_album(d): + return { + "type": "album", + "id": d.get("id"), + "title": d.get("title"), + "artist": { + "id": d.get("artist", {}).get("id"), + "name": d.get("artist", {}).get("name"), + }, + "releaseDate": d.get("releaseDate"), + "cover": d.get("cover"), + "explicit": d.get("explicit"), + "audioQuality": d.get("audioQuality"), + "mediaMetadata": d.get("mediaMetadata"), + } + + +def transform_artist(d): + return { + "type": "artist", + "id": d.get("id"), + "name": d.get("name"), + "picture": d.get("picture"), + } + + +def transform_track(d): + album = d.get("album") or {} + return { + "type": "track", + "id": d.get("id"), + "title": d.get("title"), + "artist": { + "id": d.get("artist", {}).get("id"), + "name": d.get("artist", {}).get("name"), + }, + "album": { + "id": album.get("id"), + "title": album.get("title"), + "cover": album.get("cover"), + }, + "duration": d.get("duration"), + "explicit": d.get("explicit"), + "audioQuality": d.get("audioQuality"), + "mediaMetadata": d.get("mediaMetadata"), + } + + +def transform_playlist(d): + # Tidal editorial playlist → rendered as album card with playlist href + cover = d.get("squareImage") or d.get("image") or d.get("cover") + return { + "type": "playlist", + "id": d.get("uuid"), + "title": d.get("title"), + "cover": cover, + "numberOfTracks": d.get("numberOfTracks", 0), + } + + +def transform_userplaylist(d): + # User playlist → rendered with createUserPlaylistCardHTML + cover = d.get("squareImage") or d.get("image") or d.get("cover") + creator = d.get("creator") or {} + return { + "type": "user-playlist", + "id": d.get("uuid"), + "name": d.get("title"), + "cover": cover, + "numberOfTracks": d.get("numberOfTracks", 0), + "username": creator.get("name"), + } + + +def transform_podcast(d): + feed = d.get("feed") or {} + return { + "type": "podcast", + "id": str(feed.get("id", "")), + "title": feed.get("title"), + "author": feed.get("author") or feed.get("ownerName"), + "image": feed.get("image") or feed.get("artwork"), + "episodeCount": feed.get("episodeCount", 0), + } + + +# ── Input parser ────────────────────────────────────────────────────────────── + +def read_items(path): + """ + Parses editors-picks-input.txt. + Each non-comment line is either: + - a bare number → album:- Listen to music together with your friends in real-time. - Host controls the music, everyone enjoys it. + Listen to music together with your friends in real-time. Host controls the music, everyone + enjoys it.