[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(source): Start implementing recent from API

FoxxMD (Jan 31, 2024, 12:36 PM EST) d88ac1b4 c807611e

+107 -14
+17 -2
src/backend/server/api.ts
··· 202 202 hasAuthInteraction: requiresAuthInteraction, 203 203 authed, 204 204 players: 'players' in x ? (x as MemorySource).playersToObject() : {}, 205 - sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY 205 + sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY, 206 + supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed 206 207 }; 207 208 if(!x.isReady()) { 208 209 if(x.buildOK === false) { ··· 264 265 const { 265 266 // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message 266 267 scrobbleSource: source, 268 + query: { 269 + upstream = 'false' 270 + } 267 271 } = req; 268 272 269 273 let result: PlayObject[] = []; 270 274 if (source !== undefined) { 271 - result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays(); 275 + if (upstream === 'true' || upstream === '1') { 276 + if (!(source as AbstractSource).supportsUpstreamRecentlyPlayed) { 277 + return res.status(409).json({message: 'Fetching upstream recently played is not supported for this source'}); 278 + } 279 + try { 280 + result = await (source as AbstractSource).getUpstreamRecentlyPlayed(); 281 + } catch (e) { 282 + return res.status(500).json({message: e.message}); 283 + } 284 + } else { 285 + result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays(); 286 + } 272 287 } 273 288 274 289 return res.json(result);
+11
src/backend/sources/AbstractSource.ts
··· 79 79 pollRetries: number = 0; 80 80 tracksDiscovered: number = 0; 81 81 82 + supportsUpstreamRecentlyPlayed: boolean = false; 83 + supportsUpstreamNowPlaying: boolean = false; 84 + 82 85 emitter: EventEmitter; 83 86 84 87 protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>(); ··· 217 220 218 221 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 219 222 return []; 223 + } 224 + 225 + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 226 + throw new Error('Not implemented'); 227 + } 228 + 229 + getUpstreamNowPlaying = async(): Promise<PlayObject[]> => { 230 + throw new Error('Not implemented'); 220 231 } 221 232 222 233 // by default if the track was recently played it is valid
+33 -4
src/backend/sources/LastfmSource.ts
··· 10 10 import dayjs from "dayjs"; 11 11 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 12 12 import {ErrorWithCause} from "pony-cause"; 13 - import request from "superagent"; 13 + import request, {options} from "superagent"; 14 14 15 15 export default class LastfmSource extends MemorySource { 16 16 ··· 31 31 super('lastfm', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter); 32 32 this.canPoll = true; 33 33 this.canBacklog = true; 34 + this.supportsUpstreamRecentlyPlayed = true; 35 + this.supportsUpstreamNowPlaying = true; 34 36 this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir, localUrl: internal.localUrl}); 35 37 this.playerSourceOfTruth = SOURCE_SOT.HISTORY; 36 38 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 73 } 72 74 73 75 74 - getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 76 + getLastfmRecentTrack = async(options: RecentlyPlayedOptions = {}): Promise<[PlayObject[], PlayObject[]]> => { 75 77 const {limit = 20} = options; 76 78 const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({ 77 79 user: this.api.user, ··· 119 121 // 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 120 122 const history = plays.filter(x => x.meta.nowPlaying !== true); 121 123 const now = plays.filter(x => x.meta.nowPlaying === true); 122 - this.processRecentPlays(now); 123 - return history; 124 + return [history, now]; 125 + } 126 + 127 + getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 128 + try { 129 + const [history, now] = await this.getLastfmRecentTrack(options); 130 + this.processRecentPlays(now); 131 + return history; 132 + } catch (e) { 133 + throw e; 134 + } 135 + } 136 + 137 + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 138 + try { 139 + const [history, now] = await this.getLastfmRecentTrack(options); 140 + return history; 141 + } catch (e) { 142 + throw e; 143 + } 144 + } 145 + 146 + getUpstreamNowPlaying = async (): Promise<PlayObject[]> => { 147 + try { 148 + const [history, now] = await this.getLastfmRecentTrack(); 149 + return now; 150 + } catch (e) { 151 + throw e; 152 + } 124 153 } 125 154 126 155 protected getBackloggedPlays = async () => {
+9
src/backend/sources/SpotifySource.ts
··· 68 68 this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; 69 69 this.canPoll = true; 70 70 this.canBacklog = true; 71 + this.supportsUpstreamRecentlyPlayed = true; 71 72 } 72 73 73 74 static formatPlayObj(obj: PlayHistoryObject | CurrentlyPlayingObject, options: FormatPlayObjectOptions = {}): PlayObject { ··· 348 349 }); 349 350 const result = await this.callApi<ReturnType<typeof this.spotifyApi.getMyRecentlyPlayedTracks>>(func); 350 351 return result.body.items.map((x: any) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); 352 + } 353 + 354 + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 355 + try { 356 + return await this.getPlayHistory(options); 357 + } catch (e) { 358 + throw e; 359 + } 351 360 } 352 361 353 362 getNowPlaying = async () => {
+9 -2
src/client/components/Tooltip.tsx
··· 1 1 import {PropsWithChildren, ReactElement} from "react"; 2 + import clsx from "clsx"; 2 3 3 4 export interface TooltipProps { 4 5 message: string | ReactElement 6 + classNames?: string[] 7 + style?: object 5 8 } 6 9 10 + const defaultStyle = {}; 11 + 7 12 const Tooltip = (props: PropsWithChildren<TooltipProps>) => { 8 - const {children, message} = props; 13 + const {children, message, classNames = [], style = defaultStyle } = props; 14 + const classes = ['group','relative','flex']; 15 + clsx(classes.concat(classNames)) 9 16 return ( 10 - <div className="group relative flex"> 17 + <div className={clsx(classes.concat(classNames))} style={style}> 11 18 {children} 12 19 <span 13 20 className="absolute top-5 scale-0 transition-all rounded bg-gray-800 p-2 text-xs text-white group-hover:scale-100">{message}</span>
+8 -1
src/client/components/statusCard/SourceStatusCard.tsx
··· 55 55 hasAuthInteraction, 56 56 type, 57 57 players = {}, 58 - sot 58 + sot, 59 + supportsUpstreamRecentlyPlayed 59 60 } = data; 60 61 if(type === 'listenbrainz' || type === 'lastfm') { 61 62 header = `${display} (Source)`; ··· 65 66 66 67 const discovered = (!hasAuth || authed) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>; 67 68 69 + let upstreamRecent = null; 70 + if(supportsUpstreamRecentlyPlayed && (!hasAuth || authed)) { 71 + upstreamRecent = <div><Link to={`/recent?type=${type}&name=${name}&upstream=1`}>See Recent from Source API</Link></div>; 72 + } 73 + 68 74 if((!hasAuth || authed) && canPoll) { 69 75 startSourceElement = <div onClick={poll} className="capitalize underline cursor-pointer">{status === 'Polling' ? 'Restart' : 'Start'}</div> 70 76 } ··· 73 79 body = (<div className="statusCardBody"> 74 80 {platformIds.map(x => <Player key={x} data={players[x]} sot={sot}/>)} 75 81 <div>{discovered}: {tracksDiscovered}</div> 82 + {upstreamRecent} 76 83 {canPoll && hasAuthInteraction ? <a target="_blank" href={`/api/source/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null} 77 84 </div>); 78 85 }
+17 -3
src/client/recent/RecentPage.tsx
··· 1 - import React from 'react'; 1 + import React, {Fragment} from 'react'; 2 2 import PlayDisplay from "../components/PlayDisplay"; 3 3 import {recentIncludes} from "../../core/Atomic"; 4 4 import {useSearchParams} from "react-router-dom"; 5 5 import {useGetRecentQuery} from "./recentDucks"; 6 + import Tooltip from "../components/Tooltip"; 7 + import {faQuestionCircle} from "@fortawesome/free-solid-svg-icons"; 8 + import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 9 + import {data} from "autoprefixer"; 6 10 7 11 const displayOpts = { 8 12 include: recentIncludes, 9 13 includeWeb: true 10 14 } 11 15 16 + const apiTip = <Fragment> 17 + <div>Data that is directly returned by the Source API.</div> 18 + <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> 19 + </Fragment> 20 + 12 21 const recent = () => { 13 22 let [searchParams, setSearchParams] = useSearchParams(); 14 23 const { ··· 16 25 error, 17 26 isLoading, 18 27 isSuccess 19 - } = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type')}); 28 + } = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type'), upstream: searchParams.get('upstream')}); 29 + 30 + const isUpstream = searchParams.get('upstream') === '1'; 20 31 21 32 return ( 22 33 <div className="grid"> 23 34 <div className="shadow-md rounded bg-gray-500 text-white"> 24 35 <div className="p-3 font-semibold bg-gray-700 text-white"> 25 - <h2>Recently Played 36 + <h2>Recently Played{isUpstream ? ' from Source API' : null}{isUpstream ? <Tooltip message={apiTip} 37 + classNames={['ml-2']} 38 + style={{display: 'inline-flex', width: '35%'}}><FontAwesomeIcon color="white" icon={faQuestionCircle}/></Tooltip> : null} 26 39 </h2> 27 40 </div> 28 41 <div className="p-5"> 42 + {/*{isUpstream ? <span className="mb-3">Below is data directly returned by the Source API. MS uses</span> : null}*/} 29 43 {isSuccess && !isLoading && data.length === 0 ? 'No recently played tracks!' : null} 30 44 <ul>{data.map(x => <li key={x.index}><PlayDisplay data={x} buildOptions={displayOpts}/></li>)}</ul> 31 45 </div>
+2 -2
src/client/recent/recentDucks.ts
··· 6 6 reducerPath: 'recentApi', 7 7 baseQuery: fetchBaseQuery({ baseUrl: './api/' }), 8 8 endpoints: (builder) => ({ 9 - getRecent: builder.query<RecentResponse, {name: string, type: string}>({ 10 - query: (params) => `recent?name=${params.name}&type=${params.type}`, 9 + getRecent: builder.query<RecentResponse, {name: string, type: string, upstream?: string}>({ 10 + query: (params) => `recent?name=${params.name}&type=${params.type}&upstream=${params.upstream ?? 0}`, 11 11 transformResponse: (response: RecentResponse, meta, arg) => { 12 12 return response.map((x, index) => ({...x, index: index + 1})) 13 13 }
+1
src/core/Atomic.ts
··· 14 14 authed: boolean; 15 15 players: Record<string, SourcePlayerJson> 16 16 sot: SOURCE_SOT_TYPES 17 + supportsUpstreamRecentlyPlayed: boolean; 17 18 } 18 19 19 20 export interface ClientStatusData {