[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(ui): Implement play activity sortby seen/played

FoxxMD (Mar 24, 2026, 7:15 PM UTC) e68b46f6 7605cd3f

+31 -22
+3 -3
src/client/components/PlayData.tsx
··· 222 222 } 223 223 } 224 224 if (dates.includes('seen') || dates.includes('all')) { 225 - dateElements.push((<TextMuted key="seen">{`Seen ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</TextMuted>)); 225 + dateElements.push((<TextMuted key="seen">{`Seen ${shortTodayAwareFormat(dayjs(play.meta.seenAt))}`}</TextMuted>)); 226 226 } 227 227 datesItem = ( 228 228 <DataList.Item flexGrow="1"> ··· 255 255 playDate = <Text textStyle="xs" color="fg.muted">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text> 256 256 } 257 257 // TODO implement seenAt for play data 258 - if (play.data.playDateCompleted !== undefined && ['all', 'seen'].includes(dates)) { 259 - seenDate = <Text textStyle="xs" color="fg.muted">{`Seen ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`}</Text> 258 + if (play.meta.seenAt !== undefined && ['all', 'seen'].includes(dates)) { 259 + seenDate = <Text textStyle="xs" color="fg.muted">{`Seen ${shortTodayAwareFormat(dayjs(play.meta.seenAt))}`}</Text> 260 260 } 261 261 if (playDate !== undefined && seenDate !== undefined) { 262 262 dateElm = <HStack gap="1">{playDate}<Separator orientation="vertical" height="4" />{seenDate}</HStack>
+14 -6
src/client/components/playActivity/PlayList.tsx
··· 10 10 import { VscDebugRestart } from "react-icons/vsc"; 11 11 import { GroupedVirtuoso } from 'react-virtuoso' 12 12 import { ActivityDetails } from '../ActivityDetail.js'; 13 - import { sortByNewestPlayDate } from '../../../core/PlayUtils.js'; 13 + import { sortByNewestPlayDate, sortByNewestSeenDate } from '../../../core/PlayUtils.js'; 14 14 import "./PlayList.scss"; 15 15 16 16 dayjs.extend(doy); ··· 73 73 render = 'accordian' 74 74 } = props; 75 75 76 - const sorted = useMemo(() => props.data.toSorted((a, b) => sortByNewestPlayDate(a.play, b.play)), [data, sortBy]); 76 + const sorted = useMemo(() => props.data.toSorted((a, b) => { 77 + if(sortBy === 'played') { 78 + return sortByNewestPlayDate(a.play, b.play) 79 + } 80 + return sortByNewestSeenDate(a.play, b.play); 81 + }), [data, sortBy]); 77 82 78 83 if (render === 'accordian') { 79 - return <PlainAccordian data={sorted} /> 84 + return <PlainAccordian data={sorted} sortBy={sortBy} /> 80 85 } 81 86 if (render === 'virtCollapse') { 82 87 return <VirtualizedCollapse data={sorted} /> ··· 285 290 ); 286 291 } 287 292 288 - const PlainAccordian = (props: { data: PlayActivity[] }) => { 289 - const { data = [] } = props; 293 + const PlainAccordian = (props: { data: PlayActivity[], sortBy: 'played' | 'seen' }) => { 294 + const { 295 + data = [], 296 + sortBy 297 + } = props; 290 298 const groups = generateGroupPlays(data); 291 299 return ( 292 300 <Stack gap="2"> ··· 325 333 <Span>{play.data.track}</Span> 326 334 <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 327 335 <HStack gap="1"> 328 - <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 336 + <ShortDateDisplay date={sortBy === 'played' ? play.data.playDate : play.meta?.seenAt} prefix={sortBy === 'played' ? 'Played' : 'Seen'} /><Separator orientation="vertical" height="4" /> 329 337 <TextMuted>{play.meta?.source}</TextMuted> 330 338 </HStack> 331 339 </Stack>
+3 -1
src/core/Atomic.ts
··· 198 198 source?: string 199 199 sourceSOT?: SOURCE_SOT_TYPES 200 200 201 + seenAt?: D 202 + 201 203 /* 202 204 * If applicable, the name of the Service providing the track (Spotify, Tidal, etc...) 203 205 */ ··· 345 347 346 348 export interface AmbPlayObject<D extends DateLike = Dayjs> { 347 349 data: PlayData<D>, 348 - meta: PlayMeta<D> | MarkOptional<PlayMeta<D>, 'lifecycle'> 350 + meta: PlayMeta<D> | Omit<PlayMeta<D>, 'lifecycle'> 349 351 } 350 352 351 353 export const isPlayObject = (obj: object): obj is PlayObject => {
+1
src/core/PlayTestUtils.ts
··· 181 181 }, 182 182 meta: { 183 183 source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})], 184 + seenAt: dayjs(), 184 185 ...meta, 185 186 lifecycle: { 186 187 original: {
+10 -12
src/core/PlayUtils.ts
··· 2 2 import { AmbPlayObject, DateLike, PlayObject, PlayObjectLifecycleless } from "./Atomic.js"; 3 3 import dayjs from "dayjs"; 4 4 5 - 6 5 /** sorts playObj formatted objects by playDate in descending (newest first) order */ 7 - export const sortByNewestPlayDate = (a: AmbPlayObject<DateLike>, b: AmbPlayObject<DateLike>) => { 8 - const { 9 - data: { 10 - playDate: aPlayDate 11 - } = {} 12 - } = a; 13 - const { 14 - data: { 15 - playDate: bPlayDate 16 - } = {} 17 - } = b; 6 + export const sortByNewestDate = (accessor: (play: AmbPlayObject<DateLike>) => DateLike) => (a: AmbPlayObject<DateLike>, b: AmbPlayObject<DateLike>) => { 7 + const aPlayDate = accessor(a); 8 + const bPlayDate = accessor(b); 18 9 if (aPlayDate === undefined && bPlayDate === undefined) { 19 10 return 0; 20 11 } ··· 29 20 const realB = typeof bPlayDate === 'string' ? dayjs(bPlayDate) : bPlayDate; 30 21 return realA.isBefore(realB) ? 1 : -1; 31 22 }; 23 + 24 + 25 + /** sorts playObj formatted objects by playDate in descending (newest first) order */ 26 + export const sortByNewestPlayDate = sortByNewestDate((play) => play.data?.playDate); 27 + 28 + /** sorts playObj formatted objects by playDate in descending (newest first) order */ 29 + export const sortByNewestSeenDate = sortByNewestDate((play) => play.meta?.seenAt); 32 30 33 31 export const genGroupIdStr = (id: PlayPlatformId) => { 34 32 return `${id[0]}-${id[1]}`;