From d88ac1b4242a6e82df213ca2439dec45cd0351da Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 31 Jan 2024 12:36:35 -0500 Subject: [PATCH] feat(source): Start implementing recent from API --- src/backend/server/api.ts | 19 +++++++++- src/backend/sources/AbstractSource.ts | 11 ++++++ src/backend/sources/LastfmSource.ts | 37 +++++++++++++++++-- src/backend/sources/SpotifySource.ts | 9 +++++ src/client/components/Tooltip.tsx | 11 +++++- .../statusCard/SourceStatusCard.tsx | 9 ++++- src/client/recent/RecentPage.tsx | 20 ++++++++-- src/client/recent/recentDucks.ts | 4 +- src/core/Atomic.ts | 1 + 9 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 56abe051..c9aea643 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -202,7 +202,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput hasAuthInteraction: requiresAuthInteraction, authed, players: 'players' in x ? (x as MemorySource).playersToObject() : {}, - sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY + sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY, + supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed }; if(!x.isReady()) { if(x.buildOK === false) { @@ -264,11 +265,25 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleSource: source, + query: { + upstream = 'false' + } } = req; let result: PlayObject[] = []; if (source !== undefined) { - result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays(); + if (upstream === 'true' || upstream === '1') { + if (!(source as AbstractSource).supportsUpstreamRecentlyPlayed) { + return res.status(409).json({message: 'Fetching upstream recently played is not supported for this source'}); + } + try { + result = await (source as AbstractSource).getUpstreamRecentlyPlayed(); + } catch (e) { + return res.status(500).json({message: e.message}); + } + } else { + result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays(); + } } return res.json(result); diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 23a4b354..e8c0cc96 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -79,6 +79,9 @@ export default abstract class AbstractSource implements Authenticatable { pollRetries: number = 0; tracksDiscovered: number = 0; + supportsUpstreamRecentlyPlayed: boolean = false; + supportsUpstreamNowPlaying: boolean = false; + emitter: EventEmitter; protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap>(); @@ -219,6 +222,14 @@ export default abstract class AbstractSource implements Authenticatable { return []; } + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { + throw new Error('Not implemented'); + } + + getUpstreamNowPlaying = async(): Promise => { + throw new Error('Not implemented'); + } + // by default if the track was recently played it is valid // this is useful for sources where the track doesn't have complete information like Subsonic // TODO make this more descriptive? or move it elsewhere diff --git a/src/backend/sources/LastfmSource.ts b/src/backend/sources/LastfmSource.ts index c66ee53b..a6bf01cb 100644 --- a/src/backend/sources/LastfmSource.ts +++ b/src/backend/sources/LastfmSource.ts @@ -10,7 +10,7 @@ import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastf import dayjs from "dayjs"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import {ErrorWithCause} from "pony-cause"; -import request from "superagent"; +import request, {options} from "superagent"; export default class LastfmSource extends MemorySource { @@ -31,6 +31,8 @@ export default class LastfmSource extends MemorySource { super('lastfm', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter); this.canPoll = true; this.canBacklog = true; + this.supportsUpstreamRecentlyPlayed = true; + this.supportsUpstreamNowPlaying = true; this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir, localUrl: internal.localUrl}); this.playerSourceOfTruth = SOURCE_SOT.HISTORY; this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`) @@ -71,7 +73,7 @@ export default class LastfmSource extends MemorySource { } - getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise => { + getLastfmRecentTrack = async(options: RecentlyPlayedOptions = {}): Promise<[PlayObject[], PlayObject[]]> => { const {limit = 20} = options; const resp = await this.api.callApi((client: any) => client.userGetRecentTracks({ user: this.api.user, @@ -119,8 +121,35 @@ export default class LastfmSource extends MemorySource { // 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 const history = plays.filter(x => x.meta.nowPlaying !== true); const now = plays.filter(x => x.meta.nowPlaying === true); - this.processRecentPlays(now); - return history; + return [history, now]; + } + + getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise => { + try { + const [history, now] = await this.getLastfmRecentTrack(options); + this.processRecentPlays(now); + return history; + } catch (e) { + throw e; + } + } + + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { + try { + const [history, now] = await this.getLastfmRecentTrack(options); + return history; + } catch (e) { + throw e; + } + } + + getUpstreamNowPlaying = async (): Promise => { + try { + const [history, now] = await this.getLastfmRecentTrack(); + return now; + } catch (e) { + throw e; + } } protected getBackloggedPlays = async () => { diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 99a05c1f..f6dcc84d 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -68,6 +68,7 @@ export default class SpotifySource extends MemorySource { this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; this.canPoll = true; this.canBacklog = true; + this.supportsUpstreamRecentlyPlayed = true; } static formatPlayObj(obj: PlayHistoryObject | CurrentlyPlayingObject, options: FormatPlayObjectOptions = {}): PlayObject { @@ -350,6 +351,14 @@ export default class SpotifySource extends MemorySource { return result.body.items.map((x: any) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); } + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { + try { + return await this.getPlayHistory(options); + } catch (e) { + throw e; + } + } + getNowPlaying = async () => { const func = (api: SpotifyWebApi) => api.getMyCurrentPlayingTrack(); const playingRes = await this.callApi>(func); diff --git a/src/client/components/Tooltip.tsx b/src/client/components/Tooltip.tsx index 70b7ff6e..086b6a41 100644 --- a/src/client/components/Tooltip.tsx +++ b/src/client/components/Tooltip.tsx @@ -1,13 +1,20 @@ import {PropsWithChildren, ReactElement} from "react"; +import clsx from "clsx"; export interface TooltipProps { message: string | ReactElement + classNames?: string[] + style?: object } +const defaultStyle = {}; + const Tooltip = (props: PropsWithChildren) => { - const {children, message} = props; + const {children, message, classNames = [], style = defaultStyle } = props; + const classes = ['group','relative','flex']; + clsx(classes.concat(classNames)) return ( -
+
{children} {message} diff --git a/src/client/components/statusCard/SourceStatusCard.tsx b/src/client/components/statusCard/SourceStatusCard.tsx index 7d861fc8..269db1db 100644 --- a/src/client/components/statusCard/SourceStatusCard.tsx +++ b/src/client/components/statusCard/SourceStatusCard.tsx @@ -55,7 +55,8 @@ const SourceStatusCard = (props: SourceStatusCardData) => { hasAuthInteraction, type, players = {}, - sot + sot, + supportsUpstreamRecentlyPlayed } = data; if(type === 'listenbrainz' || type === 'lastfm') { header = `${display} (Source)`; @@ -65,6 +66,11 @@ const SourceStatusCard = (props: SourceStatusCardData) => { const discovered = (!hasAuth || authed) ? Tracks Discovered : Tracks Discovered; + let upstreamRecent = null; + if(supportsUpstreamRecentlyPlayed && (!hasAuth || authed)) { + upstreamRecent =
See Recent from Source API
; + } + if((!hasAuth || authed) && canPoll) { startSourceElement =
{status === 'Polling' ? 'Restart' : 'Start'}
} @@ -73,6 +79,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => { body = (
{platformIds.map(x => )}
{discovered}: {tracksDiscovered}
+ {upstreamRecent} {canPoll && hasAuthInteraction ? (Re)authenticate : null}
); } diff --git a/src/client/recent/RecentPage.tsx b/src/client/recent/RecentPage.tsx index 82dd4d1f..c59338f8 100644 --- a/src/client/recent/RecentPage.tsx +++ b/src/client/recent/RecentPage.tsx @@ -1,14 +1,23 @@ -import React from 'react'; +import React, {Fragment} from 'react'; import PlayDisplay from "../components/PlayDisplay"; import {recentIncludes} from "../../core/Atomic"; import {useSearchParams} from "react-router-dom"; import {useGetRecentQuery} from "./recentDucks"; +import Tooltip from "../components/Tooltip"; +import {faQuestionCircle} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {data} from "autoprefixer"; const displayOpts = { include: recentIncludes, includeWeb: true } +const apiTip = +
Data that is directly returned by the Source API.
+
If you do not see your recent plays in this data it is likely the Source's data is lagging behind your actual activity.
+
+ const recent = () => { let [searchParams, setSearchParams] = useSearchParams(); const { @@ -16,16 +25,21 @@ const recent = () => { error, isLoading, isSuccess - } = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type')}); + } = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type'), upstream: searchParams.get('upstream')}); + + const isUpstream = searchParams.get('upstream') === '1'; return (
-

Recently Played +

Recently Played{isUpstream ? ' from Source API' : null}{isUpstream ? : null}

+ {/*{isUpstream ? Below is data directly returned by the Source API. MS uses : null}*/} {isSuccess && !isLoading && data.length === 0 ? 'No recently played tracks!' : null}
    {data.map(x =>
  • )}
diff --git a/src/client/recent/recentDucks.ts b/src/client/recent/recentDucks.ts index 9589dc0c..df515511 100644 --- a/src/client/recent/recentDucks.ts +++ b/src/client/recent/recentDucks.ts @@ -6,8 +6,8 @@ export const recentApi = createApi({ reducerPath: 'recentApi', baseQuery: fetchBaseQuery({ baseUrl: './api/' }), endpoints: (builder) => ({ - getRecent: builder.query({ - query: (params) => `recent?name=${params.name}&type=${params.type}`, + getRecent: builder.query({ + query: (params) => `recent?name=${params.name}&type=${params.type}&upstream=${params.upstream ?? 0}`, transformResponse: (response: RecentResponse, meta, arg) => { return response.map((x, index) => ({...x, index: index + 1})) } diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 6f65b878..a9e724d6 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -14,6 +14,7 @@ export interface SourceStatusData { authed: boolean; players: Record sot: SOURCE_SOT_TYPES + supportsUpstreamRecentlyPlayed: boolean; } export interface ClientStatusData { -- 2.51.2