Files
pipu-music/apps/mobile/src/offline/downloads.ts
T
mrleo1nidandClaude Opus 5.5 ed063d3763
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
- My Wave is rendered with react-native-skia on the UI thread; physics
  and colour helpers moved to @pipu/core (wave-anim) and shared with web.
- Full-screen player honours ui_prefs.mobile_player (cover / wave).
- Theme switching (dark / light / system) via a theme context and
  useColors() across all screens.
- Profile: theme, wave variant, player view, sound quality sheet
  (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next
  cold start, and a change-password sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:30:21 +03:00

290 lines
11 KiB
TypeScript

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<string, DownloadItem>;
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/<owner>/, метаданные (DTO трека) — в SQLite.
* Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске.
*/
class Downloads {
private session: AuthSession | null = null;
private server = '';
private owner = '';
private active = new Map<string, FileSystem.DownloadResumable | null>();
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<DownloadsState>) {
this.current = { ...this.current, ...patch };
for (const fn of this.listeners) fn();
}
private setItem(id: string, patch: Partial<DownloadItem> | 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<string, DownloadItem>();
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<DownloadItem> = {}, 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();