refactor(teal): Generate and use real lexicon definitions for tealfm implementation

Removes hand-rolled interfaces and generates interfaces+validation for official lexicon definitions
This commit is contained in:
FoxxMD 2026-06-03 18:14:20 +00:00
parent 18c520ce2f
commit 95d7d276d7
22 changed files with 1069 additions and 81 deletions

View file

@ -31,4 +31,5 @@ tmp-*
.env
*.env
.storybook
.stories
.stories
**/lexicons/**/*.json

4
.gitignore vendored
View file

@ -153,4 +153,6 @@ tmp-*
storybook-static
*.db
*.db*
*.db*
**/lexicons/**/*.json

260
package-lock.json generated
View file

@ -11,7 +11,9 @@
"license": "MIT",
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
"@atcute/bluesky": "^4.0.5",
"@atcute/identity-resolver-node": "^1.0.3",
"@atcute/lexicons": "^2.0.0",
"@atcute/repo": "^1.0.0",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
@ -103,6 +105,7 @@
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@atcute/lex-cli": "^3.1.0",
"@chakra-ui/react": "^3.34.0",
"@chromatic-com/storybook": "^5.1.2",
"@dbus-types/notifications": "^0.0.5",
@ -277,6 +280,31 @@
"integrity": "sha512-9bVdQ15Tbzpor1z/bteaemC9NvRDZgMD4Cfbu1/r/6Zv6fCBXvIJJoJ6Dh/BMvTlAiT2Xewm36i0VWRnZ4yw6A==",
"license": "MIT"
},
"node_modules/@atcute/atproto": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.2.tgz",
"integrity": "sha512-hLnvjiIOStpdUm0cEN+R5YydvbV0d6ap17Iv+t7i/nhSCN3TGMya7M0ftCWtCo+xoQ1EU6HK74R8jqXWlyrM0w==",
"license": "0BSD",
"dependencies": {
"@atcute/lexicons": "^2.0.0"
},
"peerDependencies": {
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/bluesky": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@atcute/bluesky/-/bluesky-4.0.5.tgz",
"integrity": "sha512-kw+kc41cNXBFUyFU9oPDKhltB9JTEKNDTiNoSbtOvonwg2Wej9Zg8vt0UhLvb6958rSwLvG0xX+SuQbZ0O8k2Q==",
"license": "0BSD",
"dependencies": {
"@atcute/atproto": "^4.0.2",
"@atcute/lexicons": "^2.0.0"
},
"peerDependencies": {
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/car": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@atcute/car/-/car-6.0.0.tgz",
@ -372,7 +400,7 @@
"@atcute/lexicons": "^1.0.0"
}
},
"node_modules/@atcute/lexicons": {
"node_modules/@atcute/identity-resolver-node/node_modules/@atcute/lexicons": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.3.1.tgz",
"integrity": "sha512-2JVxDmHt+QwsUoPyVYWIN7ZLRLfLx4GeJxKFjA9ofStuby9hCMv7Q4GAPIXuJD8wPv8vrnhr1yRNQhiJX+bthw==",
@ -384,6 +412,158 @@
"esm-env": "^1.2.2"
}
},
"node_modules/@atcute/identity-resolver/node_modules/@atcute/lexicons": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.3.1.tgz",
"integrity": "sha512-2JVxDmHt+QwsUoPyVYWIN7ZLRLfLx4GeJxKFjA9ofStuby9hCMv7Q4GAPIXuJD8wPv8vrnhr1yRNQhiJX+bthw==",
"license": "0BSD",
"peer": true,
"dependencies": {
"@atcute/uint8array": "^1.1.1",
"@atcute/util-text": "^1.3.1",
"@standard-schema/spec": "^1.1.0",
"esm-env": "^1.2.2"
}
},
"node_modules/@atcute/identity/node_modules/@atcute/lexicons": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.3.1.tgz",
"integrity": "sha512-2JVxDmHt+QwsUoPyVYWIN7ZLRLfLx4GeJxKFjA9ofStuby9hCMv7Q4GAPIXuJD8wPv8vrnhr1yRNQhiJX+bthw==",
"license": "0BSD",
"peer": true,
"dependencies": {
"@atcute/uint8array": "^1.1.1",
"@atcute/util-text": "^1.3.1",
"@standard-schema/spec": "^1.1.0",
"esm-env": "^1.2.2"
}
},
"node_modules/@atcute/lex-cli": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-3.1.0.tgz",
"integrity": "sha512-Mba7y12ENIbdj+cr+gt5mJTalpuz/UbS4ykm/wT7t9ipoJtidXMSeuQFfeoyBvvidW77Vg4G6zxZkoDxtlr6Mg==",
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/identity": "^2.0.0",
"@atcute/identity-resolver": "^2.0.0",
"@atcute/lexicon-doc": "^3.0.0",
"@atcute/lexicon-resolver": "^1.0.0",
"@atcute/lexicons": "^2.0.0",
"@optique/core": "^1.0.2",
"@optique/run": "^1.0.2",
"picocolors": "^1.1.1",
"prettier": "^3.8.3",
"valibot": "^1.4.0"
},
"bin": {
"lex-cli": "cli.mjs"
}
},
"node_modules/@atcute/lex-cli/node_modules/@atcute/identity": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.0.tgz",
"integrity": "sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==",
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/lexicons": "^2.0.0",
"valibot": "^1.4.0"
},
"peerDependencies": {
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/lex-cli/node_modules/@atcute/identity-resolver": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-2.0.0.tgz",
"integrity": "sha512-IKg1BDQAF2bIdN10DL6KAXmTjK+3enTU2IRbuani9TsFahBwGZ7O5FiVmTiL6QlGfauGNW5S0xNCOxWXWMoR2Q==",
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/lexicons": "^2.0.0",
"@atcute/util-fetch": "^2.0.0",
"valibot": "^1.4.0"
},
"peerDependencies": {
"@atcute/identity": "^2.0.0",
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/lex-cli/node_modules/@atcute/lexicon-resolver": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@atcute/lexicon-resolver/-/lexicon-resolver-1.0.0.tgz",
"integrity": "sha512-4v3oSijgd3w9JS2BMsV1dfD+GD1ClQeuEJatM9b7rNffi1kMTSn/3nOQbJ1aNeXfqZbCkn3ATDRdvgki1xIAZw==",
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/crypto": "^2.4.1",
"@atcute/lexicon-doc": "^3.0.0",
"@atcute/lexicons": "^2.0.0",
"@atcute/repo": "^1.0.0",
"@atcute/util-fetch": "^2.0.0",
"valibot": "^1.4.0"
},
"peerDependencies": {
"@atcute/identity": "^2.0.0",
"@atcute/identity-resolver": "^2.0.0",
"@atcute/lexicon-doc": "^3.0.0",
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/lex-cli/node_modules/@atcute/util-fetch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-2.0.0.tgz",
"integrity": "sha512-v+4aFQ/tuBqTV+URDJaFgm3mASWdglKXiPaGutJ1bs7QtQKmPZeesPY5MzW/a+MtI8GWCEJk8X9wOfalPOFSlg==",
"dev": true,
"license": "0BSD",
"dependencies": {
"valibot": "^1.4.0"
}
},
"node_modules/@atcute/lexicon-doc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-3.0.0.tgz",
"integrity": "sha512-hxBTEvO78X4j/HsGgcH8GKgMA9hP9FM6jtwQL8Yd6MIHtGXdB8QygvBmOAJEqm1oE9ckU1SSU3cwa2t4tbZZNw==",
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/identity": "^2.0.0",
"@atcute/lexicons": "^2.0.0",
"@atcute/uint8array": "^1.1.1",
"@atcute/util-text": "^1.3.1",
"valibot": "^1.4.0"
},
"peerDependencies": {
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/lexicon-doc/node_modules/@atcute/identity": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.0.tgz",
"integrity": "sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==",
"dev": true,
"license": "0BSD",
"dependencies": {
"@atcute/lexicons": "^2.0.0",
"valibot": "^1.4.0"
},
"peerDependencies": {
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/lexicons": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.0.tgz",
"integrity": "sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==",
"license": "0BSD",
"dependencies": {
"@atcute/uint8array": "^1.1.1",
"@atcute/util-text": "^1.3.1",
"@standard-schema/spec": "^1.1.0",
"esm-env": "^1.2.2"
}
},
"node_modules/@atcute/mst": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@atcute/mst/-/mst-1.0.1.tgz",
@ -428,18 +608,6 @@
"@atcute/lexicons": "^2.0.0"
}
},
"node_modules/@atcute/repo/node_modules/@atcute/lexicons": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.0.tgz",
"integrity": "sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==",
"license": "0BSD",
"dependencies": {
"@atcute/uint8array": "^1.1.1",
"@atcute/util-text": "^1.3.1",
"@standard-schema/spec": "^1.1.0",
"esm-env": "^1.2.2"
}
},
"node_modules/@atcute/uint8array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.2.tgz",
@ -2712,6 +2880,39 @@
"node": ">=8.0.0"
}
},
"node_modules/@optique/core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@optique/core/-/core-1.0.2.tgz",
"integrity": "sha512-znsqMmjAdeOgSJzdJlpZpgAscojwQmeQYXzYnuEKllz5VCj6WyEkdzU4QuvJQtWQY3ve2taXwudEBRur0VHBOQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/dahlia"
],
"license": "MIT",
"engines": {
"bun": ">=1.2.0",
"deno": ">=2.3.0",
"node": ">=20.0.0"
}
},
"node_modules/@optique/run": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@optique/run/-/run-1.0.2.tgz",
"integrity": "sha512-0Wc+zC8SLGV8zXQX+pk+o0c6wE/ddx/36CHZ0toTh5lApsjruUuGhqbxvljerAAG5un1xQbOLxzksBVC6UPgSg==",
"dev": true,
"funding": [
"https://github.com/sponsors/dahlia"
],
"license": "MIT",
"dependencies": {
"@optique/core": "1.0.2"
},
"engines": {
"bun": ">=1.2.0",
"deno": ">=2.3.0",
"node": ">=20.0.0"
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz",
@ -14751,6 +14952,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@ -17631,7 +17848,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -18024,6 +18241,21 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/valibot": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz",
"integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View file

@ -24,7 +24,8 @@
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
"cliff": "git-cliff --unreleased",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"atproto:lex:tealfm": "npm exec -- lex-cli pull -c src/backend/common/vendor/teal/lex.config.ts && npm exec -- lex-cli generate -c src/backend/common/vendor/teal/lex.config.ts"
},
"exports": {
".": {
@ -49,7 +50,9 @@
"homepage": ".",
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
"@atcute/bluesky": "^4.0.5",
"@atcute/identity-resolver-node": "^1.0.3",
"@atcute/lexicons": "^2.0.0",
"@atcute/repo": "^1.0.0",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
@ -141,6 +144,7 @@
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@atcute/lex-cli": "^3.1.0",
"@chakra-ui/react": "^3.34.0",
"@chromatic-com/storybook": "^5.1.2",
"@dbus-types/notifications": "^0.0.5",

View file

@ -51,50 +51,6 @@ export interface TealClientOptions extends TealOptions,CommonClientOptions {
export interface TealClientAIOConfig extends TealClientConfig {
type: 'tealfm'
}
export interface TealArtistCredit {
artistName?: string,
/** The MusicBrainz artist ID URI, formatted as mbid:<uuid> */
artistMbId?: string
}
/**
* https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/play.json
* https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/defs.json
* */
export interface ScrobbleRecord {
$type: "fm.teal.alpha.feed.play",
trackName: string,
playedTime: string,
duration?: number
artists?: TealArtistCredit[]
/** Album name */
releaseName?: string
/** A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)` */
submissionClientAgent: string,
musicServiceBaseDomain?: string
/** The MusicBrainz ID URI of the track, formatted as mbid:<uuid> */
trackMbId?: string
/** The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid> */
recordingMbId?: string
/** The MusicBrainz release ID URI, formatted as mbid:<uuid> */
releaseMbId?: string
isrc?: string,
/** The URL associated with this track */
originUrl?: string
/** Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities. */
trackDiscriminant?: string;
/** Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities. */
releaseDiscriminant?: string;
[x: string]: unknown
}
export interface StatusRecord {
$type: "fm.teal.alpha.actor.status",
/** item is just ScrobbleRecord, but without $type */
item: Omit<ScrobbleRecord, '$type'>,
time: string,
expiry: string,
[x: string]: unknown
}
export interface ListRecord<T> {
uri: string;

View file

@ -1,10 +1,10 @@
import { getRoot } from "../../../ioc.js";
import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js";
import { ListRecord, ScrobbleRecord, StatusRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js";
import { ListRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api";
import { MSCache } from "../../Cache.js";
import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID, NowPlayingUpdateThreshold, SourcePlayerObj, Second } from "../../../../core/Atomic.js";
import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID, SourcePlayerObj } from "../../../../core/Atomic.js";
import { musicServiceToCononical } from '../listenbrainz/lzUtils.js';
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import { RecordOptions } from "../../infrastructure/config/client/tealfm.js";
@ -17,6 +17,7 @@ import { UpstreamError } from "../../errors/UpstreamError.js";
import { decodeTid, generateTID } from '@ewanc26/tid';
import { Duration } from "dayjs/plugin/duration.js";
import { streamBodyProgress } from "../../../utils/NetworkUtils.js";
import { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "../teal/lexicons/index.js";
export abstract class AbstractBlueSkyApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
@ -36,7 +37,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme
abstract restoreSession(): Promise<boolean>;
async createScrobbleRecord(record: ScrobbleRecord): Promise<ScrobbleActionResult> {
async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise<ScrobbleActionResult> {
const input: ComAtprotoRepoCreateRecord.InputSchema = {
repo: this.agent.sessionManager.did,
collection: "fm.teal.alpha.feed.play",
@ -50,7 +51,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme
}
}
async updateStatusRecord(record: StatusRecord): Promise<ScrobbleActionResult> {
async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise<ScrobbleActionResult> {
const input: ComAtprotoRepoPutRecord.InputSchema = {
repo: this.agent.sessionManager.did,
collection: "fm.teal.alpha.actor.status",
@ -118,7 +119,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme
fromTS = usecToUnix(timestampUs);
}
const plays = (resp.data.records as unknown as ListRecord<ScrobbleRecord>[]).map(x => listRecordToPlay(x));
const plays = (resp.data.records as unknown as ListRecord<FmTealAlphaFeedPlay.Main>[]).map(x => listRecordToPlay(x));
return {data: plays, meta: {to, from: fromTS, limit}};
}
@ -128,9 +129,9 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme
}
}
export const playToRecord = (play: PlayObject): ScrobbleRecord => {
export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
const record: ScrobbleRecord = {
const record: FmTealAlphaFeedPlay.Main = {
$type: "fm.teal.alpha.feed.play",
trackName: play.data.track,
artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
@ -160,7 +161,7 @@ export const mbidToUri = (mbid: MBID): MBIDURI => {
return `mbid:${mbid}`;
}
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): StatusRecord => {
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => {
const { $type, ...item } = notPlaying
? { trackName: "", artists: [] }
: playToRecord(play);
@ -177,7 +178,10 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi
$type: "fm.teal.alpha.actor.status",
time: dayjs().toISOString(),
expiry: expiry.toISOString(),
item
item: {
artists: [],
...item
}
};
}
@ -201,7 +205,7 @@ export const nowPlayingExpirationDuration = (data: Pick<SourcePlayerObj, 'play'
return dayjs.duration(expiry.diff(dayjs(), 'ms'));
}
export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => {
export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main>): PlayObject => {
const opts: RecordOptions = {};
const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri);
if (uriRes !== undefined) {
@ -212,7 +216,7 @@ export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayOb
return recordToPlay(listRecord.value, opts);
}
export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {}): PlayObject => {
export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOptions = {}): PlayObject => {
const play: PlayObjectLifecycleless = {
data: {

View file

@ -0,0 +1,26 @@
import { defineLexiconConfig } from '@atcute/lex-cli';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineLexiconConfig({
generate: {
files: ['lexicons/**/*.json'],
outdir: 'lexicons/',
imports: ['@atcute/bluesky'],
},
pull: {
outdir: path.resolve(__dirname, 'lexicons/'),
sources: [
{
type: 'git',
remote: 'https://github.com/teal-fm/teal.git',
ref: 'main',
pattern: ['lexicons/fm.teal.alpha/feed/*.json','lexicons/fm.teal.alpha/actor/*.json'],
},
],
},
});

View file

@ -0,0 +1,6 @@
# lexicon sources
this directory contains lexicon documents pulled from the following sources:
- https://github.com/teal-fm/teal.git (ref: main)
- commit: f661868af2a56199f1b3cae1ad20cd5831ec4212

View file

@ -0,0 +1,11 @@
export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.js";
export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.js";
export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.js";
export * as FmTealAlphaActorProfile from "./types/fm/teal/alpha/actor/profile.js";
export * as FmTealAlphaActorProfileStatus from "./types/fm/teal/alpha/actor/profileStatus.js";
export * as FmTealAlphaActorSearchActors from "./types/fm/teal/alpha/actor/searchActors.js";
export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.js";
export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.js";
export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.js";
export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.js";
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.js";

View file

@ -0,0 +1,96 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
import * as FmTealAlphaActorProfile from "./profile.js";
import * as FmTealAlphaFeedDefs from "../feed/defs.js";
const _miniProfileViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.alpha.actor.defs#miniProfileView"),
),
/**
* IPLD of the avatar
*/
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The decentralized identifier of the actor
*/
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
});
const _profileViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.alpha.actor.defs#profileView"),
),
/**
* IPLD of the avatar
*/
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* IPLD of the banner image
*/
banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* Free-form profile description text.
*/
description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.
*/
get descriptionFacets() {
return /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
);
},
/**
* The decentralized identifier of the actor
*/
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The user's most recent item featured on their profile.
*/
get featuredItem() {
return /*#__PURE__*/ v.optional(FmTealAlphaActorProfile.featuredItemSchema);
},
get status() {
return /*#__PURE__*/ v.optional(statusViewSchema);
},
});
const _statusViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.alpha.actor.defs#statusView"),
),
/**
* The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time.
*/
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
get item() {
return /*#__PURE__*/ v.optional(FmTealAlphaFeedDefs.playViewSchema);
},
/**
* The unix timestamp of when the item was recorded
*/
time: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
});
type miniProfileView$schematype = typeof _miniProfileViewSchema;
type profileView$schematype = typeof _profileViewSchema;
type statusView$schematype = typeof _statusViewSchema;
export interface miniProfileViewSchema extends miniProfileView$schematype {}
export interface profileViewSchema extends profileView$schematype {}
export interface statusViewSchema extends statusView$schematype {}
export const miniProfileViewSchema =
_miniProfileViewSchema as miniProfileViewSchema;
export const profileViewSchema = _profileViewSchema as profileViewSchema;
export const statusViewSchema = _statusViewSchema as statusViewSchema;
export interface MiniProfileView extends v.InferInput<
typeof miniProfileViewSchema
> {}
export interface ProfileView extends v.InferInput<typeof profileViewSchema> {}
export interface StatusView extends v.InferInput<typeof statusViewSchema> {}

