From f3b1d8593698e7440ba3a4eb4fe1d8b46853ded3 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Mon, 13 Jul 2026 05:54:25 -0700 Subject: [PATCH] Randomize the home spotlight, rank Heavy Rotation by completed-listen play counts, and order Recently Added by server creation time. Hop-State: A_06FNQ2R6AQ3B9NSHVW23CQR Hop-Proposal: R_06FNQ2QHRBKW41S1ABWG2H0 Hop-Task: T_06FNQ1AJ3NQR3AKN8Z3A1F0 Hop-Attempt: AT_06FNQ1AJ3NAGBVDV111JNTG --- src/App.tsx | 58 +++++++++++++++++++++++++------- src/data/demo.ts | 28 ++++++++-------- src/hooks/usePlayer.test.ts | 22 +++++++++++- src/hooks/usePlayer.ts | 10 ++++-- src/lib/libraryOrdering.test.ts | 59 +++++++++++++++++++++++++++++++++ src/lib/libraryOrdering.ts | 34 +++++++++++++++++++ src/lib/subsonic.test.ts | 21 +++++++++++- src/lib/subsonic.ts | 4 +++ src/types.ts | 2 ++ 9 files changed, 208 insertions(+), 30 deletions(-) create mode 100644 src/lib/libraryOrdering.test.ts create mode 100644 src/lib/libraryOrdering.ts diff --git a/src/App.tsx b/src/App.tsx index 330dc22..e419f5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { usePlayer } from "./hooks/usePlayer"; import { buildArtistSummaries, type ArtistSummary } from "./lib/artists"; +import { orderAlbumsByRecentlyAdded, orderTracksByPlayCount, pickRandomTrack } from "./lib/libraryOrdering"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { chooseMusicFolder, @@ -45,7 +46,7 @@ import { stopManagedServer, type ManagedServerStatus, } from "./lib/managedServer"; -import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl } from "./lib/subsonic"; +import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl, submitSubsonicScrobble } from "./lib/subsonic"; import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types"; const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"]; @@ -93,8 +94,8 @@ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [ { view: "favorites", label: "Favorites", icon: }, ]; -interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string } -interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string } +interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string; created?: string } +interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string; playCount?: number } interface LoadedLibrary { albums: Album[]; tracks: Track[] } type LibraryPhase = "ready" | "scanning" | "empty"; @@ -108,6 +109,7 @@ export default function App() { const [query, setQuery] = useState(""); const [libraryAlbums, setLibraryAlbums] = useState(demoAlbums); const [libraryTracks, setLibraryTracks] = useState(demoTracks); + const [spotlightTrackId, setSpotlightTrackId] = useState(() => pickRandomTrack(demoTracks)?.id ?? ""); const [favorites, setFavorites] = useState(readDemoFavorites); const favoritesRef = useRef(favorites); const favoritePendingRef = useRef(new Set()); @@ -129,8 +131,25 @@ export default function App() { username: localStorage.getItem("resonant.username") ?? "", password: "", }); - const player = usePlayer(libraryTracks); + const recordCompletedPlay = (track: Track) => { + const incrementPlayCount = () => { + setLibraryTracks((tracks) => tracks.map((item) => item.id === track.id + ? { ...item, playCount: (item.playCount ?? 0) + 1 } + : item)); + }; + + if (connectionStatus === "demo") { + incrementPlayCount(); + return; + } + + if (connectionStatus !== "connected" || !server.url || !server.username || !server.password) return; + void submitSubsonicScrobble(server, track.id).then(incrementPlayCount).catch(() => undefined); + }; + const player = usePlayer(libraryTracks, recordCompletedPlay); const hasLibraryTracks = libraryTracks.length > 0; + const spotlightTrack = libraryTracks.find((track) => track.id === spotlightTrackId) ?? libraryTracks[0]; + const spotlightIsPlaying = spotlightTrack?.id === player.current.id && player.isPlaying; const repeatLabel = player.repeatMode === "off" ? "Turn repeat all on" : player.repeatMode === "all" @@ -144,10 +163,17 @@ export default function App() { return result; }, [favorites, libraryTracks, query, view]); + const displayedTracks = useMemo(() => { + const tracks = view === "home" ? orderTracksByPlayCount(visibleTracks) : visibleTracks; + return tracks.slice(0, view === "songs" ? tracks.length : view === "home" && !query ? 6 : 30); + }, [query, view, visibleTracks]); + + const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]); + const visibleAlbums = useMemo(() => { const needle = query.trim().toLowerCase(); - return needle ? libraryAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : libraryAlbums; - }, [libraryAlbums, query]); + return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums; + }, [query, recentlyAddedAlbums]); const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]); @@ -259,6 +285,7 @@ export default function App() { year: album.year ?? 0, coverTone: tones[index % tones.length], coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined, + addedAt: album.created, })); const tracks = serverSongs.map((song, index): Track => ({ id: song.id, @@ -271,6 +298,7 @@ export default function App() { coverUrl: song.coverArt ? buildApiUrl(config, "getCoverArt", { id: song.coverArt, size: 300 }).toString() : undefined, streamUrl: streamUrl(config, song.id), favorite: Boolean(song.starred), + playCount: song.playCount ?? 0, })); return { albums, tracks }; }; @@ -278,6 +306,7 @@ export default function App() { const applyServerLibrary = ({ albums, tracks }: LoadedLibrary) => { setLibraryAlbums(albums); setLibraryTracks(tracks); + setSpotlightTrackId((current) => pickRandomTrack(tracks, current)?.id ?? ""); commitFavorites(new Set(tracks.filter((track) => track.favorite).map((track) => track.id))); if (tracks.length) player.replaceTracks(tracks); else player.clearTracks(); }; @@ -341,6 +370,7 @@ export default function App() { setLibraryAlbums([]); setLibraryTracks([]); + setSpotlightTrackId(""); commitFavorites(new Set()); player.clearTracks(); setServer(config); @@ -383,6 +413,7 @@ export default function App() { setManagedStatus(status); setLibraryAlbums(demoAlbums); setLibraryTracks(demoTracks); + setSpotlightTrackId((current) => pickRandomTrack(demoTracks, current)?.id ?? ""); commitFavorites(readDemoFavorites()); player.replaceTracks(demoTracks); setLibraryPhase("ready"); @@ -430,6 +461,9 @@ export default function App() { }, []); const goTo = (next: View) => { + if (next === "home") { + setSpotlightTrackId((current) => pickRandomTrack(libraryTracks, current)?.id ?? ""); + } setView(next); setMobileNav(false); if (next !== "favorites") setQuery(""); @@ -507,10 +541,10 @@ export default function App() { {libraryPhase === "scanning" ?
Scanning the selected folder
: } ) : <> - {hasLibraryTracks && view === "home" && !query && ( + {hasLibraryTracks && spotlightTrack && view === "home" && !query && (
-
Picked from your library

Let it find you again.

{player.current.artist} has been in your rotation lately. Start here, then let the queue wander.

-
)} @@ -519,13 +553,13 @@ export default function App() { {visibleArtists.length ?
{visibleArtists.map((artist) =>
)}
:

No artists found

Try another artist name.

} : <> {view !== "songs" && view !== "favorites" &&
-
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}

{query ? "Albums" : "Recently added"}

+
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Newest in your library"}

{query || view === "albums" ? "Albums" : "Recently added"}

{visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return

{album.title}

{album.artist} · {album.year || "Unknown year"}

; })}
:

No albums found

Try a title, artist, or track name.

}
}
-
{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}

{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}

{visibleTracks.length} tracks
- {view === "favorites" && visibleTracks.length === 0 ?

No favorite tracks yet

Use the heart beside a song to keep it here.

:
{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.has(track.id); return
{track.album} void toggleFavorite(track)}>{formatTime(track.duration)} addTrackToQueue(track)}>{justQueued ? : }
; })}
} +
{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}

