diff --git a/src/backend/common/database/appMigrations/001_lifecycleLoc.ts b/src/backend/common/database/appMigrations/001_lifecycleLoc.ts index a8fa51e7..baa8a9a2 100644 --- a/src/backend/common/database/appMigrations/001_lifecycleLoc.ts +++ b/src/backend/common/database/appMigrations/001_lifecycleLoc.ts @@ -3,6 +3,7 @@ import { MigrateBaseContext } from '../appMigrator.js'; import { plays as drizzlePlays } from '../drizzle/schema/schema.js'; import clone from 'clone'; import { eq } from 'drizzle-orm'; +import { PlayLifecycle, PlayObject } from '../../../../core/Atomic.js'; export const up: Migration['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise => { @@ -16,6 +17,7 @@ export const up: Migration['up'] = async (db: SqliteDatabase while (more) { const playRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset); for (const row of playRows) { + const play = row.play as PlayObject<{lifecycle?: PlayLifecycle}>; try { const { meta: { @@ -25,7 +27,7 @@ export const up: Migration['up'] = async (db: SqliteDatabase scrobble } = {} } = {} - } = row.play; + } = play; if (lifecycle === undefined) { // already migrated or unneeded processed++; @@ -37,6 +39,7 @@ export const up: Migration['up'] = async (db: SqliteDatabase if (scrobble !== undefined) { row.play.scrobble = clone(scrobble); } + // @ts-expect-error delete row.play.meta.lifecycle; await ctx.db.update(drizzlePlays).set({ play: row.play }).where(eq(drizzlePlays.id, row.id)); updated++; diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index 5ca96137..b4974471 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -311,7 +311,6 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina playDate, }, meta: { - mbid, nowPlaying, } } = formatted; @@ -319,13 +318,13 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina // if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing // and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp // so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway - this.logger.trace({ track, mbid }, `Ignoring 'now playing' track returned from ${this.upstreamName} client`); + this.logger.trace({ track }, `Ignoring 'now playing' track returned from ${this.upstreamName} client`); return acc; } else if (playDate === undefined) { if(nowPlaying === true) { formatted.data.playDate = dayjs(); } else { - this.logger.warn({ track, mbid }, `${this.upstreamName} recently scrobbled track did not contain a timestamp, omitting from time frame check`); + this.logger.warn({ track }, `${this.upstreamName} recently scrobbled track did not contain a timestamp, omitting from time frame check`); return acc; } } @@ -706,7 +705,6 @@ export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectO }, meta: { nowPlaying: nowplaying === 'true', - mbid, source, url: { web: url, diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 1c707644..e9567828 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -701,11 +701,7 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version]) } } - - if(trackId !== undefined) { - play.meta.trackid = trackId; - } - + const brainzMeta = removeUndefinedKeys({ album: release_mbid, releaseGroup: release_group_mbid, diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index d46af659..9548476b 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -17,7 +17,8 @@ import { ErrorLike, CLIENT_INGRESS_QUEUE, CLIENT_DEAD_QUEUE, - PlayOriginal + PlayOriginal, + PlayLifecycle } from "../../core/Atomic.js"; import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; import AbstractComponent from "../common/AbstractComponent.js"; @@ -88,7 +89,6 @@ type NowPlayingQueue = Map; const platformTruncate = truncateStringToLength(10); - export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable { name: string; @@ -567,7 +567,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i if (shouldMigrate) { const migrationEntry: ComponentMigrationNew = migration !== undefined ? migration : {componentId: this.dbComponent.id, name: 'cachedScrobbles'}; try { - const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble[] ?? []); + const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble>[] ?? []); const migratedQueue: QueuedScrobble[] = []; let allGood = true; if (cachedQueue.length > 0) { @@ -577,10 +577,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i logger.debug(`Skipping already migrated play => ${buildTrackString(cachedQueuedScrobble.play)}`); continue; } - const play = asPlay(cachedQueuedScrobble.play); + const play = asPlay(cachedQueuedScrobble.play) as PlayObject<{migrated?: boolean, lifecycle?: PlayLifecycle}>; const { meta: { - lifecycle = {}, + lifecycle, ...metaRest }, data: { @@ -599,19 +599,20 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i ...dataRest }, meta: metaRest, - // @ts-expect-error - lifecycle: lifecycle.steps - } - if('scrobble' in lifecycle) { - updatedPlay.scrobble = lifecycle.scrobble; + + lifecycle: lifecycle?.steps } - if('input' in lifecycle || 'original' in lifecycle) { - updatedPlay.original = removeUndefinedKeys({ - // @ts-expect-error - data: lifecycle.input, - // @ts-expect-error - play: lifecycle.original - }) + if(lifecycle !== undefined) { + if('scrobble' in lifecycle) { + updatedPlay.scrobble = lifecycle.scrobble; + } + if('input' in lifecycle || 'original' in lifecycle) { + updatedPlay.original = removeUndefinedKeys({ + + data: lifecycle.input, + play: lifecycle.original + }) + } } // return play object without going through transform since it was (presumably) already transformed before being cached const res = await this.queueScrobble(updatedPlay, updatedPlay.meta.source, async (x) => x); @@ -632,7 +633,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i logger.info('No scrobbles to migrate'); } - const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble[] ?? []); + const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble>[] ?? []); const migratedDead: DeadLetterScrobble[] = []; if (cachedDead.length > 0) { logger.info('Migrating failed scrobbles to database...'); @@ -642,10 +643,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i logger.debug(`Skipping already migrated play => ${buildTrackString(cDeadScrobble.play)}`) continue; } - const play = asPlay(cDeadScrobble.play); + const play = asPlay(cDeadScrobble.play) as PlayObject<{migrated?: boolean, lifecycle?: PlayLifecycle}>; const { meta: { - lifecycle = {}, + lifecycle, ...metaRest }, data: { @@ -663,19 +664,18 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i ...dataRest }, meta: metaRest, - // @ts-expect-error - lifecycle: lifecycle.steps - } - if('scrobble' in lifecycle) { - updatedDeadPlay.scrobble = lifecycle.scrobble; + lifecycle: lifecycle?.steps } - if('input' in lifecycle || 'original' in lifecycle) { - updatedDeadPlay.original = removeUndefinedKeys({ - // @ts-expect-error - data: lifecycle.input, - // @ts-expect-error - play: lifecycle.original - }) + if(lifecycle !== undefined) { + if('scrobble' in lifecycle) { + updatedDeadPlay.scrobble = lifecycle.scrobble; + } + if('input' in lifecycle || 'original' in lifecycle) { + updatedDeadPlay.original = removeUndefinedKeys({ + data: lifecycle.input, + play: lifecycle.original + }) + } } try { const res = await this.playRepo.createPlays([ diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 779b4869..882eb236 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -261,10 +261,12 @@ export class MPDSource extends MemoryPositionalSource { albumArtists: albumArtists !== undefined ? artistNamesToCredits(albumArtists) : [], album, track: trackName, - duration + duration, + meta: { + brainz + } }, meta: { - brainz, trackProgressPosition: position, mediaPlayerName: 'mpd' } diff --git a/src/backend/sources/PlayerState/JellyfinPlayerState.ts b/src/backend/sources/PlayerState/JellyfinPlayerState.ts index 425dea39..1ecac4f6 100644 --- a/src/backend/sources/PlayerState/JellyfinPlayerState.ts +++ b/src/backend/sources/PlayerState/JellyfinPlayerState.ts @@ -12,9 +12,9 @@ export class JellyfinPlayerState extends PositionalPlayerState { update(state: PlayerStateDataMaybePlay) { let stat: ReportedPlayerStatus = state.status; - if(stat === undefined && state.play?.meta?.event === 'PlaybackProgress') { - stat = 'playing'; - } + // if(stat === undefined && state.play?.meta?.event === 'PlaybackProgress') { + // stat = 'playing'; + // } return super.update({...state, status: stat}); } } diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index d0eec854..f5053dce 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -1,4 +1,4 @@ -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import EventEmitter from "events"; import { PlayObject, PlayObjectMinimal, SOURCE_SOT } from "../../core/Atomic.js"; import { @@ -25,6 +25,12 @@ import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; import { artistCreditToName, artistNameToCredit } from "../../core/StringUtils.js"; +interface WebScrobbleMeta { + scrobbleAllowed?: boolean +} + +type WebScrobblerPlayObject = PlayObjectMinimal; + export class WebScrobblerSource extends MemorySource { declare config: WebScrobblerSourceConfig; @@ -97,7 +103,7 @@ export class WebScrobblerSource extends MemorySource { static formatPlayObj(obj: WebScrobblerSong, options: FormatPlayObjectOptions & { nowPlaying?: boolean - } = {}): PlayObject { + } = {}): WebScrobblerPlayObject { const { connectorLabel, connector: { @@ -125,7 +131,7 @@ export class WebScrobblerSource extends MemorySource { const albumArtist = processed.albumArtist ?? parsed.albumArtist; const duration = parsed.duration ?? processed.duration; - const play: PlayObjectMinimal = { + const play: PlayObjectMinimal = { data: { track, artists: [artistNameToCredit(artist)], @@ -158,7 +164,7 @@ export class WebScrobblerSource extends MemorySource { getRecentlyPlayed = async (options = {}) => await this.getFlatRecentlyDiscoveredPlays() - isValidScrobble = (playObj: PlayObject) => { + isValidScrobble = (playObj: WebScrobblerPlayObject) => { if (playObj.meta?.scrobbleAllowed === false) { this.logger.debug(`Will not scrobble play because it was marked as 'Do Not Scrobble' by extension`); return false; diff --git a/src/backend/tests/jellyfin/jellyfin.test.ts b/src/backend/tests/jellyfin/jellyfin.test.ts index 8503fe5c..2ca248fb 100644 --- a/src/backend/tests/jellyfin/jellyfin.test.ts +++ b/src/backend/tests/jellyfin/jellyfin.test.ts @@ -18,7 +18,6 @@ import { // @ts-expect-error weird typings? import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js"; import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; -import { MarkOptional } from "ts-essentials"; const dataAsFixture = (data: any): TestFixture => { return data as TestFixture; @@ -44,7 +43,7 @@ const validPlayerState: PlayerStateDataMaybePlay = { platformId: ['1234', 'MyUser'], play: generatePlay({}, {mediaType: 'Audio', user: 'MyUser', deviceId: '1234'}) } -const playWithMeta = (meta: MarkOptional): PlayerStateDataMaybePlay => { +const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { const {user, deviceId} = meta; const platformId = validPlayerState.platformId; return { diff --git a/src/backend/tests/plex/plex.test.ts b/src/backend/tests/plex/plex.test.ts index 8777d161..5da5c603 100644 --- a/src/backend/tests/plex/plex.test.ts +++ b/src/backend/tests/plex/plex.test.ts @@ -2,7 +2,7 @@ import { loggerTest } from "@foxxmd/logging"; import { assert, expect } from 'chai'; import EventEmitter from "events"; import { describe, it } from 'mocha'; -import { JsonPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; +import { PlayMeta } from "../../../core/Atomic.js"; import validSessionResponse from './validSession.json' with { type: "json" }; import { generatePlay } from "../../../core/PlayTestUtils.js"; @@ -10,7 +10,6 @@ import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js" import { PlexApiData } from "../../common/infrastructure/config/source/plex.js"; import PlexApiSource from "../../sources/PlexApiSource.js"; import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; -import { MarkOptional } from "ts-essentials"; const validSession = validSessionResponse.object.mediaContainer.metadata[0]; @@ -34,7 +33,7 @@ const validPlayerState: PlayerStateDataMaybePlay = { platformId: ['1234', 'MyUser'], play: generatePlay({}, {mediaType: 'track', user: 'MyUser', deviceId: '1234', library: 'Music'}) } -const playWithMeta = (meta: MarkOptional): PlayerStateDataMaybePlay => { +const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { const {user, deviceId} = meta; const platformId = validPlayerState.platformId; return { diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index a900b0dc..923d7dcb 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -1,8 +1,7 @@ import { LogDataPretty, LogLevel } from "@foxxmd/logging"; import { Dayjs } from "dayjs"; -import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js"; import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; -import { MarkOptional, RequiredKeys } from "ts-essentials"; +import { Merge, RequiredKeys } from "ts-essentials"; import { ErrorObject } from "serialize-error"; import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js"; import { FlowControlTerm, TransformHook } from "../backend/common/infrastructure/Transform.js"; @@ -201,7 +200,9 @@ export interface ArtMeta { artist?: string } -export interface PlayMeta { +export type PlayMeta = Merge, T>; + +export interface PlayMetaBase { source?: string sourceSOT?: SOURCE_SOT_TYPES @@ -285,7 +286,7 @@ export interface PlayMeta { //lifecycle: PlayLifecycle lifecycleInputs?: LifecycleInput[] - [key: string]: any + //[key: string]: any } export interface LifecycleInput { @@ -304,12 +305,12 @@ export interface ScrobbleResult { mergedScrobble?: AmbPlayObjectMinimal } -// export interface PlayLifecycle { -// //input?: object -// //original?: PlayObjectLifecycleless -// //steps: LifecycleStep[] -// //scrobble?: ScrobbleResult -// } +export interface PlayLifecycle { + input?: object + original?: PlayObjectMinimal + steps: LifecycleStep[] + scrobble?: ScrobbleResult +} export interface LifecycleStep { stageName: string @@ -361,24 +362,24 @@ export interface PlayOriginal { play?: PlayObjectMinimal } -export interface AmbPlayObject { +export interface AmbPlayObject { id?: number uid?: string data: PlayData, - meta: PlayMeta + meta: PlayMeta original?: PlayOriginal scrobble?: ScrobbleResult lifecycle?: LifecycleStep[] } -export type AmbPlayObjectMinimal = Pick, RequiredKeys>>; +export type AmbPlayObjectMinimal = Pick, RequiredKeys>>; export const isPlayObject = (obj: object): obj is PlayObject => { return obj !== undefined && obj !== null && 'data' in obj && typeof obj.data === 'object' && 'meta' in obj && typeof obj.meta === 'object'; } -export type PlayObject = AmbPlayObject; -export type PlayObjectMinimal = AmbPlayObjectMinimal; +export type PlayObject = AmbPlayObject; +export type PlayObjectMinimal = AmbPlayObjectMinimal; export interface PlayActivity { play: JsonPlayObject status: string diff --git a/src/core/PlayTestUtils.ts b/src/core/PlayTestUtils.ts index 78faee22..54e44d87 100644 --- a/src/core/PlayTestUtils.ts +++ b/src/core/PlayTestUtils.ts @@ -1,4 +1,4 @@ -import { faker, fakerEL, FakerError } from '@faker-js/faker'; +import { faker } from '@faker-js/faker'; import dayjs, { Dayjs } from "dayjs"; import duration from "dayjs/plugin/duration.js"; import isBetween from "dayjs/plugin/isBetween.js"; @@ -14,7 +14,6 @@ import { findDelimiters } from "./StringUtils.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 clone from 'clone'; import { removeUndefinedKeys } from '../backend/utils.js'; import { FmTealAlphaFeedPlay } from '../backend/common/vendor/teal/lexicons/index.js'; @@ -32,7 +31,7 @@ export const normalizePlays = (plays: PlayObject[], endDate?: Dayjs defaultDuration?: number, defaultData?: ObjectPlayData, - defaultMeta?: MarkOptional + defaultMeta?: PlayMeta } ): PlayObject[] => { const { @@ -165,7 +164,7 @@ export interface GeneratePlayOpts { playDateCompleted?: boolean, listenRanges?: boolean } -export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional = {}, opts: GeneratePlayOpts = {}): PlayObject => { +export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}, opts: GeneratePlayOpts = {}): PlayObject => { const { playDateCompleted = false, listenRanges = false, @@ -292,7 +291,7 @@ export const generatePlayPlatformId = (deviceId?: string, userId?: string): Play return [did, uid]; } -export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional = {}, opts: GeneratePlayOpts = {}): PlayObject[] => { +export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}, opts: GeneratePlayOpts = {}): PlayObject[] => { return Array.from(Array(numberOfPlays), () => generatePlay(data, meta, opts)); } diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index 2aeabbe8..0acd7b42 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -21,7 +21,7 @@ export interface ScrobbleMatchOptions { export interface GeneratePlayWithLifecycleOptions { original?: { data?: ObjectPlayData, - meta?: MarkOptional + meta?: PlayMeta opts?: GeneratePlayOpts }, lifecycleSteps?: {