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(),