diff --git a/.gitignore b/.gitignore index f7cd5758..eb32db20 100644 --- a/.gitignore +++ b/.gitignore @@ -156,7 +156,9 @@ storybook-static *.db* **/lexicons/**/*.json +!src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json +!src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json bun.lock .DS_Store -**/.DS_Store \ No newline at end of file +**/.DS_Store diff --git a/src/backend/common/vendor/teal/TealApiClient.ts b/src/backend/common/vendor/teal/TealApiClient.ts index b7152bd3..5326273a 100644 --- a/src/backend/common/vendor/teal/TealApiClient.ts +++ b/src/backend/common/vendor/teal/TealApiClient.ts @@ -1,5 +1,5 @@ import dayjs, { type Dayjs, type ManipulateType } from "dayjs"; -import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult, UnixTimestamp} from "../../../../core/Atomic.ts"; +import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult} from "../../../../core/Atomic.ts"; import { getRoot } from "../../../ioc.ts"; import { removeUndefinedKeys } from '../../../../core/DataUtils.ts'; import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts"; @@ -8,7 +8,7 @@ import type {AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRa import type {ListRecord, RecordOptions, TealClientData} from "../../infrastructure/config/client/tealfm.ts"; import AbstractApiClient from "../AbstractApiClient.ts"; import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.ts"; -import type { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.ts"; +import type { FmTealAlphaActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts"; import { ScrobbleSubmitError } from "../../errors/MSErrors.ts"; import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.ts"; import { musicServiceToCononical } from "../listenbrainz/lzUtils.ts"; @@ -20,6 +20,17 @@ import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcut import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts"; import { isrcNoHyphens } from "../../../../core/PlayUtils.ts"; +type TealPlayRecord = FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main; + +const TEAL_PLAY_COLLECTIONS = ['fm.teal.feed.play', 'fm.teal.alpha.feed.play'] as const; + +const asMusicServiceUri = (musicService?: string): string | undefined => { + if (musicService === undefined) { + return undefined; + } + return /^[a-z][a-z\d+.-]*:\/\//i.test(musicService) ? musicService : `https://${musicService}`; +}; + export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens { declare config: TealClientData; @@ -43,10 +54,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang } - async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise { + async createScrobbleRecord(record: FmTealFeedPlay.Main): Promise { const input: ComAtprotoRepoCreateRecord.$input = { repo: this.client.userData.did, - collection: 'fm.teal.alpha.feed.play', + collection: 'fm.teal.feed.play', record }; try { @@ -92,37 +103,44 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang cursor = generateTID(dayjs.unix(to).toISOString()); } - const resp = await this.client.get((client) => client.get('com.atproto.repo.listRecords', { + const responses = await Promise.all(TEAL_PLAY_COLLECTIONS.map(collection => this.client.get((client) => client.get('com.atproto.repo.listRecords', { params: { repo: this.client.userData.did, - collection: "fm.teal.alpha.feed.play", + collection, limit, cursor } - })); + })))); - if(!resp.ok) { - throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data}); - } - - let fromTS: UnixTimestamp; - if(resp.data.cursor !== undefined) { - const { timestampUs } = decodeTid(resp.data.cursor); - fromTS = usecToUnix(timestampUs); + for (const resp of responses) { + if (!resp.ok) { + throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data}); + } } - const plays = (resp.data.records as unknown as ListRecord[]).map(x => listRecordToPlay(x)); - - return {data: plays, meta: {to, from: fromTS, limit}}; + const fromTimestamps = responses + .flatMap(resp => resp.data.cursor === undefined ? [] : [usecToUnix(decodeTid(resp.data.cursor).timestampUs)]); + const from = fromTimestamps.length > 0 + ? fromTimestamps.reduce((earliest, current) => current < earliest ? current : earliest) + : undefined; + const plays = responses + .flatMap(resp => resp.data.records as unknown as ListRecord[]) + .map(listRecordToPlay) + .sort((a, b) => (b.data.playDate?.valueOf() ?? 0) - (a.data.playDate?.valueOf() ?? 0)); + + return {data: plays, meta: {to, from, limit, more: false, order: 'desc'}}; } } -export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOptions = {}): PlayObject => { +export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {}): PlayObject => { + const artists = record.artists ?? []; + const musicService = 'musicServiceUri' in record ? record.musicServiceUri : record.musicServiceBaseDomain; + const origin = 'originUri' in record ? record.originUri : record.originUrl; const play: PlayObjectMinimal = { data: { track: record.trackName, - artists: record.artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })), + artists: artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })), duration: record.duration, playDate: dayjs(record.playedTime), album: record.releaseName, @@ -131,10 +149,11 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp meta: { source: 'tealfm', parsedFrom: 'history', - musicService: record.musicServiceBaseDomain, + musicService, playId: options.playId, url: { - web: options.web + web: options.web, + origin }, user: options.user } @@ -143,7 +162,7 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp const brainz = removeUndefinedKeys({ recording: record.recordingMbId, album: record.releaseMbId, - artist: record.artists.filter(x => x.artistMbId !== undefined).length > 0 ? record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined + artist: artists.filter(x => x.artistMbId !== undefined).length > 0 ? artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined }); if (brainz !== undefined) { @@ -154,9 +173,23 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp } export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => { - const { $type, ...item } = notPlaying + const item = notPlaying ? { trackName: "", artists: [] } - : playToRecord(play); + : (() => { + const { + $type, + musicServiceUri, + originUri, + trackDiscriminant, + releaseDiscriminant, + ...record + } = playToRecord(play); + return { + ...record, + musicServiceBaseDomain: musicServiceUri, + originUrl: originUri + }; + })(); let expiry: Dayjs; if (notPlaying) { @@ -186,17 +219,19 @@ export const mbidUriOrUndefined = (mbid?: MBID): undefined | MBIDURI => { return mbidToUri(mbid); }; export type MBIDURI = `mbid:${MBID}`; -export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => { +export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => { + const musicService = musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService; - const record: FmTealAlphaFeedPlay.Main = { - $type: "fm.teal.alpha.feed.play", + const record: FmTealFeedPlay.Main = { + $type: "fm.teal.feed.play", trackName: play.data.track, - artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })), + artists: (play.data.artists ?? []).map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })), duration: Math.round(play.data.duration), playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(), releaseName: play.data.album, submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, - musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService, + musicServiceUri: asMusicServiceUri(musicService), + originUri: play.meta.url?.origin, isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID), recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID), @@ -205,7 +240,7 @@ export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => { return record; }; -export const listRecordToPlay = (listRecord: ListRecord): PlayObject => { +export const listRecordToPlay = (listRecord: ListRecord): PlayObject => { const opts: RecordOptions = {}; const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri); if (uriRes !== undefined) { @@ -215,5 +250,4 @@ export const listRecordToPlay = (listRecord: ListRecord(?did.*?)\/fm.teal.alpha.feed.play\/(?.*))/); - +export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?(?did.*?)\/fm.teal(?:\.alpha)?\.feed.play\/(?.*))/); diff --git a/src/backend/common/vendor/teal/lex.config.ts b/src/backend/common/vendor/teal/lex.config.ts index fe14d1ec..8a5c566a 100644 --- a/src/backend/common/vendor/teal/lex.config.ts +++ b/src/backend/common/vendor/teal/lex.config.ts @@ -11,6 +11,7 @@ export default defineLexiconConfig({ files: ['lexicons/**/*.json'], outdir: 'lexicons/', imports: ['@atcute/bluesky'], + modules: { importSuffix: '.ts' }, }, pull: { outdir: path.resolve(__dirname, 'lexicons/'), @@ -19,8 +20,8 @@ export default defineLexiconConfig({ 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'], + pattern: ['lexicons/fm.teal/feed/play.json', 'lexicons/fm.teal/feed/defs.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 index 82b1c2fc..0efd8cf0 100644 --- a/src/backend/common/vendor/teal/lexicons/README.md +++ b/src/backend/common/vendor/teal/lexicons/README.md @@ -3,4 +3,4 @@ this directory contains lexicon documents pulled from the following sources: - https://github.com/teal-fm/teal.git (ref: main) - - commit: f661868af2a56199f1b3cae1ad20cd5831ec4212 + - commit: 82ea66cadbde3f1aee9c72b05f372c6543a4b3f6 diff --git a/src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json b/src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json new file mode 100644 index 00000000..80439f2c --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json @@ -0,0 +1,96 @@ +{ + "lexicon": 1, + "id": "fm.teal.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", + "format": "uri", + "description": "The MusicBrainz ID URI of the track, formatted as mbid:" + }, + "recordingMbId": { + "type": "string", + "format": "uri", + "description": "The MusicBrainz recording ID URI of the track, formatted as mbid:" + }, + "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", + "format": "uri", + "description": "The MusicBrainz release ID URI, formatted as mbid:" + }, + "isrc": { + "type": "string", + "description": "The ISRC code associated with the recording" + }, + "originUri": { + "type": "string", + "format": "uri", + "description": "The exact URI where the listening event originated." + }, + "musicServiceUri": { + "type": "string", + "format": "uri", + "description": "The canonical URI identifying the listening surface or music service." + }, + "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 datetime at which playback began." + } + } + }, + "artist": { + "type": "object", + "required": ["artistName"], + "properties": { + "artistName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the artist" + }, + "artistMbId": { + "type": "string", + "format": "uri", + "description": "The MusicBrainz artist ID URI, formatted as mbid:" + } + } + } + } +} diff --git a/src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json b/src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json new file mode 100644 index 00000000..df5a526e --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json @@ -0,0 +1,111 @@ +{ + "lexicon": 1, + "id": "fm.teal.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", + "format": "uri", + "description": "The MusicBrainz ID URI of the track, formatted as mbid:" + }, + "recordingMbId": { + "type": "string", + "format": "uri", + "description": "The MusicBrainz recording ID URI of the track, formatted as mbid:" + }, + "duration": { + "type": "integer", + "description": "The length of the track in seconds" + }, + "artistNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560 + }, + "description": "DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance." + }, + "artistMbIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs." + }, + "artists": { + "type": "array", + "items": { + "type": "ref", + "ref": "fm.teal.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", + "format": "uri", + "description": "The MusicBrainz release ID URI, formatted as mbid:" + }, + "isrc": { + "type": "string", + "description": "The ISRC code associated with the recording" + }, + "originUri": { + "type": "string", + "format": "uri", + "description": "The exact URI where the listening event originated." + }, + "musicServiceUri": { + "type": "string", + "format": "uri", + "description": "The canonical URI identifying the listening surface or music service." + }, + "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 datetime at which playback began." + }, + "trackDiscriminant": { + "type": "string", + "maxLength": 128, + "maxGraphemes": 1280, + "description": "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." + }, + "releaseDiscriminant": { + "type": "string", + "maxLength": 128, + "maxGraphemes": 1280, + "description": "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." + } + } + } + } + } +} diff --git a/src/backend/common/vendor/teal/lexicons/index.ts b/src/backend/common/vendor/teal/lexicons/index.ts index 6a535cd1..476a883c 100644 --- a/src/backend/common/vendor/teal/lexicons/index.ts +++ b/src/backend/common/vendor/teal/lexicons/index.ts @@ -9,3 +9,5 @@ export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.ts"; export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.ts"; export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.ts"; export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts"; +export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts"; +export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts"; diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/defs.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/defs.ts new file mode 100644 index 00000000..d0ce1aee --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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.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.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 canonical URI identifying the listening surface or music service. + */ + musicServiceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The exact URI where the listening event originated. + */ + originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The datetime at which playback began. + */ + 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/feed/play.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/play.ts new file mode 100644 index 00000000..a1d00bc4 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/play.ts @@ -0,0 +1,140 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealFeedDefs from "./defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.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(FmTealFeedDefs.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 canonical URI identifying the listening surface or music service. + */ + musicServiceUri: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.genericUriString(), + ), + /** + * The exact URI where the listening event originated. + */ + originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The datetime at which playback began. + */ + 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.feed.play": mainSchema; + } +} diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index ffd608cf..7f6b870d 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -24,7 +24,7 @@ import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient import { fromStream } from '@atcute/repo'; import { playToRepositoryCreatePlayHistoricalOpts, type RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts"; import { isAbortError } from "abort-controller-x"; -import type { FmTealAlphaFeedPlay } from "../common/vendor/teal/lexicons/index.ts"; +import type { FmTealAlphaFeedPlay, FmTealFeedPlay } from "../common/vendor/teal/lexicons/index.ts"; export default class TealScrobbler extends AbstractHistoricalScrobbleClient { @@ -209,11 +209,11 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { logger.info('Starting CAR conversion to historical plays...'); for await (const entry of repo) { - if(entry.collection === 'fm.teal.alpha.feed.play') { + if(entry.collection === 'fm.teal.feed.play' || entry.collection === 'fm.teal.alpha.feed.play') { let play: PlayObject; try { - play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main, { - web: did !== undefined ? `at://did:plc:${did}/fm.teal.alpha.feed.play/${entry.rkey}` : undefined, + play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main, { + web: did !== undefined ? `at://did:plc:${did}/${entry.collection}/${entry.rkey}` : undefined, playId: entry.rkey, user: did }); @@ -287,4 +287,3 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { } } - diff --git a/src/backend/tests/tealfm/tealfm.test.ts b/src/backend/tests/tealfm/tealfm.test.ts index 9827de03..cdeb309c 100644 --- a/src/backend/tests/tealfm/tealfm.test.ts +++ b/src/backend/tests/tealfm/tealfm.test.ts @@ -61,20 +61,42 @@ describe('#tealfm Record to Play', function() { expect(play.data.meta?.brainz.artist).to.be.undefined; }); + it('Parses production namespace records', function() { + const [legacyRecord] = generateTealPlayRecord(); + const productionRecord = { + ...legacyRecord, + uri: legacyRecord.uri.replace('fm.teal.alpha.feed.play', 'fm.teal.feed.play'), + value: { + ...legacyRecord.value, + $type: 'fm.teal.feed.play' as const, + musicServiceUri: 'https://spotify.com', + originUri: 'https://example.com/track' + } + }; + + const play = listRecordToPlay(productionRecord); + + expect(play.meta.musicService).eq('https://spotify.com'); + expect(play.meta.url.origin).eq('https://example.com/track'); + }); + }); describe('#tealfm Play To Record', function () { it('Adds mbids with uri format', function () { - const play = withBrainz(generatePlay({artists: generateArtistCredits(2)}), {include: ['recording']}); + const play = withBrainz(generatePlay({artists: generateArtistCredits(2)}, {musicService: 'Spotify'}), {include: ['recording']}); const record = playToRecord(play); + expect(record.$type).to.eq('fm.teal.feed.play'); expect(record.recordingMbId).to.eq(`mbid:${play.data.meta.brainz.recording}`); expect(record.releaseMbId).is.undefined; expect(record.artists).length(2); expect(record.artists[0].artistName).eq(play.data.artists[0].name); expect(record.artists[0].artistMbId).eq(`mbid:${play.data.artists[0].mbid}`); + expect(record.musicServiceUri).eq('https://spotify.com'); + expect(record.originUri).eq(play.meta.url.origin); }); }); @@ -117,4 +139,4 @@ describe('#tealfmCar', function() { await tfm.parseScrobblesFromCar(path.resolve(getConfigDir(), 'tealfm-myteal-1778870858.car'), 100); }); -}); \ No newline at end of file +}); -- 2.51.2 From bc18993cd3d05a66b4f072e808c05a8f845add2e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Aug 2026 13:48:16 +0000 Subject: [PATCH 2/2] fix(tealfm): Fix AI mistakes * Remove gitignore entries and remove json lexicon files, we don't redistribute the lexicon * regenerate lexicon with full definitions so we get functionality non-alpha namespace and re-add alpha to barrel export * fix type narrowing for musicservice/origin * reuse isGenericUri instead of reinventing the regex wheel * fix property assertions for renamed musicservice/origin(uri) * use clientresponse type guard in a way typescript can actually understand it, fixes cursor type error * reuse sortByNewestPlayDate instead of reinventing the sort wheel --- .gitignore | 2 - .../common/vendor/teal/TealApiClient.ts | 51 ++++---- src/backend/common/vendor/teal/lex.config.ts | 2 +- .../teal/lexicons/fm.teal/feed/defs.json | 96 --------------- .../teal/lexicons/fm.teal/feed/play.json | 111 ------------------ .../common/vendor/teal/lexicons/index.ts | 15 ++- .../teal/lexicons/types/fm/teal/actor/defs.ts | 96 +++++++++++++++ .../types/fm/teal/actor/getProfile.ts | 36 ++++++ .../types/fm/teal/actor/getProfiles.ts | 40 +++++++ .../lexicons/types/fm/teal/actor/profile.ts | 102 ++++++++++++++++ .../types/fm/teal/actor/profileStatus.ts | 42 +++++++ .../types/fm/teal/actor/searchActors.ts | 59 ++++++++++ .../lexicons/types/fm/teal/actor/status.ts | 36 ++++++ .../types/fm/teal/feed/getActorFeed.ts | 44 +++++++ .../lexicons/types/fm/teal/feed/getPlay.ts | 40 +++++++ src/backend/tests/tealfm/tealfm.test.ts | 6 +- src/core/tests/utils/PlayTestUtils.ts | 10 +- 17 files changed, 547 insertions(+), 241 deletions(-) delete mode 100644 src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json delete mode 100644 src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/defs.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/getProfile.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/getProfiles.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profile.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profileStatus.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/searchActors.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/status.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/getActorFeed.ts create mode 100644 src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/getPlay.ts diff --git a/.gitignore b/.gitignore index eb32db20..d848b05a 100644 --- a/.gitignore +++ b/.gitignore @@ -156,8 +156,6 @@ storybook-static *.db* **/lexicons/**/*.json -!src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json -!src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json bun.lock .DS_Store diff --git a/src/backend/common/vendor/teal/TealApiClient.ts b/src/backend/common/vendor/teal/TealApiClient.ts index 5326273a..ea9101d4 100644 --- a/src/backend/common/vendor/teal/TealApiClient.ts +++ b/src/backend/common/vendor/teal/TealApiClient.ts @@ -8,7 +8,7 @@ import type {AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRa import type {ListRecord, RecordOptions, TealClientData} from "../../infrastructure/config/client/tealfm.ts"; import AbstractApiClient from "../AbstractApiClient.ts"; import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.ts"; -import type { FmTealAlphaActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts"; +import type { FmTealActorStatus, FmTealAlphaActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts"; import { ScrobbleSubmitError } from "../../errors/MSErrors.ts"; import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.ts"; import { musicServiceToCononical } from "../listenbrainz/lzUtils.ts"; @@ -18,17 +18,18 @@ import type { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthentica import { UpstreamError } from "../../errors/UpstreamError.ts"; import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto'; import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts"; -import { isrcNoHyphens } from "../../../../core/PlayUtils.ts"; +import { isrcNoHyphens, sortByNewestPlayDate } from "../../../../core/PlayUtils.ts"; +import { isGenericUri } from "@atcute/lexicons/syntax"; type TealPlayRecord = FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main; const TEAL_PLAY_COLLECTIONS = ['fm.teal.feed.play', 'fm.teal.alpha.feed.play'] as const; -const asMusicServiceUri = (musicService?: string): string | undefined => { +const asMusicServiceUri = (musicService?: string): `${string}:${string}` | undefined => { if (musicService === undefined) { return undefined; } - return /^[a-z][a-z\d+.-]*:\/\//i.test(musicService) ? musicService : `https://${musicService}`; + return isGenericUri(musicService) ? musicService : `https://${musicService}`; }; export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens { @@ -73,10 +74,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang } } - async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise { + async updateStatusRecord(record: FmTealActorStatus.Main): Promise { const input: ComAtprotoRepoPutRecord.$input = { repo: this.client.userData.did, - collection: "fm.teal.alpha.actor.status", + collection: "fm.teal.actor.status", rkey: "self", record }; @@ -112,21 +113,21 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang } })))); + const fromTimestamps: number[] = [], + playSets: PlayObject[][] = []; + for (const resp of responses) { if (!resp.ok) { throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data}); } + if(resp.data.cursor !== undefined) { + fromTimestamps.push(usecToUnix(decodeTid(resp.data.cursor).timestampUs)); + } + playSets.push((resp.data.records as ListRecord[]).map(x => listRecordToPlay(x))); } - const fromTimestamps = responses - .flatMap(resp => resp.data.cursor === undefined ? [] : [usecToUnix(decodeTid(resp.data.cursor).timestampUs)]); - const from = fromTimestamps.length > 0 - ? fromTimestamps.reduce((earliest, current) => current < earliest ? current : earliest) - : undefined; - const plays = responses - .flatMap(resp => resp.data.records as unknown as ListRecord[]) - .map(listRecordToPlay) - .sort((a, b) => (b.data.playDate?.valueOf() ?? 0) - (a.data.playDate?.valueOf() ?? 0)); + const from = fromTimestamps.length > 0 ? fromTimestamps.reduce((earliest, current) => current < earliest ? current : earliest) : undefined; + const plays = playSets.flat().sort(sortByNewestPlayDate) return {data: plays, meta: {to, from, limit, more: false, order: 'desc'}}; } @@ -134,8 +135,18 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {}): PlayObject => { const artists = record.artists ?? []; - const musicService = 'musicServiceUri' in record ? record.musicServiceUri : record.musicServiceBaseDomain; - const origin = 'originUri' in record ? record.originUri : record.originUrl; + let musicService: string; + if('musicServiceUri' in record) { + musicService = record.musicServiceUri + } else if(`musicServiceBaseDomain` in record) { + musicService = record.musicServiceBaseDomain; + } + let origin: string; + if('originUri' in record) { + origin = record.originUri; + } else if(`originUrl` in record) { + origin = record.originUrl; + } const play: PlayObjectMinimal = { data: { @@ -172,7 +183,7 @@ export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {} return baseFormatPlayObj(record, play); } -export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => { +export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealActorStatus.Main => { const item = notPlaying ? { trackName: "", artists: [] } : (() => { @@ -200,7 +211,7 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi } return { - $type: "fm.teal.alpha.actor.status", + $type: "fm.teal.actor.status", time: dayjs().toISOString(), expiry: expiry.toISOString(), item: { @@ -231,7 +242,7 @@ export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => { releaseName: play.data.album, submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, musicServiceUri: asMusicServiceUri(musicService), - originUri: play.meta.url?.origin, + originUri: isGenericUri(play.meta.url?.origin) ? play.meta.url?.origin : undefined, isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID), recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID), diff --git a/src/backend/common/vendor/teal/lex.config.ts b/src/backend/common/vendor/teal/lex.config.ts index 8a5c566a..fefad343 100644 --- a/src/backend/common/vendor/teal/lex.config.ts +++ b/src/backend/common/vendor/teal/lex.config.ts @@ -20,7 +20,7 @@ export default defineLexiconConfig({ type: 'git', remote: 'https://github.com/teal-fm/teal.git', ref: 'main', - pattern: ['lexicons/fm.teal/feed/play.json', 'lexicons/fm.teal/feed/defs.json'], + pattern: ['lexicons/fm.teal/feed/*.json','lexicons/fm.teal/actor/*.json'], }, ], }, diff --git a/src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json b/src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json deleted file mode 100644 index 80439f2c..00000000 --- a/src/backend/common/vendor/teal/lexicons/fm.teal/feed/defs.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "lexicon": 1, - "id": "fm.teal.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", - "format": "uri", - "description": "The MusicBrainz ID URI of the track, formatted as mbid:" - }, - "recordingMbId": { - "type": "string", - "format": "uri", - "description": "The MusicBrainz recording ID URI of the track, formatted as mbid:" - }, - "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", - "format": "uri", - "description": "The MusicBrainz release ID URI, formatted as mbid:" - }, - "isrc": { - "type": "string", - "description": "The ISRC code associated with the recording" - }, - "originUri": { - "type": "string", - "format": "uri", - "description": "The exact URI where the listening event originated." - }, - "musicServiceUri": { - "type": "string", - "format": "uri", - "description": "The canonical URI identifying the listening surface or music service." - }, - "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 datetime at which playback began." - } - } - }, - "artist": { - "type": "object", - "required": ["artistName"], - "properties": { - "artistName": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "maxGraphemes": 2560, - "description": "The name of the artist" - }, - "artistMbId": { - "type": "string", - "format": "uri", - "description": "The MusicBrainz artist ID URI, formatted as mbid:" - } - } - } - } -} diff --git a/src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json b/src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json deleted file mode 100644 index df5a526e..00000000 --- a/src/backend/common/vendor/teal/lexicons/fm.teal/feed/play.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "lexicon": 1, - "id": "fm.teal.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", - "format": "uri", - "description": "The MusicBrainz ID URI of the track, formatted as mbid:" - }, - "recordingMbId": { - "type": "string", - "format": "uri", - "description": "The MusicBrainz recording ID URI of the track, formatted as mbid:" - }, - "duration": { - "type": "integer", - "description": "The length of the track in seconds" - }, - "artistNames": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "maxGraphemes": 2560 - }, - "description": "DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance." - }, - "artistMbIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs." - }, - "artists": { - "type": "array", - "items": { - "type": "ref", - "ref": "fm.teal.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", - "format": "uri", - "description": "The MusicBrainz release ID URI, formatted as mbid:" - }, - "isrc": { - "type": "string", - "description": "The ISRC code associated with the recording" - }, - "originUri": { - "type": "string", - "format": "uri", - "description": "The exact URI where the listening event originated." - }, - "musicServiceUri": { - "type": "string", - "format": "uri", - "description": "The canonical URI identifying the listening surface or music service." - }, - "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 datetime at which playback began." - }, - "trackDiscriminant": { - "type": "string", - "maxLength": 128, - "maxGraphemes": 1280, - "description": "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." - }, - "releaseDiscriminant": { - "type": "string", - "maxLength": 128, - "maxGraphemes": 1280, - "description": "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." - } - } - } - } - } -} diff --git a/src/backend/common/vendor/teal/lexicons/index.ts b/src/backend/common/vendor/teal/lexicons/index.ts index 476a883c..661b25b4 100644 --- a/src/backend/common/vendor/teal/lexicons/index.ts +++ b/src/backend/common/vendor/teal/lexicons/index.ts @@ -1,3 +1,14 @@ +export * as FmTealActorDefs from "./types/fm/teal/actor/defs.ts"; +export * as FmTealActorGetProfile from "./types/fm/teal/actor/getProfile.ts"; +export * as FmTealActorGetProfiles from "./types/fm/teal/actor/getProfiles.ts"; +export * as FmTealActorProfile from "./types/fm/teal/actor/profile.ts"; +export * as FmTealActorProfileStatus from "./types/fm/teal/actor/profileStatus.ts"; +export * as FmTealActorSearchActors from "./types/fm/teal/actor/searchActors.ts"; +export * as FmTealActorStatus from "./types/fm/teal/actor/status.ts"; +export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts"; +export * as FmTealFeedGetActorFeed from "./types/fm/teal/feed/getActorFeed.ts"; +export * as FmTealFeedGetPlay from "./types/fm/teal/feed/getPlay.ts"; +export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts"; export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.ts"; export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.ts"; export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.ts"; @@ -8,6 +19,4 @@ export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.ts"; export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.ts"; export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.ts"; export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.ts"; -export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts"; -export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts"; -export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts"; +export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts"; \ No newline at end of file diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/defs.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/defs.ts new file mode 100644 index 00000000..68c21048 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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 FmTealActorProfile from "./profile.ts"; +import * as FmTealFeedDefs from "../feed/defs.ts"; + +const _miniProfileViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.actor.defs#miniProfileView"), + ), + /** + * IPLD of the avatar + */ + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + /** + * The decentralized identifier of the actor + */ + did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), + displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()), +}); +const _profileViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.actor.defs#profileView"), + ), + /** + * IPLD of the avatar + */ + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + /** + * IPLD of the banner image + */ + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + 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.didString()), + displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The user's most recent item featured on their profile. + */ + get featuredItem() { + return /*#__PURE__*/ v.optional(FmTealActorProfile.featuredItemSchema); + }, + get status() { + return /*#__PURE__*/ v.optional(statusViewSchema); + }, +}); +const _statusViewSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("fm.teal.actor.defs#statusView"), + ), + /** + * The datetime after which the status is no longer current. + */ + expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + get item() { + return /*#__PURE__*/ v.optional(FmTealFeedDefs.playViewSchema); + }, + /** + * The datetime at which the status 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/actor/getProfile.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/getProfile.ts new file mode 100644 index 00000000..bf88ab00 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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 FmTealActorDefs from "./defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfile", { + params: /*#__PURE__*/ v.object({ + /** + * The actor's DID or handle + */ + actor: /*#__PURE__*/ v.actorIdentifierString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get actor() { + return FmTealActorDefs.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.actor.getProfile": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/getProfiles.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/getProfiles.ts new file mode 100644 index 00000000..3184375e --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/getProfiles.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 FmTealActorDefs from "./defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfiles", { + params: /*#__PURE__*/ v.object({ + /** + * Array of actor DIDs or handles + * @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(FmTealActorDefs.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.actor.getProfiles": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profile.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profile.ts new file mode 100644 index 00000000..d1de381e --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profile.ts @@ -0,0 +1,102 @@ +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.actor.profile#featuredItem"), + ), + /** + * The MusicBrainz ID URI of the item, formatted as mbid: + */ + mbid: /*#__PURE__*/ v.genericUriString(), + /** + * The MusicBrainz entity type of the item. + */ + type: /*#__PURE__*/ v.string< + "artist" | "recording" | "release" | (string & {}) + >(), +}); +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.literal("self"), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.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.actor.profile": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profileStatus.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/profileStatus.ts new file mode 100644 index 00000000..cbc7574c --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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.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.actor.profileStatus": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/searchActors.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/searchActors.ts new file mode 100644 index 00000000..cee9300d --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/searchActors.ts @@ -0,0 +1,59 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as FmTealActorDefs from "./defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.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(FmTealActorDefs.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.actor.searchActors": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/status.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/actor/status.ts new file mode 100644 index 00000000..ef842229 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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 FmTealFeedDefs from "../feed/defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.literal("self"), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("fm.teal.actor.status"), + /** + * The datetime after which the status is no longer current. If unavailable, default to 10 minutes after the start time. + */ + expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + get item() { + return FmTealFeedDefs.playViewSchema; + }, + /** + * The datetime at which the status 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.actor.status": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/getActorFeed.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/getActorFeed.ts new file mode 100644 index 00000000..f5afbfe5 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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 FmTealFeedDefs from "./defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getActorFeed", { + params: /*#__PURE__*/ v.object({ + /** + * The author's DID for the play + */ + authorDID: /*#__PURE__*/ v.didString(), + /** + * 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(FmTealFeedDefs.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.feed.getActorFeed": mainSchema; + } +} diff --git a/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/getPlay.ts b/src/backend/common/vendor/teal/lexicons/types/fm/teal/feed/getPlay.ts new file mode 100644 index 00000000..4720fdd6 --- /dev/null +++ b/src/backend/common/vendor/teal/lexicons/types/fm/teal/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 FmTealFeedDefs from "./defs.ts"; + +const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getPlay", { + params: /*#__PURE__*/ v.object({ + /** + * The author's DID for the play + */ + authorDID: /*#__PURE__*/ v.didString(), + /** + * The record key of the play + */ + rkey: /*#__PURE__*/ v.recordKeyString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get play() { + return FmTealFeedDefs.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.feed.getPlay": mainSchema; + } +} diff --git a/src/backend/tests/tealfm/tealfm.test.ts b/src/backend/tests/tealfm/tealfm.test.ts index cdeb309c..ccb2ea9f 100644 --- a/src/backend/tests/tealfm/tealfm.test.ts +++ b/src/backend/tests/tealfm/tealfm.test.ts @@ -65,12 +65,12 @@ describe('#tealfm Record to Play', function() { const [legacyRecord] = generateTealPlayRecord(); const productionRecord = { ...legacyRecord, - uri: legacyRecord.uri.replace('fm.teal.alpha.feed.play', 'fm.teal.feed.play'), + uri: legacyRecord.uri.replace('fm.teal.feed.play', 'fm.teal.feed.play'), value: { ...legacyRecord.value, $type: 'fm.teal.feed.play' as const, - musicServiceUri: 'https://spotify.com', - originUri: 'https://example.com/track' + musicServiceUri: 'https://spotify.com' as `${string}:${string}`, + originUri: 'https://example.com/track' as `${string}:${string}` } }; diff --git a/src/core/tests/utils/PlayTestUtils.ts b/src/core/tests/utils/PlayTestUtils.ts index e0f8cc3f..ca18625f 100644 --- a/src/core/tests/utils/PlayTestUtils.ts +++ b/src/core/tests/utils/PlayTestUtils.ts @@ -22,7 +22,7 @@ import { nanoid } from 'nanoid'; import type {LastFMTrackObject} from '../../../backend/common/vendor/LastfmApiClient.ts'; import clone from 'clone'; import { removeUndefinedKeys } from '../../DataUtils.ts'; -import type { FmTealAlphaFeedPlay } from '../../../backend/common/vendor/teal/lexicons/index.ts'; +import type { FmTealFeedPlay } from '../../../backend/common/vendor/teal/lexicons/index.ts'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -475,7 +475,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, @@ -487,11 +487,11 @@ export const generateTealPlayRecord = (opts: { const did = nanoid(12); const tid = nanoid(10); - const rec: ListRecord = { - uri: `at://did:plc:${did}/fm.teal.alpha.feed.play/${tid}`, + const rec: ListRecord = { + uri: `at://did:plc:${did}/fm.teal.feed.play/${tid}`, cid: nanoid(12), value: { - '$type': 'fm.teal.alpha.feed.play', + '$type': 'fm.teal.feed.play', artists: artists.map(x => withMbids ? { artistName: x, artistMbId: `mbid:${generateMbid()}` } : { artistName: x }), releaseName: faker.music.album(), trackName: faker.music.songName(),