Skip to content

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):

interface SlskAltPeer {
  slskUsername: string;
  slskFilepath: string;
  size?: number;
}


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.