Track & Album Model¶
The frontend's domain model is a class hierarchy rooted in Track and Album. Providers emit plain data objects (TrackData, AlbumData); the entity registry converts them to class instances via factory functions.
Sources: src/track/, src/album/
TrackData — the wire shape¶
export interface TrackData {
type: "track";
id: string;
title: string;
artist: string | null;
albumTitle?: string | null;
albumId: string | null;
fileName: string;
format: string | null; // "FLAC", "MP3", etc.
bitrate: number | null;
duration: number | null;
size: number | null;
coverUrl?: string | null;
sources: [TrackSource, ...TrackSource[]]; // at least one source
score?: number;
mergedFrom?: number;
}
sources is a non-empty array of provider-specific source descriptors. The first element determines the subclass.
Source descriptors¶
// RuTracker
interface RutrackerRefs {
topicId: string | null;
magnet: string;
fileIdx: number;
coverFileIdx: number | null;
albumDirPath: string | null;
}
// SoulSeek
interface SoulseekRefs {
slskUsername: string;
slskFilepath: string;
slskFolder?: string;
}
RutrackerRefs.fileIdx is the zero-based position of this file in TorrentDetails.files. It's passed as fileIdx to torrent_prepare_stream.
SoulseekRefs also carries alternativePeers in raw (up to 5 backup peers):
Track class hierarchy¶
abstract Track
├── RutrackerTrack (sources[0].kind === "rutracker")
├── SoulseekTrack (sources[0].kind === "soulseek")
└── MagnetTrack (sources[0].kind === "magnet")
buildTrack(data) (from src/track/factory.ts) dispatches to the correct subclass based on data.sources[0].kind.
Abstract API¶
abstract class Track {
// Identity (getters delegating to this.data)
get id(): string
get title(): string
get artist(): string | null
get albumId(): string | null
get albumTitle(): string | null
get fileName(): string
get size(): number | null
get bitrate(): number | null
get duration(): number | null
get format(): string | null
get kind(): ProviderKind
// Behavior (must implement in subclass)
abstract prepareStream(): Promise<string> // returns HTTP stream URL
abstract hasPlaybackIdentity(): boolean // whether stream can be started
abstract exportToDisk(onProgress?): Promise<void>
abstract navigationTarget(): NavigationTarget | null
abstract coverUrl(): string | null // reactive cache read
abstract startCoverFetch(signal?, opts?): void // lazy cover kick
// Serialization
toJSON(): TrackData // the only persistence path
}
prepareStream() per subclass¶
RutrackerTrack:
async prepareStream(): Promise<string> {
return streamUrl(this.refs.magnet, this.refs.fileIdx, {
source: "rutracker",
torrentId: this.refs.topicId,
});
}
SoulseekTrack (with failover):
async prepareStream(): Promise<string> {
// Try primary peer first
try {
return await streamUrl("", 0, {
source: "soulseek",
slskUsername: this.refs.slskUsername,
slskFilepath: this.refs.slskFilepath,
slskFilesize: this.data.size ?? 0,
});
} catch {
// Try alternative peers in peerRank order
for (const alt of this.altPeers) {
try {
return await streamUrl(…alt);
} catch { /* next */ }
}
throw new Error("All peers failed");
}
}
Album model¶
export interface AlbumData {
type: "album";
id: string;
title: string;
artist: string | null;
year: string | null;
format: string | null;
bitrate: number | null;
size: number | null;
seeders: number | null;
leechers: number | null;
peers: number | null;
trackIds: string[];
sources: AlbumSource[];
score?: number;
mergedFrom?: number;
}
trackIds is an ordered array of track entity IDs. The queue, likes, and TorrentView all resolve actual Track instances through the entity registry using these IDs.
buildAlbum(data) (from src/album/factory.ts) creates an Album class instance. The Album.coverUrl() method reads from a reactive cache — the same entitiesVersion mechanism used by queue computeds.
Factory and normalization¶
buildTrack(data) and buildAlbum(data) are called by:
1. normalize() in stores/entities.ts — when raw data arrives from a provider.
2. hydrateTrack(id) — when restoring from trackCache on app start.
The factories do NOT make copies — the class instance holds a reference to the passed data object. Class getters (get title(), get artist()) delegate directly to this.data.
registerEntity calls withMergedPersisted(entity) before normalizing, which merges any persisted coverUrl or albumTitle from a previous session. This prevents catalog enrichment from vanishing on a new search session for a track the user has already liked.
toJSON() — the persistence contract¶
Track.toJSON() returns this.data directly (the original TrackData object). This is the only serialization path. trackCache, likes.json, queue.json, and playlists all store and read this shape.
There are no separate "row" shapes for different persistence stores — everything goes through TrackData → entity registry → class instance.