@rocksky/cli
Version:
Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history
411 lines (369 loc) • 12.3 kB
text/typescript
import { MatchTrackResult } from "lib/matchTrack";
import { logger } from "logger";
import dayjs from "dayjs";
import { createAgent } from "lib/agent";
import { getDidAndHandle } from "lib/getDidAndHandle";
import { ctx } from "context";
import schema from "schema";
import { and, eq, gte, lte, or, sql } from "drizzle-orm";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import chalk from "chalk";
import * as Album from "lexicon/types/app/rocksky/album";
import * as Artist from "lexicon/types/app/rocksky/artist";
import * as Scrobble from "lexicon/types/app/rocksky/scrobble";
import * as Song from "lexicon/types/app/rocksky/song";
import { TID } from "@atproto/common";
import { Agent } from "@atproto/api";
import { createUser, subscribeToJetstream, sync } from "cmd/sync";
import _ from "lodash";
export async function publishScrobble(
track: MatchTrackResult,
timestamp?: number,
dryRun?: boolean,
) {
const [did, handle] = await getDidAndHandle();
const agent: Agent = await createAgent(did, handle);
const recentScrobble = await getRecentScrobble(did, track, timestamp);
const user = await createUser(agent, did, handle);
await subscribeToJetstream(user);
const lockFilePath = path.join(os.tmpdir(), `rocksky-${did}.lock`);
if (fs.existsSync(lockFilePath)) {
logger.error(
`${chalk.greenBright(handle)} Scrobble publishing failed: lock file exists, maybe rocksky-cli is still syncing?\nPlease wait for rocksky to finish syncing before publishing scrobbles or delete the lock file manually ${chalk.greenBright(lockFilePath)}`,
);
return false;
}
if (recentScrobble) {
logger.info`${handle} Skipping scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")} (already scrobbled)`;
return true;
}
const totalScrobbles = await countScrobbles(did);
if (totalScrobbles === 0) {
logger.warn`${handle} No scrobbles found for this user. Are you sure you have successfully synced your scrobbles locally?\nIf not, please run ${"rocksky sync"} to sync your scrobbles before publishing scrobbles.`;
}
logger.info`${handle} Publishing scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")}`;
if (await shouldSync(agent)) {
logger.info`${handle} Syncing scrobbles before publishing`;
await sync();
} else {
logger.info`${handle} Local scrobbles are up-to-date, skipping sync`;
}
if (dryRun) {
logger.info`${handle} Dry run: Skipping publishing scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")}`;
return true;
}
const existingTrack = await ctx.db
.select()
.from(schema.tracks)
.where(
or(
and(
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
sql`LOWER(${schema.tracks.artist}) = LOWER(${track.artist})`,
),
and(
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
sql`LOWER(${schema.tracks.albumArtist}) = LOWER(${track.artist})`,
),
and(
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
sql`LOWER(${schema.tracks.albumArtist}) = LOWER(${track.albumArtist})`,
),
),
)
.limit(1)
.execute()
.then((rows) => rows[0]);
if (!existingTrack) {
await putSongRecord(agent, track);
}
const existingArtist = await ctx.db
.select()
.from(schema.artists)
.where(
or(
sql`LOWER(${schema.artists.name}) = LOWER(${track.artist})`,
sql`LOWER(${schema.artists.name}) = LOWER(${track.albumArtist})`,
),
)
.limit(1)
.execute()
.then((rows) => rows[0]);
if (!existingArtist) {
await putArtistRecord(agent, track);
}
const existingAlbum = await ctx.db
.select()
.from(schema.albums)
.where(
and(
sql`LOWER(${schema.albums.title}) = LOWER(${track.album})`,
sql`LOWER(${schema.albums.artist}) = LOWER(${track.albumArtist})`,
),
)
.limit(1)
.execute()
.then((rows) => rows[0]);
if (!existingAlbum) {
await putAlbumRecord(agent, track);
}
const scrobbleUri = await putScrobbleRecord(agent, track, timestamp);
// wait for the scrobble to be published
if (scrobbleUri) {
const MAX_ATTEMPTS = 40;
let attempts = 0;
do {
const count = await ctx.db
.select({
count: sql`COUNT(*)`,
})
.from(schema.scrobbles)
.where(eq(schema.scrobbles.uri, scrobbleUri))
.execute()
.then((rows) => _.get(rows, "[0].count", 0) as number);
if (count > 0 || attempts >= MAX_ATTEMPTS) {
if (attempts == MAX_ATTEMPTS) {
logger.error`Failed to detect published scrobble after ${MAX_ATTEMPTS} attempts`;
}
break;
}
await new Promise((resolve) => setTimeout(resolve, 600));
attempts += 1;
} while (true);
}
return true;
}
async function getRecentScrobble(
did: string,
track: MatchTrackResult,
timestamp?: number,
) {
const scrobbleTime = dayjs.unix(timestamp || dayjs().unix());
return ctx.db
.select({
scrobble: schema.scrobbles,
user: schema.users,
track: schema.tracks,
})
.from(schema.scrobbles)
.innerJoin(schema.users, eq(schema.scrobbles.userId, schema.users.id))
.innerJoin(schema.tracks, eq(schema.scrobbles.trackId, schema.tracks.id))
.where(
and(
eq(schema.users.did, did),
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
sql`LOWER(${schema.tracks.artist}) = LOWER(${track.artist})`,
gte(
schema.scrobbles.timestamp,
scrobbleTime.subtract(60, "seconds").toDate(),
),
lte(
schema.scrobbles.timestamp,
scrobbleTime.add(60, "seconds").toDate(),
),
),
)
.limit(1)
.then((rows) => rows[0]);
}
async function countScrobbles(did: string): Promise<number> {
return ctx.db
.select({ count: sql<number>`count(*)` })
.from(schema.scrobbles)
.innerJoin(schema.users, eq(schema.scrobbles.userId, schema.users.id))
.where(eq(schema.users.did, did))
.then((rows) => rows[0].count);
}
async function putSongRecord(agent: Agent, track: MatchTrackResult) {
const rkey = TID.nextStr();
const record: Song.Record = {
$type: "app.rocksky.song",
title: track.title,
artist: track.artist,
artists: track.mbArtists === null ? undefined : track.mbArtists,
album: track.album,
albumArtist: track.albumArtist,
duration: track.duration,
releaseDate: track.releaseDate
? new Date(track.releaseDate).toISOString()
: undefined,
year: track.year === null ? undefined : track.year,
albumArtUrl: track.albumArt,
composer: track.composer ? track.composer : undefined,
lyrics: track.lyrics ? track.lyrics : undefined,
trackNumber: track.trackNumber,
discNumber: track.discNumber === 0 ? 1 : track.discNumber,
copyrightMessage: track.copyrightMessage
? track.copyrightMessage
: undefined,
createdAt: new Date().toISOString(),
spotifyLink: track.spotifyLink ? track.spotifyLink : undefined,
tags: track.genres || [],
mbid: track.mbId,
};
if (!Song.validateRecord(record).success) {
logger.info`${Song.validateRecord(record)}`;
logger.info`${record}`;
throw new Error("Invalid Song record");
}
try {
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: "app.rocksky.song",
rkey,
record,
validate: false,
});
const uri = res.data.uri;
logger.info`Song record created at ${uri}`;
return uri;
} catch (e) {
logger.error`Error creating song record: ${e}`;
return null;
}
}
async function putArtistRecord(agent: Agent, track: MatchTrackResult) {
const rkey = TID.nextStr();
const record: Artist.Record = {
$type: "app.rocksky.artist",
name: track.albumArtist,
createdAt: new Date().toISOString(),
pictureUrl: track.artistPicture || undefined,
tags: track.genres || [],
};
if (!Artist.validateRecord(record).success) {
logger.info`${Artist.validateRecord(record)}`;
logger.info`${record}`;
throw new Error("Invalid Artist record");
}
try {
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: "app.rocksky.artist",
rkey,
record,
validate: false,
});
const uri = res.data.uri;
logger.info`Artist record created at ${uri}`;
return uri;
} catch (e) {
logger.error`Error creating artist record: ${e}`;
return null;
}
}
async function putAlbumRecord(agent: Agent, track: MatchTrackResult) {
const rkey = TID.nextStr();
const record = {
$type: "app.rocksky.album",
title: track.album,
artist: track.albumArtist,
year: track.year === null ? undefined : track.year,
releaseDate: track.releaseDate
? new Date(track.releaseDate).toISOString()
: undefined,
createdAt: new Date().toISOString(),
albumArtUrl: track.albumArt,
};
if (!Album.validateRecord(record).success) {
logger.info`${Album.validateRecord(record)}`;
logger.info`${record}`;
throw new Error("Invalid Album record");
}
try {
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: "app.rocksky.album",
rkey,
record,
validate: false,
});
const uri = res.data.uri;
logger.info`Album record created at ${uri}`;
return uri;
} catch (e) {
logger.error`Error creating album record: ${e}`;
return null;
}
}
async function putScrobbleRecord(
agent: Agent,
track: MatchTrackResult,
timestamp?: number,
) {
const rkey = TID.nextStr();
const record: Scrobble.Record = {
$type: "app.rocksky.scrobble",
title: track.title,
albumArtist: track.albumArtist,
albumArtUrl: track.albumArt,
artist: track.artist,
artists: track.mbArtists === null ? undefined : track.mbArtists,
album: track.album,
duration: track.duration,
trackNumber: track.trackNumber,
discNumber: track.discNumber === 0 ? 1 : track.discNumber,
releaseDate: track.releaseDate
? new Date(track.releaseDate).toISOString()
: undefined,
year: track.year === null ? undefined : track.year,
composer: track.composer ? track.composer : undefined,
lyrics: track.lyrics ? track.lyrics : undefined,
copyrightMessage: track.copyrightMessage
? track.copyrightMessage
: undefined,
createdAt: timestamp
? dayjs.unix(timestamp).toISOString()
: new Date().toISOString(),
spotifyLink: track.spotifyLink ? track.spotifyLink : undefined,
tags: track.genres || [],
mbid: track.mbId,
};
if (!Scrobble.validateRecord(record).success) {
logger.info`${Scrobble.validateRecord(record)}`;
logger.info`${record}`;
throw new Error("Invalid Scrobble record");
}
try {
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: "app.rocksky.scrobble",
rkey,
record,
validate: false,
});
const uri = res.data.uri;
logger.info`Scrobble record created at ${uri}`;
return uri;
} catch (e) {
logger.error`Error creating scrobble record: ${e}`;
return null;
}
}
async function shouldSync(agent: Agent): Promise<boolean> {
const res = await agent.com.atproto.repo.listRecords({
repo: agent.assertDid,
collection: "app.rocksky.scrobble",
limit: 1,
});
const records = res.data.records as Array<{
uri: string;
cid: string;
value: Scrobble.Record;
}>;
if (!records.length) {
logger.info`No scrobble records found`;
return true;
}
const { count } = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(schema.scrobbles)
.where(eq(schema.scrobbles.cid, records[0].cid))
.execute()
.then((result) => result[0]);
return count === 0;
}