diff --git a/.gitignore b/.gitignore index 7162a9ba..ff227154 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ local/ # Coverage directory coverage/ +.DS_Store diff --git a/API.md b/API.md new file mode 100644 index 00000000..56365e15 --- /dev/null +++ b/API.md @@ -0,0 +1,344 @@ +# Harmony API + +A JSON API for querying music metadata from Harmony's aggregated provider data. + +## Setup + +### Environment Variables + +```bash +HARMONY_API_PORT=5221 # Required: Port to run the API on +HARMONY_API_HOST=127.0.0.1 # Optional: Host to bind to (default: 127.0.0.1) +``` + +### Running + +The API starts automatically when `HARMONY_API_PORT` is set: + +```bash +# Via Fresh server (recommended) +deno task server + +# Standalone +deno task api +``` + +--- + +## Endpoints + +All release endpoints accept: + +| Parameter | Description | +| ------------------- | ---------------------------------------------------------------------------- | +| `url` | Provider URL (Spotify, Deezer, iTunes, Bandcamp, Beatport, Tidal, etc.) | +| `gtin` or `barcode` | Barcode/GTIN number | +| `merge=true` | Optional: Query all providers and merge results (may fail if GTINs conflict) | + +--- + +### `GET /api/health` + +Health check endpoint. + +```bash +curl http://localhost:5221/api/health +``` + +```json +{ + "ok": true, + "version": "mvp-20", + "timestamp": "2026-01-17T05:21:12.453Z" +} +``` + +--- + +### `GET /api/v1/barcode` + +Quick lookup for barcode and basic release metadata. + +```bash +curl "http://localhost:5221/api/v1/barcode?url=https://open.spotify.com/album/4LH4d3cOWNNsVw41Gqt2kv" +curl "http://localhost:5221/api/v1/barcode?gtin=886445613261" +``` + +```json +{ + "mbid": null, + "barcode": "886445613261", + "title": "The Dark Side of the Moon", + "artist": "Pink Floyd", + "trackCount": 10, + "mediaCount": 1, + "source": "harmony" +} +``` + +--- + +### `GET /api/v1/match` + +Candidate matching format — useful for matching workflows. + +```bash +curl "http://localhost:5221/api/v1/match?url=https://open.spotify.com/album/..." +curl "http://localhost:5221/api/v1/match?gtin=886445613261" +``` + +```json +{ + "input_url": "https://open.spotify.com/album/...", + "input_gtin": null, + "candidates": [{ + "mbid": null, + "barcode": "886445613261", + "title": "The Dark Side of the Moon", + "artist": "Pink Floyd", + "trackCount": 10, + "mediaCount": 1, + "score": 1.0, + "evidence": ["harmony-lookup"] + }], + "best": { + "mbid": null, + "barcode": "886445613261", + "title": "The Dark Side of the Moon", + "artist": "Pink Floyd", + "trackCount": 10, + "mediaCount": 1, + "score": 1.0, + "evidence": ["harmony-lookup"] + }, + "providers": [ + { "service": "Spotify", "url": "https://open.spotify.com/album/..." } + ], + "source": "harmony" +} +``` + +--- + +### `GET /api/v1/tracks` + +Detailed tracklist with ISRCs and durations. + +```bash +curl "http://localhost:5221/api/v1/tracks?url=https://open.spotify.com/album/..." +curl "http://localhost:5221/api/v1/tracks?gtin=886445613261" +``` + +```json +{ + "mbid": null, + "barcode": "886445613261", + "title": "The Dark Side of the Moon", + "artist": "Pink Floyd", + "mediaCount": 1, + "totalTracks": 10, + "trackCounts": [10], + "media": [{ + "number": 1, + "format": "Digital Media", + "title": null, + "trackCount": 10, + "tracks": [ + { + "number": 1, + "title": "Speak to Me", + "length": 68000, + "lengthFormatted": "1:08", + "isrc": "GBN9Y1100085", + "artists": "Pink Floyd" + }, + { + "number": 2, + "title": "Breathe (In the Air)", + "length": 169000, + "lengthFormatted": "2:49", + "isrc": "GBN9Y1100086", + "artists": "Pink Floyd" + } + ] + }], + "source": "harmony" +} +``` + +--- + +### `GET /api/v1/providers` + +Shows which providers were queried and their URLs or errors. + +```bash +curl "http://localhost:5221/api/v1/providers?url=https://open.spotify.com/album/..." +curl "http://localhost:5221/api/v1/providers?gtin=886445613261" +``` + +**Single provider (default):** + +```json +{ + "mbid": null, + "barcode": "886445613261", + "providers": [ + { "service": "Spotify", "url": "https://open.spotify.com/album/..." } + ], + "source": "harmony" +} +``` + +**With `merge=true` (cross-provider lookup):** + +```json +{ + "mbid": "6987048f-faa9-404e-b6a9-ac0333bda669", + "barcode": "723277017655", + "providers": [ + { "service": "Deezer", "url": "https://www.deezer.com/album/480232895" }, + { "service": "MusicBrainz", "url": "https://musicbrainz.org/release/ab494448-1ad2-44fa-91a4-ba2b6d00458e" }, + { "service": "iTunes", "url": "https://music.apple.com/ae/album/1704230259" }, + { "service": "Spotify", "url": "https://open.spotify.com/album/3qKhqPPWMjqi8x8N6C2PXk" }, + { "service": "Tidal", "url": "https://tidal.com/album/312689838" }, + { "service": "Beatport", "error": "Search returned no matching results for '723277017655'" } + ], + "source": "harmony" +} +``` + +--- + +### `GET /api/v1/lookup` + +Full release data — everything Harmony knows about the release. + +```bash +curl "http://localhost:5221/api/v1/lookup?url=https://open.spotify.com/album/..." +curl "http://localhost:5221/api/v1/lookup?gtin=886445613261&merge=true" +``` + +```json +{ + "release": { + "title": "Internet G", + "artists": [ + { "name": "INFEKT", "creditedName": "INFEKT" } + ], + "gtin": "723277017655", + "mbid": "6987048f-faa9-404e-b6a9-ac0333bda669", + "releaseDate": { "year": 2023, "month": 9, "day": 29 }, + "labels": [ + { "name": "Disciple", "mbid": "a4153324-731e-47d5-a7f2-99c5b5229ede" } + ], + "types": ["EP"], + "status": "Official", + "packaging": "None", + "media": [{ + "number": 1, + "format": "Digital Media", + "trackCount": 2, + "tracks": [ + { + "number": 1, + "title": "Internet G", + "length": 177103, + "isrc": "CA5KR2379024", + "artists": [{ "name": "INFEKT", "creditedName": "INFEKT" }] + }, + { + "number": 2, + "title": "Chef's Kiss", + "length": 172138, + "isrc": "CA5KR2379025", + "artists": [{ "name": "INFEKT", "creditedName": "INFEKT" }] + } + ] + }], + "mediaCount": 1, + "totalTracks": 2, + "trackCounts": [2], + "externalLinks": [ + { "url": "https://www.deezer.com/album/480232895", "types": ["free streaming"] }, + { "url": "https://music.apple.com/ae/album/1704230259", "types": ["paid download", "paid streaming"] }, + { "url": "https://open.spotify.com/album/3qKhqPPWMjqi8x8N6C2PXk", "types": ["free streaming"] }, + { "url": "https://tidal.com/album/312689838", "types": ["paid streaming"] } + ], + "images": [{ + "url": "https://a1.mzstatic.com/us/r1000/063/Music116/v4/91/02/5d/91025d5a-c0ef-0899-0a63-8804df5cb9a2/cover.jpg", + "thumbUrl": "https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/91/02/5d/91025d5a-c0ef-0899-0a63-8804df5cb9a2/cover.jpg/250x250bb.jpg", + "types": ["front"] + }], + "availableIn": ["US", "GB", "DE", "..."], + "copyright": "℗ 2023 Disciple", + "language": { "code": "eng" }, + "providers": [ + { + "name": "Deezer", + "internalName": "deezer", + "id": "480232895", + "url": "https://www.deezer.com/album/480232895", + "apiUrl": "https://api.deezer.com/album/480232895" + } + ], + "messages": [ + { "provider": "Bandcamp", "type": "warning", "text": "GTIN lookups are not supported" }, + { "provider": "Beatport", "type": "error", "text": "Search returned no matching results for '723277017655'" } + ] + }, + "providers": [ + { "service": "Deezer", "url": "https://www.deezer.com/album/480232895" }, + { "service": "MusicBrainz", "url": "https://musicbrainz.org/release/ab494448-1ad2-44fa-91a4-ba2b6d00458e" } + ], + "source": "harmony" +} +``` + +--- + +## Summary + +| Endpoint | Use Case | Key Fields | +| ------------------- | ----------------------------- | ------------------------------------------ | +| `/api/health` | Health check | `ok`, `version` | +| `/api/v1/barcode` | Quick barcode lookup | `barcode`, `trackCount`, `title`, `artist` | +| `/api/v1/match` | Matching workflows | `candidates`, `best`, `score` | +| `/api/v1/tracks` | Tracklist with ISRCs | `media[].tracks[]` with `isrc`, `length` | +| `/api/v1/providers` | See which providers responded | `providers[]` with `url` or `error` | +| `/api/v1/lookup` | Full release data | Complete `HarmonyRelease` object | + +--- + +## Behavior Notes + +### Single vs. Merged Lookups + +| Input | Default Behavior | With `merge=true` | +| ---------- | ------------------------------------- | --------------------------------------------------- | +| `url=...` | Query only that provider | Query provider → extract GTIN → query all providers | +| `gtin=...` | Query all providers that support GTIN | Same | + +### Error Handling + +Cross-provider merges may fail if providers return conflicting GTINs: + +```json +{ + "error": "Providers have returned multiple different GTIN: 886445613261 (Spotify, MusicBrainz, Deezer), 196589805232 (iTunes)" +} +``` + +In this case, use single-provider lookups (omit `merge=true`). + +### Supported Providers + +- Spotify +- Deezer +- iTunes / Apple Music +- Bandcamp (URL lookup only, no GTIN) +- Beatport +- Tidal +- MusicBrainz +- mora (URL lookup only, no GTIN) +- OTOTOY (URL lookup only, no GTIN) diff --git a/server/api.ts b/server/api.ts new file mode 100644 index 00000000..e98dc70a --- /dev/null +++ b/server/api.ts @@ -0,0 +1,434 @@ +// server/api.ts +// Read-only HTTP API for Harmony (mvp-18) +// Fixed: proper extraction of MBID, barcode, and provider URLs from HarmonyRelease structure + +import { CombinedReleaseLookup } from '@/lookup.ts'; +import { defaultProviderPreferences } from '@/providers/mod.ts'; + +import type { HarmonyRelease, ProviderReleaseErrorMap } from '@/harmonizer/types.ts'; + +type JSONVal = Record | Array | string | number | boolean | null; + +function json(data: JSONVal, init: ResponseInit = {}) { + const hdr = new Headers(init.headers || {}); + if (!hdr.has('content-type')) hdr.set('content-type', 'application/json; charset=utf-8'); + return new Response(JSON.stringify(data), { ...init, headers: hdr }); +} +function ok(data: JSONVal) { + return json(data, { status: 200 }); +} +function bad(msg: string, status = 400) { + return json({ error: msg }, { status }); +} + +/** + * Extract MBID from a HarmonyRelease. + * The MBID is stored in releaseGroup.mbid after MBID resolution. + */ +function extractMbid(release: HarmonyRelease): string | null { + // Primary location: release group MBID (populated by MBID resolver) + if (release.releaseGroup?.mbid) { + return release.releaseGroup.mbid; + } + + // Fallback: check if MusicBrainz provider returned data + const mbProvider = release.info.providers.find((p) => p.internalName === 'musicbrainz'); + if (mbProvider?.id) { + return mbProvider.id; + } + + return null; +} + +/** + * Extract barcode (GTIN) from a HarmonyRelease. + * GTIN can be number or string, so handle both. + */ +function extractBarcode(release: HarmonyRelease): string | null { + const gtin = release.gtin; + if (gtin === undefined || gtin === null) return null; + + // Convert to string (handles both number and string types) + const str = String(gtin).trim(); + return str || null; +} + +/** + * Extract track count information from a HarmonyRelease. + */ +function extractTrackInfo(release: HarmonyRelease): { mediaCount: number; trackCounts: number[]; totalTracks: number } { + const trackCounts = release.media.map((m) => m.tracklist.length); + const totalTracks = trackCounts.reduce((sum, c) => sum + c, 0); + return { + mediaCount: release.media.length, + trackCounts, + totalTracks, + }; +} + +/** + * Extract provider information from the provider release mapping. + */ +function extractProviders( + providerMap: ProviderReleaseErrorMap, +): Array<{ service: string; url?: string; error?: string }> { + const out: Array<{ service: string; url?: string; error?: string }> = []; + + for (const [serviceName, releaseOrError] of Object.entries(providerMap)) { + if (releaseOrError instanceof Error) { + out.push({ service: serviceName, error: releaseOrError.message }); + continue; + } + + // It's a HarmonyRelease - get URL from externalLinks or provider info + const release = releaseOrError as HarmonyRelease; + const url = release.externalLinks[0]?.url || release.info.providers[0]?.url; + out.push({ service: serviceName, url }); + } + + return out; +} + +/** + * Serialize a HarmonyRelease to a JSON-friendly format. + */ +function serializeRelease(release: HarmonyRelease) { + const trackInfo = extractTrackInfo(release); + + return { + title: release.title, + artists: release.artists.map((a) => ({ + name: a.name, + creditedName: a.creditedName, + joinPhrase: a.joinPhrase, + mbid: a.mbid, + })), + gtin: release.gtin ? String(release.gtin) : null, + mbid: extractMbid(release), + releaseDate: release.releaseDate, + labels: release.labels?.map((l) => ({ + name: l.name, + catalogNumber: l.catalogNumber, + mbid: l.mbid, + })), + types: release.types, + status: release.status, + packaging: release.packaging, + media: release.media.map((m) => ({ + number: m.number, + format: m.format, + title: m.title, + trackCount: m.tracklist.length, + tracks: m.tracklist.map((t) => ({ + number: t.number, + title: t.title, + length: t.length, + isrc: t.isrc, + artists: t.artists?.map((a) => ({ + name: a.name, + creditedName: a.creditedName, + joinPhrase: a.joinPhrase, + })), + })), + })), + mediaCount: trackInfo.mediaCount, + totalTracks: trackInfo.totalTracks, + trackCounts: trackInfo.trackCounts, + externalLinks: release.externalLinks, + images: release.images, + availableIn: release.availableIn, + copyright: release.copyright, + language: release.language, + providers: release.info.providers.map((p) => ({ + name: p.name, + internalName: p.internalName, + id: p.id, + url: p.url, + apiUrl: p.apiUrl, + })), + messages: release.info.messages, + }; +} + +interface LookupResult { + merged: HarmonyRelease; + providerMap: ProviderReleaseErrorMap; +} + +/** + * Lookup by URL using only the matching provider (no cross-provider merge). + * This avoids GTIN conflicts when different providers have different editions. + */ +async function lookupByUrlSingle(urlString: string): Promise { + const u = new URL(urlString); + const input = { urls: [u] }; + const options = { + withSeparateMedia: true, + withAllTrackArtists: true, + withISRC: true, + }; + + const lookup = new CombinedReleaseLookup(input, options); + // Only get the provider mapping, don't try to merge with other providers + const providerMap = await lookup.getProviderReleaseMapping(); + + // Get the single release directly (no cross-provider GTIN lookup) + const releases = Object.values(providerMap).filter((r): r is HarmonyRelease => !(r instanceof Error)); + if (releases.length === 0) { + throw new Error('No release found'); + } + + return { merged: releases[0], providerMap }; +} + +/** + * Lookup by URL with full cross-provider merge. + * May fail if providers return conflicting GTINs. + */ +async function lookupByUrl(urlString: string, merge = false): Promise { + if (!merge) { + return lookupByUrlSingle(urlString); + } + + const u = new URL(urlString); + const input = { urls: [u] }; + const options = { + withSeparateMedia: true, + withAllTrackArtists: true, + withISRC: true, + }; + + const lookup = new CombinedReleaseLookup(input, options); + const providerMap = await lookup.getCompleteProviderReleaseMapping(); + const merged = await lookup.getMergedRelease(defaultProviderPreferences); + + return { merged, providerMap }; +} + +async function lookupByGtin(gtin: string): Promise { + const input = { gtin }; + const options = { + withSeparateMedia: true, + withAllTrackArtists: true, + withISRC: true, + }; + + const lookup = new CombinedReleaseLookup(input, options); + const providerMap = await lookup.getCompleteProviderReleaseMapping(); + const merged = await lookup.getMergedRelease(defaultProviderPreferences); + + return { merged, providerMap }; +} + +type RouteHandler = (req: Request, url: URL) => Promise | Response; + +const routes: Record> = { + '/api/health': { + GET: () => ok({ ok: true, version: 'mvp-20', timestamp: new Date().toISOString() }), + }, + + '/api/v1/lookup': { + GET: async (_req, url) => { + const urlParam = url.searchParams.get('url'); + const gtinParam = url.searchParams.get('gtin') || url.searchParams.get('barcode'); + const merge = url.searchParams.get('merge') === 'true'; + + if (!urlParam && !gtinParam) { + return bad('url or gtin/barcode parameter required'); + } + + try { + const result = urlParam ? await lookupByUrl(urlParam, merge) : await lookupByGtin(gtinParam!); + + return ok({ + release: serializeRelease(result.merged), + providers: extractProviders(result.providerMap), + source: 'harmony', + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return bad(msg, 500); + } + }, + }, + + '/api/v1/match': { + GET: async (_req, url) => { + const urlParam = url.searchParams.get('url'); + const gtinParam = url.searchParams.get('gtin') || url.searchParams.get('barcode'); + const merge = url.searchParams.get('merge') === 'true'; + + if (!urlParam && !gtinParam) { + return bad('url or gtin/barcode parameter required'); + } + + try { + const { merged, providerMap } = urlParam ? await lookupByUrl(urlParam, merge) : await lookupByGtin(gtinParam!); + const providers = extractProviders(providerMap); + const trackInfo = extractTrackInfo(merged); + + const candidate = { + mbid: extractMbid(merged), + barcode: extractBarcode(merged), + title: merged.title, + artist: merged.artists.map((a) => a.creditedName || a.name).join(', '), + trackCount: trackInfo.totalTracks, + mediaCount: trackInfo.mediaCount, + score: 1.0, + evidence: ['harmony-lookup'], + }; + + return ok({ + input_url: urlParam || null, + input_gtin: gtinParam || null, + candidates: [candidate], + best: candidate, + providers, + source: 'harmony', + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return bad(msg, 500); + } + }, + }, + + '/api/v1/barcode': { + GET: async (_req, url) => { + const urlParam = url.searchParams.get('url'); + const gtinParam = url.searchParams.get('gtin') || url.searchParams.get('barcode'); + const merge = url.searchParams.get('merge') === 'true'; + + if (!urlParam && !gtinParam) { + return bad('url or gtin/barcode parameter required'); + } + + try { + const result = urlParam ? await lookupByUrl(urlParam, merge) : await lookupByGtin(gtinParam!); + + const trackInfo = extractTrackInfo(result.merged); + + return ok({ + mbid: extractMbid(result.merged), + barcode: extractBarcode(result.merged), + title: result.merged.title, + artist: result.merged.artists.map((a) => a.creditedName || a.name).join(', '), + trackCount: trackInfo.totalTracks, + mediaCount: trackInfo.mediaCount, + source: 'harmony', + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return bad(msg, 500); + } + }, + }, + + '/api/v1/providers': { + GET: async (_req, url) => { + const urlParam = url.searchParams.get('url'); + const gtinParam = url.searchParams.get('gtin') || url.searchParams.get('barcode'); + const merge = url.searchParams.get('merge') === 'true'; + + if (!urlParam && !gtinParam) { + return bad('url or gtin/barcode parameter required'); + } + + try { + const { merged, providerMap } = urlParam ? await lookupByUrl(urlParam, merge) : await lookupByGtin(gtinParam!); + + return ok({ + mbid: extractMbid(merged), + barcode: extractBarcode(merged), + providers: extractProviders(providerMap), + source: 'harmony', + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return bad(msg, 500); + } + }, + }, + + '/api/v1/tracks': { + GET: async (_req, url) => { + const urlParam = url.searchParams.get('url'); + const gtinParam = url.searchParams.get('gtin') || url.searchParams.get('barcode'); + const merge = url.searchParams.get('merge') === 'true'; + + if (!urlParam && !gtinParam) { + return bad('url or gtin/barcode parameter required'); + } + + try { + const result = urlParam ? await lookupByUrl(urlParam, merge) : await lookupByGtin(gtinParam!); + + const release = result.merged; + const trackInfo = extractTrackInfo(release); + + return ok({ + mbid: extractMbid(release), + barcode: extractBarcode(release), + title: release.title, + artist: release.artists.map((a) => a.creditedName || a.name).join(', '), + mediaCount: trackInfo.mediaCount, + totalTracks: trackInfo.totalTracks, + trackCounts: trackInfo.trackCounts, + media: release.media.map((m) => ({ + number: m.number, + format: m.format, + title: m.title, + trackCount: m.tracklist.length, + tracks: m.tracklist.map((t) => ({ + number: t.number, + title: t.title, + length: t.length, + lengthFormatted: t.length ? formatDuration(t.length) : null, + isrc: t.isrc, + artists: t.artists?.map((a) => a.creditedName || a.name).join(', '), + })), + })), + source: 'harmony', + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return bad(msg, 500); + } + }, + }, +}; + +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function handleRequest(req: Request): Promise | Response { + const url = new URL(req.url); + const routeHandlers = routes[url.pathname]; + + if (!routeHandlers) { + return new Response(null, { status: 404 }); + } + + const handler = routeHandlers[req.method]; + if (!handler) { + return new Response(null, { status: 405 }); + } + + return handler(req, url); +} + +export function startApi(port: number, host = '127.0.0.1') { + console.log(`Starting Harmony API server on http://${host}:${port}`); + return Deno.serve({ hostname: host, port }, handleRequest); +} + +// Allow running directly: deno run -A server/api.ts +if (import.meta.main) { + const port = parseInt(Deno.env.get('HARMONY_API_PORT') || '5221'); + const host = Deno.env.get('HARMONY_API_HOST') || '127.0.0.1'; + startApi(port, host); +} diff --git a/server/main.ts b/server/main.ts index 0048ebac..5bf1200b 100644 --- a/server/main.ts +++ b/server/main.ts @@ -2,6 +2,21 @@ import '@std/dotenv/load'; import './logging.ts'; +// ---- Harmony local API bootstrap (read-only) -------------------------------- +// Only start when HARMONY_API_PORT is defined. Binds to 127.0.0.1. +// Runs alongside the Fresh app. +{ + const apiPort = Deno.env.get('HARMONY_API_PORT'); + if (apiPort) { + const { startApi } = await import('./api.ts'); + const port = Number(apiPort); + if (Number.isFinite(port) && port > 0) { + startApi(port, '127.0.0.1'); + } + } +} +// ---- END Harmony local API bootstrap ---------------------------------------- + import { shortRevision } from '@/config.ts'; import { start } from 'fresh/server.ts'; import { getLogger } from 'std/log/get_logger.ts';