diff --git a/.dockerignore b/.dockerignore index 614faa74..33276326 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ node_modules Dockerfile *.ddb *.ddb.wal -rocksky-backup.sql \ No newline at end of file +rocksky-backup.sql +*.db \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3824910e..2e31e038 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ node_modules/ *.ddb.wal .vscode/ *.DS_Store -rocksky-backup.sql \ No newline at end of file +rocksky-backup.sql +*.db \ No newline at end of file diff --git a/README.md b/README.md index 7e1201ec..bd49da9c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ A decentralized music tracking and discovery platform built on the AT Protocol - Node.js (v22 or later) - Rust +- Go - Turbo - Docker - Wasm Pack https://rustwasm.github.io/wasm-pack/installer/ @@ -92,7 +93,11 @@ A decentralized music tracking and discovery platform built on the AT Protocol ```bash bun run dev:jetstream ``` -9. Start the development server: +9. Start musicbrainz: + ```bash + bun run mb + ``` +10. Start the development server: ```bash turbo dev --filter=@rocksky/api --filter=@rocksky/web ``` diff --git a/apps/api/package.json b/apps/api/package.json index 7a6fc687..74a6a4be 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "type": "module", "module": "dist/index.js", "scripts": { - "lexgen": "lex gen-server ./src/lexicon ./lexicons/**/* ./lexicons/*", + "lexgen": "lex gen-server ./src/lexicon ./src/tealfm/lexicons/teal/**/* ./lexicons/**/* ./lexicons/* ./src/tealfm/lexicons/**/*", "dev": "concurrently 'tsx --watch ./src/index.ts' 'tsx --watch ./src/server.ts'", "prod": "tsx ./src/index.ts", "build": "pkgroll", diff --git a/apps/api/src/bsky/app.ts b/apps/api/src/bsky/app.ts index bdba1cfa..3df36588 100644 --- a/apps/api/src/bsky/app.ts +++ b/apps/api/src/bsky/app.ts @@ -81,7 +81,7 @@ app.get("/oauth/callback", async (c) => { ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 1000 : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, }, - env.JWT_SECRET + env.JWT_SECRET, ); ctx.kv.set(did, token); } catch (err) { @@ -93,7 +93,10 @@ app.get("/oauth/callback", async (c) => { .select() .from(spotifyAccounts) .where( - and(eq(spotifyAccounts.userId, did), eq(spotifyAccounts.isBetaUser, true)) + and( + eq(spotifyAccounts.userId, did), + eq(spotifyAccounts.isBetaUser, true), + ), ) .limit(1) .execute(); @@ -179,7 +182,7 @@ app.get("/profile", async (c) => { ctx.nc.publish( "rocksky.user", - Buffer.from(JSON.stringify(deepSnakeCaseKeys(user))) + Buffer.from(JSON.stringify(deepSnakeCaseKeys(user))), ); await ctx.kv.set("lastUser", lastUser[0].id); @@ -192,8 +195,8 @@ app.get("/profile", async (c) => { .where( and( eq(spotifyAccounts.userId, did), - eq(spotifyAccounts.isBetaUser, true) - ) + eq(spotifyAccounts.isBetaUser, true), + ), ) .limit(1) .execute(), @@ -209,8 +212,8 @@ app.get("/profile", async (c) => { .where( and( eq(googleDriveAccounts.userId, did), - eq(googleDriveAccounts.isBetaUser, true) - ) + eq(googleDriveAccounts.isBetaUser, true), + ), ) .limit(1) .execute(), @@ -220,8 +223,8 @@ app.get("/profile", async (c) => { .where( and( eq(dropboxAccounts.userId, did), - eq(dropboxAccounts.isBetaUser, true) - ) + eq(dropboxAccounts.isBetaUser, true), + ), ) .limit(1) .execute(), diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 048c2162..a14a6939 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -31,6 +31,7 @@ export const ctx = { dropbox: axios.create({ baseURL: env.DROPBOX }), googledrive: axios.create({ baseURL: env.GOOGLE_DRIVE }), tracklist: axios.create({ baseURL: env.TRACKLIST }), + musicbrainz: axios.create({ baseURL: env.MUSICBRAINZ_URL }), redis: await redis .createClient({ url: env.REDIS_URL }) .on("error", (err) => { diff --git a/apps/api/src/dropbox/app.ts b/apps/api/src/dropbox/app.ts index 77d441b8..df4be33f 100644 --- a/apps/api/src/dropbox/app.ts +++ b/apps/api/src/dropbox/app.ts @@ -55,7 +55,7 @@ app.get("/oauth/callback", async (c) => { client_id: env.DROPBOX_CLIENT_ID, client_secret: env.DROPBOX_CLIENT_SECRET, redirect_uri: env.DROPBOX_REDIRECT_URI, - } + }, ); const { dropbox, dropbox_tokens } = await ctx.db @@ -64,7 +64,7 @@ app.get("/oauth/callback", async (c) => { .where(eq(tables.dropbox.userId, entries.state)) .leftJoin( tables.dropboxTokens, - eq(tables.dropboxTokens.id, tables.dropbox.dropboxTokenId) + eq(tables.dropboxTokens.id, tables.dropbox.dropboxTokenId), ) .limit(1) .execute() @@ -76,7 +76,7 @@ app.get("/oauth/callback", async (c) => { id: dropbox_tokens?.id, refreshToken: encrypt( response.data.refresh_token, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ), }) .onConflictDoUpdate({ @@ -84,7 +84,7 @@ app.get("/oauth/callback", async (c) => { set: { refreshToken: encrypt( response.data.refresh_token, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ), }, }) @@ -377,11 +377,11 @@ app.get("/download", async (c) => { c.header( "Content-Type", - response.headers["content-type"] || "application/octet-stream" + response.headers["content-type"] || "application/octet-stream", ); c.header( "Content-Disposition", - response.headers["content-disposition"] || "attachment" + response.headers["content-disposition"] || "attachment", ); return new Response(response.data, { diff --git a/apps/api/src/googledrive/app.ts b/apps/api/src/googledrive/app.ts index d2aff780..c6a1fa6d 100644 --- a/apps/api/src/googledrive/app.ts +++ b/apps/api/src/googledrive/app.ts @@ -42,13 +42,13 @@ app.get("/login", async (c) => { } const credentials = JSON.parse( - fs.readFileSync("credentials.json").toString("utf-8") + fs.readFileSync("credentials.json").toString("utf-8"), ); const { client_id, client_secret } = credentials.installed || credentials.web; const oAuth2Client = new google.auth.OAuth2( client_id, client_secret, - env.GOOGLE_REDIRECT_URI + env.GOOGLE_REDIRECT_URI, ); // Generate Auth URL @@ -70,7 +70,7 @@ app.get("/oauth/callback", async (c) => { const entries = Object.fromEntries(params.entries()); const credentials = JSON.parse( - fs.readFileSync("credentials.json").toString("utf-8") + fs.readFileSync("credentials.json").toString("utf-8"), ); const { client_id, client_secret } = credentials.installed || credentials.web; @@ -92,7 +92,7 @@ app.get("/oauth/callback", async (c) => { .innerJoin(users, eq(googleDrive.userId, users.id)) .leftJoin( googleDriveTokens, - eq(googleDrive.googleDriveTokenId, googleDriveTokens.id) + eq(googleDrive.googleDriveTokenId, googleDriveTokens.id), ) .where(eq(users.id, entries.state)) .limit(1) @@ -105,7 +105,7 @@ app.get("/oauth/callback", async (c) => { .set({ refreshToken: encrypt( response.data.refresh_token, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ), }) .where(eq(googleDriveTokens.id, existingGoogleDrive.token.id)) @@ -117,7 +117,7 @@ app.get("/oauth/callback", async (c) => { .values({ refreshToken: encrypt( response.data.refresh_token, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ), }) .returning(); @@ -237,7 +237,7 @@ app.get("/files", async (c) => { { did, parent_id, - } + }, ); return c.json(data); } @@ -258,7 +258,7 @@ app.get("/files", async (c) => { { did, parent_id: response.data.files[0].id, - } + }, ); return c.json(data); } catch (error) { @@ -266,14 +266,14 @@ app.get("/files", async (c) => { console.error("Axios error:", error.response?.data || error.message); const credentials = JSON.parse( - fs.readFileSync("credentials.json").toString("utf-8") + fs.readFileSync("credentials.json").toString("utf-8"), ); const { client_id, client_secret } = credentials.installed || credentials.web; const oAuth2Client = new google.auth.OAuth2( client_id, client_secret, - env.GOOGLE_REDIRECT_URI + env.GOOGLE_REDIRECT_URI, ); // Generate Auth URL @@ -362,11 +362,11 @@ app.get("/files/:id/download", async (c) => { c.header( "Content-Type", - response.headers["content-type"] || "application/octet-stream" + response.headers["content-type"] || "application/octet-stream", ); c.header( "Content-Disposition", - response.headers["content-disposition"] || "attachment" + response.headers["content-disposition"] || "attachment", ); return new Response(response.data, { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d1d5dd53..870cbd38 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -47,7 +47,7 @@ app.use( rateLimiter({ limit: 1000, window: 30, // 👈 30 seconds - }) + }), ); app.use("*", async (c, next) => { @@ -162,7 +162,7 @@ app.get("/now-playing", async (c) => { ctx.redis.get(`nowplaying:${user.did}:status`), ]); return c.json( - nowPlaying ? { ...JSON.parse(nowPlaying), is_playing: status === "1" } : {} + nowPlaying ? { ...JSON.parse(nowPlaying), is_playing: status === "1" } : {}, ); }); @@ -314,7 +314,7 @@ app.get("/public/scrobbles", async (c) => { listeners: 1, sha256: item.track.sha256, id: item.scrobble.id, - })) + })), ); }); diff --git a/apps/api/src/lexicon/index.ts b/apps/api/src/lexicon/index.ts index f98407bf..5df583e2 100644 --- a/apps/api/src/lexicon/index.ts +++ b/apps/api/src/lexicon/index.ts @@ -9,6 +9,11 @@ import { type StreamAuthVerifier, } from "@atproto/xrpc-server"; import { schemas } from "./lexicons"; +import type * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile"; +import type * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles"; +import type * as FmTealAlphaActorSearchActors from "./types/fm/teal/alpha/actor/searchActors"; +import type * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed"; +import type * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay"; import type * as AppRockskyActorGetActorAlbums from "./types/app/rocksky/actor/getActorAlbums"; import type * as AppRockskyActorGetActorArtists from "./types/app/rocksky/actor/getActorArtists"; import type * as AppRockskyActorGetActorLovedSongs from "./types/app/rocksky/actor/getActorLovedSongs"; @@ -90,16 +95,121 @@ export function createServer(options?: XrpcOptions): Server { export class Server { xrpc: XrpcServer; + fm: FmNS; app: AppNS; com: ComNS; constructor(options?: XrpcOptions) { this.xrpc = createXrpcServer(schemas, options); + this.fm = new FmNS(this); this.app = new AppNS(this); this.com = new ComNS(this); } } +export class FmNS { + _server: Server; + teal: FmTealNS; + + constructor(server: Server) { + this._server = server; + this.teal = new FmTealNS(server); + } +} + +export class FmTealNS { + _server: Server; + alpha: FmTealAlphaNS; + + constructor(server: Server) { + this._server = server; + this.alpha = new FmTealAlphaNS(server); + } +} + +export class FmTealAlphaNS { + _server: Server; + actor: FmTealAlphaActorNS; + feed: FmTealAlphaFeedNS; + + constructor(server: Server) { + this._server = server; + this.actor = new FmTealAlphaActorNS(server); + this.feed = new FmTealAlphaFeedNS(server); + } +} + +export class FmTealAlphaActorNS { + _server: Server; + + constructor(server: Server) { + this._server = server; + } + + getProfile( + cfg: ConfigOf< + AV, + FmTealAlphaActorGetProfile.Handler>, + FmTealAlphaActorGetProfile.HandlerReqCtx> + >, + ) { + const nsid = "fm.teal.alpha.actor.getProfile"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } + + getProfiles( + cfg: ConfigOf< + AV, + FmTealAlphaActorGetProfiles.Handler>, + FmTealAlphaActorGetProfiles.HandlerReqCtx> + >, + ) { + const nsid = "fm.teal.alpha.actor.getProfiles"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } + + searchActors( + cfg: ConfigOf< + AV, + FmTealAlphaActorSearchActors.Handler>, + FmTealAlphaActorSearchActors.HandlerReqCtx> + >, + ) { + const nsid = "fm.teal.alpha.actor.searchActors"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } +} + +export class FmTealAlphaFeedNS { + _server: Server; + + constructor(server: Server) { + this._server = server; + } + + getActorFeed( + cfg: ConfigOf< + AV, + FmTealAlphaFeedGetActorFeed.Handler>, + FmTealAlphaFeedGetActorFeed.HandlerReqCtx> + >, + ) { + const nsid = "fm.teal.alpha.feed.getActorFeed"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } + + getPlay( + cfg: ConfigOf< + AV, + FmTealAlphaFeedGetPlay.Handler>, + FmTealAlphaFeedGetPlay.HandlerReqCtx> + >, + ) { + const nsid = "fm.teal.alpha.feed.getPlay"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } +} + export class AppNS { _server: Server; rocksky: AppRockskyNS; diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index 1fbcd7f3..222c3b51 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -4,6 +4,590 @@ import { type LexiconDoc, Lexicons } from "@atproto/lexicon"; export const schemaDict = { + FmTealAlphaActorDefs: { + lexicon: 1, + id: "fm.teal.alpha.actor.defs", + defs: { + profileView: { + type: "object", + properties: { + did: { + type: "string", + description: "The decentralized identifier of the actor", + }, + displayName: { + type: "string", + }, + description: { + type: "string", + description: "Free-form profile description text.", + }, + descriptionFacets: { + type: "array", + description: + "Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.", + items: { + type: "ref", + ref: "lex:app.bsky.richtext.facet", + }, + }, + featuredItem: { + type: "ref", + description: + "The user's most recent item featured on their profile.", + ref: "lex:fm.teal.alpha.actor.profile#featuredItem", + }, + avatar: { + type: "string", + description: "IPLD of the avatar", + }, + banner: { + type: "string", + description: "IPLD of the banner image", + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + miniProfileView: { + type: "object", + properties: { + did: { + type: "string", + description: "The decentralized identifier of the actor", + }, + displayName: { + type: "string", + }, + handle: { + type: "string", + }, + avatar: { + type: "string", + description: "IPLD of the avatar", + }, + }, + }, + }, + }, + FmTealAlphaActorGetProfile: { + lexicon: 1, + id: "fm.teal.alpha.actor.getProfile", + description: + "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.", + defs: { + main: { + type: "query", + parameters: { + type: "params", + required: ["actor"], + properties: { + actor: { + type: "string", + format: "at-identifier", + description: "The author's DID", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["actor"], + properties: { + actor: { + type: "ref", + ref: "lex:fm.teal.alpha.actor.defs#profileView", + }, + }, + }, + }, + }, + }, + }, + FmTealAlphaActorGetProfiles: { + lexicon: 1, + id: "fm.teal.alpha.actor.getProfiles", + description: + "This lexicon is in a not officially released state. It is subject to change. | Retrieves the associated profile.", + defs: { + main: { + type: "query", + parameters: { + type: "params", + required: ["actors"], + properties: { + actors: { + type: "array", + items: { + type: "string", + format: "at-identifier", + }, + description: "Array of actor DIDs", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["actors"], + properties: { + actors: { + type: "array", + items: { + type: "ref", + ref: "lex:fm.teal.alpha.actor.defs#miniProfileView", + }, + }, + }, + }, + }, + }, + }, + }, + FmTealAlphaActorProfile: { + lexicon: 1, + id: "fm.teal.alpha.actor.profile", + defs: { + main: { + type: "record", + description: + "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm account profile.", + key: "literal:self", + record: { + type: "object", + properties: { + displayName: { + type: "string", + maxGraphemes: 64, + maxLength: 640, + }, + description: { + type: "string", + description: "Free-form profile description text.", + maxGraphemes: 256, + maxLength: 2560, + }, + descriptionFacets: { + type: "array", + description: + "Annotations of text in the profile description (mentions, URLs, hashtags, etc).", + items: { + type: "ref", + ref: "lex:app.bsky.richtext.facet", + }, + }, + featuredItem: { + type: "ref", + description: + "The user's most recent item featured on their profile.", + ref: "lex:fm.teal.alpha.actor.profile#featuredItem", + }, + avatar: { + type: "blob", + description: + "Small image to be displayed next to posts from account. AKA, 'profile picture'", + accept: ["image/png", "image/jpeg"], + maxSize: 1000000, + }, + banner: { + type: "blob", + description: + "Larger horizontal image to display behind profile view.", + accept: ["image/png", "image/jpeg"], + maxSize: 1000000, + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + featuredItem: { + type: "object", + required: ["mbid", "type"], + properties: { + mbid: { + type: "string", + description: "The Musicbrainz ID of the item", + }, + type: { + type: "string", + description: + "The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc.", + }, + }, + }, + }, + }, + FmTealAlphaActorSearchActors: { + lexicon: 1, + id: "fm.teal.alpha.actor.searchActors", + description: + "This lexicon is in a not officially released state. It is subject to change. | Searches for actors based on profile contents.", + defs: { + main: { + type: "query", + parameters: { + type: "params", + required: ["q"], + properties: { + q: { + type: "string", + description: "The search query", + maxGraphemes: 128, + maxLength: 640, + }, + limit: { + type: "integer", + description: "The maximum number of actors to return", + minimum: 1, + maximum: 25, + }, + cursor: { + type: "string", + description: "Cursor for pagination", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["actors"], + properties: { + actors: { + type: "array", + items: { + type: "ref", + ref: "lex:fm.teal.alpha.actor.defs#miniProfileView", + }, + }, + cursor: { + type: "string", + description: "Cursor for pagination", + }, + }, + }, + }, + }, + }, + }, + FmTealAlphaActorStatus: { + lexicon: 1, + id: "fm.teal.alpha.actor.status", + defs: { + main: { + type: "record", + description: + "This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned.", + key: "literal:self", + record: { + type: "object", + required: ["time", "item"], + properties: { + time: { + type: "string", + format: "datetime", + description: "The unix timestamp of when the item was recorded", + }, + expiry: { + type: "string", + format: "datetime", + description: + "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time.", + }, + item: { + type: "ref", + ref: "lex:fm.teal.alpha.feed.defs#playView", + }, + }, + }, + }, + }, + }, + FmTealAlphaFeedDefs: { + lexicon: 1, + id: "fm.teal.alpha.feed.defs", + description: + "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to feeds.", + defs: { + playView: { + type: "object", + required: ["trackName", "artists"], + properties: { + trackName: { + type: "string", + minLength: 1, + maxLength: 256, + maxGraphemes: 2560, + description: "The name of the track", + }, + trackMbId: { + type: "string", + description: "The Musicbrainz ID of the track", + }, + recordingMbId: { + type: "string", + description: "The Musicbrainz recording ID of the track", + }, + duration: { + type: "integer", + description: "The length of the track in seconds", + }, + artists: { + type: "array", + items: { + type: "ref", + ref: "lex:fm.teal.alpha.feed.defs#artist", + }, + description: "Array of artists in order of original appearance.", + }, + releaseName: { + type: "string", + maxLength: 256, + maxGraphemes: 2560, + description: "The name of the release/album", + }, + releaseMbId: { + type: "string", + description: "The Musicbrainz release ID", + }, + isrc: { + type: "string", + description: "The ISRC code associated with the recording", + }, + originUrl: { + type: "string", + description: "The URL associated with this track", + }, + musicServiceBaseDomain: { + type: "string", + description: + "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided.", + }, + submissionClientAgent: { + type: "string", + maxLength: 256, + maxGraphemes: 2560, + description: + "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided.", + }, + playedTime: { + type: "string", + format: "datetime", + description: "The unix timestamp of when the track was played", + }, + }, + }, + artist: { + type: "object", + required: ["artistName"], + properties: { + artistName: { + type: "string", + minLength: 1, + maxLength: 256, + maxGraphemes: 2560, + description: "The name of the artist", + }, + artistMbId: { + type: "string", + description: "The Musicbrainz ID of the artist", + }, + }, + }, + }, + }, + FmTealAlphaFeedGetActorFeed: { + lexicon: 1, + id: "fm.teal.alpha.feed.getActorFeed", + description: + "This lexicon is in a not officially released state. It is subject to change. | Retrieves multiple plays from the index or via an author's DID.", + defs: { + main: { + type: "query", + parameters: { + type: "params", + required: ["authorDID"], + properties: { + authorDID: { + type: "string", + format: "at-identifier", + description: "The author's DID for the play", + }, + cursor: { + type: "string", + description: "The cursor to start the query from", + }, + limit: { + type: "integer", + description: + "The upper limit of tracks to get per request. Default is 20, max is 50.", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["plays"], + properties: { + plays: { + type: "array", + items: { + type: "ref", + ref: "lex:fm.teal.alpha.feed.defs#playView", + }, + }, + }, + }, + }, + }, + }, + }, + FmTealAlphaFeedGetPlay: { + lexicon: 1, + id: "fm.teal.alpha.feed.getPlay", + description: + "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.", + defs: { + main: { + type: "query", + parameters: { + type: "params", + required: ["authorDID", "rkey"], + properties: { + authorDID: { + type: "string", + format: "at-identifier", + description: "The author's DID for the play", + }, + rkey: { + type: "string", + description: "The record key of the play", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["play"], + properties: { + play: { + type: "ref", + ref: "lex:fm.teal.alpha.feed.defs#playView", + }, + }, + }, + }, + }, + }, + }, + FmTealAlphaFeedPlay: { + lexicon: 1, + id: "fm.teal.alpha.feed.play", + description: + "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm play. Plays are submitted as a result of a user listening to a track. Plays should be marked as tracked when a user has listened to the entire track if it's under 2 minutes long, or half of the track's duration up to 4 minutes, whichever is longest.", + defs: { + main: { + type: "record", + key: "tid", + record: { + type: "object", + required: ["trackName"], + properties: { + trackName: { + type: "string", + minLength: 1, + maxLength: 256, + maxGraphemes: 2560, + description: "The name of the track", + }, + trackMbId: { + type: "string", + description: "The Musicbrainz ID of the track", + }, + recordingMbId: { + type: "string", + description: "The Musicbrainz recording ID of the track", + }, + duration: { + type: "integer", + description: "The length of the track in seconds", + }, + artistNames: { + type: "array", + items: { + type: "string", + minLength: 1, + maxLength: 256, + maxGraphemes: 2560, + }, + description: + "Array of artist names in order of original appearance. Prefer using 'artists'.", + }, + artistMbIds: { + type: "array", + items: { + type: "string", + }, + description: + "Array of Musicbrainz artist IDs. Prefer using 'artists'.", + }, + artists: { + type: "array", + items: { + type: "ref", + ref: "lex:fm.teal.alpha.feed.defs#artist", + }, + description: "Array of artists in order of original appearance.", + }, + releaseName: { + type: "string", + maxLength: 256, + maxGraphemes: 2560, + description: "The name of the release/album", + }, + releaseMbId: { + type: "string", + description: "The Musicbrainz release ID", + }, + isrc: { + type: "string", + description: "The ISRC code associated with the recording", + }, + originUrl: { + type: "string", + description: "The URL associated with this track", + }, + musicServiceBaseDomain: { + type: "string", + description: + "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided.", + }, + submissionClientAgent: { + type: "string", + maxLength: 256, + maxGraphemes: 2560, + description: + "A metadata string specifying the user agent where the format is `/ (; ; )`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided.", + }, + playedTime: { + type: "string", + format: "datetime", + description: "The unix timestamp of when the track was played", + }, + }, + }, + }, + }, + }, AppRockskyActorDefs: { lexicon: 1, id: "app.rocksky.actor.defs", @@ -4421,6 +5005,16 @@ export const schemaDict = { export const schemas = Object.values(schemaDict); export const lexicons: Lexicons = new Lexicons(schemas); export const ids = { + FmTealAlphaActorDefs: "fm.teal.alpha.actor.defs", + FmTealAlphaActorGetProfile: "fm.teal.alpha.actor.getProfile", + FmTealAlphaActorGetProfiles: "fm.teal.alpha.actor.getProfiles", + FmTealAlphaActorProfile: "fm.teal.alpha.actor.profile", + FmTealAlphaActorSearchActors: "fm.teal.alpha.actor.searchActors", + FmTealAlphaActorStatus: "fm.teal.alpha.actor.status", + FmTealAlphaFeedDefs: "fm.teal.alpha.feed.defs", + FmTealAlphaFeedGetActorFeed: "fm.teal.alpha.feed.getActorFeed", + FmTealAlphaFeedGetPlay: "fm.teal.alpha.feed.getPlay", + FmTealAlphaFeedPlay: "fm.teal.alpha.feed.play", AppRockskyActorDefs: "app.rocksky.actor.defs", AppRockskyActorGetActorAlbums: "app.rocksky.actor.getActorAlbums", AppRockskyActorGetActorArtists: "app.rocksky.actor.getActorArtists", diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/actor/defs.ts b/apps/api/src/lexicon/types/fm/teal/alpha/actor/defs.ts new file mode 100644 index 00000000..3483340b --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/actor/defs.ts @@ -0,0 +1,60 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type * as AppBskyRichtextFacet from "../../../../app/bsky/richtext/facet"; +import type * as FmTealAlphaActorProfile from "./profile"; + +export interface ProfileView { + /** The decentralized identifier of the actor */ + did?: string; + displayName?: string; + /** Free-form profile description text. */ + description?: string; + /** Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon. */ + descriptionFacets?: AppBskyRichtextFacet.Main[]; + featuredItem?: FmTealAlphaActorProfile.FeaturedItem; + /** IPLD of the avatar */ + avatar?: string; + /** IPLD of the banner image */ + banner?: string; + createdAt?: string; + [k: string]: unknown; +} + +export function isProfileView(v: unknown): v is ProfileView { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "fm.teal.alpha.actor.defs#profileView" + ); +} + +export function validateProfileView(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.actor.defs#profileView", v); +} + +export interface MiniProfileView { + /** The decentralized identifier of the actor */ + did?: string; + displayName?: string; + handle?: string; + /** IPLD of the avatar */ + avatar?: string; + [k: string]: unknown; +} + +export function isMiniProfileView(v: unknown): v is MiniProfileView { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "fm.teal.alpha.actor.defs#miniProfileView" + ); +} + +export function validateMiniProfileView(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.actor.defs#miniProfileView", v); +} diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/actor/getProfile.ts b/apps/api/src/lexicon/types/fm/teal/alpha/actor/getProfile.ts new file mode 100644 index 00000000..af4451b1 --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/actor/getProfile.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as FmTealAlphaActorDefs from "./defs"; + +export interface QueryParams { + /** The author's DID */ + actor: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + actor: FmTealAlphaActorDefs.ProfileView; + [k: string]: unknown; +} + +export type HandlerInput = undefined; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; +export type HandlerReqCtx = { + auth: HA; + params: QueryParams; + input: HandlerInput; + req: express.Request; + res: express.Response; + resetRouteRateLimits: () => Promise; +}; +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput; diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/actor/getProfiles.ts b/apps/api/src/lexicon/types/fm/teal/alpha/actor/getProfiles.ts new file mode 100644 index 00000000..6ae2d32a --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/actor/getProfiles.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as FmTealAlphaActorDefs from "./defs"; + +export interface QueryParams { + /** Array of actor DIDs */ + actors: string[]; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + actors: FmTealAlphaActorDefs.MiniProfileView[]; + [k: string]: unknown; +} + +export type HandlerInput = undefined; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; +export type HandlerReqCtx = { + auth: HA; + params: QueryParams; + input: HandlerInput; + req: express.Request; + res: express.Response; + resetRouteRateLimits: () => Promise; +}; +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput; diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/actor/profile.ts b/apps/api/src/lexicon/types/fm/teal/alpha/actor/profile.ts new file mode 100644 index 00000000..31b43bf2 --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/actor/profile.ts @@ -0,0 +1,56 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type * as AppBskyRichtextFacet from "../../../../app/bsky/richtext/facet"; + +export interface Record { + displayName?: string; + /** Free-form profile description text. */ + description?: string; + /** Annotations of text in the profile description (mentions, URLs, hashtags, etc). */ + descriptionFacets?: AppBskyRichtextFacet.Main[]; + featuredItem?: FeaturedItem; + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ + avatar?: BlobRef; + /** Larger horizontal image to display behind profile view. */ + banner?: BlobRef; + createdAt?: string; + [k: string]: unknown; +} + +export function isRecord(v: unknown): v is Record { + return ( + isObj(v) && + hasProp(v, "$type") && + (v.$type === "fm.teal.alpha.actor.profile#main" || + v.$type === "fm.teal.alpha.actor.profile") + ); +} + +export function validateRecord(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.actor.profile#main", v); +} + +export interface FeaturedItem { + /** The Musicbrainz ID of the item */ + mbid: string; + /** The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc. */ + type: string; + [k: string]: unknown; +} + +export function isFeaturedItem(v: unknown): v is FeaturedItem { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "fm.teal.alpha.actor.profile#featuredItem" + ); +} + +export function validateFeaturedItem(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.actor.profile#featuredItem", v); +} diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/actor/searchActors.ts b/apps/api/src/lexicon/types/fm/teal/alpha/actor/searchActors.ts new file mode 100644 index 00000000..de844b90 --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/actor/searchActors.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as FmTealAlphaActorDefs from "./defs"; + +export interface QueryParams { + /** The search query */ + q: string; + /** The maximum number of actors to return */ + limit?: number; + /** Cursor for pagination */ + cursor?: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + actors: FmTealAlphaActorDefs.MiniProfileView[]; + /** Cursor for pagination */ + cursor?: string; + [k: string]: unknown; +} + +export type HandlerInput = undefined; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; +export type HandlerReqCtx = { + auth: HA; + params: QueryParams; + input: HandlerInput; + req: express.Request; + res: express.Response; + resetRouteRateLimits: () => Promise; +}; +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput; diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/actor/status.ts b/apps/api/src/lexicon/types/fm/teal/alpha/actor/status.ts new file mode 100644 index 00000000..b19d04f8 --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/actor/status.ts @@ -0,0 +1,30 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type * as FmTealAlphaFeedDefs from "../feed/defs"; + +export interface Record { + /** The unix timestamp of when the item was recorded */ + time: string; + /** The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time. */ + expiry?: string; + item: FmTealAlphaFeedDefs.PlayView; + [k: string]: unknown; +} + +export function isRecord(v: unknown): v is Record { + return ( + isObj(v) && + hasProp(v, "$type") && + (v.$type === "fm.teal.alpha.actor.status#main" || + v.$type === "fm.teal.alpha.actor.status") + ); +} + +export function validateRecord(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.actor.status#main", v); +} diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/feed/defs.ts b/apps/api/src/lexicon/types/fm/teal/alpha/feed/defs.ts new file mode 100644 index 00000000..6fc4b00b --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/feed/defs.ts @@ -0,0 +1,67 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; + +export interface PlayView { + /** The name of the track */ + trackName: string; + /** The Musicbrainz ID of the track */ + trackMbId?: string; + /** The Musicbrainz recording ID of the track */ + recordingMbId?: string; + /** The length of the track in seconds */ + duration?: number; + /** Array of artists in order of original appearance. */ + artists: Artist[]; + /** The name of the release/album */ + releaseName?: string; + /** The Musicbrainz release ID */ + releaseMbId?: string; + /** The ISRC code associated with the recording */ + isrc?: string; + /** The URL associated with this track */ + originUrl?: string; + /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided. */ + musicServiceBaseDomain?: string; + /** A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided. */ + submissionClientAgent?: string; + /** The unix timestamp of when the track was played */ + playedTime?: string; + [k: string]: unknown; +} + +export function isPlayView(v: unknown): v is PlayView { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "fm.teal.alpha.feed.defs#playView" + ); +} + +export function validatePlayView(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.feed.defs#playView", v); +} + +export interface Artist { + /** The name of the artist */ + artistName: string; + /** The Musicbrainz ID of the artist */ + artistMbId?: string; + [k: string]: unknown; +} + +export function isArtist(v: unknown): v is Artist { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "fm.teal.alpha.feed.defs#artist" + ); +} + +export function validateArtist(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.feed.defs#artist", v); +} diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/feed/getActorFeed.ts b/apps/api/src/lexicon/types/fm/teal/alpha/feed/getActorFeed.ts new file mode 100644 index 00000000..3f132675 --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/feed/getActorFeed.ts @@ -0,0 +1,52 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as FmTealAlphaFeedDefs from "./defs"; + +export interface QueryParams { + /** The author's DID for the play */ + authorDID: string; + /** The cursor to start the query from */ + cursor?: string; + /** The upper limit of tracks to get per request. Default is 20, max is 50. */ + limit?: number; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + plays: FmTealAlphaFeedDefs.PlayView[]; + [k: string]: unknown; +} + +export type HandlerInput = undefined; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; +export type HandlerReqCtx = { + auth: HA; + params: QueryParams; + input: HandlerInput; + req: express.Request; + res: express.Response; + resetRouteRateLimits: () => Promise; +}; +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput; diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/feed/getPlay.ts b/apps/api/src/lexicon/types/fm/teal/alpha/feed/getPlay.ts new file mode 100644 index 00000000..139e6fea --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/feed/getPlay.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as FmTealAlphaFeedDefs from "./defs"; + +export interface QueryParams { + /** The author's DID for the play */ + authorDID: string; + /** The record key of the play */ + rkey: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + play: FmTealAlphaFeedDefs.PlayView; + [k: string]: unknown; +} + +export type HandlerInput = undefined; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; +export type HandlerReqCtx = { + auth: HA; + params: QueryParams; + input: HandlerInput; + req: express.Request; + res: express.Response; + resetRouteRateLimits: () => Promise; +}; +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput; diff --git a/apps/api/src/lexicon/types/fm/teal/alpha/feed/play.ts b/apps/api/src/lexicon/types/fm/teal/alpha/feed/play.ts new file mode 100644 index 00000000..dbe7b90c --- /dev/null +++ b/apps/api/src/lexicon/types/fm/teal/alpha/feed/play.ts @@ -0,0 +1,53 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../../lexicons"; +import { isObj, hasProp } from "../../../../../util"; +import { CID } from "multiformats/cid"; +import type * as FmTealAlphaFeedDefs from "./defs"; + +export interface Record { + /** The name of the track */ + trackName: string; + /** The Musicbrainz ID of the track */ + trackMbId?: string; + /** The Musicbrainz recording ID of the track */ + recordingMbId?: string; + /** The length of the track in seconds */ + duration?: number; + /** Array of artist names in order of original appearance. Prefer using 'artists'. */ + artistNames?: string[]; + /** Array of Musicbrainz artist IDs. Prefer using 'artists'. */ + artistMbIds?: string[]; + /** Array of artists in order of original appearance. */ + artists?: FmTealAlphaFeedDefs.Artist[]; + /** The name of the release/album */ + releaseName?: string; + /** The Musicbrainz release ID */ + releaseMbId?: string; + /** The ISRC code associated with the recording */ + isrc?: string; + /** The URL associated with this track */ + originUrl?: string; + /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided. */ + musicServiceBaseDomain?: string; + /** A metadata string specifying the user agent where the format is `/ (; ; )`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided. */ + submissionClientAgent?: string; + /** The unix timestamp of when the track was played */ + playedTime?: string; + [k: string]: unknown; +} + +export function isRecord(v: unknown): v is Record { + return ( + isObj(v) && + hasProp(v, "$type") && + (v.$type === "fm.teal.alpha.feed.play#main" || + v.$type === "fm.teal.alpha.feed.play") + ); +} + +export function validateRecord(v: unknown): ValidationResult { + return lexicons.validate("fm.teal.alpha.feed.play#main", v); +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 839dff30..4cb49186 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -35,6 +35,7 @@ export const env = cleanEnv(process.env, { DROPBOX: str({ default: "http://localhost:7881" }), TRACKLIST: str({ default: "http://localhost:7884" }), REDIS_URL: str({ default: "redis://localhost:6379" }), + MUSICBRAINZ_URL: str({ devDefault: "http://localhost:8088" }), PRIVATE_KEY_1: str({}), PRIVATE_KEY_2: str({}), PRIVATE_KEY_3: str({}), diff --git a/apps/api/src/lovedtracks/lovedtracks.service.ts b/apps/api/src/lovedtracks/lovedtracks.service.ts index 85e5deae..1a960542 100644 --- a/apps/api/src/lovedtracks/lovedtracks.service.ts +++ b/apps/api/src/lovedtracks/lovedtracks.service.ts @@ -18,7 +18,7 @@ export async function likeTrack( ctx: Context, track: Track, user, - agent: Agent + agent: Agent, ) { const trackSha256 = createHash("sha256") .update(`${track.title} - ${track.artist} - ${track.album}`.toLowerCase()) @@ -156,7 +156,7 @@ export async function likeTrack( .select() .from(albumTracks) .where( - and(eq(albumTracks.albumId, albumId), eq(albumTracks.trackId, trackId)) + and(eq(albumTracks.albumId, albumId), eq(albumTracks.trackId, trackId)), ) .limit(1) .then((rows) => rows[0]); @@ -175,8 +175,8 @@ export async function likeTrack( .where( and( eq(artistTracks.artistId, artistId), - eq(artistTracks.trackId, trackId) - ) + eq(artistTracks.trackId, trackId), + ), ) .limit(1) .then((rows) => rows[0]); @@ -195,8 +195,8 @@ export async function likeTrack( .where( and( eq(artistAlbums.artistId, artistId), - eq(artistAlbums.albumId, albumId) - ) + eq(artistAlbums.albumId, albumId), + ), ) .limit(1) .then((rows) => rows[0]); @@ -213,7 +213,7 @@ export async function likeTrack( .select() .from(lovedTracks) .where( - and(eq(lovedTracks.userId, user.id), eq(lovedTracks.trackId, trackId)) + and(eq(lovedTracks.userId, user.id), eq(lovedTracks.trackId, trackId)), ) .limit(1) .then((rows) => rows[0]); @@ -304,7 +304,7 @@ export async function unLikeTrack( ctx: Context, trackSha256: string, user, - agent: Agent + agent: Agent, ) { const track = await ctx.db .select() @@ -321,7 +321,7 @@ export async function unLikeTrack( .select() .from(lovedTracks) .where( - and(eq(lovedTracks.userId, user.id), eq(lovedTracks.trackId, track.id)) + and(eq(lovedTracks.userId, user.id), eq(lovedTracks.trackId, track.id)), ) .limit(1) .then((rows) => rows[0]); @@ -351,7 +351,7 @@ export async function getLovedTracks( ctx: Context, user, size = 10, - offset = 0 + offset = 0, ) { const lovedTracksData = await ctx.db .select({ diff --git a/apps/api/src/nowplaying/nowplaying.service.ts b/apps/api/src/nowplaying/nowplaying.service.ts index 25c47e49..4cf27076 100644 --- a/apps/api/src/nowplaying/nowplaying.service.ts +++ b/apps/api/src/nowplaying/nowplaying.service.ts @@ -10,7 +10,7 @@ import * as Scrobble from "lexicon/types/app/rocksky/scrobble"; import * as Song from "lexicon/types/app/rocksky/song"; import { deepSnakeCaseKeys } from "lib"; import { createHash } from "node:crypto"; -import type { Track } from "types/track"; +import type { MusicbrainzTrack, Track } from "types/track"; import albumTracks from "../schema/album-tracks"; import albums from "../schema/albums"; import artistAlbums from "../schema/artist-albums"; @@ -22,6 +22,7 @@ import userAlbums from "../schema/user-albums"; import userArtists from "../schema/user-artists"; import userTracks from "../schema/user-tracks"; import users from "../schema/users"; +import tealfm from "../tealfm"; export async function putArtistRecord( track: Track, @@ -825,6 +826,28 @@ export async function scrobbleTrack( ); } + const { data: mbTrack } = await ctx.musicbrainz.post( + "/hydrate", + { + artist: track.artist.split(",").map((a) => ({ name: a.trim() })), + name: track.title, + album: track.album, + } + ); + + track.mbId = mbTrack?.trackMBID; + + if (userDid === "did:plc:7vdlgi2bflelz7mmuxoqjfcr" && mbTrack?.trackMBID) { + mbTrack.timestamp = track.timestamp + ? dayjs.unix(track.timestamp).toISOString() + : new Date().toISOString(); + await tealfm.publishPlayingNow( + agent, + mbTrack, + Math.floor(track.duration / 1000) + ); + } + const scrobbleUri = await putScrobbleRecord(track, agent); // loop while scrobble is null, try 30 times, sleep 1 second between tries diff --git a/apps/api/src/schema/album-tracks.ts b/apps/api/src/schema/album-tracks.ts index 75ace615..4f31dabd 100644 --- a/apps/api/src/schema/album-tracks.ts +++ b/apps/api/src/schema/album-tracks.ts @@ -4,9 +4,7 @@ import albums from "./albums"; import tracks from "./tracks"; const albumTracks = pgTable("album_tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), albumId: text("album_id") .notNull() .references(() => albums.id), diff --git a/apps/api/src/schema/albums.ts b/apps/api/src/schema/albums.ts index eb765242..ed6eda7c 100644 --- a/apps/api/src/schema/albums.ts +++ b/apps/api/src/schema/albums.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; const albums = pgTable("albums", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), title: text("title").notNull(), artist: text("artist").notNull(), releaseDate: text("release_date"), diff --git a/apps/api/src/schema/api-keys.ts b/apps/api/src/schema/api-keys.ts index a4883b93..64b4bc8f 100644 --- a/apps/api/src/schema/api-keys.ts +++ b/apps/api/src/schema/api-keys.ts @@ -3,9 +3,7 @@ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import users from "./users"; const apiKeys = pgTable("api_keys", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), name: text("name").notNull(), apiKey: text("api_key").notNull(), sharedSecret: text("shared_secret").notNull(), diff --git a/apps/api/src/schema/artist-albums.ts b/apps/api/src/schema/artist-albums.ts index 3c268346..45f7982b 100644 --- a/apps/api/src/schema/artist-albums.ts +++ b/apps/api/src/schema/artist-albums.ts @@ -4,9 +4,7 @@ import albums from "./albums"; import artists from "./artists"; const artistAlbums = pgTable("artist_albums", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), artistId: text("artist_id") .notNull() .references(() => artists.id), diff --git a/apps/api/src/schema/artist-tracks.ts b/apps/api/src/schema/artist-tracks.ts index 325a2853..dc12f4a4 100644 --- a/apps/api/src/schema/artist-tracks.ts +++ b/apps/api/src/schema/artist-tracks.ts @@ -4,9 +4,7 @@ import artists from "./artists"; import tracks from "./tracks"; const artistTracks = pgTable("artist_tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), artistId: text("artist_id") .notNull() .references(() => artists.id), diff --git a/apps/api/src/schema/artists.ts b/apps/api/src/schema/artists.ts index b9d3e034..46f4ead0 100644 --- a/apps/api/src/schema/artists.ts +++ b/apps/api/src/schema/artists.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; const artists = pgTable("artists", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), name: text("name").notNull(), biography: text("biography"), born: timestamp("born"), diff --git a/apps/api/src/schema/dropbox-accounts.ts b/apps/api/src/schema/dropbox-accounts.ts index 32ac2ff8..61fbd6f6 100644 --- a/apps/api/src/schema/dropbox-accounts.ts +++ b/apps/api/src/schema/dropbox-accounts.ts @@ -3,9 +3,7 @@ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import users from "./users"; const dropboxAccounts = pgTable("dropbox_accounts", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), email: text("email").unique().notNull(), isBetaUser: boolean("is_beta_user").default(false).notNull(), userId: text("user_id") diff --git a/apps/api/src/schema/dropbox-directories.ts b/apps/api/src/schema/dropbox-directories.ts index b55d018c..49c95645 100644 --- a/apps/api/src/schema/dropbox-directories.ts +++ b/apps/api/src/schema/dropbox-directories.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; const dropboxDirectories = pgTable("dropbox_directories", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), name: text("name").notNull(), path: text("path").notNull(), parentId: text("parent_id").references(() => dropboxDirectories.id), diff --git a/apps/api/src/schema/dropbox-paths.ts b/apps/api/src/schema/dropbox-paths.ts index c2f230e5..34481368 100644 --- a/apps/api/src/schema/dropbox-paths.ts +++ b/apps/api/src/schema/dropbox-paths.ts @@ -3,9 +3,7 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; import dropboxDirectories from "./dropbox-directories"; const dropboxPaths = pgTable("dropbox_paths", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), path: text("path").notNull(), name: text("name").notNull(), dropboxId: text("dropbox_id").notNull(), diff --git a/apps/api/src/schema/dropbox-tokens.ts b/apps/api/src/schema/dropbox-tokens.ts index abb7104c..c5ddab49 100644 --- a/apps/api/src/schema/dropbox-tokens.ts +++ b/apps/api/src/schema/dropbox-tokens.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; const dropboxTokens = pgTable("dropbox_tokens", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), refreshToken: text("refresh_token").notNull(), createdAt: timestamp("xata_createdat").defaultNow().notNull(), updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), diff --git a/apps/api/src/schema/dropbox.ts b/apps/api/src/schema/dropbox.ts index 396a49b8..706f481e 100644 --- a/apps/api/src/schema/dropbox.ts +++ b/apps/api/src/schema/dropbox.ts @@ -4,9 +4,7 @@ import dropboxTokens from "./dropbox-tokens"; import users from "./users"; const dropbox = pgTable("dropbox", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/google-drive-accounts.ts b/apps/api/src/schema/google-drive-accounts.ts index 5f78ceb8..46ff1d18 100644 --- a/apps/api/src/schema/google-drive-accounts.ts +++ b/apps/api/src/schema/google-drive-accounts.ts @@ -3,9 +3,7 @@ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import users from "./users"; const googleDriveAccounts = pgTable("google_drive_accounts", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), email: text("email").unique().notNull(), isBetaUser: boolean("is_beta_user").default(false).notNull(), userId: text("user_id") diff --git a/apps/api/src/schema/google-drive-directories.ts b/apps/api/src/schema/google-drive-directories.ts index b31c6eb2..58c33bac 100644 --- a/apps/api/src/schema/google-drive-directories.ts +++ b/apps/api/src/schema/google-drive-directories.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; const googleDriveDirectories = pgTable("google_drive_directories", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), name: text("name").notNull(), path: text("path").notNull(), parentId: text("parent_id").references(() => googleDriveDirectories.id), diff --git a/apps/api/src/schema/google-drive-paths.ts b/apps/api/src/schema/google-drive-paths.ts index f34c30ad..3aecaade 100644 --- a/apps/api/src/schema/google-drive-paths.ts +++ b/apps/api/src/schema/google-drive-paths.ts @@ -3,9 +3,7 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; import googleDriveDirectories from "./google-drive-directories"; const googleDrivePaths = pgTable("google_drive_paths", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), googleDriveId: text("google_drive_id").notNull(), trackId: text("track_id").notNull(), name: text("name").notNull(), diff --git a/apps/api/src/schema/google-drive-tokens.ts b/apps/api/src/schema/google-drive-tokens.ts index 74ba56d7..0298b32e 100644 --- a/apps/api/src/schema/google-drive-tokens.ts +++ b/apps/api/src/schema/google-drive-tokens.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; const googleDriveTokens = pgTable("google_drive_tokens", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), refreshToken: text("refresh_token").notNull(), createdAt: timestamp("xata_createdat").defaultNow().notNull(), updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), diff --git a/apps/api/src/schema/googledrive.ts b/apps/api/src/schema/googledrive.ts index 0aa46f26..7008c389 100644 --- a/apps/api/src/schema/googledrive.ts +++ b/apps/api/src/schema/googledrive.ts @@ -4,9 +4,7 @@ import googleDriveTokens from "./google-drive-tokens"; import users from "./users"; const googleDrive = pgTable("google_drive", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), googleDriveTokenId: text("google_drive_token_id") .notNull() .references(() => googleDriveTokens.id), diff --git a/apps/api/src/schema/loved-tracks.ts b/apps/api/src/schema/loved-tracks.ts index 580e0d11..5f03b3c0 100644 --- a/apps/api/src/schema/loved-tracks.ts +++ b/apps/api/src/schema/loved-tracks.ts @@ -4,9 +4,7 @@ import tracks from "./tracks"; import users from "./users"; const lovedTracks = pgTable("loved_tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/playlist-tracks.ts b/apps/api/src/schema/playlist-tracks.ts index 407c5a27..66ff0200 100644 --- a/apps/api/src/schema/playlist-tracks.ts +++ b/apps/api/src/schema/playlist-tracks.ts @@ -4,9 +4,7 @@ import playlists from "./playlists"; import tracks from "./tracks"; const playlistTracks = pgTable("playlist_tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), playlistId: text("playlist_id") .notNull() .references(() => playlists.id), diff --git a/apps/api/src/schema/playlists.ts b/apps/api/src/schema/playlists.ts index 07a906d8..b0c1400c 100644 --- a/apps/api/src/schema/playlists.ts +++ b/apps/api/src/schema/playlists.ts @@ -3,9 +3,7 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; import users from "./users"; const playlists = pgTable("playlists", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), name: text("name").notNull(), picture: text("picture"), description: text("description"), diff --git a/apps/api/src/schema/profile-shouts.ts b/apps/api/src/schema/profile-shouts.ts index ab9670e8..e47d3d68 100644 --- a/apps/api/src/schema/profile-shouts.ts +++ b/apps/api/src/schema/profile-shouts.ts @@ -4,9 +4,7 @@ import shouts from "./shouts"; import users from "./users"; const profileShouts = pgTable("profile_shouts", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/queue-tracks.ts b/apps/api/src/schema/queue-tracks.ts index 7802c6bc..110ad284 100644 --- a/apps/api/src/schema/queue-tracks.ts +++ b/apps/api/src/schema/queue-tracks.ts @@ -4,9 +4,7 @@ import tracks from "./tracks"; import users from "./users"; const queueTracks = pgTable("queue_tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/scrobbles.ts b/apps/api/src/schema/scrobbles.ts index 8c5c0438..4af9e5a6 100644 --- a/apps/api/src/schema/scrobbles.ts +++ b/apps/api/src/schema/scrobbles.ts @@ -6,9 +6,7 @@ import tracks from "./tracks"; import users from "./users"; const scrobbles = pgTable("scrobbles", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id").references(() => users.id), trackId: text("track_id").references(() => tracks.id), albumId: text("album_id").references(() => albums.id), diff --git a/apps/api/src/schema/shout-likes.ts b/apps/api/src/schema/shout-likes.ts index 0f685493..89558a48 100644 --- a/apps/api/src/schema/shout-likes.ts +++ b/apps/api/src/schema/shout-likes.ts @@ -4,9 +4,7 @@ import shouts from "./shouts"; import users from "./users"; const shoutLikes = pgTable("shout_likes", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/shout-reports.ts b/apps/api/src/schema/shout-reports.ts index 2e34cc5f..aef9c4c4 100644 --- a/apps/api/src/schema/shout-reports.ts +++ b/apps/api/src/schema/shout-reports.ts @@ -4,9 +4,7 @@ import shouts from "./shouts"; import users from "./users"; const shoutReports = pgTable("shout_reports", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/shouts.ts b/apps/api/src/schema/shouts.ts index 7f5ff363..14001741 100644 --- a/apps/api/src/schema/shouts.ts +++ b/apps/api/src/schema/shouts.ts @@ -6,9 +6,7 @@ import tracks from "./tracks"; import users from "./users"; const shouts = pgTable("shouts", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), content: text("content").notNull(), trackId: text("track_id").references(() => tracks.id), artistId: text("artist_id").references(() => users.id), diff --git a/apps/api/src/schema/spotify-accounts.ts b/apps/api/src/schema/spotify-accounts.ts index 10ecfd5a..79d27407 100644 --- a/apps/api/src/schema/spotify-accounts.ts +++ b/apps/api/src/schema/spotify-accounts.ts @@ -9,9 +9,7 @@ import { import users from "./users"; const spotifyAccounts = pgTable("spotify_accounts", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), xataVersion: integer("xata_version"), email: text("email").notNull(), userId: text("user_id") diff --git a/apps/api/src/schema/spotify-tokens.ts b/apps/api/src/schema/spotify-tokens.ts index 25532ea1..b3b0f9d4 100644 --- a/apps/api/src/schema/spotify-tokens.ts +++ b/apps/api/src/schema/spotify-tokens.ts @@ -3,9 +3,7 @@ import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import users from "./users"; const spotifyTokens = pgTable("spotify_tokens", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), xataVersion: integer("xata_version"), accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), diff --git a/apps/api/src/schema/tracks.ts b/apps/api/src/schema/tracks.ts index 601ce598..0eaeb14e 100644 --- a/apps/api/src/schema/tracks.ts +++ b/apps/api/src/schema/tracks.ts @@ -2,9 +2,7 @@ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; const tracks = pgTable("tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), title: text("title").notNull(), artist: text("artist").notNull(), albumArtist: text("album_artist").notNull(), diff --git a/apps/api/src/schema/user-albums.ts b/apps/api/src/schema/user-albums.ts index c57c9e0e..f1d08c41 100644 --- a/apps/api/src/schema/user-albums.ts +++ b/apps/api/src/schema/user-albums.ts @@ -4,9 +4,7 @@ import albums from "./albums"; import users from "./users"; const userAlbums = pgTable("user_albums", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/user-artists.ts b/apps/api/src/schema/user-artists.ts index 6479ae39..8ef78401 100644 --- a/apps/api/src/schema/user-artists.ts +++ b/apps/api/src/schema/user-artists.ts @@ -4,9 +4,7 @@ import artists from "./artists"; import users from "./users"; const userArtists = pgTable("user_artists", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/user-playlists.ts b/apps/api/src/schema/user-playlists.ts index 771856fe..d200c27d 100644 --- a/apps/api/src/schema/user-playlists.ts +++ b/apps/api/src/schema/user-playlists.ts @@ -4,9 +4,7 @@ import playlists from "./playlists"; import users from "./users"; const userPlaylists = pgTable("user_playlists", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/user-tracks.ts b/apps/api/src/schema/user-tracks.ts index d23e6a17..0d8ef828 100644 --- a/apps/api/src/schema/user-tracks.ts +++ b/apps/api/src/schema/user-tracks.ts @@ -4,9 +4,7 @@ import tracks from "./tracks"; import users from "./users"; const userTracks = pgTable("user_tracks", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), userId: text("user_id") .notNull() .references(() => users.id), diff --git a/apps/api/src/schema/users.ts b/apps/api/src/schema/users.ts index 4b4df2bc..53d035d6 100644 --- a/apps/api/src/schema/users.ts +++ b/apps/api/src/schema/users.ts @@ -2,9 +2,7 @@ import { type InferSelectModel, sql } from "drizzle-orm"; import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; const users = pgTable("users", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), did: text("did").unique().notNull(), displayName: text("display_name"), handle: text("handle").unique().notNull(), diff --git a/apps/api/src/schema/webscrobblers.ts b/apps/api/src/schema/webscrobblers.ts index b6dbb5b9..8872ea65 100644 --- a/apps/api/src/schema/webscrobblers.ts +++ b/apps/api/src/schema/webscrobblers.ts @@ -3,9 +3,7 @@ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import users from "./users"; const webscrobblers = pgTable("webscrobblers", { - id: text("xata_id") - .primaryKey() - .default(sql`xata_id()`), + id: text("xata_id").primaryKey().default(sql`xata_id()`), name: text("name").notNull(), uuid: text("uuid").notNull(), description: text("description"), diff --git a/apps/api/src/scripts/avatar.ts b/apps/api/src/scripts/avatar.ts index d79f7237..84fef8ca 100644 --- a/apps/api/src/scripts/avatar.ts +++ b/apps/api/src/scripts/avatar.ts @@ -19,7 +19,7 @@ for (const did of args) { } const plc = await fetch(`https://plc.directory/${user.did}`).then((res) => - res.json() + res.json(), ); const serviceEndpoint = _.get(plc, "service.0.serviceEndpoint"); @@ -29,7 +29,7 @@ for (const did of args) { } const profile = await fetch( - `${serviceEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${user.did}&collection=app.bsky.actor.profile&rkey=self` + `${serviceEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${user.did}&collection=app.bsky.actor.profile&rkey=self`, ).then((res) => res.json()); const ref = _.get(profile, "value.avatar.ref.$link"); const type = _.get(profile, "value.avatar.mimeType", "").split("/")[1]; @@ -53,7 +53,7 @@ for (const did of args) { ctx.nc.publish( "rocksky.user", - Buffer.from(JSON.stringify(deepSnakeCaseKeys(u))) + Buffer.from(JSON.stringify(deepSnakeCaseKeys(u))), ); } diff --git a/apps/api/src/scripts/genres.ts b/apps/api/src/scripts/genres.ts index 35790a7f..ab0ff5a8 100644 --- a/apps/api/src/scripts/genres.ts +++ b/apps/api/src/scripts/genres.ts @@ -11,7 +11,7 @@ async function getSpotifyToken(): Promise { .from(tables.spotifyTokens) .leftJoin( tables.spotifyAccounts, - eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId) + eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId), ) .where(eq(tables.spotifyAccounts.isBetaUser, true)) .execute() @@ -51,7 +51,7 @@ async function getGenresAndPicture(artists) { headers: { Authorization: `Bearer ${token}`, }, - } + }, ) .then( (res) => @@ -64,7 +64,7 @@ async function getGenresAndPicture(artists) { images: Array<{ url: string }>; }>; }; - }> + }>, ) .then(async (data) => _.get(data, "artists.items.0")); diff --git a/apps/api/src/scripts/meili.ts b/apps/api/src/scripts/meili.ts index 61abfa0e..c3d5f569 100644 --- a/apps/api/src/scripts/meili.ts +++ b/apps/api/src/scripts/meili.ts @@ -32,7 +32,7 @@ async function createAlbums() { for (let i = 0; i < total; i += size) { const skip = i; console.log( - `Processing ${chalk.magentaBright("albums")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}` + `Processing ${chalk.magentaBright("albums")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}`, ); const results = await ctx.db .select() @@ -56,7 +56,7 @@ async function createArtists() { for (let i = 0; i < total; i += size) { const skip = i; console.log( - `Processing ${chalk.magentaBright("artists")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}` + `Processing ${chalk.magentaBright("artists")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}`, ); const results = await ctx.db .select() @@ -80,7 +80,7 @@ async function createTracks() { for (let i = 0; i < total; i += size) { const skip = i; console.log( - `Processing ${chalk.magentaBright("tracks")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}` + `Processing ${chalk.magentaBright("tracks")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}`, ); const results = await ctx.db .select() @@ -105,7 +105,7 @@ async function createUsers() { for (let i = 0; i < total; i += size) { const skip = i; console.log( - `Processing ${chalk.magentaBright("users")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}` + `Processing ${chalk.magentaBright("users")}: ${chalk.magentaBright(skip)} to ${chalk.magentaBright(skip + size)}`, ); const results = await ctx.db .select() diff --git a/apps/api/src/shouts/shouts.service.ts b/apps/api/src/shouts/shouts.service.ts index 4f6ed358..77a6ca14 100644 --- a/apps/api/src/shouts/shouts.service.ts +++ b/apps/api/src/shouts/shouts.service.ts @@ -21,7 +21,7 @@ export async function createShout( shout: Shout, uri: string, user, - agent: Agent + agent: Agent, ) { let album: SelectAlbum, artist: SelectArtist, @@ -157,7 +157,7 @@ export async function replyShout( reply: Shout, shoutUri: string, user, - agent: Agent + agent: Agent, ) { const shout = await ctx.db .select({ @@ -212,7 +212,7 @@ export async function replyShout( profileRecord.uri; let service = await fetch( - `https://plc.directory/${subjectUri.split("/").slice(0, 3).join("/").split("at://")[1]}` + `https://plc.directory/${subjectUri.split("/").slice(0, 3).join("/").split("at://")[1]}`, ) .then((res) => res.json<{ service: { seviceEndpoint: string }[] }>()) .then((data) => data.service); @@ -236,7 +236,7 @@ export async function replyShout( } service = await fetch( - `https://plc.directory/${shoutUri.split("/").slice(0, 3).join("/").split("at://")[1]}` + `https://plc.directory/${shoutUri.split("/").slice(0, 3).join("/").split("at://")[1]}`, ) .then((res) => res.json<{ service: { seviceEndpoint: string }[] }>()) .then((data) => data.service); @@ -322,7 +322,7 @@ export async function likeShout( ctx: Context, shoutUri: string, user, - agent: Agent + agent: Agent, ) { const rkey = TID.nextStr(); @@ -342,7 +342,7 @@ export async function likeShout( } const { service } = await fetch( - `https://plc.directory/${shoutUri.split("/").slice(0, 3).join("/").split("at://")[1]}` + `https://plc.directory/${shoutUri.split("/").slice(0, 3).join("/").split("at://")[1]}`, ).then((res) => res.json<{ service: [{ serviceEndpoint: string }] }>()); const atpAgent = new AtpAgent({ @@ -410,7 +410,7 @@ export async function unlikeShout( ctx: Context, shoutUri: string, user, - agent: Agent + agent: Agent, ) { const likes = await ctx.db .select({ diff --git a/apps/api/src/spotify/app.ts b/apps/api/src/spotify/app.ts index d35ac15a..08d62ece 100644 --- a/apps/api/src/spotify/app.ts +++ b/apps/api/src/spotify/app.ts @@ -22,7 +22,7 @@ app.use( limit: 10, // max Spotify API calls window: 15, // per 10 seconds keyPrefix: "spotify-ratelimit", - }) + }), ); app.get("/login", async (c) => { @@ -55,7 +55,7 @@ app.get("/login", async (c) => { const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${env.SPOTIFY_CLIENT_ID}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; c.header( "Set-Cookie", - `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure` + `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, ); return c.json({ redirectUrl }); }); @@ -133,8 +133,8 @@ app.get("/callback", async (c) => { .where( and( eq(spotifyAccounts.userId, user.id), - eq(spotifyAccounts.isBetaUser, true) - ) + eq(spotifyAccounts.isBetaUser, true), + ), ) .limit(1) .then((rows) => rows[0]); @@ -251,7 +251,7 @@ app.get("/currently-playing", async (c) => { } const cached = await ctx.redis.get( - `${spotifyAccount.spotifyAccount.email}:current` + `${spotifyAccount.spotifyAccount.email}:current`, ); if (!cached) { return c.json({}); @@ -261,7 +261,7 @@ app.get("/currently-playing", async (c) => { const sha256 = createHash("sha256") .update( - `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase() + `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), ) .digest("hex"); @@ -334,7 +334,7 @@ app.put("/pause", async (c) => { const refreshToken = decrypt( spotifyToken.refreshToken, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ); // get new access token @@ -410,7 +410,7 @@ app.put("/play", async (c) => { const refreshToken = decrypt( spotifyToken.refreshToken, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ); // get new access token @@ -486,7 +486,7 @@ app.post("/next", async (c) => { const refreshToken = decrypt( spotifyToken.refreshToken, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ); // get new access token @@ -562,7 +562,7 @@ app.post("/previous", async (c) => { const refreshToken = decrypt( spotifyToken.refreshToken, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ); // get new access token @@ -590,7 +590,7 @@ app.post("/previous", async (c) => { headers: { Authorization: `Bearer ${access_token}`, }, - } + }, ); if (response.status === 403) { @@ -641,7 +641,7 @@ app.put("/seek", async (c) => { const refreshToken = decrypt( spotifyToken.refreshToken, - env.SPOTIFY_ENCRYPTION_KEY + env.SPOTIFY_ENCRYPTION_KEY, ); // get new access token @@ -670,7 +670,7 @@ app.put("/seek", async (c) => { headers: { Authorization: `Bearer ${access_token}`, }, - } + }, ); if (response.status === 403) { diff --git a/apps/api/src/tealfm/index.ts b/apps/api/src/tealfm/index.ts new file mode 100644 index 00000000..781eaf23 --- /dev/null +++ b/apps/api/src/tealfm/index.ts @@ -0,0 +1,51 @@ +import type { Agent } from "@atproto/api"; +import { TID } from "@atproto/common"; +import chalk from "chalk"; +import * as Play from "lexicon/types/fm/teal/alpha/feed/play"; +import type { MusicbrainzTrack } from "types/track"; + +const SUBMISSION_CLIENT_AGENT = "rocksky/v0.0.1"; + +async function publishPlayingNow( + agent: Agent, + track: MusicbrainzTrack, + duration: number +) { + try { + const rkey = TID.nextStr(); + const record: Play.Record = { + $type: "fm.teal.alpha.feed.play", + duration, + trackName: track.name, + playedTime: track.timestamp, + artists: track.artist.map((artist) => ({ + artistMbid: artist.mbid, + artistName: artist.name, + })), + releaseMbid: track.releaseMBID, + releaseName: track.album, + recordingMbId: track.trackMBID, + submissionClientAgent: SUBMISSION_CLIENT_AGENT, + }; + + if (!Play.validateRecord(record).success) { + console.log(Play.validateRecord(record)); + console.log(chalk.cyan(JSON.stringify(record, null, 2))); + throw new Error("Invalid record"); + } + + const res = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection: "fm.teal.alpha.feed.play", + rkey, + record, + validate: false, + }); + const uri = res.data.uri; + console.log(`tealfm Play record created at ${uri}`); + } catch (error) { + console.error("Error publishing teal.fm record:", error); + } +} + +export default { publishPlayingNow }; diff --git a/apps/api/src/tealfm/lexicons/teal/actor/defs.json b/apps/api/src/tealfm/lexicons/teal/actor/defs.json new file mode 100644 index 00000000..9ebdc459 --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/actor/defs.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.actor.defs", + "defs": { + "profileView": { + "type": "object", + "properties": { + "did": { + "type": "string", + "description": "The decentralized identifier of the actor" + }, + "displayName": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Free-form profile description text." + }, + "descriptionFacets": { + "type": "array", + "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "featuredItem": { + "type": "ref", + "description": "The user's most recent item featured on their profile.", + "ref": "fm.teal.alpha.actor.profile#featuredItem" + }, + "avatar": { + "type": "string", + "description": "IPLD of the avatar" + }, + "banner": { + "type": "string", + "description": "IPLD of the banner image" + }, + "createdAt": { "type": "string", "format": "datetime" } + } + }, + "miniProfileView": { + "type": "object", + "properties": { + "did": { + "type": "string", + "description": "The decentralized identifier of the actor" + }, + "displayName": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "avatar": { + "type": "string", + "description": "IPLD of the avatar" + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/actor/getProfile.json b/apps/api/src/tealfm/lexicons/teal/actor/getProfile.json new file mode 100644 index 00000000..6f2987de --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/actor/getProfile.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.actor.getProfile", + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "The author's DID" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actor"], + "properties": { + "actor": { + "type": "ref", + "ref": "fm.teal.alpha.actor.defs#profileView" + } + } + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/actor/getProfiles.json b/apps/api/src/tealfm/lexicons/teal/actor/getProfiles.json new file mode 100644 index 00000000..0d33064f --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/actor/getProfiles.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.actor.getProfiles", + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves the associated profile.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "items": { + "type": "string", + "format": "at-identifier" + }, + "description": "Array of actor DIDs" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "items": { + "type": "ref", + "ref": "fm.teal.alpha.actor.defs#miniProfileView" + } + } + } + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/actor/profile.json b/apps/api/src/tealfm/lexicons/teal/actor/profile.json new file mode 100644 index 00000000..66fab852 --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/actor/profile.json @@ -0,0 +1,64 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.actor.profile", + "defs": { + "main": { + "type": "record", + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm account profile.", + "key": "literal:self", + "record": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "description": "Free-form profile description text.", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "descriptionFacets": { + "type": "array", + "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc).", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "featuredItem": { + "type": "ref", + "description": "The user's most recent item featured on their profile.", + "ref": "#featuredItem" + }, + "avatar": { + "type": "blob", + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000 + }, + "banner": { + "type": "blob", + "description": "Larger horizontal image to display behind profile view.", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000 + }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + }, + "featuredItem": { + "type": "object", + "required": ["mbid", "type"], + "properties": { + "mbid": { + "type": "string", + "description": "The Musicbrainz ID of the item" + }, + "type": { + "type": "string", + "description": "The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc." + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/actor/searchActors.json b/apps/api/src/tealfm/lexicons/teal/actor/searchActors.json new file mode 100644 index 00000000..8f4b2fac --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/actor/searchActors.json @@ -0,0 +1,52 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.actor.searchActors", + "description": "This lexicon is in a not officially released state. It is subject to change. | Searches for actors based on profile contents.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "description": "The search query", + "maxGraphemes": 128, + "maxLength": 640 + }, + "limit": { + "type": "integer", + "description": "The maximum number of actors to return", + "minimum": 1, + "maximum": 25 + }, + "cursor": { + "type": "string", + "description": "Cursor for pagination" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "items": { + "type": "ref", + "ref": "fm.teal.alpha.actor.defs#miniProfileView" + } + }, + "cursor": { + "type": "string", + "description": "Cursor for pagination" + } + } + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/actor/status.json b/apps/api/src/tealfm/lexicons/teal/actor/status.json new file mode 100644 index 00000000..33144d0a --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/actor/status.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.actor.status", + "defs": { + "main": { + "type": "record", + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned.", + "key": "literal:self", + "record": { + "type": "object", + "required": ["time", "item"], + "properties": { + "time": { + "type": "string", + "format": "datetime", + "description": "The unix timestamp of when the item was recorded" + }, + "expiry": { + "type": "string", + "format": "datetime", + "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time." + }, + "item": { + "type": "ref", + "ref": "fm.teal.alpha.feed.defs#playView" + } + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/feed/defs.json b/apps/api/src/tealfm/lexicons/teal/feed/defs.json new file mode 100644 index 00000000..c4a90c45 --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/feed/defs.json @@ -0,0 +1,90 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.defs", + "description": "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to feeds.", + "defs": { + "playView": { + "type": "object", + "required": ["trackName", "artists"], + "properties": { + "trackName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the track" + }, + "trackMbId": { + "type": "string", + "description": "The Musicbrainz ID of the track" + }, + "recordingMbId": { + "type": "string", + "description": "The Musicbrainz recording ID of the track" + }, + "duration": { + "type": "integer", + "description": "The length of the track in seconds" + }, + "artists": { + "type": "array", + "items": { + "type": "ref", + "ref": "#artist" + }, + "description": "Array of artists in order of original appearance." + }, + "releaseName": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the release/album" + }, + "releaseMbId": { + "type": "string", + "description": "The Musicbrainz release ID" + }, + "isrc": { + "type": "string", + "description": "The ISRC code associated with the recording" + }, + "originUrl": { + "type": "string", + "description": "The URL associated with this track" + }, + "musicServiceBaseDomain": { + "type": "string", + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided." + }, + "submissionClientAgent": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided." + }, + "playedTime": { + "type": "string", + "format": "datetime", + "description": "The unix timestamp of when the track was played" + } + } + }, + "artist": { + "type": "object", + "required": ["artistName"], + "properties": { + "artistName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the artist" + }, + "artistMbId": { + "type": "string", + "description": "The Musicbrainz ID of the artist" + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/feed/getActorFeed.json b/apps/api/src/tealfm/lexicons/teal/feed/getActorFeed.json new file mode 100644 index 00000000..51b6c9da --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/feed/getActorFeed.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.getActorFeed", + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves multiple plays from the index or via an author's DID.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["authorDID"], + "properties": { + "authorDID": { + "type": "string", + "format": "at-identifier", + "description": "The author's DID for the play" + }, + "cursor": { + "type": "string", + "description": "The cursor to start the query from" + }, + "limit": { + "type": "integer", + "description": "The upper limit of tracks to get per request. Default is 20, max is 50." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["plays"], + "properties": { + "plays": { + "type": "array", + "items": { + "type": "ref", + "ref": "fm.teal.alpha.feed.defs#playView" + } + } + } + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/feed/getPlay.json b/apps/api/src/tealfm/lexicons/teal/feed/getPlay.json new file mode 100644 index 00000000..5ceba07f --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/feed/getPlay.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.getPlay", + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["authorDID", "rkey"], + "properties": { + "authorDID": { + "type": "string", + "format": "at-identifier", + "description": "The author's DID for the play" + }, + "rkey": { + "type": "string", + "description": "The record key of the play" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["play"], + "properties": { + "play": { + "type": "ref", + "ref": "fm.teal.alpha.feed.defs#playView" + } + } + } + } + } + } +} diff --git a/apps/api/src/tealfm/lexicons/teal/feed/play.json b/apps/api/src/tealfm/lexicons/teal/feed/play.json new file mode 100644 index 00000000..a9216b4e --- /dev/null +++ b/apps/api/src/tealfm/lexicons/teal/feed/play.json @@ -0,0 +1,95 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.play", + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm play. Plays are submitted as a result of a user listening to a track. Plays should be marked as tracked when a user has listened to the entire track if it's under 2 minutes long, or half of the track's duration up to 4 minutes, whichever is longest.", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["trackName"], + "properties": { + "trackName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the track" + }, + "trackMbId": { + "type": "string", + + "description": "The Musicbrainz ID of the track" + }, + "recordingMbId": { + "type": "string", + "description": "The Musicbrainz recording ID of the track" + }, + "duration": { + "type": "integer", + "description": "The length of the track in seconds" + }, + "artistNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560 + }, + "description": "Array of artist names in order of original appearance. Prefer using 'artists'." + }, + "artistMbIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'." + }, + "artists": { + "type": "array", + "items": { + "type": "ref", + "ref": "fm.teal.alpha.feed.defs#artist" + }, + "description": "Array of artists in order of original appearance." + }, + "releaseName": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the release/album" + }, + "releaseMbId": { + "type": "string", + "description": "The Musicbrainz release ID" + }, + "isrc": { + "type": "string", + "description": "The ISRC code associated with the recording" + }, + "originUrl": { + "type": "string", + "description": "The URL associated with this track" + }, + "musicServiceBaseDomain": { + "type": "string", + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided." + }, + "submissionClientAgent": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "A metadata string specifying the user agent where the format is `/ (; ; )`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided." + }, + "playedTime": { + "type": "string", + "format": "datetime", + "description": "The unix timestamp of when the track was played" + } + } + } + } + } +} diff --git a/apps/api/src/tracks/tracks.service.ts b/apps/api/src/tracks/tracks.service.ts index ce5d5d0c..e92d43c8 100644 --- a/apps/api/src/tracks/tracks.service.ts +++ b/apps/api/src/tracks/tracks.service.ts @@ -132,7 +132,7 @@ export async function saveTrack(ctx: Context, track: Track, agent: Agent) { if (!track_id || !album_id || !artist_id) { console.log( "Track not yet saved (uri not saved), retrying...", - tries + 1 + tries + 1, ); await new Promise((resolve) => setTimeout(resolve, 1000)); tries += 1; @@ -145,8 +145,8 @@ export async function saveTrack(ctx: Context, track: Track, agent: Agent) { .where( and( eq(albumTracks.albumId, album_id.id), - eq(albumTracks.trackId, track_id.id) - ) + eq(albumTracks.trackId, track_id.id), + ), ) .limit(1) .then((results) => results[0]); @@ -157,8 +157,8 @@ export async function saveTrack(ctx: Context, track: Track, agent: Agent) { .where( and( eq(artistTracks.artistId, artist_id.id), - eq(artistTracks.trackId, track_id.id) - ) + eq(artistTracks.trackId, track_id.id), + ), ) .limit(1) .then((results) => results[0]); @@ -169,8 +169,8 @@ export async function saveTrack(ctx: Context, track: Track, agent: Agent) { .where( and( eq(artistAlbums.artistId, artist_id.id), - eq(artistAlbums.albumId, album_id.id) - ) + eq(artistAlbums.albumId, album_id.id), + ), ) .limit(1) .then((results) => results[0]); @@ -264,12 +264,12 @@ export async function saveTrack(ctx: Context, track: Track, agent: Agent) { xata_createdat: artist_album.createdAt.toISOString(), xata_updatedat: artist_album.updatedAt.toISOString(), }, - }) + }), ); ctx.nc.publish( "rocksky.track", - Buffer.from(message.replaceAll("sha_256", "sha256")) + Buffer.from(message.replaceAll("sha_256", "sha256")), ); break; } diff --git a/apps/api/src/types/track.ts b/apps/api/src/types/track.ts index 0eb52946..d3a5b01d 100644 --- a/apps/api/src/types/track.ts +++ b/apps/api/src/types/track.ts @@ -34,3 +34,16 @@ export const trackSchema = z.object({ }); export type Track = z.infer; + +export type MusicbrainzTrack = { + trackMBID: string; + releaseMBID: string; + name: string; + artist: { + id: string; + mbid: string; + name: string; + }[]; + album: string; + timestamp: string; +}; diff --git a/apps/api/src/users/app.ts b/apps/api/src/users/app.ts index 7ce9973d..eb781e30 100644 --- a/apps/api/src/users/app.ts +++ b/apps/api/src/users/app.ts @@ -20,7 +20,7 @@ import { likeTrack, unLikeTrack } from "lovedtracks/lovedtracks.service"; import { requestCounter } from "metrics"; import * as R from "ramda"; import tables from "schema"; -import { SelectUser } from "schema/users"; +import type { SelectUser } from "schema/users"; import { createShout, likeShout, @@ -123,7 +123,7 @@ app.get("/:did/tracks", async (c) => { data.map((item) => ({ ...item, tags: [], - })) + })), ); }); @@ -153,7 +153,7 @@ app.get("/:did/playlists", async (c) => { results.map((x) => ({ ...x.playlists, trackCount: +x.trackCount, - })) + })), ); }); @@ -219,7 +219,7 @@ app.get("/:did/app.rocksky.artist/:rkey", async (c) => { .from(tables.userArtists) .leftJoin( tables.artists, - eq(tables.userArtists.artistId, tables.artists.id) + eq(tables.userArtists.artistId, tables.artists.id), ) .where(or(eq(tables.userArtists.uri, uri), eq(tables.artists.uri, uri))) .limit(1) @@ -309,7 +309,7 @@ app.get("/:did/app.rocksky.album/:rkey", async (c) => { scrobbles: scrobbles || 1, label: tracks[0]?.tracks.label || "", tracks: dedupeTracksKeepLyrics(tracks.map((track) => track.tracks)).sort( - (a, b) => a.track_number - b.track_number + (a, b) => a.track_number - b.track_number, ), tags: [], }); @@ -398,7 +398,7 @@ app.get("/:did/app.rocksky.artist/:rkey/tracks", async (c) => { ...R.omit(["id"], item.tracks), id: item.tracks.id, xata_version: item.artist_tracks.xataVersion, - })) + })), ); }); @@ -430,8 +430,8 @@ app.get("/:did/app.rocksky.artist/:rkey/albums", async (c) => { ...R.omit(["id"], item.albums), id: item.albums.id, xata_version: item.artist_albums.xataVersion, - })) - ) + })), + ), ); }); @@ -461,11 +461,11 @@ app.get("/:did/app.rocksky.playlist/:rkey", async (c) => { .from(tables.playlistTracks) .leftJoin( tables.playlists, - eq(tables.playlistTracks.playlistId, tables.playlists.id) + eq(tables.playlistTracks.playlistId, tables.playlists.id), ) .leftJoin( tables.tracks, - eq(tables.playlistTracks.trackId, tables.tracks.id) + eq(tables.playlistTracks.trackId, tables.tracks.id), ) .where(eq(tables.playlists.uri, uri)) .groupBy( @@ -509,7 +509,7 @@ app.get("/:did/app.rocksky.playlist/:rkey", async (c) => { tables.playlists.picture, tables.playlists.spotifyLink, tables.playlists.tidalLink, - tables.playlists.appleMusicLink + tables.playlists.appleMusicLink, ) .orderBy(asc(tables.playlistTracks.createdAt)) .execute(); @@ -632,7 +632,7 @@ app.post("/:did/app.rocksky.artist/:rkey/shouts", async (c) => { parsed.data, `at://${did}/app.rocksky.artist/${rkey}`, user, - agent + agent, ); return c.json({}); }); @@ -681,7 +681,7 @@ app.post("/:did/app.rocksky.album/:rkey/shouts", async (c) => { parsed.data, `at://${did}/app.rocksky.album/${rkey}`, user, - agent + agent, ); return c.json({}); }); @@ -730,7 +730,7 @@ app.post("/:did/app.rocksky.song/:rkey/shouts", async (c) => { parsed.data, `at://${did}/app.rocksky.song/${rkey}`, user, - agent + agent, ); return c.json({}); @@ -780,7 +780,7 @@ app.post("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => { parsed.data, `at://${did}/app.rocksky.scrobble/${rkey}`, user, - agent + agent, ); return c.json({}); @@ -1063,7 +1063,7 @@ app.post("/:did/app.rocksky.shout/:rkey/replies", async (c) => { parsed.data, `at://${did}/app.rocksky.shout/${rkey}`, user, - agent + agent, ); return c.json({}); }); @@ -1132,7 +1132,7 @@ app.get("/:did/app.rocksky.artist/:rkey/shouts", async (c) => { .leftJoin(tables.artists, eq(tables.shouts.artistId, tables.artists.id)) .leftJoin( tables.shoutLikes, - eq(tables.shouts.id, tables.shoutLikes.shoutId) + eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.artists.uri, `at://${did}/app.rocksky.artist/${rkey}`)) .groupBy( @@ -1145,7 +1145,7 @@ app.get("/:did/app.rocksky.artist/:rkey/shouts", async (c) => { tables.users.did, tables.users.handle, tables.users.displayName, - tables.users.avatar + tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute(); @@ -1217,7 +1217,7 @@ app.get("/:did/app.rocksky.album/:rkey/shouts", async (c) => { .leftJoin(tables.albums, eq(tables.shouts.albumId, tables.albums.id)) .leftJoin( tables.shoutLikes, - eq(tables.shouts.id, tables.shoutLikes.shoutId) + eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.albums.uri, `at://${did}/app.rocksky.album/${rkey}`)) .groupBy( @@ -1230,7 +1230,7 @@ app.get("/:did/app.rocksky.album/:rkey/shouts", async (c) => { tables.users.did, tables.users.handle, tables.users.displayName, - tables.users.avatar + tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute(); @@ -1302,7 +1302,7 @@ app.get("/:did/app.rocksky.song/:rkey/shouts", async (c) => { .leftJoin(tables.tracks, eq(tables.shouts.trackId, tables.tracks.id)) .leftJoin( tables.shoutLikes, - eq(tables.shouts.id, tables.shoutLikes.shoutId) + eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.tracks.uri, `at://${did}/app.rocksky.song/${rkey}`)) .groupBy( @@ -1315,7 +1315,7 @@ app.get("/:did/app.rocksky.song/:rkey/shouts", async (c) => { tables.users.did, tables.users.handle, tables.users.displayName, - tables.users.avatar + tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute(); @@ -1386,11 +1386,11 @@ app.get("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => { .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id)) .leftJoin( tables.scrobbles, - eq(tables.shouts.scrobbleId, tables.scrobbles.id) + eq(tables.shouts.scrobbleId, tables.scrobbles.id), ) .leftJoin( tables.shoutLikes, - eq(tables.shouts.id, tables.shoutLikes.shoutId) + eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.scrobbles.uri, `at://${did}/app.rocksky.scrobble/${rkey}`)) .groupBy( @@ -1403,7 +1403,7 @@ app.get("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => { tables.users.did, tables.users.handle, tables.users.displayName, - tables.users.avatar + tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute(); @@ -1482,12 +1482,12 @@ app.get("/:did/shouts", async (c) => { .leftJoin(tables.shouts, eq(tables.profileShouts.shoutId, tables.shouts.id)) .leftJoin( aliasedTable(tables.users, "authors"), - eq(tables.shouts.authorId, aliasedTable(tables.users, "authors").id) + eq(tables.shouts.authorId, aliasedTable(tables.users, "authors").id), ) .leftJoin(tables.users, eq(tables.profileShouts.userId, tables.users.id)) .leftJoin( tables.shoutLikes, - eq(tables.shouts.id, tables.shoutLikes.shoutId) + eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .groupBy( tables.profileShouts.id, @@ -1506,7 +1506,7 @@ app.get("/:did/shouts", async (c) => { aliasedTable(tables.users, "authors").did, aliasedTable(tables.users, "authors").handle, aliasedTable(tables.users, "authors").displayName, - aliasedTable(tables.users, "authors").avatar + aliasedTable(tables.users, "authors").avatar, ) .orderBy(desc(tables.profileShouts.createdAt)) .execute(); @@ -1615,8 +1615,8 @@ app.post("/:did/app.rocksky.shout/:rkey/report", async (c) => { .where( and( eq(tables.shoutReports.userId, user.id), - eq(tables.shoutReports.shoutId, shout.id) - ) + eq(tables.shoutReports.shoutId, shout.id), + ), ) .limit(1) .execute() @@ -1689,8 +1689,8 @@ app.delete("/:did/app.rocksky.shout/:rkey/report", async (c) => { .where( and( eq(tables.shoutReports.userId, user.id), - eq(tables.shoutReports.shoutId, shout.id) - ) + eq(tables.shoutReports.shoutId, shout.id), + ), ) .limit(1) .execute() diff --git a/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts b/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts index 8f575b16..20253089 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts @@ -33,7 +33,7 @@ export default function (server: Server, ctx: Context) { Effect.catchAll((err) => { console.error(err); return Effect.succeed({}); - }) + }), ); server.app.rocksky.actor.getProfile({ auth: ctx.authVerifier, @@ -194,7 +194,7 @@ const retrieveProfile = ({ .from(tables.spotifyAccounts) .leftJoin( tables.users, - eq(tables.spotifyAccounts.userId, tables.users.id) + eq(tables.spotifyAccounts.userId, tables.users.id), ) .where(eq(tables.users.did, did)) .execute() @@ -204,7 +204,7 @@ const retrieveProfile = ({ .from(tables.spotifyTokens) .leftJoin( tables.users, - eq(tables.spotifyTokens.userId, tables.users.id) + eq(tables.spotifyTokens.userId, tables.users.id), ) .where(eq(tables.users.did, did)) .execute() @@ -214,7 +214,7 @@ const retrieveProfile = ({ .from(tables.googleDriveAccounts) .leftJoin( tables.users, - eq(tables.googleDriveAccounts.userId, tables.users.id) + eq(tables.googleDriveAccounts.userId, tables.users.id), ) .where(eq(tables.users.did, did)) .execute() @@ -224,7 +224,7 @@ const retrieveProfile = ({ .from(tables.dropboxAccounts) .leftJoin( tables.users, - eq(tables.dropboxAccounts.userId, tables.users.id) + eq(tables.dropboxAccounts.userId, tables.users.id), ) .where(eq(tables.users.did, did)) .execute() @@ -281,8 +281,8 @@ const refreshProfile = ([ xata_createdat: profile.user.createdAt.toISOString(), xata_updatedat: profile.user.updatedAt.toISOString(), xata_version: 1, - }) - ) + }), + ), ); } else { // Update existing user in background if handle or avatar or displayName changed @@ -315,8 +315,8 @@ const refreshProfile = ([ xata_createdat: profile.user.createdAt.toISOString(), xata_updatedat: new Date().toISOString(), xata_version: (profile.user.xataVersion || 1) + 1, - }) - ) + }), + ), ); } } diff --git a/musicbrainz/go.mod b/musicbrainz/go.mod new file mode 100644 index 00000000..8352b81a --- /dev/null +++ b/musicbrainz/go.mod @@ -0,0 +1,78 @@ +module github.com/tsirysndr/rocksky/musicbrainz + +go 1.25.1 + +require ( + github.com/labstack/echo/v4 v4.13.3 + github.com/teal-fm/piper v0.0.0-20251003185718-473cb159ecbe +) + +require ( + github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e // indirect + github.com/carlmjohnson/versioninfo v0.22.5 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/haileyok/atproto-oauth-golang v0.0.2 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/ipfs/bbloom v0.0.4 // indirect + github.com/ipfs/go-block-format v0.2.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect + github.com/ipfs/go-ipfs-util v0.0.3 // indirect + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect + github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/ipfs/go-metrics-interface v0.0.1 // indirect + github.com/jbenet/goprocess v0.1.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.4 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.27 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect + lukechampine.com/blake3 v1.2.1 // indirect +) diff --git a/musicbrainz/go.sum b/musicbrainz/go.sum new file mode 100644 index 00000000..1c6f4fef --- /dev/null +++ b/musicbrainz/go.sum @@ -0,0 +1,303 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e h1:yEW1njmALj7i1AjLhq6Lsxts48JUCTT+wpM9m7GNLVY= +github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= +github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= +github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= +github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= +github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= +github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= +github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= +github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= +github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= +github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= +github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= +github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= +github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= +github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= +github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= +github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= +github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= +github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= +github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= +github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= +github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= +github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= +github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= +github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= +github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= +github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= +github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= +github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= +github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= +github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teal-fm/piper v0.0.0-20251003185718-473cb159ecbe h1:tQAxIgPCFq+VNm4sCGBCIMmtPzwFzPjTUQnsYI7Dx/I= +github.com/teal-fm/piper v0.0.0-20251003185718-473cb159ecbe/go.mod h1:T4s0uuV1isetJS7RMSDrPUBUpgnfmLO1Sn1WeoYOMS8= +github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= +github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= +github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/musicbrainz/main.go b/musicbrainz/main.go new file mode 100644 index 00000000..db457a6b --- /dev/null +++ b/musicbrainz/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "time" + + "github.com/labstack/echo/v4" + "github.com/teal-fm/piper/db" + "github.com/teal-fm/piper/models" + "github.com/teal-fm/piper/service/musicbrainz" +) + +type Server struct { + mb *musicbrainz.MusicBrainzService +} + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./piper.db" + } + + database, err := db.New(dbPath) + if err != nil { + log.Fatalf("Error connecting to database: %v", err) + } + + if err := database.Initialize(); err != nil { + log.Fatalf("Error initializing database: %v", err) + } + + srv := &Server{ + mb: musicbrainz.NewMusicBrainzService(database), + } + + e := echo.New() + + e.POST("/search", srv.searchHandler) + e.POST("/hydrate", srv.hydrateHandler) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + go func() { + port := os.Getenv("PORT") + + if port == "" { + port = "8088" + } + + if err := e.Start(fmt.Sprintf(":%s", port)); err != nil && err != http.ErrServerClosed { + e.Logger.Fatal(err) + } + }() + + <-ctx.Done() // wait for Ctrl+C + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = e.Shutdown(shutdownCtx) +} + +func (s *Server) searchHandler(c echo.Context) error { + var req musicbrainz.SearchParams + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + resp, _ := s.mb.SearchMusicBrainz(c.Request().Context(), req) + + return c.JSON(http.StatusOK, resp) +} + +func (s *Server) hydrateHandler(c echo.Context) error { + var req models.Track + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + resp, _ := musicbrainz.HydrateTrack(s.mb, req) + + return c.JSON(http.StatusOK, resp) +} diff --git a/package.json b/package.json index d6ee5795..de38cafc 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "dev:storage": "cargo run -p rocksky-storage --release -- serve", "dev:webscrobbler": "cargo run -p rockskyd --release -- webscrobbler", "dev:tracklist": "cargo run -p rockskyd --release -- tracklist", - "db:pgpull": "cargo run -p rockskyd --release -- pull && rm -f rocksky-analytics.ddb* rocksky-feed.ddb* && curl -o rocksky-analytics.ddb https://backup.rocksky.app/rocksky-analytics.ddb && curl -o rocksky-feed.ddb https://backup.rocksky.app/rocksky-feed.ddb" + "db:pgpull": "cargo run -p rockskyd --release -- pull && rm -f rocksky-analytics.ddb* rocksky-feed.ddb* && curl -o rocksky-analytics.ddb https://backup.rocksky.app/rocksky-analytics.ddb && curl -o rocksky-feed.ddb https://backup.rocksky.app/rocksky-feed.ddb", + "mb": "cd musicbrainz && go run main.go" }, "workspaces": [ "apps/api",