diff --git a/.dockerignore b/.dockerignore index d0497b1a..5e4c9437 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,4 +31,5 @@ tmp-* .env *.env .storybook -.stories \ No newline at end of file +.stories +**/lexicons/**/*.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index ee6a7851..874fa825 100644 --- a/.gitignore +++ b/.gitignore @@ -153,4 +153,6 @@ tmp-* storybook-static *.db -*.db* \ No newline at end of file +*.db* + +**/lexicons/**/*.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7528fdf8..54bcf29f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "license": "MIT", "dependencies": { "@astronautlabs/mdns": "^1.0.7", + "@atcute/bluesky": "^4.0.5", "@atcute/identity-resolver-node": "^1.0.3", + "@atcute/lexicons": "^2.0.0", "@atcute/repo": "^1.0.0", "@atproto/api": "^0.18.0", "@atproto/oauth-client-node": "^0.3.10", @@ -103,6 +105,7 @@ "youtubei.js": "^15.0.1" }, "devDependencies": { + "@atcute/lex-cli": "^3.1.0", "@chakra-ui/react": "^3.34.0", "@chromatic-com/storybook": "^5.1.2", "@dbus-types/notifications": "^0.0.5", @@ -277,6 +280,31 @@ "integrity": "sha512-9bVdQ15Tbzpor1z/bteaemC9NvRDZgMD4Cfbu1/r/6Zv6fCBXvIJJoJ6Dh/BMvTlAiT2Xewm36i0VWRnZ4yw6A==", "license": "MIT" }, + "node_modules/@atcute/atproto": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.2.tgz", + "integrity": "sha512-hLnvjiIOStpdUm0cEN+R5YydvbV0d6ap17Iv+t7i/nhSCN3TGMya7M0ftCWtCo+xoQ1EU6HK74R8jqXWlyrM0w==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/bluesky": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@atcute/bluesky/-/bluesky-4.0.5.tgz", + "integrity": "sha512-kw+kc41cNXBFUyFU9oPDKhltB9JTEKNDTiNoSbtOvonwg2Wej9Zg8vt0UhLvb6958rSwLvG0xX+SuQbZ0O8k2Q==", + "license": "0BSD", + "dependencies": { + "@atcute/atproto": "^4.0.2", + "@atcute/lexicons": "^2.0.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, "node_modules/@atcute/car": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@atcute/car/-/car-6.0.0.tgz", @@ -372,7 +400,7 @@ "@atcute/lexicons": "^1.0.0" } }, - "node_modules/@atcute/lexicons": { + "node_modules/@atcute/identity-resolver-node/node_modules/@atcute/lexicons": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.3.1.tgz", "integrity": "sha512-2JVxDmHt+QwsUoPyVYWIN7ZLRLfLx4GeJxKFjA9ofStuby9hCMv7Q4GAPIXuJD8wPv8vrnhr1yRNQhiJX+bthw==", @@ -384,6 +412,158 @@ "esm-env": "^1.2.2" } }, + "node_modules/@atcute/identity-resolver/node_modules/@atcute/lexicons": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.3.1.tgz", + "integrity": "sha512-2JVxDmHt+QwsUoPyVYWIN7ZLRLfLx4GeJxKFjA9ofStuby9hCMv7Q4GAPIXuJD8wPv8vrnhr1yRNQhiJX+bthw==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@atcute/uint8array": "^1.1.1", + "@atcute/util-text": "^1.3.1", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/identity/node_modules/@atcute/lexicons": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.3.1.tgz", + "integrity": "sha512-2JVxDmHt+QwsUoPyVYWIN7ZLRLfLx4GeJxKFjA9ofStuby9hCMv7Q4GAPIXuJD8wPv8vrnhr1yRNQhiJX+bthw==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@atcute/uint8array": "^1.1.1", + "@atcute/util-text": "^1.3.1", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/lex-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-3.1.0.tgz", + "integrity": "sha512-Mba7y12ENIbdj+cr+gt5mJTalpuz/UbS4ykm/wT7t9ipoJtidXMSeuQFfeoyBvvidW77Vg4G6zxZkoDxtlr6Mg==", + "dev": true, + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicon-doc": "^3.0.0", + "@atcute/lexicon-resolver": "^1.0.0", + "@atcute/lexicons": "^2.0.0", + "@optique/core": "^1.0.2", + "@optique/run": "^1.0.2", + "picocolors": "^1.1.1", + "prettier": "^3.8.3", + "valibot": "^1.4.0" + }, + "bin": { + "lex-cli": "cli.mjs" + } + }, + "node_modules/@atcute/lex-cli/node_modules/@atcute/identity": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.0.tgz", + "integrity": "sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.0", + "valibot": "^1.4.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lex-cli/node_modules/@atcute/identity-resolver": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-2.0.0.tgz", + "integrity": "sha512-IKg1BDQAF2bIdN10DL6KAXmTjK+3enTU2IRbuani9TsFahBwGZ7O5FiVmTiL6QlGfauGNW5S0xNCOxWXWMoR2Q==", + "dev": true, + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.0", + "@atcute/util-fetch": "^2.0.0", + "valibot": "^1.4.0" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lex-cli/node_modules/@atcute/lexicon-resolver": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@atcute/lexicon-resolver/-/lexicon-resolver-1.0.0.tgz", + "integrity": "sha512-4v3oSijgd3w9JS2BMsV1dfD+GD1ClQeuEJatM9b7rNffi1kMTSn/3nOQbJ1aNeXfqZbCkn3ATDRdvgki1xIAZw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "@atcute/crypto": "^2.4.1", + "@atcute/lexicon-doc": "^3.0.0", + "@atcute/lexicons": "^2.0.0", + "@atcute/repo": "^1.0.0", + "@atcute/util-fetch": "^2.0.0", + "valibot": "^1.4.0" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicon-doc": "^3.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lex-cli/node_modules/@atcute/util-fetch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-2.0.0.tgz", + "integrity": "sha512-v+4aFQ/tuBqTV+URDJaFgm3mASWdglKXiPaGutJ1bs7QtQKmPZeesPY5MzW/a+MtI8GWCEJk8X9wOfalPOFSlg==", + "dev": true, + "license": "0BSD", + "dependencies": { + "valibot": "^1.4.0" + } + }, + "node_modules/@atcute/lexicon-doc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-3.0.0.tgz", + "integrity": "sha512-hxBTEvO78X4j/HsGgcH8GKgMA9hP9FM6jtwQL8Yd6MIHtGXdB8QygvBmOAJEqm1oE9ckU1SSU3cwa2t4tbZZNw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/lexicons": "^2.0.0", + "@atcute/uint8array": "^1.1.1", + "@atcute/util-text": "^1.3.1", + "valibot": "^1.4.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lexicon-doc/node_modules/@atcute/identity": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.0.tgz", + "integrity": "sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.0", + "valibot": "^1.4.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lexicons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.0.tgz", + "integrity": "sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.1", + "@atcute/util-text": "^1.3.1", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, "node_modules/@atcute/mst": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@atcute/mst/-/mst-1.0.1.tgz", @@ -428,18 +608,6 @@ "@atcute/lexicons": "^2.0.0" } }, - "node_modules/@atcute/repo/node_modules/@atcute/lexicons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.0.tgz", - "integrity": "sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==", - "license": "0BSD", - "dependencies": { - "@atcute/uint8array": "^1.1.1", - "@atcute/util-text": "^1.3.1", - "@standard-schema/spec": "^1.1.0", - "esm-env": "^1.2.2" - } - }, "node_modules/@atcute/uint8array": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.2.tgz", @@ -2712,6 +2880,39 @@ "node": ">=8.0.0" } }, + "node_modules/@optique/core": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@optique/core/-/core-1.0.2.tgz", + "integrity": "sha512-znsqMmjAdeOgSJzdJlpZpgAscojwQmeQYXzYnuEKllz5VCj6WyEkdzU4QuvJQtWQY3ve2taXwudEBRur0VHBOQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/dahlia" + ], + "license": "MIT", + "engines": { + "bun": ">=1.2.0", + "deno": ">=2.3.0", + "node": ">=20.0.0" + } + }, + "node_modules/@optique/run": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@optique/run/-/run-1.0.2.tgz", + "integrity": "sha512-0Wc+zC8SLGV8zXQX+pk+o0c6wE/ddx/36CHZ0toTh5lApsjruUuGhqbxvljerAAG5un1xQbOLxzksBVC6UPgSg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/dahlia" + ], + "license": "MIT", + "dependencies": { + "@optique/core": "1.0.2" + }, + "engines": { + "bun": ">=1.2.0", + "deno": ">=2.3.0", + "node": ">=20.0.0" + } + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", @@ -14751,6 +14952,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -17631,7 +17848,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -18024,6 +18241,21 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valibot": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz", + "integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index b0a531f2..70c6182c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"", "cliff": "git-cliff --unreleased", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "atproto:lex:tealfm": "npm exec -- lex-cli pull -c src/backend/common/vendor/teal/lex.config.ts && npm exec -- lex-cli generate -c src/backend/common/vendor/teal/lex.config.ts" }, "exports": { ".": { @@ -49,7 +50,9 @@ "homepage": ".", "dependencies": { "@astronautlabs/mdns": "^1.0.7", + "@atcute/bluesky": "^4.0.5", "@atcute/identity-resolver-node": "^1.0.3", + "@atcute/lexicons": "^2.0.0", "@atcute/repo": "^1.0.0", "@atproto/api": "^0.18.0", "@atproto/oauth-client-node": "^0.3.10", @@ -141,6 +144,7 @@ "youtubei.js": "^15.0.1" }, "devDependencies": { + "@atcute/lex-cli": "^3.1.0", "@chakra-ui/react": "^3.34.0", "@chromatic-com/storybook": "^5.1.2", "@dbus-types/notifications": "^0.0.5", diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index 4afab8b2..4bc84bf2 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -51,50 +51,6 @@ export interface TealClientOptions extends TealOptions,CommonClientOptions { export interface TealClientAIOConfig extends TealClientConfig { type: 'tealfm' } -export interface TealArtistCredit { - artistName?: string, - /** The MusicBrainz artist ID URI, formatted as mbid: */ - artistMbId?: string -} -/** - * https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/play.json - * https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/defs.json - * */ -export interface ScrobbleRecord { - $type: "fm.teal.alpha.feed.play", - trackName: string, - playedTime: string, - duration?: number - artists?: TealArtistCredit[] - /** Album name */ - releaseName?: string - /** A metadata string specifying the user agent where the format is `/ (; ; )` */ - submissionClientAgent: string, - musicServiceBaseDomain?: string - /** The MusicBrainz ID URI of the track, formatted as mbid: */ - trackMbId?: string - /** The MusicBrainz recording ID URI of the track, formatted as mbid: */ - recordingMbId?: string - /** The MusicBrainz release ID URI, formatted as mbid: */ - releaseMbId?: string - isrc?: string, - /** The URL associated with this track */ - originUrl?: string - /** Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities. */ - trackDiscriminant?: string; - /** Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities. */ - releaseDiscriminant?: string; - [x: string]: unknown -} - -export interface StatusRecord { - $type: "fm.teal.alpha.actor.status", - /** item is just ScrobbleRecord, but without $type */ - item: Omit, - time: string, - expiry: string, - [x: string]: unknown -} export interface ListRecord { uri: string; diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index 02a36812..da5bad1d 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -1,10 +1,10 @@ import { getRoot } from "../../../ioc.js"; import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js"; -import { ListRecord, ScrobbleRecord, StatusRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; +import { ListRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; import AbstractApiClient from "../AbstractApiClient.js"; import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api"; import { MSCache } from "../../Cache.js"; -import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID, NowPlayingUpdateThreshold, SourcePlayerObj, Second } from "../../../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID, SourcePlayerObj } from "../../../../core/Atomic.js"; import { musicServiceToCononical } from '../listenbrainz/lzUtils.js'; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; @@ -17,6 +17,7 @@ import { UpstreamError } from "../../errors/UpstreamError.js"; import { decodeTid, generateTID } from '@ewanc26/tid'; import { Duration } from "dayjs/plugin/duration.js"; import { streamBodyProgress } from "../../../utils/NetworkUtils.js"; +import { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "../teal/lexicons/index.js"; export abstract class AbstractBlueSkyApiClient extends AbstractApiClient implements PagelessTimeRangeListens { @@ -36,7 +37,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme abstract restoreSession(): Promise; - async createScrobbleRecord(record: ScrobbleRecord): Promise { + async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise { const input: ComAtprotoRepoCreateRecord.InputSchema = { repo: this.agent.sessionManager.did, collection: "fm.teal.alpha.feed.play", @@ -50,7 +51,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme } } - async updateStatusRecord(record: StatusRecord): Promise { + async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise { const input: ComAtprotoRepoPutRecord.InputSchema = { repo: this.agent.sessionManager.did, collection: "fm.teal.alpha.actor.status", @@ -118,7 +119,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme fromTS = usecToUnix(timestampUs); } - const plays = (resp.data.records as unknown as ListRecord[]).map(x => listRecordToPlay(x)); + const plays = (resp.data.records as unknown as ListRecord[]).map(x => listRecordToPlay(x)); return {data: plays, meta: {to, from: fromTS, limit}}; } @@ -128,9 +129,9 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme } } -export const playToRecord = (play: PlayObject): ScrobbleRecord => { +export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => { - const record: ScrobbleRecord = { + const record: FmTealAlphaFeedPlay.Main = { $type: "fm.teal.alpha.feed.play", trackName: play.data.track, artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })), @@ -160,7 +161,7 @@ export const mbidToUri = (mbid: MBID): MBIDURI => { return `mbid:${mbid}`; } -export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): StatusRecord => { +export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => { const { $type, ...item } = notPlaying ? { trackName: "", artists: [] } : playToRecord(play); @@ -177,7 +178,10 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi $type: "fm.teal.alpha.actor.status", time: dayjs().toISOString(), expiry: expiry.toISOString(), - item + item: { + artists: [], + ...item + } }; } @@ -201,7 +205,7 @@ export const nowPlayingExpirationDuration = (data: Pick): PlayObject => { +export const listRecordToPlay = (listRecord: ListRecord): PlayObject => { const opts: RecordOptions = {}; const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri); if (uriRes !== undefined) { @@ -212,7 +216,7 @@ export const listRecordToPlay = (listRecord: ListRecord): PlayOb return recordToPlay(listRecord.value, opts); } -export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {}): PlayObject => { +export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOptions = {}): PlayObject => { const play: PlayObjectLifecycleless = { data: { diff --git a/src/backend/common/vendor/teal/lex.config.ts b/src/backend/common/vendor/teal/lex.config.ts new file mode 100644 index 00000000..fe14d1ec --- /dev/null +++ b/src/backend/common/vendor/teal/lex.config.ts @@ -0,0 +1,26 @@ +import { defineLexiconConfig } from '@atcute/lex-cli'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export default defineLexiconConfig({ + generate: { + files: ['lexicons/**/*.json'], + outdir: 'lexicons/', + imports: ['@atcute/bluesky'], + }, + pull: { + outdir: path.resolve(__dirname, 'lexicons/'), + sources: [ + { + type: 'git', + remote: 'https://github.com/teal-fm/teal.git', + ref: 'main', + pattern: ['lexicons/fm.teal.alpha/feed/*.json','lexicons/fm.teal.alpha/actor/*.json'], + }, + ], + }, +}); \ No newline at end of file diff --git a/src/backend/common/vendor/teal/lexicons/README.md b/src/backend/common/vendor/teal/lexicons/README.md new file mode 100644 index 00000000..82b1c2fc --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/README.md @@ -0,0 +1,6 @@ +# lexicon sources + +this directory contains lexicon documents pulled from the following sources: + +- https://github.com/teal-fm/teal.git (ref: main) + - commit: f661868af2a56199f1b3cae1ad20cd5831ec4212 diff --git a/src/backend/common/vendor/teal/lexicons/index.ts b/src/backend/common/vendor/teal/lexicons/index.ts new file mode 100644 index 00000000..370496f9 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/index.ts @@ -0,0 +1,11 @@ +export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.js"; +export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.js"; +export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.js"; +export * as FmTealAlphaActorProfile from "./types/fm/teal/alpha/actor/profile.js"; +export * as FmTealAlphaActorProfileStatus from "./types/fm/teal/alpha/actor/profileStatus.js"; +export * as FmTealAlphaActorSearchActors from "./types/fm/teal/alpha/actor/searchActors.js"; +export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.js"; +export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.js"; +export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.js"; +export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.js"; +export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.js"; diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/defs.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/defs.ts new file mode 100644 index 00000000..a9112f99 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/defs.ts @@ -0,0 +1,96 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet"; +import * as FmTealAlphaActorProfile from "./profile.js"; +import * as FmTealAlphaFeedDefs from "../feed/defs.js"; + +const _miniProfileViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.alpha.actor.defs#miniProfileView"), + ), + /** + * IPLD of the avatar + */ + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The decentralized identifier of the actor + */ + did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), +}); +const _profileViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.alpha.actor.defs#profileView"), + ), + /** + * IPLD of the avatar + */ + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * IPLD of the banner image + */ + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * Free-form profile description text. + */ + description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon. + */ + get descriptionFacets() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema), + ); + }, + /** + * The decentralized identifier of the actor + */ + did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The user's most recent item featured on their profile. + */ + get featuredItem() { + return /*#__PURE__*/ v.optional(FmTealAlphaActorProfile.featuredItemSchema); + }, + get status() { + return /*#__PURE__*/ v.optional(statusViewSchema); + }, +}); +const _statusViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.alpha.actor.defs#statusView"), + ), + /** + * The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time. + */ + expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + get item() { + return /*#__PURE__*/ v.optional(FmTealAlphaFeedDefs.playViewSchema); + }, + /** + * The unix timestamp of when the item was recorded + */ + time: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), +}); + +type miniProfileView$schematype = typeof _miniProfileViewSchema; +type profileView$schematype = typeof _profileViewSchema; +type statusView$schematype = typeof _statusViewSchema; + +export interface miniProfileViewSchema extends miniProfileView$schematype {} +export interface profileViewSchema extends profileView$schematype {} +export interface statusViewSchema extends statusView$schematype {} + +export const miniProfileViewSchema = + _miniProfileViewSchema as miniProfileViewSchema; +export const profileViewSchema = _profileViewSchema as profileViewSchema; +export const statusViewSchema = _statusViewSchema as statusViewSchema; + +export interface MiniProfileView extends v.InferInput< + typeof miniProfileViewSchema +> {} +export interface ProfileView extends v.InferInput {} +export interface StatusView extends v.InferInput {} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/getProfile.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/getProfile.ts new file mode 100644 index 00000000..7b0db33b --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/getProfile.ts @@ -0,0 +1,36 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaActorDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.actor.getProfile", { + params: /*#__PURE__*/ v.object({ + /** + * The author's DID + */ + actor: /*#__PURE__*/ v.actorIdentifierString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get actor() { + return FmTealAlphaActorDefs.profileViewSchema; + }, + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "fm.teal.alpha.actor.getProfile": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/getProfiles.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/getProfiles.ts new file mode 100644 index 00000000..4d0c5669 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/getProfiles.ts @@ -0,0 +1,42 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaActorDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.actor.getProfiles", { + params: /*#__PURE__*/ v.object({ + /** + * Array of actor DIDs + * @minLength 1 + */ + actors: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.actorIdentifierString()), + [/*#__PURE__*/ v.arrayLength(1)], + ), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get actors() { + return /*#__PURE__*/ v.array( + FmTealAlphaActorDefs.miniProfileViewSchema, + ); + }, + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "fm.teal.alpha.actor.getProfiles": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/profile.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/profile.ts new file mode 100644 index 00000000..e2e24591 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/profile.ts @@ -0,0 +1,100 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet"; + +const _featuredItemSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.alpha.actor.profile#featuredItem"), + ), + /** + * The MusicBrainz ID URI of the item, formatted as mbid: + */ + mbid: /*#__PURE__*/ v.genericUriString(), + /** + * The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc. + */ + type: /*#__PURE__*/ v.string(), +}); +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.literal("self"), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.alpha.actor.profile"), + /** + * Small image to be displayed next to posts from account. AKA, 'profile picture' + * @accept image/jpeg, image/png + * @maxSize 1000000 + */ + avatar: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]), + ]), + ), + /** + * Larger horizontal image to display behind profile view. + * @accept image/jpeg, image/png + * @maxSize 1000000 + */ + banner: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]), + ]), + ), + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * Free-form profile description text. + * @maxLength 2560 + * @maxGraphemes 256 + */ + description: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 2560), + /*#__PURE__*/ v.stringGraphemes(0, 256), + ]), + ), + /** + * Annotations of text in the profile description (mentions, URLs, hashtags, etc). + */ + get descriptionFacets() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema), + ); + }, + /** + * @maxLength 640 + * @maxGraphemes 64 + */ + displayName: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 640), + /*#__PURE__*/ v.stringGraphemes(0, 64), + ]), + ), + /** + * The user's most recent item featured on their profile. + */ + get featuredItem() { + return /*#__PURE__*/ v.optional(featuredItemSchema); + }, + }), +); + +type featuredItem$schematype = typeof _featuredItemSchema; +type main$schematype = typeof _mainSchema; + +export interface featuredItemSchema extends featuredItem$schematype {} +export interface mainSchema extends main$schematype {} + +export const featuredItemSchema = _featuredItemSchema as featuredItemSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface FeaturedItem extends v.InferInput {} +export interface Main extends v.InferInput {} + +declare module "@atcute/lexicons/ambient" { + interface Records { + "fm.teal.alpha.actor.profile": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/profileStatus.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/profileStatus.ts new file mode 100644 index 00000000..f0051233 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/profileStatus.ts @@ -0,0 +1,42 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.literal("self"), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.alpha.actor.profileStatus"), + /** + * The onboarding completion status + */ + completedOnboarding: /*#__PURE__*/ v.string< + | "complete" + | "none" + | "playOnboarding" + | "profileOnboarding" + | (string & {}) + >(), + /** + * The timestamp when this status was created + */ + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * The timestamp when this status was last updated + */ + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + }), +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface Main extends v.InferInput {} + +declare module "@atcute/lexicons/ambient" { + interface Records { + "fm.teal.alpha.actor.profileStatus": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/searchActors.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/searchActors.ts new file mode 100644 index 00000000..749ab745 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/searchActors.ts @@ -0,0 +1,61 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaActorDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.actor.searchActors", { + params: /*#__PURE__*/ v.object({ + /** + * Cursor for pagination + */ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The maximum number of actors to return + * @minimum 1 + * @maximum 25 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 25), + ]), + ), + /** + * The search query + * @maxLength 640 + * @maxGraphemes 128 + */ + q: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 640), + /*#__PURE__*/ v.stringGraphemes(0, 128), + ]), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get actors() { + return /*#__PURE__*/ v.array( + FmTealAlphaActorDefs.miniProfileViewSchema, + ); + }, + /** + * Cursor for pagination + */ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "fm.teal.alpha.actor.searchActors": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/status.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/status.ts new file mode 100644 index 00000000..28cc9955 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/actor/status.ts @@ -0,0 +1,36 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaFeedDefs from "../feed/defs.js"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.literal("self"), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.alpha.actor.status"), + /** + * The RFC 3339 formatted time of the expiry time of the item. If unavailable, default to 10 minutes past the start time. + */ + expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + get item() { + return FmTealAlphaFeedDefs.playViewSchema; + }, + /** + * The RFC 3339 formatted time of when the item was recorded + */ + time: /*#__PURE__*/ v.datetimeString(), + }), +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface Main extends v.InferInput {} + +declare module "@atcute/lexicons/ambient" { + interface Records { + "fm.teal.alpha.actor.status": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/defs.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/defs.ts new file mode 100644 index 00000000..c887e7fb --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/defs.ts @@ -0,0 +1,109 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; + +const _artistSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.alpha.feed.defs#artist"), + ), + /** + * The MusicBrainz artist ID URI, formatted as mbid: + */ + artistMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The name of the artist + * @minLength 1 + * @maxLength 256 + * @maxGraphemes 2560 + */ + artistName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), +}); +const _playViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.alpha.feed.defs#playView"), + ), + /** + * Array of artists in order of original appearance. + */ + get artists() { + return /*#__PURE__*/ v.array(artistSchema); + }, + /** + * The length of the track in seconds + */ + duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + /** + * The ISRC code associated with the recording + */ + isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided. + */ + musicServiceBaseDomain: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The URL associated with this track + */ + originUrl: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The unix timestamp of when the track was played + */ + playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * The MusicBrainz recording ID URI of the track, formatted as mbid: + */ + recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The MusicBrainz release ID URI, formatted as mbid: + */ + releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The name of the release/album + * @maxLength 256 + * @maxGraphemes 2560 + */ + releaseName: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), + ), + /** + * 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. + * @maxLength 256 + * @maxGraphemes 2560 + */ + submissionClientAgent: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), + ), + /** + * The MusicBrainz ID URI of the track, formatted as mbid: + */ + trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The name of the track + * @minLength 1 + * @maxLength 256 + * @maxGraphemes 2560 + */ + trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), +}); + +type artist$schematype = typeof _artistSchema; +type playView$schematype = typeof _playViewSchema; + +export interface artistSchema extends artist$schematype {} +export interface playViewSchema extends playView$schematype {} + +export const artistSchema = _artistSchema as artistSchema; +export const playViewSchema = _playViewSchema as playViewSchema; + +export interface Artist extends v.InferInput {} +export interface PlayView extends v.InferInput {} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/getActorFeed.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/getActorFeed.ts new file mode 100644 index 00000000..d448164d --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/getActorFeed.ts @@ -0,0 +1,44 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaFeedDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.feed.getActorFeed", { + params: /*#__PURE__*/ v.object({ + /** + * The author's DID for the play + */ + authorDID: /*#__PURE__*/ v.actorIdentifierString(), + /** + * The cursor to start the query from + */ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The upper limit of tracks to get per request. Default is 20, max is 50. + */ + limit: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get plays() { + return /*#__PURE__*/ v.array(FmTealAlphaFeedDefs.playViewSchema); + }, + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "fm.teal.alpha.feed.getActorFeed": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/getPlay.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/getPlay.ts new file mode 100644 index 00000000..e5ea10c5 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/getPlay.ts @@ -0,0 +1,40 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaFeedDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.feed.getPlay", { + params: /*#__PURE__*/ v.object({ + /** + * The author's DID for the play + */ + authorDID: /*#__PURE__*/ v.actorIdentifierString(), + /** + * The record key of the play + */ + rkey: /*#__PURE__*/ v.string(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get play() { + return FmTealAlphaFeedDefs.playViewSchema; + }, + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "fm.teal.alpha.feed.getPlay": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/play.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/play.ts new file mode 100644 index 00000000..8c96c15e --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/alpha/feed/play.ts @@ -0,0 +1,138 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealAlphaFeedDefs from "./defs.js"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.alpha.feed.play"), + /** + * DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs. + * @deprecated + */ + artistMbIds: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), + ), + /** + * DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance. + * @deprecated + */ + artistNames: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), + ), + ), + /** + * Array of artists in order of original appearance. + */ + get artists() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(FmTealAlphaFeedDefs.artistSchema), + ); + }, + /** + * The length of the track in seconds + */ + duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + /** + * The ISRC code associated with the recording + */ + isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.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: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The URL associated with this track + */ + originUrl: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The unix timestamp of when the track was played + */ + playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * The MusicBrainz recording ID URI of the track, formatted as mbid: + */ + recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities. + * @maxLength 128 + * @maxGraphemes 1280 + */ + releaseDiscriminant: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 128), + /*#__PURE__*/ v.stringGraphemes(0, 1280), + ]), + ), + /** + * The MusicBrainz release ID URI, formatted as mbid: + */ + releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The name of the release/album + * @maxLength 256 + * @maxGraphemes 2560 + */ + releaseName: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), + ), + /** + * 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. + * @maxLength 256 + * @maxGraphemes 2560 + */ + submissionClientAgent: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), + ), + /** + * Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities. + * @maxLength 128 + * @maxGraphemes 1280 + */ + trackDiscriminant: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 128), + /*#__PURE__*/ v.stringGraphemes(0, 1280), + ]), + ), + /** + * The MusicBrainz ID URI of the track, formatted as mbid: + */ + trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The name of the track + * @minLength 1 + * @maxLength 256 + * @maxGraphemes 2560 + */ + trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 256), + /*#__PURE__*/ v.stringGraphemes(0, 2560), + ]), + }), +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface Main extends v.InferInput {} + +declare module "@atcute/lexicons/ambient" { + interface Records { + "fm.teal.alpha.feed.play": mainSchema; + } +} diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index 91a80b58..6d9ce995 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -12,8 +12,8 @@ import { FormatPlayObjectOptions, CALCULATED_PLAYER_STATUSES, ReportedPlayerStat import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; import { Notifiers } from "../notifier/Notifiers.js"; -import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js"; -import { ScrobbleRecord, TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; +import { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js"; +import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; import { AbstractBlueSkyApiClient, nowPlayingExpirationDuration, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; @@ -23,6 +23,7 @@ import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient import { fromStream } from '@atcute/repo'; import { playToRepositoryCreatePlayHistoricalOpts, RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.js"; import { isAbortError } from "abort-controller-x"; +import { FmTealAlphaFeedPlay } from "../common/vendor/teal/lexicons/index.js"; export default class TealScrobbler extends AbstractHistoricalScrobbleClient { @@ -234,7 +235,7 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { if(entry.collection === 'fm.teal.alpha.feed.play') { let play: PlayObject; try { - play = recordToPlay(entry.record as ScrobbleRecord, { + play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main, { web: did !== undefined ? `at://did:plc:${did}/fm.teal.alpha.feed.play/${entry.rkey}` : undefined, playId: entry.rkey, user: did diff --git a/src/core/PlayTestUtils.ts b/src/core/PlayTestUtils.ts index c23f6310..3fa47321 100644 --- a/src/core/PlayTestUtils.ts +++ b/src/core/PlayTestUtils.ts @@ -11,13 +11,14 @@ import { sortByNewestPlayDate } from './PlayUtils.js'; import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js'; import { arrayListAnd, artistNamesToCredits } from './StringUtils.js'; import { findDelimiters } from "./StringUtils.js"; -import { ListRecord, ScrobbleRecord } from '../backend/common/infrastructure/config/client/tealfm.js'; +import { ListRecord } from '../backend/common/infrastructure/config/client/tealfm.js'; import { nanoid } from 'nanoid'; import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js'; import { MarkOptional } from 'ts-essentials'; import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js'; import clone from 'clone'; import { removeUndefinedKeys } from '../backend/utils.js'; +import { FmTealAlphaFeedPlay } from '../backend/common/vendor/teal/lexicons/index.js'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -476,7 +477,7 @@ export const generateMbid = (): MBID => { export const generateTealPlayRecord = (opts: { withMbids?: boolean, withIsrc?: boolean -} = {}): [ListRecord, { did: string, tid: string }] => { +} = {}): [ListRecord, { did: string, tid: string }] => { const { withMbids = true, withIsrc = true, @@ -488,12 +489,12 @@ export const generateTealPlayRecord = (opts: { const did = nanoid(12); const tid = nanoid(10); - const rec: ListRecord = { + const rec: ListRecord = { uri: `at://did:plc:${did}/fm.teal.alpha.feed.play/${tid}`, cid: nanoid(12), value: { '$type': 'fm.teal.alpha.feed.play', - artists: artists.map(x => withMbids ? { artistName: x, artistMbId: generateMbid() } : { artistName: x }), + artists: artists.map(x => withMbids ? { artistName: x, artistMbId: `mbid:${generateMbid()}` } : { artistName: x }), releaseName: faker.music.album(), trackName: faker.music.songName(), playedTime: now.toISOString(), @@ -503,8 +504,8 @@ export const generateTealPlayRecord = (opts: { } if (withMbids) { - rec.value.releaseMbId = generateMbid(); - rec.value.recordingMbId = generateMbid(); + rec.value.releaseMbId = `mbid:${generateMbid()}`; + rec.value.recordingMbId = `mbid:${generateMbid()}`; } if (withIsrc) {