Files
streamyfin/providers/AudioStorage/index.ts

671 lines
17 KiB
TypeScript

/**
* Audio Storage Module
*
* Unified storage manager for audio files supporting:
* - Look-ahead cache (auto-managed, ephemeral, stored in cache directory)
* - Future: Full music downloads (user-initiated, permanent, stored in documents)
*
* getLocalPath() checks permanent storage first, then cache.
*/
import { EventEmitter } from "eventemitter3";
import { Directory, File, Paths } from "expo-file-system";
import type { EventSubscription } from "expo-modules-core";
import type {
DownloadCompleteEvent as BGDownloadCompleteEvent,
DownloadErrorEvent as BGDownloadErrorEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
import { storage } from "@/utils/mmkv";
import type {
AudioStorageIndex,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadOptions,
StoredTrackInfo,
} from "./types";
// Storage keys
const AUDIO_STORAGE_INDEX_KEY = "audio_storage.v1.json";
// Directory names
const AUDIO_CACHE_DIR = "streamyfin-audio-cache";
const AUDIO_PERMANENT_DIR = "streamyfin-audio";
// Default limits
const DEFAULT_MAX_CACHE_TRACKS = 10;
const DEFAULT_MAX_CACHE_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
// Configurable limits (can be updated at runtime)
let configuredMaxCacheSizeBytes = DEFAULT_MAX_CACHE_SIZE_BYTES;
// Event emitter for notifying about download completion
class AudioStorageEventEmitter extends EventEmitter<{
complete: (event: DownloadCompleteEvent) => void;
error: (event: DownloadErrorEvent) => void;
}> {}
export const audioStorageEvents = new AudioStorageEventEmitter();
// Track active downloads: taskId -> { itemId, permanent }
const activeDownloads = new Map<
number,
{ itemId: string; permanent: boolean }
>();
// Track items being downloaded by itemId for quick lookup
const downloadingItems = new Set<string>();
// Track permanent downloads separately for UI indicator
const permanentDownloadingItems = new Set<string>();
// Cached index (loaded from storage on init)
let storageIndex: AudioStorageIndex | null = null;
// Directories (initialized on first use)
let cacheDir: Directory | null = null;
let permanentDir: Directory | null = null;
// Event listener subscriptions (for cleanup)
let _completeSubscription: EventSubscription | null = null;
let _errorSubscription: EventSubscription | null = null;
let listenersSetup = false;
/**
* Get the storage index from MMKV
*/
function getStorageIndex(): AudioStorageIndex {
if (storageIndex) {
return storageIndex;
}
try {
const data = storage.getString(AUDIO_STORAGE_INDEX_KEY);
if (data) {
storageIndex = JSON.parse(data) as AudioStorageIndex;
return storageIndex;
}
} catch {
// Ignore parse errors
}
storageIndex = {
tracks: {},
totalCacheSize: 0,
totalPermanentSize: 0,
};
return storageIndex;
}
/**
* Save the storage index to MMKV
*/
function saveStorageIndex(): void {
if (storageIndex) {
try {
storage.set(AUDIO_STORAGE_INDEX_KEY, JSON.stringify(storageIndex));
} catch {
// Ignore save errors
}
}
}
/**
* Ensure directories exist
*/
async function ensureDirectories(): Promise<void> {
try {
if (!cacheDir) {
cacheDir = new Directory(Paths.cache, AUDIO_CACHE_DIR);
if (!cacheDir.exists) {
await cacheDir.create();
}
}
if (!permanentDir) {
permanentDir = new Directory(Paths.document, AUDIO_PERMANENT_DIR);
if (!permanentDir.exists) {
await permanentDir.create();
}
}
} catch (error) {
console.warn("[AudioStorage] Failed to create directories:", error);
}
}
/**
* Set the maximum cache size in megabytes
* Call this when settings change
*/
export function setMaxCacheSizeMB(sizeMB: number): void {
configuredMaxCacheSizeBytes = sizeMB * 1024 * 1024;
console.log(
`[AudioStorage] Max cache size set to ${sizeMB}MB (${configuredMaxCacheSizeBytes} bytes)`,
);
}
/**
* Initialize audio storage - call this on app startup
*/
export async function initAudioStorage(): Promise<void> {
console.log("[AudioStorage] Initializing...");
try {
await ensureDirectories();
getStorageIndex();
setupEventListeners();
console.log("[AudioStorage] Initialization complete");
} catch (error) {
console.warn("[AudioStorage] Initialization error:", error);
}
}
/**
* Set up BackgroundDownloader event listeners
* Safe to call multiple times - will only set up once
*/
function setupEventListeners(): void {
// Prevent duplicate listeners
if (listenersSetup) return;
listenersSetup = true;
try {
console.log("[AudioStorage] Setting up event listeners...");
_completeSubscription = BackgroundDownloader.addCompleteListener(
(event: BGDownloadCompleteEvent) => {
console.log(
`[AudioStorage] Complete event received: taskId=${event.taskId}, activeDownloads=${JSON.stringify([...activeDownloads.entries()])}`,
);
const downloadInfo = activeDownloads.get(event.taskId);
if (!downloadInfo) {
console.log(
`[AudioStorage] Ignoring complete event for unknown taskId: ${event.taskId}`,
);
return; // Not an audio download
}
handleDownloadComplete(event, downloadInfo);
},
);
_errorSubscription = BackgroundDownloader.addErrorListener(
(event: BGDownloadErrorEvent) => {
console.log(
`[AudioStorage] Error event received: taskId=${event.taskId}, error=${event.error}`,
);
const downloadInfo = activeDownloads.get(event.taskId);
if (!downloadInfo) return; // Not an audio download
handleDownloadError(event, downloadInfo);
},
);
console.log("[AudioStorage] Event listeners set up successfully");
} catch (error) {
console.warn("[AudioStorage] Failed to setup event listeners:", error);
listenersSetup = false;
}
}
/**
* Handle download completion
*/
async function handleDownloadComplete(
event: BGDownloadCompleteEvent,
downloadInfo: { itemId: string; permanent: boolean },
): Promise<void> {
const { itemId, permanent } = downloadInfo;
try {
const file = new File(`file://${event.filePath}`);
const fileInfo = file.info();
const size = fileInfo.size || 0;
const index = getStorageIndex();
// Add to index
const trackInfo: StoredTrackInfo = {
itemId,
localPath: event.filePath,
size,
storedAt: Date.now(),
permanent,
};
index.tracks[itemId] = trackInfo;
if (permanent) {
index.totalPermanentSize += size;
} else {
index.totalCacheSize += size;
}
saveStorageIndex();
console.log(
`[AudioStorage] Downloaded ${itemId} (${(size / 1024 / 1024).toFixed(1)}MB, permanent=${permanent})`,
);
// Emit completion event
audioStorageEvents.emit("complete", {
itemId,
localPath: event.filePath,
permanent,
});
// Clean up tracking
activeDownloads.delete(event.taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
// Evict old cache if needed (only for cache downloads)
if (!permanent) {
evictCacheIfNeeded().catch(() => {
// Ignore eviction errors
});
}
} catch (error) {
console.error(`[AudioStorage] Error handling download complete:`, error);
activeDownloads.delete(event.taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
}
}
/**
* Handle download error
*/
function handleDownloadError(
event: BGDownloadErrorEvent,
downloadInfo: { itemId: string; permanent: boolean },
): void {
const { itemId } = downloadInfo;
console.error(`[AudioStorage] Download failed for ${itemId}:`, event.error);
audioStorageEvents.emit("error", {
itemId,
error: event.error,
});
activeDownloads.delete(event.taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
}
/**
* Get the local file path for a track if it exists
* Checks permanent storage first, then cache
* Returns the path WITH file:// prefix for TrackPlayer
*/
export function getLocalPath(itemId: string | undefined): string | null {
if (!itemId) return null;
try {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (info) {
// Verify file still exists (File constructor needs file:// URI)
try {
const fileUri = info.localPath.startsWith("file://")
? info.localPath
: `file://${info.localPath}`;
const file = new File(fileUri);
if (file.exists) {
// Return the URI with file:// prefix for TrackPlayer
return fileUri;
}
} catch {
// File doesn't exist, remove from index
if (info.permanent) {
index.totalPermanentSize -= info.size;
} else {
index.totalCacheSize -= info.size;
}
delete index.tracks[itemId];
saveStorageIndex();
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Check if a track is currently being downloaded (any type)
*/
export function isDownloading(itemId: string | undefined): boolean {
if (!itemId) return false;
return downloadingItems.has(itemId);
}
/**
* Check if a track is currently being permanently downloaded (user-initiated)
* Use this for UI indicators - we don't want to show spinners for auto-caching
*/
export function isPermanentDownloading(itemId: string | undefined): boolean {
if (!itemId) return false;
return permanentDownloadingItems.has(itemId);
}
/**
* Check if a track is permanently downloaded (not just cached)
*/
export function isPermanentlyDownloaded(itemId: string | undefined): boolean {
if (!itemId) return false;
try {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (info?.permanent) {
// Verify file still exists
try {
const fileUri = info.localPath.startsWith("file://")
? info.localPath
: `file://${info.localPath}`;
const file = new File(fileUri);
if (file.exists) {
return true;
}
} catch {
// File doesn't exist
}
}
} catch {
// Ignore errors
}
return false;
}
/**
* Check if a track is cached (not permanently downloaded)
*/
export function isCached(itemId: string | undefined): boolean {
if (!itemId) return false;
try {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (info && !info.permanent) {
// Verify file still exists
try {
const fileUri = info.localPath.startsWith("file://")
? info.localPath
: `file://${info.localPath}`;
const file = new File(fileUri);
if (file.exists) {
return true;
}
// File doesn't exist - clean up stale index entry
console.log(
`[AudioStorage] Cleaning up stale cache entry: ${itemId} (file missing)`,
);
index.totalCacheSize -= info.size;
delete index.tracks[itemId];
saveStorageIndex();
} catch {
// File check failed - clean up stale index entry
index.totalCacheSize -= info.size;
delete index.tracks[itemId];
saveStorageIndex();
}
}
} catch {
// Ignore errors
}
return false;
}
/**
* Download a track to storage
* @param itemId - Jellyfin item ID
* @param url - Stream URL to download from
* @param options - Download options (permanent: true for user downloads, false for cache)
*/
export async function downloadTrack(
itemId: string,
url: string,
options: DownloadOptions = { permanent: false },
): Promise<void> {
const { permanent } = options;
// Skip if already downloading
if (isDownloading(itemId)) {
return;
}
// Skip if already permanently downloaded
if (isPermanentlyDownloaded(itemId)) {
return;
}
// If requesting permanent download and file is only cached, delete cached version first
if (permanent && isCached(itemId)) {
console.log(
`[AudioStorage] Upgrading cached track to permanent: ${itemId}`,
);
await deleteTrack(itemId);
}
// Skip if already cached and not requesting permanent
if (!permanent && getLocalPath(itemId)) {
return;
}
// Ensure listeners are set up
setupEventListeners();
await ensureDirectories();
const targetDir = permanent ? permanentDir : cacheDir;
if (!targetDir) {
console.warn("[AudioStorage] Target directory not initialized");
return;
}
// Use the actual container format as extension, fallback to m4a
const extension = options.container?.toLowerCase() || "m4a";
const filename = `${itemId}.${extension}`;
const destinationPath =
`${targetDir.uri.replace(/\/$/, "")}/${filename}`.replace("file://", "");
console.log(
`[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`,
);
try {
downloadingItems.add(itemId);
if (permanent) {
permanentDownloadingItems.add(itemId);
}
const taskId = await BackgroundDownloader.startDownload(
url,
destinationPath,
);
activeDownloads.set(taskId, { itemId, permanent });
console.log(
`[AudioStorage] Download started with taskId=${taskId}, tracking ${activeDownloads.size} downloads`,
);
} catch (error) {
console.error(`[AudioStorage] Failed to start download:`, error);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
}
}
/**
* Cancel a download in progress
*/
export function cancelDownload(itemId: string): void {
for (const [taskId, info] of activeDownloads.entries()) {
if (info.itemId === itemId) {
try {
BackgroundDownloader.cancelDownload(taskId);
} catch {
// Ignore cancel errors
}
activeDownloads.delete(taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
console.log(`[AudioStorage] Cancelled download: ${itemId}`);
break;
}
}
}
/**
* Delete a stored track
*/
export async function deleteTrack(itemId: string): Promise<void> {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (!info) return;
try {
const file = new File(info.localPath);
if (file.exists) {
await file.delete();
}
} catch (error) {
console.warn(`[AudioStorage] Failed to delete file:`, error);
}
if (info.permanent) {
index.totalPermanentSize -= info.size;
} else {
index.totalCacheSize -= info.size;
}
delete index.tracks[itemId];
saveStorageIndex();
console.log(`[AudioStorage] Deleted track: ${itemId}`);
}
/**
* Evict old cache entries if limits are exceeded
*/
async function evictCacheIfNeeded(
maxTracks: number = DEFAULT_MAX_CACHE_TRACKS,
maxSizeBytes: number = configuredMaxCacheSizeBytes,
): Promise<void> {
const index = getStorageIndex();
// Get all cache entries sorted by storedAt (oldest first)
const cacheEntries = Object.values(index.tracks)
.filter((t) => !t.permanent)
.sort((a, b) => a.storedAt - b.storedAt);
// Evict if over track limit or size limit
while (
cacheEntries.length > maxTracks ||
index.totalCacheSize > maxSizeBytes
) {
const oldest = cacheEntries.shift();
if (!oldest) break;
console.log(
`[AudioStorage] Evicting cache entry: ${oldest.itemId} (${(oldest.size / 1024 / 1024).toFixed(1)}MB)`,
);
try {
const file = new File(oldest.localPath);
if (file.exists) {
await file.delete();
}
} catch {
// Ignore deletion errors
}
index.totalCacheSize -= oldest.size;
delete index.tracks[oldest.itemId];
}
saveStorageIndex();
}
/**
* Clear all cached tracks (keeps permanent downloads)
*/
export async function clearCache(): Promise<void> {
const index = getStorageIndex();
const cacheEntries = Object.values(index.tracks).filter((t) => !t.permanent);
for (const entry of cacheEntries) {
try {
const file = new File(entry.localPath);
if (file.exists) {
await file.delete();
}
} catch {
// Ignore deletion errors
}
delete index.tracks[entry.itemId];
}
index.totalCacheSize = 0;
saveStorageIndex();
console.log(`[AudioStorage] Cache cleared`);
}
/**
* Clear all permanent downloads (keeps cache)
*/
export async function clearPermanentDownloads(): Promise<void> {
const index = getStorageIndex();
const permanentEntries = Object.values(index.tracks).filter(
(t) => t.permanent,
);
for (const entry of permanentEntries) {
try {
const fileUri = entry.localPath.startsWith("file://")
? entry.localPath
: `file://${entry.localPath}`;
const file = new File(fileUri);
if (file.exists) {
await file.delete();
}
} catch {
// Ignore deletion errors
}
delete index.tracks[entry.itemId];
}
index.totalPermanentSize = 0;
saveStorageIndex();
console.log(`[AudioStorage] Permanent downloads cleared`);
}
/**
* Get storage statistics
*/
export function getStorageStats(): {
cacheCount: number;
cacheSize: number;
permanentCount: number;
permanentSize: number;
} {
const index = getStorageIndex();
const entries = Object.values(index.tracks);
return {
cacheCount: entries.filter((t) => !t.permanent).length,
cacheSize: index.totalCacheSize,
permanentCount: entries.filter((t) => t.permanent).length,
permanentSize: index.totalPermanentSize,
};
}