View file

@ -0,0 +1,36 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaActorDefs from "./defs.js";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.actor.getProfile", {
params: /*#__PURE__*/ v.object({
/**
* The author's DID
*/
actor: /*#__PURE__*/ v.actorIdentifierString(),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get actor() {
return FmTealAlphaActorDefs.profileViewSchema;
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.alpha.actor.getProfile": mainSchema;
}
}

View file

@ -0,0 +1,42 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaActorDefs from "./defs.js";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.actor.getProfiles", {
params: /*#__PURE__*/ v.object({
/**
* Array of actor DIDs
* @minLength 1
*/
actors: /*#__PURE__*/ v.constrain(
/*#__PURE__*/ v.array(/*#__PURE__*/ v.actorIdentifierString()),
[/*#__PURE__*/ v.arrayLength(1)],
),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get actors() {
return /*#__PURE__*/ v.array(
FmTealAlphaActorDefs.miniProfileViewSchema,
);
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.alpha.actor.getProfiles": mainSchema;
}
}

View file

@ -0,0 +1,100 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
const _featuredItemSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.alpha.actor.profile#featuredItem"),
),
/**
* The MusicBrainz ID URI of the item, formatted as mbid:<uuid>
*/
mbid: /*#__PURE__*/ v.genericUriString(),
/**
* The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc.
*/
type: /*#__PURE__*/ v.string(),
});
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.literal("self"),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.alpha.actor.profile"),
/**
* Small image to be displayed next to posts from account. AKA, 'profile picture'
* @accept image/jpeg, image/png
* @maxSize 1000000
*/
avatar: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
/*#__PURE__*/ v.blobSize(1000000),
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
]),
),
/**
* Larger horizontal image to display behind profile view.
* @accept image/jpeg, image/png
* @maxSize 1000000
*/
banner: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
/*#__PURE__*/ v.blobSize(1000000),
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
]),
),
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* Free-form profile description text.
* @maxLength 2560
* @maxGraphemes 256
*/
description: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 2560),
/*#__PURE__*/ v.stringGraphemes(0, 256),
]),
),
/**
* Annotations of text in the profile description (mentions, URLs, hashtags, etc).
*/
get descriptionFacets() {
return /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
);
},
/**
* @maxLength 640
* @maxGraphemes 64
*/
displayName: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 640),
/*#__PURE__*/ v.stringGraphemes(0, 64),
]),
),
/**
* The user's most recent item featured on their profile.
*/
get featuredItem() {
return /*#__PURE__*/ v.optional(featuredItemSchema);
},
}),
);
type featuredItem$schematype = typeof _featuredItemSchema;
type main$schematype = typeof _mainSchema;
export interface featuredItemSchema extends featuredItem$schematype {}
export interface mainSchema extends main$schematype {}
export const featuredItemSchema = _featuredItemSchema as featuredItemSchema;
export const mainSchema = _mainSchema as mainSchema;
export interface FeaturedItem extends v.InferInput<typeof featuredItemSchema> {}
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.alpha.actor.profile": mainSchema;
}
}

