import NetInfo, { type NetInfoState } from '@react-native-community/netinfo'; import { unwrap, type AuthSession, type Track } from '@pipu/core'; import * as FileSystem from 'expo-file-system'; import * as SecureStore from 'expo-secure-store'; import { qualityPrefs } from '../lib/prefs'; import { shortHash } from '../lib/storage'; import { getDb } from './db'; export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error'; export interface DownloadItem { track: Track; status: DownloadStatus; progress: number; bytes: number; addedAt: number; } export interface DownloadsState { items: ReadonlyMap; wifiOnly: boolean; autoLikes: boolean; /** Сеть разрешает загрузку прямо сейчас (с учётом «только Wi-Fi»). */ allowed: boolean; } const PARALLEL = 2; const WIFI_KEY = 'pipu.dl.wifi'; const AUTO_LIKES_KEY = 'pipu.dl.autolikes'; /** * Загрузки для офлайна: файлы — в documentDirectory/offline//, метаданные (DTO трека) — в SQLite. * Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске. */ class Downloads { private session: AuthSession | null = null; private server = ''; private owner = ''; private active = new Map(); private net: NetInfoState | null = null; private unsubscribeNet: (() => void) | null = null; private listeners = new Set<() => void>(); private current: DownloadsState = { items: new Map(), wifiOnly: true, autoLikes: false, allowed: false }; get state(): DownloadsState { return this.current; } subscribe = (fn: () => void) => { this.listeners.add(fn); return () => this.listeners.delete(fn); }; private set(patch: Partial) { this.current = { ...this.current, ...patch }; for (const fn of this.listeners) fn(); } private setItem(id: string, patch: Partial | null) { const items = new Map(this.current.items); if (patch === null) items.delete(id); else { const prev = items.get(id); if (!prev && !patch.track) return; items.set(id, { ...(prev as DownloadItem), ...patch }); } this.set({ items }); } private get dir() { return `${FileSystem.documentDirectory}offline/${this.owner}/`; } private file(trackId: string) { return `${this.dir}${trackId}.opus`; } private cover(albumId: string) { return `${this.dir}covers/${albumId}.webp`; } /** Привязка к пользователю: загружает список скачанного и продолжает незавершённые загрузки. */ async attach(session: AuthSession | null, server: string, userId: string | null) { const owner = session && userId ? shortHash(`${server}|${userId}`) : ''; if (owner === this.owner && session === this.session) return; for (const d of this.active.values()) void d?.pauseAsync().catch(() => undefined); this.active.clear(); this.session = session; this.server = server; this.owner = owner; if (!owner) { this.set({ items: new Map() }); return; } const [wifi, autoLikes] = await Promise.all([SecureStore.getItemAsync(WIFI_KEY), SecureStore.getItemAsync(AUTO_LIKES_KEY)]); await FileSystem.makeDirectoryAsync(`${this.dir}covers/`, { intermediates: true }).catch(() => undefined); const db = await getDb(); await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'downloading'`, owner); const rows = await db.getAllAsync<{ track_id: string; track: string; status: DownloadStatus; bytes: number; added_at: number }>( 'SELECT track_id, track, status, bytes, added_at FROM downloads WHERE owner = ? ORDER BY added_at', owner, ); const items = new Map(); for (const r of rows) { items.set(r.track_id, { track: JSON.parse(r.track) as Track, status: r.status, progress: r.status === 'done' ? 1 : 0, bytes: r.bytes, addedAt: r.added_at }); } this.set({ items, wifiOnly: wifi !== '0', autoLikes: autoLikes === '1' }); this.unsubscribeNet ??= NetInfo.addEventListener((st) => { this.net = st; this.set({ allowed: this.networkAllowed() }); this.pump(); }); this.net = await NetInfo.fetch(); this.set({ allowed: this.networkAllowed() }); this.pump(); } private networkAllowed(): boolean { if (!this.net?.isConnected) return false; return !this.current.wifiOnly || this.net.type === 'wifi' || this.net.type === 'ethernet'; } async setWifiOnly(v: boolean) { await SecureStore.setItemAsync(WIFI_KEY, v ? '1' : '0'); this.set({ wifiOnly: v }); this.set({ allowed: this.networkAllowed() }); this.pump(); } async setAutoLikes(v: boolean) { await SecureStore.setItemAsync(AUTO_LIKES_KEY, v ? '1' : '0'); this.set({ autoLikes: v }); } isDownloaded(trackId: string): boolean { return this.current.items.get(trackId)?.status === 'done'; } /** Локальный файл трека, если он скачан. */ localTrack(trackId: string): string | undefined { return this.isDownloaded(trackId) ? this.file(trackId) : undefined; } localCover(track: Track): string | undefined { return this.isDownloaded(track.id) && track.album.cover_url ? this.cover(track.album.id) : undefined; } /** Обложка альбома на устройстве, если из него что-то скачано. */ localAlbumCover(albumId: string): string | undefined { for (const it of this.current.items.values()) { if (it.status === 'done' && it.track.album.id === albumId && it.track.album.cover_url) return this.cover(albumId); } return undefined; } /** Скачанные треки в порядке добавления. */ downloaded(): Track[] { return [...this.current.items.values()].filter((i) => i.status === 'done').map((i) => i.track); } async enqueue(tracks: Track[]) { if (!this.owner) return; const db = await getDb(); const now = Date.now(); for (const [n, t] of tracks.entries()) { if (!t.available || this.current.items.has(t.id)) continue; const addedAt = now + n; await db.runAsync( `INSERT OR IGNORE INTO downloads (owner, track_id, track, status, added_at) VALUES (?, ?, ?, 'queued', ?)`, this.owner, t.id, JSON.stringify(t), addedAt, ); this.setItem(t.id, { track: t, status: 'queued', progress: 0, bytes: 0, addedAt }); } this.pump(); } async remove(trackIds: string[]) { const db = await getDb(); for (const id of trackIds) { const d = this.active.get(id); this.active.delete(id); await d?.pauseAsync().catch(() => undefined); await FileSystem.deleteAsync(this.file(id), { idempotent: true }); await FileSystem.deleteAsync(`${this.file(id)}.part`, { idempotent: true }); await db.runAsync('DELETE FROM downloads WHERE owner = ? AND track_id = ?', this.owner, id); this.setItem(id, null); } this.pump(); } /** Реакция в сохранённом DTO: офлайн-волна и списки скачанного видят её без сети. */ async setReaction(trackId: string, reaction: Track['reaction']) { const it = this.current.items.get(trackId); if (!it || it.track.reaction === reaction) return; const track = { ...it.track, reaction }; this.setItem(trackId, { track }); const db = await getDb(); await db.runAsync('UPDATE downloads SET track = ? WHERE owner = ? AND track_id = ?', JSON.stringify(track), this.owner, trackId); } /** Повторить загрузки с ошибкой. */ async retry() { const db = await getDb(); await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'error'`, this.owner); for (const [id, it] of this.current.items) if (it.status === 'error') this.setItem(id, { status: 'queued', progress: 0 }); this.pump(); } /** Занято на устройстве и свободно в телефоне (байты). */ async usage(): Promise<{ used: number; free: number }> { let used = 0; for (const it of this.current.items.values()) used += it.bytes; const free = await FileSystem.getFreeDiskStorageAsync().catch(() => 0); return { used, free }; } private pump() { if (!this.owner || !this.current.allowed) return; for (const [id, it] of this.current.items) { if (this.active.size >= PARALLEL) return; if (it.status !== 'queued' || this.active.has(id)) continue; this.active.set(id, null); void this.run(id).finally(() => { this.active.delete(id); this.pump(); }); } } private async run(id: string) { const session = this.session; const owner = this.owner; if (!session) return; const db = await getDb(); const status = async (s: DownloadStatus, patch: Partial = {}, track?: Track) => { if (this.owner !== owner) return; await db.runAsync( 'UPDATE downloads SET status = ?, bytes = ?, track = COALESCE(?, track) WHERE owner = ? AND track_id = ?', s, patch.bytes ?? 0, track ? JSON.stringify(track) : null, owner, id, ); this.setItem(id, { status: s, ...patch, ...(track ? { track } : {}) }); }; try { await status('downloading', { progress: 0 }); // Подписанные ссылки живут 12 ч — перед загрузкой берём свежий DTO. const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: [id] } })); const track = res.items[0]; if (!track?.available || !track.stream_url) { await status('error'); return; } const part = `${this.file(id)}.part`; const q = (await qualityPrefs.load()).download; const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=${q}`, part, {}, (p) => { if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite }); }); this.active.set(id, dl); const out = await dl.downloadAsync(); if (this.active.get(id) !== dl) return; // отменено (удалили или сменился пользователь) if (!out || out.status !== 200) throw new Error(`status ${out?.status}`); await FileSystem.moveAsync({ from: part, to: this.file(id) }); if (track.album.cover_url) { const cover = this.cover(track.album.id); const info = await FileSystem.getInfoAsync(cover); if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined); } const info = await FileSystem.getInfoAsync(this.file(id)); await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track); } catch { await status('error', { progress: 0 }); } } private abs(path: string) { return /^https?:/.test(path) ? path : this.server + path; } } export const downloads = new Downloads();