{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}

{view === "home" && !query ? "By play count" : `${visibleTracks.length} tracks`}
+ {view === "favorites" && visibleTracks.length === 0 ?

No favorite tracks yet

Use the heart beside a song to keep it here.

:
{displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.has(track.id); return
{track.album} void toggleFavorite(track)}>{formatTime(track.duration)} addTrackToQueue(track)}>{justQueued ? : }
; })}
} {queueAnnouncement.message}
} diff --git a/src/data/demo.ts b/src/data/demo.ts index 20b3aba..3e4d9c2 100644 --- a/src/data/demo.ts +++ b/src/data/demo.ts @@ -1,23 +1,23 @@ import type { Album, Track } from "../types"; export const albums: Album[] = [ - { id: "a1", title: "Afterimage", artist: "Hollow Cities", year: 2026, coverTone: "ember" }, - { id: "a2", title: "Soft Geometry", artist: "Night Archive", year: 2025, coverTone: "moss" }, - { id: "a3", title: "A Quiet Current", artist: "Mara Venn", year: 2024, coverTone: "dusk" }, - { id: "a4", title: "Signal Fires", artist: "Orchard Bloom", year: 2026, coverTone: "clay" }, - { id: "a5", title: "Low Season", artist: "North Window", year: 2023, coverTone: "indigo" }, - { id: "a6", title: "Rooms of Air", artist: "Serein", year: 2025, coverTone: "sand" }, + { id: "a1", title: "Afterimage", artist: "Hollow Cities", year: 2026, coverTone: "ember", addedAt: "2026-06-30T18:00:00Z" }, + { id: "a2", title: "Soft Geometry", artist: "Night Archive", year: 2025, coverTone: "moss", addedAt: "2026-03-05T18:00:00Z" }, + { id: "a3", title: "A Quiet Current", artist: "Mara Venn", year: 2024, coverTone: "dusk", addedAt: "2025-12-01T18:00:00Z" }, + { id: "a4", title: "Signal Fires", artist: "Orchard Bloom", year: 2026, coverTone: "clay", addedAt: "2026-07-10T18:00:00Z" }, + { id: "a5", title: "Low Season", artist: "North Window", year: 2023, coverTone: "indigo", addedAt: "2025-08-10T18:00:00Z" }, + { id: "a6", title: "Rooms of Air", artist: "Serein", year: 2025, coverTone: "sand", addedAt: "2026-05-14T18:00:00Z" }, ]; export const tracks: Track[] = [ - { id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true }, - { id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss" }, - { id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true }, - { id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay" }, - { id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo" }, - { id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand" }, - { id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember" }, - { id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss" }, + { id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true, playCount: 12 }, + { id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", playCount: 27 }, + { id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, playCount: 18 }, + { id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", playCount: 5 }, + { id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", playCount: 34 }, + { id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", playCount: 2 }, + { id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", playCount: 21 }, + { id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", playCount: 9 }, ]; export const formatTime = (seconds: number) => { diff --git a/src/hooks/usePlayer.test.ts b/src/hooks/usePlayer.test.ts index f380ae8..00a229c 100644 --- a/src/hooks/usePlayer.test.ts +++ b/src/hooks/usePlayer.test.ts @@ -25,6 +25,15 @@ class AudioMock extends EventTarget { } } +let latestAudio: AudioMock; + +class CapturedAudioMock extends AudioMock { + constructor() { + super(); + latestAudio = this; + } +} + const tracks = ["First", "Second", "Third", "Fourth"].map((title, index): Track => ({ id: String(index + 1), title, @@ -46,7 +55,7 @@ const longLibrary = Array.from({ length: 18 }, (_, index): Track => ({ })); describe("usePlayer playback modes", () => { - beforeAll(() => vi.stubGlobal("Audio", AudioMock)); + beforeAll(() => vi.stubGlobal("Audio", CapturedAudioMock)); afterAll(() => vi.unstubAllGlobals()); it("toggles queue shuffle and cycles all repeat modes", () => { @@ -88,4 +97,15 @@ describe("usePlayer playback modes", () => { expect(result.current.current.id).toBe("long-18"); expect(result.current.queue).toHaveLength(0); }); + + it("reports a completed track before advancing the queue", () => { + const onTrackCompleted = vi.fn(); + const { result } = renderHook(() => usePlayer(tracks, onTrackCompleted)); + + act(() => latestAudio.dispatchEvent(new Event("ended"))); + + expect(onTrackCompleted).toHaveBeenCalledOnce(); + expect(onTrackCompleted).toHaveBeenCalledWith(tracks[0]); + expect(result.current.current.id).toBe(tracks[1].id); + }); }); diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 694171c..7fb6904 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback"; import type { Track } from "../types"; -export function usePlayer(initialTracks: Track[]) { +export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Track) => void) { const audioRef = useRef(null); + const onTrackCompletedRef = useRef(onTrackCompleted); const tracksRef = useRef(initialTracks); const currentRef = useRef(initialTracks[0]); const queueRef = useRef(initialTracks.slice(1)); @@ -19,6 +20,8 @@ export function usePlayer(initialTracks: Track[]) { const [isShuffled, setIsShuffled] = useState(false); const [repeatMode, setRepeatMode] = useState("off"); + onTrackCompletedRef.current = onTrackCompleted; + const commitCurrent = useCallback((track: Track) => { currentRef.current = track; setCurrent(track); @@ -87,7 +90,10 @@ export function usePlayer(initialTracks: Track[]) { setProgress(audio.currentTime); if (Number.isFinite(audio.duration)) setDuration(audio.duration); }; - const ended = () => advance(true); + const ended = () => { + onTrackCompletedRef.current?.(currentRef.current); + advance(true); + }; audio.addEventListener("timeupdate", update); audio.addEventListener("loadedmetadata", update); audio.addEventListener("ended", ended); diff --git a/src/lib/libraryOrdering.test.ts b/src/lib/libraryOrdering.test.ts new file mode 100644 index 0000000..84dc9ba --- /dev/null +++ b/src/lib/libraryOrdering.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { Album, Track } from "../types"; +import { orderAlbumsByRecentlyAdded, orderTracksByPlayCount, pickRandomTrack } from "./libraryOrdering"; + +const album = (id: string, addedAt?: string): Album => ({ + id, + title: id, + artist: "Artist", + year: 2026, + coverTone: "indigo", + addedAt, +}); + +const track = (id: string, playCount?: number): Track => ({ + id, + title: id, + artist: "Artist", + album: "Album", + albumId: "album", + duration: 180, + coverTone: "indigo", + playCount, +}); + +describe("library ordering", () => { + it("puts the most recently added albums first and keeps missing dates stable", () => { + const albums = [ + album("older", "2026-01-02T12:00:00Z"), + album("missing-one"), + album("newest", "2026-07-12T12:00:00Z"), + album("missing-two"), + ]; + + expect(orderAlbumsByRecentlyAdded(albums).map(({ id }) => id)).toEqual([ + "newest", + "older", + "missing-one", + "missing-two", + ]); + }); + + it("orders heavy rotation by descending play count and preserves ties", () => { + const tracks = [track("quiet", 2), track("first-tie", 12), track("most-played", 40), track("second-tie", 12)]; + + expect(orderTracksByPlayCount(tracks).map(({ id }) => id)).toEqual([ + "most-played", + "first-tie", + "second-tie", + "quiet", + ]); + }); + + it("picks a different spotlight track when another track is available", () => { + const tracks = [track("current"), track("middle"), track("last")]; + + expect(pickRandomTrack(tracks, "current", () => 0)?.id).toBe("middle"); + expect(pickRandomTrack(tracks, "current", () => 0.999)?.id).toBe("last"); + }); +}); diff --git a/src/lib/libraryOrdering.ts b/src/lib/libraryOrdering.ts new file mode 100644 index 0000000..6fb4772 --- /dev/null +++ b/src/lib/libraryOrdering.ts @@ -0,0 +1,34 @@ +import type { Album, Track } from "../types"; + +export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] { + return albums + .map((album, index) => ({ + album, + index, + addedAt: album.addedAt ? Date.parse(album.addedAt) : Number.NaN, + })) + .sort((left, right) => { + const leftDate = Number.isFinite(left.addedAt) ? left.addedAt : Number.NEGATIVE_INFINITY; + const rightDate = Number.isFinite(right.addedAt) ? right.addedAt : Number.NEGATIVE_INFINITY; + return rightDate - leftDate || left.index - right.index; + }) + .map(({ album }) => album); +} + +export function orderTracksByPlayCount(tracks: Track[]): Track[] { + return tracks + .map((track, index) => ({ track, index })) + .sort((left, right) => (right.track.playCount ?? 0) - (left.track.playCount ?? 0) || left.index - right.index) + .map(({ track }) => track); +} + +export function pickRandomTrack( + tracks: Track[], + currentId?: string, + random: () => number = Math.random, +): Track | undefined { + if (tracks.length === 0) return undefined; + const candidates = tracks.length > 1 ? tracks.filter((track) => track.id !== currentId) : tracks; + const index = Math.floor(Math.min(Math.max(random(), 0), 0.9999999999999999) * candidates.length); + return candidates[index]; +} diff --git a/src/lib/subsonic.test.ts b/src/lib/subsonic.test.ts index a6abf47..ec51f02 100644 --- a/src/lib/subsonic.test.ts +++ b/src/lib/subsonic.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildApiUrl, setSubsonicFavorite } from "./subsonic"; +import { buildApiUrl, setSubsonicFavorite, submitSubsonicScrobble } from "./subsonic"; afterEach(() => vi.unstubAllGlobals()); @@ -51,4 +51,23 @@ describe("buildApiUrl", () => { expect(url.pathname).toBe(`/rest/${endpoint}.view`); expect(url.searchParams.get("id")).toBe("track-42"); }); + + it("submits completed playback so the server updates its play count", async () => { + vi.stubGlobal("crypto", { getRandomValues: (array: Uint8Array) => array.fill(4) }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ "subsonic-response": { status: "ok" } }), + }); + vi.stubGlobal("fetch", fetchMock); + + await submitSubsonicScrobble( + { url: "https://music.example.test", username: "listener", password: "secret" }, + "track-99", + ); + + const url = fetchMock.mock.calls[0][0] as URL; + expect(url.pathname).toBe("/rest/scrobble.view"); + expect(url.searchParams.get("id")).toBe("track-99"); + expect(url.searchParams.get("submission")).toBe("true"); + }); }); diff --git a/src/lib/subsonic.ts b/src/lib/subsonic.ts index 854c883..ba7fd67 100644 --- a/src/lib/subsonic.ts +++ b/src/lib/subsonic.ts @@ -64,6 +64,10 @@ export async function setSubsonicFavorite(config: ServerConfig, trackId: string, await requestSubsonic(config, favorite ? "star" : "unstar", { id: trackId }); } +export async function submitSubsonicScrobble(config: ServerConfig, trackId: string): Promise { + await requestSubsonic(config, "scrobble", { id: trackId, submission: true }); +} + export function streamUrl(config: ServerConfig, trackId: string): string { return buildApiUrl(config, "stream", { id: trackId }).toString(); } diff --git a/src/types.ts b/src/types.ts index f6b6885..fe5836d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export interface Album { year: number; coverTone: CoverTone; coverUrl?: string; + addedAt?: string; } export interface Track { @@ -20,6 +21,7 @@ export interface Track { coverUrl?: string; streamUrl?: string; favorite?: boolean; + playCount?: number; } export interface ServerConfig {