[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): Add more info to play data

FoxxMD (Mar 24, 2026, 1:22 AM UTC) 0daaec81 2085df44

+118 -50
+1 -1
src/backend/sources/AbstractSource.ts
··· 33 33 } from "../utils.js"; 34 34 import { sortByNewestPlayDate } from '../../core/PlayUtils.js'; 35 35 import { formatNumber } from '../../core/DataUtils.js'; 36 - import { timeToHumanTimestamp } from "../utils/TimeUtils.js"; 36 + import { timeToHumanTimestamp } from "../../core/TimeUtils.js"; 37 37 import { todayAwareFormat } from "../../core/TimeUtils.js"; 38 38 import { getRoot } from '../ioc.js'; 39 39 import { componentFileLogger } from '../common/logging.js';
+2 -1
src/backend/sources/MemorySource.ts
··· 21 21 } from "../utils.js"; 22 22 import { genGroupIdStr } from '../../core/PlayUtils.js'; 23 23 import { formatNumber } from '../../core/DataUtils.js'; 24 - import { timePassesScrobbleThreshold, timeToHumanTimestamp } from "../utils/TimeUtils.js"; 24 + import { timePassesScrobbleThreshold } from "../utils/TimeUtils.js"; 25 + import { timeToHumanTimestamp } from "../../core/TimeUtils.js"; 25 26 import { PromisePool } from "@supercharge/promise-pool"; 26 27 import AbstractSource from "./AbstractSource.js"; 27 28 import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
+2 -1
src/backend/sources/PlayerState/AbstractPlayerState.ts
··· 18 18 import { formatNumber } from '../../../core/DataUtils.js'; 19 19 import { ListenProgress } from "./ListenProgress.js"; 20 20 import { ListenRange, ListenRangePositional } from "./ListenRange.js"; 21 - import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timeToHumanTimestamp } from "../../utils/TimeUtils.js"; 21 + import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed } from "../../utils/TimeUtils.js"; 22 + import { timeToHumanTimestamp } from "../../../core/TimeUtils.js"; 22 23 import { todayAwareFormat } from "../../../core/TimeUtils.js"; 23 24 24 25 export interface PlayerStateIntervals {
-12
src/backend/utils/TimeUtils.ts
··· 318 318 319 319 export type Milliseconds = number; 320 320 321 - export const timeToHumanTimestamp = (val: ReturnType<typeof dayjs.duration> | Milliseconds): string => { 322 - const ms = dayjs.isDuration(val) ? Math.abs(val.asMilliseconds()) : val; 323 - 324 - // less than one hour 325 - if(ms < 3600000) { 326 - // EX 14:07 327 - return new Date(ms).toISOString().substring(14, 19) 328 - } 329 - // EX 01:15:45 330 - return new Date(ms).toISOString().substring(11, 19); 331 - } 332 - 333 321 /** Is Position earlier than X seconds or Y% percent of the start of a Play? */ 334 322 export const closeToPlayStart = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => { 335 323 const {
+83 -30
src/client/components/PlayData.tsx
··· 1 1 import React, { Fragment, useMemo, useState } from 'react'; 2 - import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid, Float } from "@chakra-ui/react" 3 - import { LuCode, LuText } from "react-icons/lu" 2 + import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid, Float, Spacer, Icon, Link, Span, Show } from "@chakra-ui/react" 3 + import { LuCode, LuText, LuCheck, LuX } from "react-icons/lu" 4 4 import { JsonPlayObject, PlayObjectLifecycleless } from '../../core/Atomic.js'; 5 - import { shortTodayAwareFormat } from '../../core/TimeUtils.js'; 5 + import { shortTodayAwareFormat, timeToHumanTimestamp } from '../../core/TimeUtils.js'; 6 6 import dayjs from 'dayjs'; 7 7 import { ChakraCodeBlock } from './CodeBlock.js'; 8 8 import { TextMuted } from './TextMuted.js'; 9 + import { formatNumber } from '../../core/DataUtils.js'; 10 + import { Muted } from './Typography.js'; 9 11 10 12 const EmptyPlayData = () => { 11 13 return ( ··· 114 116 115 117 const { 116 118 data: { 117 - artists = [] 119 + track, 120 + artists = [], 121 + listenedFor, 122 + duration, 123 + repeat, 124 + meta: { 125 + brainz = {} 126 + } = {} 127 + } = {}, 128 + meta: { 129 + url: { 130 + web: webUrl, 131 + origin: originUrl 132 + } = {} 118 133 } = {} 119 134 } = play; 120 135 136 + let titleElm: JSX.Element; 137 + if(webUrl !== undefined || originUrl !== undefined) { 138 + titleElm = <Link variant="underline" target="_blank" href={webUrl ?? originUrl}>{track}</Link> 139 + } else { 140 + titleElm = <Span>{track}</Span>; 141 + } 142 + 121 143 return ( 122 - <DataList.Root flexWrap="wrap" flexDirection="row"> 123 - <DataList.Item flexGrow="1"> 124 - <DataList.ItemLabel flexShrink="1">Title</DataList.ItemLabel> 125 - <DataList.ItemValue>{play.data.track}</DataList.ItemValue> 126 - </DataList.Item> 127 - <DataList.Item flexGrow="1"> 128 - <DataList.ItemLabel>Artists</DataList.ItemLabel> 129 - <DataList.ItemValue> 130 - {artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> : 131 - <HStack>{play.data.artists.map((x, index) => { 132 - return ( 133 - <Tag.Root key={index}> 134 - <Tag.Label>{x}</Tag.Label> 135 - </Tag.Root> 136 - ); 137 - })}</HStack>} 138 - </DataList.ItemValue> 139 - </DataList.Item> 140 - {albumArtistElm} 141 - <DataList.Item flexGrow="1"> 142 - <DataList.ItemLabel>Album</DataList.ItemLabel> 143 - <DataList.ItemValue>{play.data.album}</DataList.ItemValue> 144 - </DataList.Item> 145 - <PlayDatesStack play={play} dates={dates} /> 146 - </DataList.Root> 144 + <Flex flexDirection="column" gap="4"> 145 + <DataList.Root flexWrap="wrap" flexDirection="row"> 146 + <DataList.Item flexGrow="1"> 147 + <DataList.ItemLabel flexShrink="1">Title</DataList.ItemLabel> 148 + <DataList.ItemValue>{titleElm}</DataList.ItemValue> 149 + </DataList.Item> 150 + <DataList.Item flexGrow="1"> 151 + <DataList.ItemLabel>Artists</DataList.ItemLabel> 152 + <DataList.ItemValue> 153 + {artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> : 154 + <HStack>{play.data.artists.map((x, index) => { 155 + return ( 156 + <Tag.Root key={index}> 157 + <Tag.Label>{x}</Tag.Label> 158 + </Tag.Root> 159 + ); 160 + })}</HStack>} 161 + </DataList.ItemValue> 162 + </DataList.Item> 163 + {albumArtistElm} 164 + <DataList.Item flexGrow="1"> 165 + <DataList.ItemLabel>Album</DataList.ItemLabel> 166 + <DataList.ItemValue>{play.data.album}</DataList.ItemValue> 167 + </DataList.Item> 168 + </DataList.Root> 169 + <DataList.Root flexWrap="wrap" flexDirection="row"> 170 + <PlayDatesStack play={play} dates={dates} /> 171 + <DataList.Item flexGrow="1" hideBelow="sm"> 172 + <DataList.ItemLabel>Duration</DataList.ItemLabel> 173 + <DataList.ItemValue> 174 + <Stack gap="1"> 175 + <Text textStyle="xs">Track Length: {timeToHumanTimestamp(dayjs.duration(duration, 's'))}</Text> 176 + {listenedFor !== undefined ? <Muted textStyle="xs">Listened For: {timeToHumanTimestamp(dayjs.duration(listenedFor, 's'))} ({formatNumber((listenedFor / duration) * 100)}%)</Muted> : null} 177 + </Stack> 178 + </DataList.ItemValue> 179 + </DataList.Item> 180 + <DataList.Item flexGrow="1" hideBelow="sm"> 181 + <DataList.ItemLabel>Repeat?</DataList.ItemLabel> 182 + <DataList.ItemValue><Icon>{repeat ? <LuCheck/> : <LuX/>}</Icon></DataList.ItemValue> 183 + </DataList.Item> 184 + </DataList.Root> 185 + <DataList.Root flexWrap="wrap" flexDirection="row" hideBelow="sm"> 186 + <Show when={Object.keys(brainz).length > 0}> 187 + <DataList.Item flexGrow="1"> 188 + <DataList.ItemLabel>MBIDs</DataList.ItemLabel> 189 + <DataList.ItemValue> 190 + <Stack gap="1"> 191 + <Show when={brainz.track !== undefined}><Text textStyle="xs"><Muted>Track:</Muted> {brainz.track}</Text></Show> 192 + <Show when={brainz.recording !== undefined}><Text textStyle="xs"><Muted>Recording</Muted>: {brainz.recording}</Text></Show> 193 + <Show when={brainz.album !== undefined}><Text textStyle="xs"><Muted>Album</Muted>: {brainz.album}</Text></Show> 194 + </Stack> 195 + </DataList.ItemValue> 196 + </DataList.Item> 197 + </Show> 198 + </DataList.Root> 199 + </Flex> 147 200 ) 148 201 } 149 202 ··· 163 216 } else { 164 217 const dateElements = []; 165 218 if (dates.includes('played') || dates.includes('all')) { 166 - dateElements.push((<TextMuted key="playDate">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</TextMuted>)); 219 + dateElements.push((<Text textStyle="xs" key="playDate">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text>)); 167 220 if (play.data.playDateCompleted !== undefined) { 168 221 dateElements.push((<TextMuted key="playDateCompleted">{`Played Until ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`}</TextMuted>)); 169 222 }
+7 -2
src/core/PlayTestUtils.ts
··· 154 154 playDateCompleted = false 155 155 } = opts; 156 156 157 + const duration = faker.number.int({min: 30, max: 300}); 157 158 const play: PlayObject = { 158 159 data: { 159 160 track: faker.music.songName(), 160 161 artists: faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}}), 161 - duration: faker.number.int({min: 30, max: 300}), 162 + duration, 162 163 playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})), 163 164 album: faker.music.album(), 165 + listenedFor: faker.number.int({min: duration * 0.5, max: duration}), 164 166 ...data 165 167 }, 166 168 meta: { ··· 172 174 meta: {} 173 175 }, 174 176 steps: [] 175 - } 177 + }, 178 + url: { 179 + origin: 'https://example.com' 180 + }, 176 181 } 177 182 } 178 183
+13 -1
src/core/TimeUtils.ts
··· 1 1 import dayjs, { Dayjs } from "dayjs"; 2 2 import isToday from 'dayjs/plugin/isToday.js'; 3 3 import { SHORT_CALENDAR_NOTZ_FORMAT, SHORT_TODAY_NOTZ_FORMAT } from "./Atomic.js"; 4 + import { Milliseconds } from "../backend/utils/TimeUtils.js"; 4 5 5 6 dayjs.extend(isToday); 6 7 ··· 13 14 14 15 export const shortTodayAwareFormat = (date: Dayjs): string => { 15 16 return todayAwareFormat(date, {fullFormat: SHORT_CALENDAR_NOTZ_FORMAT, todayFormat: SHORT_TODAY_NOTZ_FORMAT}); 16 - } 17 + }; 18 + export const timeToHumanTimestamp = (val: ReturnType<typeof dayjs.duration> | Milliseconds): string => { 19 + const ms = dayjs.isDuration(val) ? Math.abs(val.asMilliseconds()) : val; 20 + 21 + // less than one hour 22 + if (ms < 3600000) { 23 + // EX 14:07 24 + return new Date(ms).toISOString().substring(14, 19); 25 + } 26 + // EX 01:15:45 27 + return new Date(ms).toISOString().substring(11, 19); 28 + };
+10 -2
src/stories/PlayInfo.stories.tsx
··· 5 5 import { PlayData } from "../client/components/PlayData.js"; 6 6 import {Provider} from "../client/components/Provider"; 7 7 import { Container } from '@chakra-ui/react'; 8 - import { generateArtists, generateJsonPlay, generatePlay } from "../core/PlayTestUtils.js" 8 + import { generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js" 9 9 import clone from "clone"; 10 + import { asJsonPlayObject } from "../core/tests/utils/fixtures.js"; 10 11 11 12 type PropsAndCustomArgs = React.ComponentProps<typeof PlayData> & { 12 13 includeAlbumArtists?: boolean; 13 14 defaultFinal?: boolean 15 + brainz?: boolean 14 16 }; 15 17 // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 16 18 const meta = preview.type<{args: PropsAndCustomArgs}>().meta({ ··· 29 31 play: generateJsonPlay(), 30 32 includeAlbumArtists: false, 31 33 showCodeToggle: true, 32 - defaultFinal: true 34 + defaultFinal: true, 35 + brainz: false 33 36 }, 34 37 // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args 35 38 }); ··· 50 53 if(args.final !== undefined) { 51 54 args.final.data.albumArtists = aa; 52 55 } 56 + } 57 + 58 + if(args.brainz) { 59 + // @ts-ignore 60 + args.play = asJsonPlayObject(withBrainz(args.play, {include: ['album','track','artist']})); 53 61 } 54 62 return (<PlayData {...args}/>) 55 63 }