View file

@ -0,0 +1,42 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.literal("self"),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.alpha.actor.profileStatus"),
/**
* The onboarding completion status
*/
completedOnboarding: /*#__PURE__*/ v.string<
| "complete"
| "none"
| "playOnboarding"
| "profileOnboarding"
| (string & {})
>(),
/**
* The timestamp when this status was created
*/
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* The timestamp when this status was last updated
*/
updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
}),
);
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.alpha.actor.profileStatus": mainSchema;
}
}

View file

@ -0,0 +1,61 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaActorDefs from "./defs.js";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.actor.searchActors", {
params: /*#__PURE__*/ v.object({
/**
* Cursor for pagination
*/
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The maximum number of actors to return
* @minimum 1
* @maximum 25
*/
limit: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [
/*#__PURE__*/ v.integerRange(1, 25),
]),
),
/**
* The search query
* @maxLength 640
* @maxGraphemes 128
*/
q: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 640),
/*#__PURE__*/ v.stringGraphemes(0, 128),
]),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get actors() {
return /*#__PURE__*/ v.array(
FmTealAlphaActorDefs.miniProfileViewSchema,
);
},
/**
* Cursor for pagination
*/
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.alpha.actor.searchActors": mainSchema;
}
}

View file

@ -0,0 +1,36 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaFeedDefs from "../feed/defs.js";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.literal("self"),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.alpha.actor.status"),
/**
* The RFC 3339 formatted time of the expiry time of the item. If unavailable, default to 10 minutes past the start time.
*/
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
get item() {
return FmTealAlphaFeedDefs.playViewSchema;
},
/**
* The RFC 3339 formatted time of when the item was recorded
*/
time: /*#__PURE__*/ v.datetimeString(),
}),
);
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.alpha.actor.status": mainSchema;
}
}

