import { internalSerializeKey, supportLocalMediaType } from "@/constants/commonConst";
import pathConst from "@/constants/pathConst";
import { IAppConfig } from "@/types/core/config";
import { IInjectable } from "@/types/infra";
import { addFileScheme, escapeCharacter, mkdirR } from "@/utils/fileUtils";
import { errorLog } from "@/utils/log";
import { patchMediaExtra } from "@/utils/mediaExtra";
import { getMediaUniqueKey, isSameMediaItem } from "@/utils/mediaUtils";
import network from "@/utils/network";
import { getQualityOrder } from "@/utils/qualities";
import EventEmitter from "eventemitter3";
import { atom, getDefaultStore, useAtomValue } from "jotai";
import { nanoid } from "nanoid";
import path from "path-browserify";
import { useEffect, useState } from "react";
import { copyFile, downloadFile, exists, unlink } from "react-native-fs";
import LocalMusicSheet from "./localMusicSheet";
import { IPluginManager } from "@/types/core/pluginManager";
export enum DownloadStatus {
Pending,
Preparing,
Downloading,
Completed,
Error
}
export enum DownloaderEvent {
DownloadError = "download-error",
DownloadTaskUpdate = "download-task-update",
DownloadTaskError = "download-task-error",
DownloadQueueCompleted = "download-queue-completed",
}
export enum DownloadFailReason {
NetworkOffline = "network-offline",
NotAllowToDownloadInCellular = "not-allow-to-download-in-cellular",
FailToFetchSource = "no-valid-source",
NoWritePermission = "no-write-permission",
Unknown = "unknown",
}
interface IDownloadTaskInfo {
status: DownloadStatus;
filename: string;
jobId?: number;
quality?: IMusic.IQualityKey;
fileSize?: number;
downloadedSize?: number;
musicItem: IMusic.IMusicItem;
errorReason?: DownloadFailReason;
}
const downloadQueueAtom = atom<IMusic.IMusicItem[]>([]);
const downloadTasks = new Map<string, IDownloadTaskInfo>();
interface IEvents {
[DownloaderEvent.DownloadError]: (reason: DownloadFailReason, error?: Error) => void;
[DownloaderEvent.DownloadTaskError]: (reason: DownloadFailReason, mediaItem: IMusic.IMusicItem, error?: Error) => void;
[DownloaderEvent.DownloadTaskUpdate]: (task: IDownloadTaskInfo) => void;
[DownloaderEvent.DownloadQueueCompleted]: () => void;
}
class Downloader extends EventEmitter<IEvents> implements IInjectable {
private configService!: IAppConfig;
private pluginManagerService!: IPluginManager;
private downloadingCount = 0;
private static generateFilename(musicItem: IMusic.IMusicItem) {
return `${escapeCharacter(musicItem.platform)}@${escapeCharacter(
musicItem.id,
)}@${escapeCharacter(musicItem.title)}@${escapeCharacter(
musicItem.artist,
)}`.slice(0, 200);
}
injectDependencies(configService: IAppConfig, pluginManager: IPluginManager): void {
this.configService = configService;
this.pluginManagerService = pluginManager;
}
private updateDownloadTask(musicItem: IMusic.IMusicItem, patch: Partial<IDownloadTaskInfo>) {
const newValue = {
...downloadTasks.get(getMediaUniqueKey(musicItem)),
...patch,
} as IDownloadTaskInfo;
downloadTasks.set(getMediaUniqueKey(musicItem), newValue);
this.emit(DownloaderEvent.DownloadTaskUpdate, newValue);
return newValue;
}
private markTaskAsStarted(musicItem: IMusic.IMusicItem) {
this.downloadingCount++;
this.updateDownloadTask(musicItem, {
status: DownloadStatus.Preparing,
});
}
private markTaskAsCompleted(musicItem: IMusic.IMusicItem) {
this.downloadingCount--;
this.updateDownloadTask(musicItem, {
status: DownloadStatus.Completed,
});
}
private markTaskAsError(musicItem: IMusic.IMusicItem, reason: DownloadFailReason, error?: Error) {
this.downloadingCount--;
this.updateDownloadTask(musicItem, {
status: DownloadStatus.Error,
errorReason: reason,
});
this.emit(DownloaderEvent.DownloadTaskError, reason, musicItem, error);
}
private getExtensionName(url: string) {
const regResult = url.match(
/^https?\:\/\/.+\.([^\?\.]+?$)|(?:([^\.]+?)\?.+$)/,
);
if (regResult) {
return regResult[1] ?? regResult[2] ?? "mp3";
} else {
return "mp3";
}
};
private getDownloadPath(fileName: string) {
const dlPath =
this.configService.getConfig("basic.downloadPath") ?? pathConst.downloadMusicPath;
if (!dlPath.endsWith("/")) {
return `${dlPath}/${fileName ?? ""}`;
}
return fileName ? dlPath + fileName : dlPath;
};
private getCacheDownloadPath(fileName: string) {
const cachePath = pathConst.downloadCachePath;
if (!cachePath.endsWith("/")) {
return `${cachePath}/${fileName ?? ""}`;
}
return fileName ? cachePath + fileName : cachePath;
}
private async downloadNextPendingTask() {
const maxDownloadCount = Math.max(1, Math.min(+(this.configService.getConfig("basic.maxDownload") || 3), 10));
const downloadQueue = getDefaultStore().get(downloadQueueAtom);
if (this.downloadingCount >= maxDownloadCount || this.downloadingCount >= downloadQueue.length) {
return;
}
let nextTask: IDownloadTaskInfo | null = null;
for (let i = 0; i < downloadQueue.length; i++) {
const musicItem = downloadQueue[i];
const key = getMediaUniqueKey(musicItem);
const task = downloadTasks.get(key);
if (task && task.status === DownloadStatus.Pending) {
nextTask = task;
break;
}
}
if (!nextTask) {
if (this.downloadingCount === 0) {
this.emit(DownloaderEvent.DownloadQueueCompleted);
}
return;
}
const musicItem = nextTask.musicItem;
this.markTaskAsStarted(musicItem);
let url = musicItem.url;
let headers = musicItem.headers;
const plugin = this.pluginManagerService.getByName(musicItem.platform);
try {
if (plugin) {
const qualityOrder = getQualityOrder(
nextTask.quality ??
this.configService.getConfig("basic.defaultDownloadQuality") ??
"standard",
this.configService.getConfig("basic.downloadQualityOrder") ?? "asc",
);
let data: IPlugin.IMediaSourceResult | null = null;
for (let quality of qualityOrder) {
try {
data = await plugin.methods.getMediaSource(
musicItem,
quality,
1,
true,
);
if (!data?.url) {
continue;
}
break;
} catch { }
}
url = data?.url ?? url;
headers = data?.headers;
}
if (!url) {
throw new Error(DownloadFailReason.FailToFetchSource);
}
} catch (e: any) {
errorLog("下载失败-无法获取下载链接", {
item: {
id: musicItem.id,
title: musicItem.title,
platform: musicItem.platform,
quality: nextTask.quality,
},
reason: e?.message ?? e,
});
if (e.message === DownloadFailReason.FailToFetchSource) {
this.markTaskAsError(musicItem, DownloadFailReason.FailToFetchSource, e);
} else {
this.markTaskAsError(musicItem, DownloadFailReason.Unknown, e);
}
return;
}
this.downloadNextPendingTask();
let extension = this.getExtensionName(url);
if (supportLocalMediaType.every(item => item !== ("." + extension))) {
extension = "mp3";
}
const cacheDownloadPath = addFileScheme(
this.getCacheDownloadPath(`${nanoid()}.${extension}`),
);
const targetDownloadPath = addFileScheme(
this.getDownloadPath(`${nextTask.filename}.${extension}`),
);
try {
const folder = path.dirname(targetDownloadPath);
const folderExists = await exists(folder);
if (!folderExists) {
await mkdirR(folder);
}
} catch (e: any) {
this.emit(DownloaderEvent.DownloadTaskError, DownloadFailReason.NoWritePermission, musicItem, e);
return;
}
const { promise } = downloadFile({
fromUrl: url ?? "",
toFile: cacheDownloadPath,
headers: headers,
background: true,
begin: (res) => {
this.updateDownloadTask(musicItem, {
status: DownloadStatus.Downloading,
downloadedSize: 0,
fileSize: res.contentLength,
jobId: res.jobId,
});
},
progress: (res) => {
this.updateDownloadTask(musicItem, {
status: DownloadStatus.Downloading,
downloadedSize: res.bytesWritten,
fileSize: res.contentLength,
jobId: res.jobId,
});
},
});
try {
await promise;
await copyFile(cacheDownloadPath, targetDownloadPath);
LocalMusicSheet.addMusic({
...musicItem,
[internalSerializeKey]: {
localPath: targetDownloadPath,
},
});
patchMediaExtra(musicItem, {
downloaded: true,
localPath: targetDownloadPath,
});
this.markTaskAsCompleted(musicItem);
} catch (e: any) {
this.markTaskAsError(musicItem, DownloadFailReason.Unknown, e);
}
await unlink(cacheDownloadPath);
this.downloadNextPendingTask();
const key = getMediaUniqueKey(musicItem);
if (downloadTasks.get(key)?.status === DownloadStatus.Completed) {
downloadTasks.delete(key);
const downloadQueue = getDefaultStore().get(downloadQueueAtom);
const newDownloadQueue = downloadQueue.filter(item => !isSameMediaItem(item, musicItem));
getDefaultStore().set(downloadQueueAtom, newDownloadQueue);
}
}
download(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], quality?: IMusic.IQualityKey) {
if (network.isOffline) {
this.emit(DownloaderEvent.DownloadError, DownloadFailReason.NetworkOffline);
return;
}
if (network.isCellular && !this.configService.getConfig("basic.useCelluarNetworkDownload")) {
this.emit(DownloaderEvent.DownloadError, DownloadFailReason.NotAllowToDownloadInCellular);
return;
}
if (!Array.isArray(musicItems)) {
musicItems = [musicItems];
}
musicItems = musicItems.filter(m => {
const key = getMediaUniqueKey(m);
if (downloadTasks.has(key)) {
return false;
}
if (LocalMusicSheet.isLocalMusic(m)) {
return false;
}
downloadTasks.set(getMediaUniqueKey(m), {
status: DownloadStatus.Pending,
filename: Downloader.generateFilename(m),
quality: quality,
musicItem: m,
});
return true;
});
if (!musicItems.length) {
return;
}
const downloadQueue = getDefaultStore().get(downloadQueueAtom);
const newDownloadQueue = [...downloadQueue, ...musicItems];
getDefaultStore().set(downloadQueueAtom, newDownloadQueue);
this.downloadNextPendingTask();
}
remove(musicItem: IMusic.IMusicItem) {
const key = getMediaUniqueKey(musicItem);
const task = downloadTasks.get(key);
if (!task) {
return false;
}
if (task.status === DownloadStatus.Pending || task.status === DownloadStatus.Error) {
downloadTasks.delete(key);
const downloadQueue = getDefaultStore().get(downloadQueueAtom);
const newDownloadQueue = downloadQueue.filter(item => !isSameMediaItem(item, musicItem));
getDefaultStore().set(downloadQueueAtom, newDownloadQueue);
return true;
}
return false;
}
}
const downloader = new Downloader();
export default downloader;
export function useDownloadTask(musicItem: IMusic.IMusicItem) {
const [downloadStatus, setDownloadStatus] = useState(downloadTasks.get(getMediaUniqueKey(musicItem)) ?? null);
useEffect(() => {
const callback = (task: IDownloadTaskInfo) => {
if (isSameMediaItem(task?.musicItem, musicItem)) {
setDownloadStatus(task);
}
};
downloader.on(DownloaderEvent.DownloadTaskUpdate, callback);
return () => {
downloader.off(DownloaderEvent.DownloadTaskUpdate, callback);
};
}, [musicItem]);
return downloadStatus;
}
export const useDownloadQueue = () => useAtomValue(downloadQueueAtom);