A decentralized music tracking and discovery platform built on AT Protocol 馃幍
fork

Configure Feed

Select the types of activity you want to include in your feed.

at feat/feed-generator 142 lines 4.4 kB view raw
1import type { Agent } from "@atproto/api"; 2import { TID } from "@atproto/common"; 3import chalk from "chalk"; 4import type * as Status from "lexicon/types/fm/teal/alpha/actor/status"; 5import type { PlayView } from "lexicon/types/fm/teal/alpha/feed/defs"; 6import * as Play from "lexicon/types/fm/teal/alpha/feed/play"; 7import type { MusicbrainzTrack } from "types/track"; 8 9const SUBMISSION_CLIENT_AGENT = "rocksky/v0.0.1"; 10 11async function getRecentPlays(agent: Agent, limit = 5) { 12 const res = await agent.com.atproto.repo.listRecords({ 13 repo: agent.assertDid, 14 collection: "fm.teal.alpha.feed.play", 15 limit, 16 }); 17 return res.data.records; 18} 19 20async function publishPlayingNow( 21 agent: Agent, 22 track: MusicbrainzTrack, 23 duration: number, 24) { 25 try { 26 // wait 60 seconds to ensure the track is actually being played 27 await new Promise((resolve) => setTimeout(resolve, 60000)); 28 const recentPlays = await getRecentPlays(agent, 5); 29 // Check if the track was played in the last 5 plays (verify by MBID and timestamp to avoid duplicates) 30 const alreadyPlayed = recentPlays.some((play) => { 31 const record = Play.isRecord(play.value) ? play.value : null; 32 return ( 33 (record?.recordingMbId === track.trackMBID || 34 (Math.abs(record?.duration - duration) < 4 && 35 record?.trackName === track.name)) && 36 // diff in seconds less than 60 seconds 37 Math.abs( 38 new Date(record.playedTime).getTime() - 39 new Date(track.timestamp).getTime(), 40 ) < 60000 41 ); 42 }); 43 if (alreadyPlayed) { 44 console.log( 45 `Track ${chalk.cyan(track.name)} by ${chalk.cyan( 46 track.artist.map((a) => a.name).join(", "), 47 )} already played recently. Skipping...`, 48 ); 49 return; 50 } 51 52 const rkey = TID.nextStr(); 53 const record: Play.Record = { 54 $type: "fm.teal.alpha.feed.play", 55 duration, 56 trackName: track.name, 57 playedTime: track.timestamp, 58 artists: track.artist.map((artist) => ({ 59 artistMbid: artist.mbid, 60 artistName: artist.name, 61 })), 62 releaseMbid: track.releaseMBID, 63 releaseName: track.album, 64 recordingMbId: track.trackMBID, 65 submissionClientAgent: SUBMISSION_CLIENT_AGENT, 66 }; 67 68 if (!Play.validateRecord(record).success) { 69 console.log(Play.validateRecord(record)); 70 console.log(chalk.cyan(JSON.stringify(record, null, 2))); 71 throw new Error("Invalid record"); 72 } 73 74 const res = await agent.com.atproto.repo.putRecord({ 75 repo: agent.assertDid, 76 collection: "fm.teal.alpha.feed.play", 77 rkey, 78 record, 79 validate: false, 80 }); 81 const uri = res.data.uri; 82 console.log(`tealfm Play record created at ${uri}`); 83 84 await publishStatus(agent, track, duration); 85 } catch (error) { 86 console.error("Error publishing teal.fm record:", error); 87 } 88} 89 90async function publishStatus( 91 agent: Agent, 92 track: MusicbrainzTrack, 93 duration: number, 94) { 95 const item: PlayView = { 96 trackName: track.name, 97 duration, 98 playedTime: track.timestamp, 99 artists: track.artist.map((artist) => ({ 100 artistMbid: artist.mbid, 101 artistName: artist.name, 102 })), 103 releaseMbid: track.releaseMBID, 104 releaseName: track.album, 105 recordingMbId: track.trackMBID, 106 submissionClientAgent: SUBMISSION_CLIENT_AGENT, 107 }; 108 const nowSec = Math.floor(Date.now() / 1000); 109 const expirySec = nowSec + 10 * 60; // 10 minutes from now 110 const record: Status.Record = { 111 $type: "fm.teal.alpha.actor.status", 112 item, 113 time: String(nowSec), 114 expiry: String(expirySec), 115 }; 116 const swapRecord = await getStatusSwapRecord(agent); 117 const res = await agent.com.atproto.repo.putRecord({ 118 repo: agent.assertDid, 119 collection: "fm.teal.alpha.actor.status", 120 rkey: "self", 121 record, 122 swapRecord, 123 }); 124 console.log(`tealfm Status record published at ${res.data.uri}`); 125} 126 127async function getStatusSwapRecord(agent: Agent): Promise<string | undefined> { 128 try { 129 const res = await agent.com.atproto.repo.getRecord({ 130 repo: agent.assertDid, 131 collection: "fm.teal.alpha.actor.status", 132 rkey: "self", 133 }); 134 return res.data.cid; 135 } catch (err) { 136 const status = err?.response?.status ?? err?.status; 137 if (status === 400) return undefined; 138 throw err; 139 } 140} 141 142export default { publishPlayingNow };