View file

@ -0,0 +1,109 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
const _artistSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.alpha.feed.defs#artist"),
),
/**
* The MusicBrainz artist ID URI, formatted as mbid:<uuid>
*/
artistMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The name of the artist
* @minLength 1
* @maxLength 256
* @maxGraphemes 2560
*/
artistName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(1, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
});
const _playViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.alpha.feed.defs#playView"),
),
/**
* Array of artists in order of original appearance.
*/
get artists() {
return /*#__PURE__*/ v.array(artistSchema);
},
/**
* The length of the track in seconds
*/
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
/**
* The ISRC code associated with the recording
*/
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided.
*/
musicServiceBaseDomain: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The URL associated with this track
*/
originUrl: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The unix timestamp of when the track was played
*/
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
*/
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
*/
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:<uuid>
*/
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<typeof artistSchema> {}
export interface PlayView extends v.InferInput<typeof playViewSchema> {}

View file

@ -0,0 +1,44 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaFeedDefs from "./defs.js";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.feed.getActorFeed", {
params: /*#__PURE__*/ v.object({
/**
* The author's DID for the play
*/
authorDID: /*#__PURE__*/ v.actorIdentifierString(),
/**
* The cursor to start the query from
*/
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The upper limit of tracks to get per request. Default is 20, max is 50.
*/
limit: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get plays() {
return /*#__PURE__*/ v.array(FmTealAlphaFeedDefs.playViewSchema);
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.alpha.feed.getActorFeed": mainSchema;
}
}

View file

@ -0,0 +1,40 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaFeedDefs from "./defs.js";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.alpha.feed.getPlay", {
params: /*#__PURE__*/ v.object({
/**
* The author's DID for the play
*/
authorDID: /*#__PURE__*/ v.actorIdentifierString(),
/**
* The record key of the play
*/
rkey: /*#__PURE__*/ v.string(),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get play() {
return FmTealAlphaFeedDefs.playViewSchema;
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.alpha.feed.getPlay": mainSchema;
}
}

View file

@ -0,0 +1,138 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealAlphaFeedDefs from "./defs.js";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.tidString(),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.alpha.feed.play"),
/**
* DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs.
* @deprecated
*/
artistMbIds: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(/*#__PURE__*/ v.string()),
),
/**
* DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance.
* @deprecated
*/
artistNames: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(1, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
),
),
/**
* Array of artists in order of original appearance.
*/
get artists() {
return /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(FmTealAlphaFeedDefs.artistSchema),
);
},
/**
* The length of the track in seconds
*/
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
/**
* The ISRC code associated with the recording
*/
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided.
*/
musicServiceBaseDomain: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The URL associated with this track
*/
originUrl: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The unix timestamp of when the track was played
*/
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
*/
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:<uuid>
*/
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 `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. 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:<uuid>
*/
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<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.alpha.feed.play": mainSchema;
}
}

View file

@ -12,8 +12,8 @@ import { FormatPlayObjectOptions, CALCULATED_PLAYER_STATUSES, ReportedPlayerStat
import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js';
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js";
import { ScrobbleRecord, TealClientConfig } from "../common/infrastructure/config/client/tealfm.js";
import { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.js";
import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js";
import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js";
import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js";
import { AbstractBlueSkyApiClient, nowPlayingExpirationDuration, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js";
@ -23,6 +23,7 @@ import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient
import { fromStream } from '@atcute/repo';
import { playToRepositoryCreatePlayHistoricalOpts, RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.js";
import { isAbortError } from "abort-controller-x";
import { FmTealAlphaFeedPlay } from "../common/vendor/teal/lexicons/index.js";
export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
@ -234,7 +235,7 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
if(entry.collection === 'fm.teal.alpha.feed.play') {
let play: PlayObject;
try {
play = recordToPlay(entry.record as ScrobbleRecord, {
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main, {
web: did !== undefined ? `at://did:plc:${did}/fm.teal.alpha.feed.play/${entry.rkey}` : undefined,
playId: entry.rkey,
user: did

