Перейти к содержанию

Модель трека и альбома

Доменная модель фронтенда — это иерархия классов с корнями Track и Album. Provider-ы отправляют плоские объекты данных (TrackData, AlbumData); реестр entities преобразует их в экземпляры классов через factory-функции.

Источники: src/track/, src/album/


TrackData — форма данных по проводу

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" и т.д.
  bitrate: number | null;
  duration: number | null;
  size: number | null;
  coverUrl?: string | null;
  sources: [TrackSource, ...TrackSource[]];  // не менее одного источника
  score?: number;
  mergedFrom?: number;
}

sources — непустой массив дескрипторов источников для конкретного provider-а. Первый элемент определяет подкласс.

Дескрипторы источников

// 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 — позиция этого файла с нуля в TorrentDetails.files. Передаётся как fileIdx в torrent_prepare_stream.

SoulseekRefs также несёт alternativePeers в raw (до 5 резервных peer-ов):

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


Иерархия классов Track

abstract Track
  ├── RutrackerTrack   (sources[0].kind === "rutracker")
  ├── SoulseekTrack    (sources[0].kind === "soulseek")
  └── MagnetTrack      (sources[0].kind === "magnet")

buildTrack(data) (из src/track/factory.ts) диспатчит к правильному подклассу на основе data.sources[0].kind.

Абстрактный API

abstract class Track {
  // Идентичность (геттеры, делегирующие к 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

  // Поведение (должно быть реализовано в подклассе)
  abstract prepareStream(): Promise<string>        // возвращает URL HTTP stream-а
  abstract hasPlaybackIdentity(): boolean          // возможно ли запустить stream
  abstract exportToDisk(onProgress?): Promise<void>
  abstract navigationTarget(): NavigationTarget | null
  abstract coverUrl(): string | null               // реактивное чтение из cache
  abstract startCoverFetch(signal?, opts?): void   // ленивый запуск загрузки обложки

  // Сериализация
  toJSON(): TrackData    // единственный путь сохранения
}

prepareStream() для каждого подкласса

RutrackerTrack:

async prepareStream(): Promise<string> {
  return streamUrl(this.refs.magnet, this.refs.fileIdx, {
    source: "rutracker",
    torrentId: this.refs.topicId,
  });
}

SoulseekTrack (с failover):

async prepareStream(): Promise<string> {
  // Сначала пробуем основного peer-а
  try {
    return await streamUrl("", 0, {
      source: "soulseek",
      slskUsername: this.refs.slskUsername,
      slskFilepath: this.refs.slskFilepath,
      slskFilesize: this.data.size ?? 0,
    });
  } catch {
    // Перебираем альтернативных peer-ов в порядке peerRank
    for (const alt of this.altPeers) {
      try {
        return await streamUrl(alt);
      } catch { /* следующий */ }
    }
    throw new Error("All peers failed");
  }
}


Модель альбома

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 — упорядоченный массив ID entity треков. Очередь, лайки и TorrentView разрешают фактические экземпляры Track через реестр entities по этим ID.

buildAlbum(data) (из src/album/factory.ts) создаёт экземпляр класса Album. Метод Album.coverUrl() читает из реактивного cache — тот же механизм entitiesVersion, что используют computed-ы очереди.


Factory и нормализация

buildTrack(data) и buildAlbum(data) вызываются: 1. normalize() в stores/entities.ts — когда сырые данные приходят от provider-а. 2. hydrateTrack(id) — при восстановлении из trackCache при запуске приложения.

Factory-функции не создают копии — экземпляр класса хранит ссылку на переданный объект data. Геттеры класса (get title(), get artist()) делегируют напрямую к this.data.

registerEntity вызывает withMergedPersisted(entity) перед нормализацией, что объединяет любые сохранённые coverUrl или albumTitle из предыдущей сессии. Это предотвращает исчезновение обогащения каталога при новом поиске для трека, который пользователь уже лайкнул.


toJSON() — контракт сохранения

Track.toJSON() возвращает this.data напрямую (исходный объект TrackData). Это единственный путь сериализации. trackCache, likes.json, queue.json и плейлисты — все хранят и читают эту форму.

Отдельных форм «строки» для разных хранилищ не существует — всё проходит через TrackData → реестр entities → экземпляр класса.