feat(mobile): offline downloads, persistent event queue and offline start (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
- Downloads queue (2 parallel, Wi-Fi only by default) stores files under the app's document directory and track metadata in SQLite; Collection gets a Downloads tab, album and player screens get download buttons. - Player plays downloaded files and local covers; My Wave falls back to a shuffle of downloaded tracks when the server is unreachable. - Playback events that fail on the network are kept in SQLite and sent when the connection returns. - AuthSession can open offline with the cached user and fetches an access token before the first request once online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
765345e0f4
commit
be34ee5361
22 files changed
+881
-387
No files matched your search
@@ -0,0 +1,277 @@
|
||||
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 { 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();
|
||||
}
|
||||
|
||||
/** Повторить загрузки с ошибкой. */
|
||||
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 dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=high`, 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();
|
||||
Reference in new issue
Block a user