View file

@ -11,13 +11,14 @@ import { sortByNewestPlayDate } from './PlayUtils.js';
import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js';
import { arrayListAnd, artistNamesToCredits } from './StringUtils.js';
import { findDelimiters } from "./StringUtils.js";
import { ListRecord, ScrobbleRecord } from '../backend/common/infrastructure/config/client/tealfm.js';
import { ListRecord } from '../backend/common/infrastructure/config/client/tealfm.js';
import { nanoid } from 'nanoid';
import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js';
import { MarkOptional } from 'ts-essentials';
import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js';
import clone from 'clone';
import { removeUndefinedKeys } from '../backend/utils.js';
import { FmTealAlphaFeedPlay } from '../backend/common/vendor/teal/lexicons/index.js';
dayjs.extend(utc)
dayjs.extend(isBetween);
@ -476,7 +477,7 @@ export const generateMbid = (): MBID => {
export const generateTealPlayRecord = (opts: {
withMbids?: boolean,
withIsrc?: boolean
} = {}): [ListRecord<ScrobbleRecord>, { did: string, tid: string }] => {
} = {}): [ListRecord<FmTealAlphaFeedPlay.Main>, { did: string, tid: string }] => {
const {
withMbids = true,
withIsrc = true,
@ -488,12 +489,12 @@ export const generateTealPlayRecord = (opts: {
const did = nanoid(12);
const tid = nanoid(10);
const rec: ListRecord<ScrobbleRecord> = {
const rec: ListRecord<FmTealAlphaFeedPlay.Main> = {
uri: `at://did:plc:${did}/fm.teal.alpha.feed.play/${tid}`,
cid: nanoid(12),
value: {
'$type': 'fm.teal.alpha.feed.play',
artists: artists.map(x => withMbids ? { artistName: x, artistMbId: generateMbid() } : { artistName: x }),
artists: artists.map(x => withMbids ? { artistName: x, artistMbId: `mbid:${generateMbid()}` } : { artistName: x }),
releaseName: faker.music.album(),
trackName: faker.music.songName(),
playedTime: now.toISOString(),
@ -503,8 +504,8 @@ export const generateTealPlayRecord = (opts: {
}
if (withMbids) {
rec.value.releaseMbId = generateMbid();
rec.value.recordingMbId = generateMbid();
rec.value.releaseMbId = `mbid:${generateMbid()}`;
rec.value.recordingMbId = `mbid:${generateMbid()}`;
}
if (withIsrc) {