mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
feat(source): Start implementing recent from API
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
|
||||
@@ -219,6 +222,14 @@ export default abstract class AbstractSource implements Authenticatable {
|
||||
return [];
|
||||
}
|
||||
|
||||
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
getUpstreamNowPlaying = async(): Promise<PlayObject[]> => {
|
||||
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
|
||||
|
||||
@@ -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<PlayObject[]> => {
|
||||
getLastfmRecentTrack = async(options: RecentlyPlayedOptions = {}): Promise<[PlayObject[], PlayObject[]]> => {
|
||||
const {limit = 20} = options;
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((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<PlayObject[]> => {
|
||||
try {
|
||||
const [history, now] = await this.getLastfmRecentTrack(options);
|
||||
this.processRecentPlays(now);
|
||||
return history;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const [history, now] = await this.getLastfmRecentTrack(options);
|
||||
return history;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getUpstreamNowPlaying = async (): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const [history, now] = await this.getLastfmRecentTrack();
|
||||
return now;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async () => {
|
||||
|
||||
@@ -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<PlayObject[]> => {
|
||||
try {
|
||||
return await this.getPlayHistory(options);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getNowPlaying = async () => {
|
||||
const func = (api: SpotifyWebApi) => api.getMyCurrentPlayingTrack();
|
||||
const playingRes = await this.callApi<ReturnType<typeof this.spotifyApi.getMyCurrentPlayingTrack>>(func);
|
||||
|
||||
@@ -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<TooltipProps>) => {
|
||||
const {children, message} = props;
|
||||
const {children, message, classNames = [], style = defaultStyle } = props;
|
||||
const classes = ['group','relative','flex'];
|
||||
clsx(classes.concat(classNames))
|
||||
return (
|
||||
<div className="group relative flex">
|
||||
<div className={clsx(classes.concat(classNames))} style={style}>
|
||||
{children}
|
||||
<span
|
||||
className="absolute top-5 scale-0 transition-all rounded bg-gray-800 p-2 text-xs text-white group-hover:scale-100">{message}</span>
|
||||
|
||||
@@ -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) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>;
|
||||
|
||||
let upstreamRecent = null;
|
||||
if(supportsUpstreamRecentlyPlayed && (!hasAuth || authed)) {
|
||||
upstreamRecent = <div><Link to={`/recent?type=${type}&name=${name}&upstream=1`}>See Recent from Source API</Link></div>;
|
||||
}
|
||||
|
||||
if((!hasAuth || authed) && canPoll) {
|
||||
startSourceElement = <div onClick={poll} className="capitalize underline cursor-pointer">{status === 'Polling' ? 'Restart' : 'Start'}</div>
|
||||
}
|
||||
@@ -73,6 +79,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
body = (<div className="statusCardBody">
|
||||
{platformIds.map(x => <Player key={x} data={players[x]} sot={sot}/>)}
|
||||
<div>{discovered}: {tracksDiscovered}</div>
|
||||
{upstreamRecent}
|
||||
{canPoll && hasAuthInteraction ? <a target="_blank" href={`/api/source/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -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 = <Fragment>
|
||||
<div>Data that is directly returned by the Source API.</div>
|
||||
<div>If you do not see your recent plays in this data it is likely the Source's data is lagging behind your actual activity.</div>
|
||||
</Fragment>
|
||||
|
||||
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 (
|
||||
<div className="grid">
|
||||
<div className="shadow-md rounded bg-gray-500 text-white">
|
||||
<div className="p-3 font-semibold bg-gray-700 text-white">
|
||||
<h2>Recently Played
|
||||
<h2>Recently Played{isUpstream ? ' from Source API' : null}{isUpstream ? <Tooltip message={apiTip}
|
||||
classNames={['ml-2']}
|
||||
style={{display: 'inline-flex', width: '35%'}}><FontAwesomeIcon color="white" icon={faQuestionCircle}/></Tooltip> : null}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{/*{isUpstream ? <span className="mb-3">Below is data directly returned by the Source API. MS uses</span> : null}*/}
|
||||
{isSuccess && !isLoading && data.length === 0 ? 'No recently played tracks!' : null}
|
||||
<ul>{data.map(x => <li key={x.index}><PlayDisplay data={x} buildOptions={displayOpts}/></li>)}</ul>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ export const recentApi = createApi({
|
||||
reducerPath: 'recentApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: './api/' }),
|
||||
endpoints: (builder) => ({
|
||||
getRecent: builder.query<RecentResponse, {name: string, type: string}>({
|
||||
query: (params) => `recent?name=${params.name}&type=${params.type}`,
|
||||
getRecent: builder.query<RecentResponse, {name: string, type: string, upstream?: string}>({
|
||||
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}))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SourceStatusData {
|
||||
authed: boolean;
|
||||
players: Record<string, SourcePlayerJson>
|
||||
sot: SOURCE_SOT_TYPES
|
||||
supportsUpstreamRecentlyPlayed: boolean;
|
||||
}
|
||||
|
||||
export interface ClientStatusData {
|
||||
|
||||
Reference in New Issue
Block a user