forked from
rocksky.app/rocksky
fork
Configure Feed
Select the types of activity you want to include in your feed.
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.
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 };