import fs from 'node:fs'; import path from 'node:path'; import {writeJSON} from './lib.mjs'; const clamp=v=>Math.max(0,Math.min(1,Number(v)||0)); export function rank(tracks,taste){ if(!taste?.lanes||!Object.values(taste.lanes).length||Object.values(taste.lanes).some(v=>!Number.isFinite(v)||v<0||v>1)||Object.values(taste.lanes).every(v=>v===0)||!Number.isFinite(taste.noveltyWeight)||taste.noveltyWeight<0||!Number.isFinite(taste.repeatArtistPenalty)||taste.repeatArtistPenalty<0||!Number.isInteger(taste.length)||taste.length<1||taste.length>100)throw new Error('Invalid taste weights or length.'); const seen=new Map(),remaining=tracks.map(t=>{ if(!t.title||!t.artist||!Object.hasOwn(taste.lanes,t.lane))throw new Error('Candidate needs title, artist and a lane from taste.json.'); return {...t,score:Number(taste.lanes[t.lane])*clamp(t.similarity)+Number(taste.noveltyWeight)*clamp(t.novelty)}; }),result=[]; while(remaining.length){remaining.sort((a,b)=>(b.score-(seen.get(b.artist)||0)*taste.repeatArtistPenalty)-(a.score-(seen.get(a.artist)||0)*taste.repeatArtistPenalty)||a.title.localeCompare(b.title));const t=remaining.shift();result.push(t);seen.set(t.artist,(seen.get(t.artist)||0)+1);} return result.slice(0,taste.length||20); } export function preview(tracks,taste){return {playlist:{title:taste.title||'CHLOE//OS ยท fresh signal',creator:'Chloe',annotation:'Reviewed candidates ranked with editable taste weights. Verify catalog matches before export.',track:rank(tracks,taste).map(t=>({title:t.title,creator:t.artist,...(t.mbid?{identifier:['https://musicbrainz.org/recording/'+t.mbid]}:{}),annotation:`${t.lane}; seed similarity ${clamp(t.similarity)}; novelty ${clamp(t.novelty)}; score ${t.score.toFixed(3)}`}))}};} async function request(endpoint,options={}){ const token=process.env.LISTENBRAINZ_TOKEN;if(!token)throw new Error('Set LISTENBRAINZ_TOKEN in the laptop environment for music account access.'); const r=await fetch('https://api.listenbrainz.org/1/'+endpoint,{...options,headers:{Authorization:'Token '+token,'Content-Type':'application/json'},signal:AbortSignal.timeout(55000)}); if(!r.ok)throw new Error(`ListenBrainz HTTP ${r.status}; do not automatically retry playlist creation.`);return r.json(); } export function checkOwner(playlists,owner){ if(!owner||!playlists.length||playlists.some(p=>p.owner?.id!==owner))throw new Error('Linked Spotify destination is unconfirmed or differs. Open ListenBrainz music services and connect Chloe, then inspect playlist accounts. For an empty account, add one private playlist in Spotify first to establish ownership.'); } export async function music(root,args){ const [command,input,output,...rest]=args; if(command==='preview'){ if(!input||!output)throw new Error('Use playlist preview CANDIDATES.json OUTPUT.jspf'); const tracks=JSON.parse(fs.readFileSync(path.resolve(root,input),'utf8')); const taste=JSON.parse(fs.readFileSync(path.join(root,'music/taste.json'),'utf8')); const result=preview(Array.isArray(tracks)?tracks:tracks.tracks,taste);writeJSON(path.resolve(root,output),result);console.log(JSON.stringify({written:output,tracks:result.playlist.track.length,exported:false})); }else if(command==='accounts'){ const list=await request('playlist/import/spotify');console.log(JSON.stringify({owners:[...new Set(list.map(p=>p.owner?.id).filter(Boolean))],playlists:list.map(p=>({name:p.name,id:p.id,owner:p.owner?.id,public:p.public}))},null,2)); }else if(command==='export'){ // output is --owner because of the command's positional shape. const owner=output==='--owner'?rest[0]:null;if(!owner)throw new Error('Use playlist export PREVIEW.jspf --owner CONFIRMED_SPOTIFY_ID'); const doc=JSON.parse(fs.readFileSync(path.resolve(root,input),'utf8')).playlist; if(!doc?.title||!doc.track?.length||doc.track.some(t=>!t.identifier?.some(x=>/^https:\/\/musicbrainz\.org\/recording\/[a-f0-9-]{36}$/.test(x))))throw new Error('Resolve and verify MusicBrainz recording IDs for every track before export.'); const list=await request('playlist/import/spotify');checkOwner(list,owner); const old=list.find(p=>p.name===doc.title&&p.owner?.id===owner&&p.public===false); if(old){console.log(JSON.stringify({reused:true,tracksUnchanged:true,url:old.external_urls?.spotify,message:'Existing title retained. Use a new title to publish a revised mix.'}));return;} let result; try{result=await request('playlist/export-jspf/spotify?is_public=false',{method:'POST',body:JSON.stringify(doc)});} catch(e){throw new Error(e.message+' Inspect Spotify/playlist accounts before retrying: creation may have succeeded.');} const id=result.external_url?.match(/playlist\/([A-Za-z0-9]+)/)?.[1]; const after=await request('playlist/import/spotify'),made=after.find(p=>p.id===id); if(!made||made.owner?.id!==owner||made.public!==false)throw new Error('Export returned, but destination/privacy could not be verified. Inspect Spotify before retrying.'); const receipt={url:result.external_url,owner,public:false,tracks:made.tracks?.total,verified:true};writeJSON(path.resolve(root,input+'.export.json'),receipt);console.log(JSON.stringify(receipt,null,2)); }else throw new Error('Use playlist preview INPUT OUTPUT | accounts | export JSPF --owner ID'); }