初始化提交

This commit is contained in:
史悦
2026-01-07 16:46:09 +08:00
commit 0dbb36be9d
113 changed files with 16197 additions and 0 deletions

1
backend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v16.13.0

View File

@@ -0,0 +1,14 @@
{
"Melody Key建议随机生成 UUID": {
"loginType": "固定为phone目前仅支持手机号+密码登录。下面为示例",
"account": "填写手机号。如18888888888",
"password": "填写密码",
"platform": "固定为wy目前仅支持网易云。"
},
"melody": {
"loginType": "phone",
"account": "",
"password": "",
"platform": "wy"
}
}

0
backend/bin/.gitkeep Normal file
View File

33
backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "melody-backend",
"version": "0.1.0",
"main": "index.js",
"scripts": {
"dev": "nodemon node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/foamzou/personal-music-assistant.git"
},
"author": "foamzou",
"license": "ISC",
"bugs": {
"url": "https://github.com/foamzou/personal-music-assistant/issues"
},
"homepage": "https://github.com/foamzou/personal-music-assistant#readme",
"dependencies": {
"NeteaseCloudMusicApi": "4.6.7",
"body-parser": "^1.19.1",
"consola": "^2.15.3",
"cors": "^2.8.5",
"express": "^4.17.2",
"got": "11",
"md5": "^2.3.0",
"node-schedule": "^2.1.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"nodemon": "^2.0.15"
}
}

1699
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
module.exports = {
StatusJobAlreadyExisted: 40010,
StatusJobNoNeedToCreate: 40011,
StatusNoNeedToSync: 40012,
}

View File

@@ -0,0 +1,6 @@
module.exports = {
Pending: "待开始",
InProgress: "进行中",
Failed: "失败",
Finished: "已完成",
}

View File

@@ -0,0 +1,7 @@
module.exports = {
UnblockedPlaylist: "UnblockedPlaylist",
UnblockedSong: "UnblockedSong",
SyncSongFromUrl: "SyncSongFromUrl",
DownloadSongFromUrl: "DownloadSongFromUrl",
SyncThePlaylistToLocalService: "SyncThePlaylistToLocalService",
}

View File

@@ -0,0 +1,4 @@
module.exports = {
High: "high",
Lossless: "lossless",
}

View File

@@ -0,0 +1,40 @@
module.exports = {
consts: {
Netease : {
code: 'netease',
label: '网易云',
},
Bilibili : {
code: 'bilibili',
label: '哔哩哔哩',
},
Douyin : {
code: 'douyin',
label: '抖音',
},
Kugou : {
code: 'kugou',
label: '酷狗',
},
Kuwo : {
code: 'kuwo',
label: '酷我',
},
Migu : {
code: 'migu',
label: '咪咕',
},
QQ : {
code: 'qq',
label: 'QQ',
},
Youtube : {
code: 'youtube',
label: 'Youtube',
},
Qmkg : {
code: 'qmkg',
label: '全民K歌',
},
},
}

View File

@@ -0,0 +1,6 @@
module.exports = class AccountNotExisted extends Error {
constructor(message) {
super(message);
this.name = 'AccountNotExisted';
}
}

View File

@@ -0,0 +1,108 @@
const AccountService = require('../service/account');
const WYAPI = require('../service/music_platform/wycloud');
const { storeCookie } = require('../service/music_platform/wycloud/transport.js');
async function get(req, res) {
res.send({
status: 0,
data: {
account: await getWyAccountInfo(req.account.uid)
}
});
}
async function set(req, res) {
const loginType = req.body.loginType;
const accountName = req.body.account;
const password = req.body.password;
const countryCode = req.body.countryCode;
const config = req.body.config;
const name = req.body.name;
if (name) {
// check if the name is already used by other accounts
const allAccounts = await AccountService.getAllAccountsWithoutSensitiveInfo();
for (const account of Object.values(allAccounts)) {
if (account.name === name && account.uid !== req.account.uid) {
res.status(412).send({ status: 1, message: '昵称已被占用啦,请换一个试试吧', data: {} });
return;
}
}
}
const ret = await AccountService.setAccount(req.account.uid, loginType, accountName, password, countryCode, config, name);
res.send({
status: ret ? 0 : 1,
data: {
account: await getWyAccountInfo(req.account.uid)
}
});
}
async function getWyAccountInfo(uid) {
const account = AccountService.getAccount(uid)
const wyInfo = await WYAPI.getMyAccount(uid);
account.wyAccount = wyInfo;
return account;
}
async function qrLoginCreate(req, res) {
const qrData = await WYAPI.qrLoginCreate(req.account.uid);
if (qrData === false) {
res.status(500).send({
status: 1,
message: 'qr login create failed',
data: {}
});
return;
}
res.send({
status: 0,
data: {
qrKey: qrData.qrKey,
qrCode: qrData.qrCode,
}
});
}
async function qrLoginCheck(req, res) {
// 800 为二维码过期; 801 为等待扫码; 802 为待确认; 803 为授权登录成功
const loginCheckRet = await WYAPI.qrLoginCheck(req.account.uid, req.query.qrKey);
let account = false;
if (loginCheckRet.code == 803) {
// it's a bad design to export the transport function here. Let's refactor it at a good time.
// should be put the cookie method to a cookie manager service
req.account.loginType = 'qrcode';
req.account.account = 'temp';
storeCookie(req.account.uid, req.account, loginCheckRet.cookie);
account = await getWyAccountInfo(req.account.uid);
req.account.account = account.wyAccount.userId;
storeCookie(req.account.uid, req.account, loginCheckRet.cookie);
AccountService.setAccount(req.account.uid, 'qrcode', account.wyAccount.userId, '', null);
account = await getWyAccountInfo(req.account.uid);
}
res.send({
status: loginCheckRet ? 0 : 1,
data: {
wyQrStatus: loginCheckRet.code,
account
}
});
}
async function getAllAccounts(req, res) {
const data = await AccountService.getAllAccountsWithoutSensitiveInfo();
res.send({
status: 0,
data: data
});
}
module.exports = {
get: get,
set: set,
qrLoginCreate,
qrLoginCheck,
getAllAccounts,
}

View File

@@ -0,0 +1,23 @@
const ConfigService = require('../service/config_manager');
async function getGlobalConfig(req, res) {
const config = await ConfigService.getGlobalConfig();
res.send({
status: 0,
data: config
});
}
async function setGlobalConfig(req, res) {
const config = req.body;
await ConfigService.setGlobalConfig(config);
res.send({
status: 0,
data: config
});
}
module.exports = {
getGlobalConfig,
setGlobalConfig,
}

View File

@@ -0,0 +1,41 @@
const logger = require('consola');
const { getMediaGetInfo, getLatestMediaGetVersion, downloadTheLatestMediaGet } = require('../service/media_fetcher/media_get');
async function checkLibVersion(req, res) {
const query = req.query;
if (!['mediaGet'].includes(query.lib)) {
res.send({
status: 1,
message: "lib name is invalid",
});
return;
}
const latestVersion = await getLatestMediaGetVersion();
const mediaGetInfo = await getMediaGetInfo();
res.send({
status: 0,
data: {
mediaGetInfo,
latestVersion,
}
});
}
async function downloadTheLatestLib(req, res) {
const {version} = req.body;
const succeed = await downloadTheLatestMediaGet(version);
res.send({
status: succeed ? 0 : 1,
data: {}
});
}
module.exports = {
checkLibVersion: checkLibVersion,
downloadTheLatestLib: downloadTheLatestLib,
}

View File

@@ -0,0 +1,92 @@
const logger = require('consola');
const { getUserAllPlaylist } = require('../service/music_platform/wycloud');
const { getPlaylistDetail } = require('../service/music_platform/tunehub');
const Source = require('../consts/source').consts;
function splitArtists(artist) {
if (!artist) {
return [];
}
return artist
.split(/[、/]/)
.map(item => item.trim())
.filter(Boolean);
}
function normalizeTunehubPlaylist(playlistId, detail) {
const info = detail && detail.info ? detail.info : {};
const list = detail && Array.isArray(detail.list) ? detail.list : [];
const songs = list.map(item => {
const artists = splitArtists(item.artist);
const primaryArtist = artists[0] || item.artist || '';
const cover = item.pic || info.pic || '';
const isBlocked = !item.types || item.types.length === 0;
return {
songId: item.id,
songName: item.name || '',
artists,
artist: primaryArtist,
duration: 0,
album: item.album || '',
cover,
pageUrl: `https://music.163.com/song?id=${item.id}`,
playUrl: item.url || '',
isBlocked,
isCloud: false,
};
});
return {
id: playlistId,
name: info.name || '',
cover: info.pic || '',
songs,
};
}
async function listAllPlaylists(req, res) {
const uid = req.account.uid;
const playlists = await getUserAllPlaylist(uid);
if (playlists === false) {
logger.error(`get user all playlist failed, uid: ${uid}`);
}
res.send({
status: playlists ? 0 : 1,
data: {
playlists,
}
});
}
async function listSongsFromPlaylist(req, res) {
const uid = req.account.uid;
const source = req.params.source;
const playlistId = req.params.id;
if (source !== Source.Netease.code || !playlistId) {
res.send({
status: 1,
message: "source or id is invalid",
});
return;
}
const detail = await getPlaylistDetail(source, playlistId);
if (detail === false) {
logger.error(`get playlist detail failed, uid: ${uid}`);
}
const playlists = detail ? normalizeTunehubPlaylist(playlistId, detail) : false;
res.send({
status: playlists ? 0 : 1,
data: {
playlists: playlists ? playlists : [],
}
});
}
module.exports = {
listAllPlaylists: listAllPlaylists,
listSongsFromPlaylist: listSongsFromPlaylist,
}

View File

@@ -0,0 +1,46 @@
const logger = require('consola');
const got = require('got');
async function proxyAudio(req, res) {
const url = req.query.url;
const source = req.query.source;
const referer = req.query.referer;
if (!url || !source) {
res.status(400).send({
status: 1,
message: "url and source are required"
});
return;
}
// 只允许 bilibili 源
if (source !== 'bilibili') {
res.status(403).send({
status: 1,
message: "only bilibili source is allowed"
});
return;
}
try {
const stream = got.stream(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)',
'Referer': referer || 'https://www.bilibili.com'
}
});
stream.pipe(res);
} catch (err) {
logger.error('proxy audio error:', err);
res.status(500).send({
status: 1,
message: "proxy failed"
});
}
}
module.exports = {
proxyAudio
};

View File

@@ -0,0 +1,27 @@
const schedulerService = require('../service/scheduler');
const AccountService = require('../service/account');
async function getNextRun(req, res) {
const localNextRun = schedulerService.getLocalSyncNextRun();
const accounts = await AccountService.getAllAccounts();
const cloudNextRuns = {};
for (const uid in accounts) {
const nextRun = schedulerService.getCloudSyncNextRun(uid);
if (nextRun) {
cloudNextRuns[uid] = nextRun;
}
}
res.send({
status: 0,
data: {
localNextRun,
cloudNextRuns
}
});
}
module.exports = {
getNextRun
};

View File

@@ -0,0 +1,31 @@
const logger = require('consola');
const { getMetaWithUrl } = require('../service/media_fetcher');
const { matchUrlFromStr } = require('../utils/regex');
async function getMeta(req, res) {
const query = req.query;
const url = matchUrlFromStr(query.url);
if (!url) {
res.send({
status: 1,
message: "url is invalid",
});
return;
}
const songMeta = await getMetaWithUrl(url);
songMeta && (songMeta.pageUrl = url);
res.send({
status: songMeta ? 0 : 1,
data: {
songMeta,
}
});
}
module.exports = {
getMeta: getMeta
}

View File

@@ -0,0 +1,76 @@
const logger = require('consola');
const { searchSongsWithKeyword, searchSongsWithSongMeta } = require('../service/search_songs');
const { getPlayUrlWithOptions } = require('../service/songs_info');
const { getMetaWithUrl } = require('../service/media_fetcher');
const { matchUrlFromStr } = require('../utils/regex');
async function search(req, res) {
const query = req.query;
const keywordOrUrl = query.keyword;
if (!keywordOrUrl) {
res.send({
status: 1,
message: "keyword is required",
});
return;
}
let songs = [];
const url = matchUrlFromStr(keywordOrUrl);
if (!url) {
songs = await searchSongsWithKeyword(keywordOrUrl);
} else {
const songMeta = await getMetaWithUrl(url);
if (!songMeta) {
res.send({
status: 2,
message: "can not get song meta with this url",
});
return;
}
songs = await searchSongsWithSongMeta({
songName: songMeta.songName,
artist: songMeta.artist,
album: songMeta.album,
duration: songMeta.duration,
}, {
expectArtistAkas: [],
allowSongsJustMatchDuration: true,
allowSongsNotMatchMeta: true,
});
}
res.send({
status: 0,
data: {
songs: songs ? songs : [],
}
});
}
async function getPlayUrl(req, res) {
const source = req.params.source;
const songId = req.params.id;
if (!source || !songId) {
res.send({
status: 1,
message: "source and songId is required",
});
return;
}
const playUrl = await getPlayUrlWithOptions(req.account.uid, source, songId);
res.send({
status: 0,
data: {
playUrl,
}
});
}
module.exports = {
search: search,
getPlayUrl: getPlayUrl
}

View File

@@ -0,0 +1,183 @@
const logger = require('consola');
const { unblockMusicInPlaylist, unblockMusicWithSongId } = require('../service/sync_music');
const JobType = require('../consts/job_type');
const Source = require('../consts/source').consts;
const { matchUrlFromStr } = require('../utils/regex');
const { syncSingleSongWithUrl, syncPlaylist } = require('../service/sync_music');
const findTheBestMatchFromWyCloud = require('../service/search_songs/find_the_best_match_from_wycloud');
const JobManager = require('../service/job_manager');
const JobStatus = require('../consts/job_status');
const BusinessCode = require('../consts/business_code');
async function createJob(req, res) {
const uid = req.account.uid;
const request = req.body;
const jobType = request.jobType;
const options = request.options;
let jobId = 0;
if (jobType === JobType.UnblockedPlaylist || jobType === JobType.SyncThePlaylistToLocalService) {
const source = request.playlist && request.playlist.source;
const playlistId = request.playlist && request.playlist.id;
if (source !== Source.Netease.code || !playlistId) {
res.status(412).send({
status: 1,
message: "source or id is invalid",
});
return;
}
if (jobType === JobType.UnblockedPlaylist) {
jobId = await unblockMusicInPlaylist(uid, source, playlistId, {
syncWySong: options.syncWySong,
syncNotWySong: options.syncNotWySong,
asyncExecute: true,
});
} else {
jobId = await syncPlaylist(uid, source, playlistId)
}
} else if (jobType === JobType.UnblockedSong) {
const source = request.source;
const songId = request.songId;
if (source !== Source.Netease.code || !songId) {
res.status(412).send({
status: 1,
message: "source or id is invalid",
});
return;
}
jobId = await unblockMusicWithSongId(uid, source, songId)
} else if (jobType === JobType.SyncSongFromUrl || jobType === JobType.DownloadSongFromUrl) {
const request = req.body;
const url = request.urlJob && matchUrlFromStr(request.urlJob.url);
if (!url) {
res.status(412).send({
status: 1,
message: "url is invalid",
});
return;
}
let meta = {};
const songId = request.urlJob && request.urlJob.meta.songId ? request.urlJob.meta.songId : "";
if (request.urlJob.meta && (request.urlJob.meta.songName !== "" && request.urlJob.meta.artist !== "")) {
meta = {
songName: request.urlJob.meta.songName,
artist: request.urlJob.meta.artist,
album : request.urlJob.meta.album ? request.urlJob.meta.album : "",
};
}
if (songId) {
const songFromWyCloud = await findTheBestMatchFromWyCloud(req.account.uid, {
songName: meta.songName,
artist: meta.artist,
album: meta.album,
musicPlatformSongId: songId,
});
if (!songFromWyCloud) {
logger.error(`song not found in wycloud`);
res.status(412).send({
status: 1,
message: "can not find song in wycloud with your songId",
});
return;
}
meta.songFromWyCloud = songFromWyCloud;
}
// create job
const args = `${jobType}: {"url":${url}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`${jobType} job is already running.`);
jobId = BusinessCode.StatusJobAlreadyExisted;
} else {
const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载";
jobId = await JobManager.createJob(uid, {
name: `${operation}歌曲:${meta.songName ? meta.songName : url}`,
args,
type: jobType,
status: JobStatus.Pending,
desc: `歌曲:${meta.songName ? meta.songName : url}`,
progress: 0,
tip: `等待${operation}`,
createdAt: Date.now()
});
// async job
syncSingleSongWithUrl(req.account.uid, url, meta, jobId, jobType).then(async ret => {
await JobManager.updateJob(uid, jobId, {
status: ret === true ? JobStatus.Finished : JobStatus.Failed,
progress: 1,
tip: ret === true ? `${operation}成功` : `${operation}失败`,
});
})
}
} else {
res.status(412).send({
status: 1,
message: "jobType is not supported",
});
return;
}
if (jobId === false) {
logger.error(`create job failed, uid: ${uid}`);
res.status(412).send({
status: 1,
message: "create job failed",
});
return;
}
if (jobId === BusinessCode.StatusJobAlreadyExisted) {
res.status(412).send({
status: BusinessCode.StatusJobAlreadyExisted,
message: "你的任务已经在跑啦,等等吧",
});
return;
}
if (jobId === BusinessCode.StatusJobNoNeedToCreate) {
res.status(412).send({
status: BusinessCode.StatusJobAlreadyExisted,
message: "你的任务无需被创建,可能是因为没有需要 sync 的歌曲",
});
return;
}
res.status(201).send({
status: jobId ? 0 : 1,
data: {
jobId,
}
});
}
async function listAllJobs(req, res) {
res.send({
status: 0,
data: {
jobs: await JobManager.listJobs(req.account.uid),
}
});
}
async function getJob(req, res) {
res.send({
status: 0,
data: {
jobs: await JobManager.getJob(req.account.uid, req.params.id),
}
});
}
module.exports = {
createJob: createJob,
listAllJobs: listAllJobs,
getJob: getJob,
}

39
backend/src/index.js Normal file
View File

@@ -0,0 +1,39 @@
const path = require('path');
const logger = require('consola');
const cors = require('cors');
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 5566;
require('./init_app')().then(() => {
const middlewareHandleError = require('./middleware/handle_error');
const middlewareAuth = require('./middleware/auth');
const proxy = require('./handler/proxy');
app.use(bodyParser.json());
app.use(cors({
origin: true,
credentials: true,
}));
// 先注册代理路由,跳过 auth 验证
app.get('/api/proxy/audio', proxy.proxyAudio);
// 其他 API 路由需要 auth
app.use('/api', middlewareAuth);
app.use('/', require('./router'));
app.use(middlewareHandleError);
app.use(express.static(path.resolve(__dirname, '../public')));
const server = app.listen(port, () => {
const host = server.address().address
const port = server.address().port
logger.info(`Express server is listening on ${host}:${port}!`)
})
const schedulerService = require('./service/scheduler');
schedulerService.start();
});

48
backend/src/init_app.js Normal file
View File

@@ -0,0 +1,48 @@
const logger = require('consola');
const fs = require('fs');
const process = require('process');
initDir();
initAccountFileIfNotExisted();
const mediaGet = require('./service/media_fetcher/media_get');
function initDir() {
// make sure all dir has been created
const dirList = [
__dirname + '/../.profile',
__dirname + '/../.profile/cookie',
__dirname + '/../.profile/data',
__dirname + '/../.profile/data/jobs',
];
dirList.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
});
}
function initAccountFileIfNotExisted() {
const targetFile = __dirname + '/../.profile/accounts.json';
const sampleFile = __dirname + '/../accounts.sample.json';
if (!fs.existsSync(targetFile)) {
fs.copyFileSync(sampleFile, targetFile);
logger.info('初始化 accounts.json 文件成功, 默认的 melody key 为: melody');
}
}
module.exports = async function() {
// check if media-get is installed
const mediaGetInfo = await mediaGet.getMediaGetInfo();
if (mediaGetInfo === false) {
process.exit(-1);
}
if (!mediaGetInfo.hasInstallFFmpeg) {
logger.error('please install FFmpeg and FFprobe first');
process.exit(-1);
}
logger.info(`[media-get] Version: ${mediaGetInfo.versionInfo}`);
// TODO check media-get latest version
}

View File

@@ -0,0 +1,19 @@
const AccountService = require('../service/account');
const AccountNotExisted = require('../errors/account_not_existed');
const logger = require('consola');
module.exports = (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
if (!req.headers['mk']) {
throw new AccountNotExisted;
}
const account = AccountService.getAccount(req.headers['mk'])
if (!account) {
throw new AccountNotExisted;
}
//logger.info('user access', account);
req.account = account
next()
}

View File

@@ -0,0 +1,19 @@
const logger = require('consola');
const AccountNotExisted = require('../errors/account_not_existed');
module.exports = async (error, req, res, next) => {
logger.error('catch error', error);
if (error instanceof AccountNotExisted) {
res.status(403).send({
status: 1,
message: "account not existed",
});
return;
}
res.status(500).send({
status: 1,
message: "Internal Server Error",
});
}

42
backend/src/router.js Normal file
View File

@@ -0,0 +1,42 @@
const router = require('express').Router();
const SyncJob = require('./handler/sync_jobs');
const Songs = require('./handler/songs');
const SongMeta = require('./handler/song_meta');
const Playlists = require('./handler/playlists');
const Account = require('./handler/account');
const MediaFetcherLib = require('./handler/media_fetcher_lib');
const Config = require('./handler/config');
const Scheduler = require('./handler/scheduler');
const asyncWrapper = (cb) => {
return (req, res, next) => cb(req, res, next).catch(next);
};
router.post('/api/sync-jobs', asyncWrapper(SyncJob.createJob));
router.get('/api/sync-jobs', asyncWrapper(SyncJob.listAllJobs));
router.get('/api/sync-jobs/:id', asyncWrapper(SyncJob.getJob));
router.get('/api/songs', asyncWrapper(Songs.search));
router.get('/api/songs/:source/:id/playUrl', asyncWrapper(Songs.getPlayUrl));
router.get('/api/songs-meta', asyncWrapper(SongMeta.getMeta));
router.get('/api/playlists', asyncWrapper(Playlists.listAllPlaylists));
router.get('/api/playlists/:source/:id/songs', asyncWrapper(Playlists.listSongsFromPlaylist));
router.get('/api/account', asyncWrapper(Account.get));
router.post('/api/account', asyncWrapper(Account.set));
router.get('/api/accounts', asyncWrapper(Account.getAllAccounts));
router.get('/api/account/qrlogin-create', asyncWrapper(Account.qrLoginCreate));
router.get('/api/account/qrlogin-check', asyncWrapper(Account.qrLoginCheck));
router.get('/api/media-fetcher-lib/version-check', asyncWrapper(MediaFetcherLib.checkLibVersion));
router.post('/api/media-fetcher-lib/update', asyncWrapper(MediaFetcherLib.downloadTheLatestLib));
router.get('/api/config/global', asyncWrapper(Config.getGlobalConfig));
router.post('/api/config/global', asyncWrapper(Config.setGlobalConfig));
router.get('/api/scheduler/next-run', asyncWrapper(Scheduler.getNextRun));
module.exports = router;

View File

@@ -0,0 +1,117 @@
const AccountPath = __dirname + '/../../.profile/accounts.json';
const CookiePath = __dirname + '/../../.profile/cookie/';
let AccountMap = require(AccountPath);
const logger = require('consola');
const locker = require('../utils/simple_locker');
const fs = require('fs');
const SoundQuality = require('../consts/sound_quality');
module.exports = {
getAccount: getAccount,
setAccount: setAccount,
getAllAccounts: getAllAccounts,
getAllAccountsWithoutSensitiveInfo: getAllAccountsWithoutSensitiveInfo,
}
const defaultConfig = {
playlistSyncToWyCloudDisk: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
onlyCreatedPlaylists: true,
},
syncWySong: true,
syncNotWySong: false,
soundQualityPreference: SoundQuality.High,
},
};
function getAccount(uid) {
const account = AccountMap[uid];
if (!account) {
logger.error(`the uid(${uid}) does not existed`);
return false;
}
if (!account.config) {
account.config = defaultConfig;
}
if (!account.config.playlistSyncToWyCloudDisk) {
account.config.playlistSyncToWyCloudDisk = defaultConfig.playlistSyncToWyCloudDisk;
}
account.uid = uid;
return account;
}
async function setAccount(uid, loginType, account, password, countryCode = '', config, name) {
const lockKey = 'setAccount';
await locker.lock(lockKey, 5);
refreshAccountFromFile();
const userAccount = getAccount(uid);
if (!userAccount) {
locker.unlock(lockKey);
return false;
}
const oldAccount = userAccount;
userAccount.loginType = loginType;
userAccount.account = account;
userAccount.password = password;
userAccount.countryCode = countryCode;
if (name) {
userAccount.name = name;
}
if (config) {
userAccount.config = config;
}
AccountMap[uid] = userAccount;
storeAccount(AccountMap);
locker.unlock(lockKey);
// clear cookie
try {
fs.unlinkSync(CookiePath + uid);
} catch(_){}
// 重启调度器以应用新的账号配置
if (config && JSON.stringify(oldAccount?.config?.playlistSyncToWyCloudDisk) !==
JSON.stringify(config.playlistSyncToWyCloudDisk)) {
const schedulerService = require('../service/scheduler');
await schedulerService.updateCloudSyncJob(uid);
}
return true;
}
function refreshAccountFromFile() {
AccountMap = JSON.parse(fs.readFileSync(AccountPath).toString());
}
function storeAccount(account) {
fs.writeFileSync(AccountPath, JSON.stringify(account, null, 4));
}
async function getAllAccounts() {
refreshAccountFromFile();
return AccountMap;
}
async function getAllAccountsWithoutSensitiveInfo() {
refreshAccountFromFile();
const filteredAccounts = {};
for (const [uid, account] of Object.entries(AccountMap)) {
filteredAccounts[uid] = {
name: account.name || uid,
uid: account.uid
};
}
return filteredAccounts;
}

View File

@@ -0,0 +1,85 @@
const sound_quality = require('../../consts/sound_quality');
const asyncFs = require('../../utils/fs');
const DataPath = `${__dirname}/../../../.profile/data`;
const ConfigPath = `${DataPath}/config`;
const GlobalConfig = `${ConfigPath}/global.json`;
const sourceConsts = require('../../consts/source').consts;
const libPath = require('path');
async function init() {
if (!await asyncFs.asyncFileExisted(ConfigPath)) {
await asyncFs.asyncMkdir(ConfigPath, { recursive: true });
}
}
init();
const GlobalDefaultConfig = {
downloadPath: '',
filenameFormat: '{songName}-{artist}',
downloadPathExisted: false,
// don't search youtube by default
sources: Object.values(sourceConsts).map(i => i.code).filter(s => s !== sourceConsts.Youtube.code),
sourceConsts,
playlistSyncToLocal: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
},
deleteLocalFile: false,
filenameFormat: `{playlistName}${libPath.sep}{songName}-{artist}`,
soundQualityPreference: sound_quality.High,
syncAccounts: [],
},
};
async function setGlobalConfig(config) {
const oldConfig = await getGlobalConfig();
await asyncFs.asyncWriteFile(GlobalConfig, JSON.stringify(config));
// 只在本地同步配置发生变化时更新调度器
if (JSON.stringify(oldConfig.playlistSyncToLocal) !== JSON.stringify(config.playlistSyncToLocal)) {
const schedulerService = require('../scheduler');
await schedulerService.updateLocalSyncJob();
}
}
async function getGlobalConfig() {
if (!await asyncFs.asyncFileExisted(GlobalConfig)) {
return GlobalDefaultConfig;
}
const config = JSON.parse(await asyncFs.asyncReadFile(GlobalConfig));
if (!config.sources) {
config.sources = GlobalDefaultConfig.sources;
}
config.sourceConsts = GlobalDefaultConfig.sourceConsts;
config.downloadPathExisted = false;
if (config.downloadPath) {
config.downloadPathExisted = await asyncFs.asyncFileExisted(config.downloadPath);
}
if (!config.filenameFormat) {
config.filenameFormat = GlobalDefaultConfig.filenameFormat;
}
if (!config.playlistSyncToLocal) {
config.playlistSyncToLocal = GlobalDefaultConfig.playlistSyncToLocal;
}
if (!config.playlistSyncToLocal.filenameFormat) {
config.playlistSyncToLocal.filenameFormat = GlobalDefaultConfig.playlistSyncToLocal.filenameFormat;
}
if (!config.playlistSyncToLocal.soundQualityPreference) {
config.playlistSyncToLocal.soundQualityPreference = GlobalDefaultConfig.playlistSyncToLocal.soundQualityPreference;
}
if (!config.playlistSyncToLocal.syncAccounts) {
config.playlistSyncToLocal.syncAccounts = GlobalDefaultConfig.playlistSyncToLocal.syncAccounts;
}
return config;
}
module.exports = {
setGlobalConfig,
getGlobalConfig,
}

View File

View File

@@ -0,0 +1,189 @@
const logger = require('consola');
const asyncFs = require('../../utils/fs');
const genUUID = require('../../utils/uuid');
const { lock, unlock } = require('../../utils/simple_locker');
const JobStatus = require('../../consts/job_status');
const DataPath = `${__dirname}/../../../.profile/data`;
const JobDataPath = `${DataPath}/jobs`;
const JobManagerInitTime = Date.now();
async function listJobs(uid) {
const list = [];
const jobs = await getUserJobs(uid);
for (const jobId in jobs) {
const job = await getJob(uid, jobId);
if (!job) {
continue;
}
job.id = jobId;
list.push(job);
}
return list.sort((a, b) => b.createdAt - a.createdAt);
}
async function getJob(uid, jobId) {
const jobFile = await getJobFilePath(uid, jobId, false);
if (!await asyncFs.asyncFileExisted(jobFile)) {
return null;
}
const fileText = await asyncFs.asyncReadFile(jobFile);
if (fileText == "") {
return null;
}
return JSON.parse(fileText);
}
async function updateJob(uid, jobId, info) {
const lockKey = getJobLockKey(jobId);
if (!await lock(lockKey, 5)) {
logger.error(`get job locker failed, uid: ${uid}, job: ${jobId}`);
return false;
}
const job = await getJob(uid, jobId);
if (info.desc) {
job.desc = info.desc;
}
if (info.progress) {
job.progress = info.progress;
}
if (info.status) {
job.status = info.status;
}
if (info.tip) {
job.tip = info.tip;
if (!info.log) {
info.log = info.tip
}
}
if (info.log) {
if (!job.logs) {
job.logs = [];
}
job.logs.push({
time: Date.now(),
info: info.log
});
}
if (info.data) {
job.data = info.data;
}
const jobFile = await getJobFilePath(uid, jobId);
await asyncFs.asyncWriteFile(jobFile, JSON.stringify(job));
unlock(lockKey);
}
async function createJob(uid, job = {
name: '',
type: '',
desc: '',
progress: 0,
tip: '',
status: '',
logs: [],
data: {},
createdAt: Date.now(),
}) {
const jobId = genUUID();
const jobFile = await getJobFilePath(uid, jobId);
await asyncFs.asyncWriteFile(jobFile, JSON.stringify(job));
await addJobIdToUserJobList(uid, jobId);
return jobId;
}
async function deleteJob(uid, jobId) {
await removeJobIdFromUserJobList(uid, jobId);
await asyncFs.asyncUnlinkFile(await getJobFilePath(uid, jobId, false));
}
async function addJobIdToUserJobList(uid, jobId) {
const lockKey = getJobListLockKey(uid);
if (!await lock(lockKey, 5)) {
logger.error(`get job_list locker failed, uid: ${uid}`);
return false;
}
const jobs = await getUserJobs(uid);
jobs[jobId] = {
createdAt: Date.now(),
};
await asyncFs.asyncWriteFile(await getJobListFilePath(uid), JSON.stringify(jobs));
unlock(lockKey);
}
async function removeJobIdFromUserJobList(uid, jobId) {
const lockKey = getJobListLockKey(uid);
if (!await lock(lockKey, 5)) {
logger.error(`get job_list locker failed, uid: ${uid}`);
return false;
}
const jobs = await getUserJobs(uid);
delete jobs[jobId];
await asyncFs.asyncWriteFile(await getJobListFilePath(uid), JSON.stringify(jobs));
unlock(lockKey);
}
function getJobListLockKey(uid) {
return `job_list_${uid}`;
}
function getJobLockKey(jobId) {
return `job_${jobId}`;
}
async function getUserJobs(uid) {
const jobListFile = await getJobListFilePath(uid);
return JSON.parse(await asyncFs.asyncReadFile(jobListFile));
}
async function getJobFilePath(uid, jobId, createIfNotExist = true) {
const path = `${await getUserJobPath(uid)}/${jobId}`;
if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) {
await asyncFs.asyncWriteFile(path, '{}');
}
return path;
}
async function getJobListFilePath(uid, createIfNotExist = true) {
const path = `${await getUserJobPath(uid)}/list`;
if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) {
await asyncFs.asyncWriteFile(path, '{}');
}
return path;
}
async function getUserJobPath(uid, createIfNotExist = true) {
const path = `${JobDataPath}/${uid}`;
if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) {
await asyncFs.asyncMkdir(path, { recursive: true });
}
return path;
}
async function findActiveJobByArgs(uid, args) {
const jobs = await listJobs(uid);
return jobs.find(job => {
if (job['args'] === args && job['status'] !== JobStatus.Failed && job['status'] !== JobStatus.Finished) {
// 如果创建时间 早于 组件 init 时间,那么认为是无效的 job意味着服务重启了而目前 job 不支持重启服务后继续 run
if (job['createdAt'] < JobManagerInitTime) {
return false;
}
// 超过 1 小时也认为超时
if (Date.now() - job['createdAt'] > 1000 * 60 * 60) {
return false;
}
return job;
}
});
}
module.exports = {
listJobs: listJobs,
createJob: createJob,
findActiveJobByArgs: findActiveJobByArgs,
deleteJob: deleteJob,
getJob: getJob,
updateJob: updateJob,
}

View File

@@ -0,0 +1,88 @@
const { lock, unlock } = require('../../utils/simple_locker');
const asyncFs = require('../../utils/fs');
const logger = require('consola');
const DbPath = `${__dirname}/../../../.profile/data/kv-db`;
async function init() {
if (!await asyncFs.asyncFileExisted(DbPath)) {
await asyncFs.asyncMkdir(DbPath);
}
}
init();
async function set(table, key, value) {
if (!await lock(table, 5)) {
logger.error(`get table locker failed, table: ${table}, key: ${key}, value: ${value}`);
return false;
}
const filePath = `${DbPath}/${table}.json`;
let data = {};
if (await asyncFs.asyncFileExisted(filePath)) {
try {
data = JSON.parse(await asyncFs.asyncReadFile(filePath));
} catch (err) {
logger.error(`parse ${filePath} failed`, err);
return false;
}
}
data[key] = value;
try {
await asyncFs.asyncWriteFile(filePath, JSON.stringify(data));
} catch (err) {
logger.error(`write ${filePath} failed`, err);
return false;
}
unlock(table);
return true;
}
async function get(table, key) {
const filePath = `${DbPath}/${table}.json`;
let data = {};
if (await asyncFs.asyncFileExisted(filePath)) {
try {
data = JSON.parse(await asyncFs.asyncReadFile(filePath));
} catch (err) {
logger.error(`parse ${filePath} failed`, err);
return false;
}
}
return data[key];
}
module.exports = {
set,
get,
fileSyncMeta: {
set: async function (source, sourceID, value) {
const key = `${source}-${sourceID}`;
return await set('fileSyncMeta', key, JSON.stringify(value));
},
get: async function (source, sourceID) {
const key = `${source}-${sourceID}`;
const ret = await get('fileSyncMeta', key);
if (!ret) {
return false;
}
return JSON.parse(ret);
},
setPlaylistMeta: async function(playlistID, meta) {
const key = `playlist-${playlistID}`;
return await set('fileSyncMeta', key, JSON.stringify({
songIDs: meta.songIDs || [],
// 预留其他字段
}));
},
getPlaylistMeta: async function(playlistID) {
const key = `playlist-${playlistID}`;
const ret = await get('fileSyncMeta', key);
if (!ret) {
return {
songIDs: []
};
}
return JSON.parse(ret);
}
}
};

View File

@@ -0,0 +1,180 @@
const logger = require('consola');
const os = require('os');
const md5 = require('md5');
const path = require('path');
const cmd = require('../../utils/cmd');
const fs = require('fs');
const configManager = require('../config_manager')
const downloadFile = require('../../utils/download');
const { getBinPath } = require('./media_get');
const basePath = path.join(os.tmpdir(), 'melody-tmp-songs');
// create path if not exists
if (!fs.existsSync(basePath)) {
fs.mkdirSync(basePath);
}
logger.info(`[tmp path] use ${basePath}`)
async function downloadViaSourceUrl(url) {
logger.info(`downloadViaSourceUrl params: url: ${url}`);
const requestHash = md5(url);
const downloadPath = `${basePath}/${requestHash}.mp3`;
logger.info(`start download from ${url}`);
const isSucceed = await downloadFile(url, downloadPath);
if (!isSucceed) {
logger.error(`download failed with ${url}`);
return false;
}
if (!fs.existsSync(downloadPath)) {
logger.error(`download failed with ${url}, the file not exists ${downloadPath}`);
return false;
}
logger.info(`download success, path: ${downloadPath}`);
return downloadPath;
}
async function fetchWithUrl(url, {
songName = "",
addMediaTag = false,
}) {
logger.info(`fetchWithUrl params: ${JSON.stringify(arguments)}`);
if (songName) {
songName = songName.replace(/ /g, '').replace(/\./g, '').replace(/\//g, '').replace(/"/g, '');
}
const requestHash = md5(`${url}${songName}${addMediaTag}`);
const fileBasePath = `${basePath}/${requestHash}`;
try {
fs.mkdirSync(fileBasePath, { recursive: true });
} catch (err) {
logger.error('create dir failed', err);
return false;
}
addMediaTag = false; // todo: 等到 media-get fix 偶现的 添加 addMediaTag 后 panic 的问题,再移除这行代码
const downloadPath = `${fileBasePath}/${songName ? songName : requestHash}.mp3`;
logger.info(`start parse and download from ${url}`);
let args = ['-u', `"${url}"`, '--out', `${downloadPath}`, '-t', 'audio', `${addMediaTag ? '--addMediaTag' : ''}`];
logger.info(`${getBinPath()} ${args.join(' ')}`);
const {code, message} = await cmd(getBinPath(), args);
logger.info('-------')
logger.info(code);
logger.info(message);
logger.info('-------')
if (code != 0) {
return false;
}
if (!fs.existsSync(downloadPath)) {
return false;
}
return downloadPath;
}
async function getMetaWithUrl(url) {
logger.info(`getMetaWithUrl from ${url}`);
let args = ['-u', `"${url}"`, '-m', '--infoFormat=json', '-l=silence'];
const {code, message} = await cmd(getBinPath(), args);
logger.info('-------')
logger.info(code);
// logger.info(message);
logger.info('-------')
if (code != 0) {
logger.error(`getMetaWithUrl failed with ${url}, err: ${message}`);
return false;
}
let meta;
try {
meta = JSON.parse(message);
} catch (e) {
logger.error(e, message)
return false;
}
return {
songName: meta.title,
artist: meta.artist,
album: meta.album,
duration: meta.duration,
coverUrl: meta.cover_url,
publicTime: meta.public_time,
isTrial: meta.is_trial,
resourceType: meta.resource_type,
audios: meta.audios,
fromMusicPlatform: meta.from_music_platform,
resourceForbidden: meta.resource_forbidden,
source: meta.source
}
}
async function searchSongFromAllPlatform({
keyword,
songName, artist, album
}) {
logger.info(`searchSong with ${JSON.stringify(arguments)}`);
const globalConfig = await configManager.getGlobalConfig();
let searchParams = keyword
? ['-k', `"${keyword}"`]
: ['--searchSongName', `"${songName}"`, '--searchArtist', `"${artist}"`, '--searchAlbum', `"${album}"`];
searchParams = searchParams.concat([
'--searchType="song"',
'-m',
`--sources=${globalConfig.sources.join(',')}`,
'--infoFormat=json',
'-l', 'silence'
]);
logger.info(`cmdStr: ${getBinPath()} ${searchParams.join(' ')}`);
const {code, message} = await cmd(getBinPath(), searchParams);
logger.info('-------')
logger.info(code);
// logger.info(message);
logger.info('-------')
if (code != 0) {
logger.error(`searchSong failed with ${arguments}, err: ${message}`);
return false;
}
let jsonResponse;
try {
jsonResponse = JSON.parse(message);
} catch (e) {
logger.error(e, message)
return false;
}
return jsonResponse.map(searchItem => {
return {
songName: searchItem.Name,
artist: searchItem.Artist,
album: searchItem.Album,
duration: searchItem.Duration,
url: searchItem.Url,
resourceForbidden: searchItem.ResourceForbidden,
source: searchItem.Source,
fromMusicPlatform: searchItem.FromMusicPlatform,
score: searchItem.Score,
}
})
}
module.exports = {
downloadViaSourceUrl: downloadViaSourceUrl,
fetchWithUrl: fetchWithUrl,
getMetaWithUrl: getMetaWithUrl,
searchSongFromAllPlatform: searchSongFromAllPlatform,
}

View File

@@ -0,0 +1,228 @@
const logger = require('consola');
const https = require('https');
const cmd = require('../../utils/cmd');
var isWin = require('os').platform().indexOf('win32') > -1;
const isLinux = require('os').platform().indexOf('linux') > -1;
const isDarwin = require('os').platform().indexOf('darwin') > -1;
const httpsGet = require('../../utils/network').asyncHttpsGet;
const RemoteConfig = require('../remote_config');
const fs = require('fs');
function getBinPath(isTemp = false) {
return `${__dirname}/../../../bin/media-get` + (isTemp ? '-tmp-' : '') + (isWin ? '.exe' : '');
}
async function getMediaGetInfo(isTempBin = false) {
try {
const {code, message, error} = await cmd(getBinPath(isTempBin), ['-h']);
logger.info('Command execution result:', {
code,
error,
binPath: getBinPath(isTempBin)
});
if (code != 0) {
logger.error(`Failed to execute media-get:`, {
code,
error,
message
});
return false;
}
const hasInstallFFmpeg = message.indexOf('FFmpeg,FFprobe: installed') > -1;
const versionInfo = message.match(/Version:(.+?)\n/);
return {
hasInstallFFmpeg,
versionInfo: versionInfo ? versionInfo[1].trim() : '',
fullMessage: message,
}
} catch (err) {
logger.error('Exception while executing media-get:', err);
return false;
}
}
async function getLatestMediaGetVersion() {
const remoteConfig = await RemoteConfig.getRemoteConfig();
const latestVerisonUrl = `${remoteConfig.bestGithubProxy}https://raw.githubusercontent.com/foamzou/media-get/main/LATEST_VERSION`;
console.log('start to get latest version from: ' + latestVerisonUrl);
const latestVersion = await httpsGet(latestVerisonUrl);
console.log('latest version: ' + latestVersion);
if (latestVersion === null || (latestVersion || "").split('.').length !== 3) {
logger.error('获取 media-get 最新版本号失败, got: ' + latestVersion);
return false;
}
return latestVersion;
}
async function downloadFile(url, filename) {
return new Promise((resolve) => {
let fileStream = fs.createWriteStream(filename);
let receivedBytes = 0;
const handleResponse = (res) => {
// Handle redirects
if (res.statusCode === 301 || res.statusCode === 302) {
logger.info('Following redirect');
fileStream.end();
fileStream = fs.createWriteStream(filename);
if (res.headers.location) {
https.get(res.headers.location, handleResponse)
.on('error', handleError);
}
return;
}
// Check for successful status code
if (res.statusCode !== 200) {
handleError(new Error(`HTTP Error: ${res.statusCode}`));
return;
}
const totalBytes = parseInt(res.headers['content-length'], 10);
res.on('error', handleError);
fileStream.on('error', handleError);
res.pipe(fileStream);
res.on('data', (chunk) => {
receivedBytes += chunk.length;
});
fileStream.on('finish', () => {
fileStream.close(() => {
if (receivedBytes === 0) {
fs.unlink(filename, () => {
logger.error('Download failed: Empty file received');
resolve(false);
});
} else if (totalBytes && receivedBytes < totalBytes) {
fs.unlink(filename, () => {
logger.error(`Download incomplete: ${receivedBytes}/${totalBytes} bytes`);
resolve(false);
});
} else {
resolve(true);
}
});
});
};
const handleError = (error) => {
fileStream.destroy();
fs.unlink(filename, () => {
logger.error('Download error:', error);
resolve(false);
});
};
const req = https.get(url, handleResponse)
.on('error', handleError)
.setTimeout(60000, () => {
handleError(new Error('Download timeout'));
});
req.on('error', handleError);
});
}
async function getMediaGetRemoteFilename(latestVersion) {
let suffix = 'win.exe';
if (isLinux) {
suffix = 'linux';
}
if (isDarwin) {
suffix = 'darwin';
}
if (process.arch === 'arm64') {
suffix += '-arm64';
}
const remoteConfig = await RemoteConfig.getRemoteConfig();
return `${remoteConfig.bestGithubProxy}https://github.com/foamzou/media-get/releases/download/v${latestVersion}/media-get-${latestVersion}-${suffix}`;
}
const renameFile = (oldName, newName) => {
return new Promise((resolve, reject) => {
fs.rename(oldName, newName, (err) => {
if (err) {
logger.error(err)
resolve(false);
} else {
resolve(true);
}
});
});
};
async function downloadTheLatestMediaGet(version) {
const remoteFile = await getMediaGetRemoteFilename(version);
logger.info('start to download media-get: ' + remoteFile);
const ret = await downloadFile(remoteFile, getBinPath(true));
if (ret === false) {
logger.error('download failed');
return false;
}
fs.chmodSync(getBinPath(true), '755');
logger.info('download finished');
// Add debug logs for binary file and validate
try {
const stats = fs.statSync(getBinPath(true));
logger.info(`Binary file stats: size=${stats.size}, mode=${stats.mode.toString(8)}`);
// Check minimum file size (should be at least 2MB)
const minSize = 2 * 1024 * 1024; // 2MB
if (stats.size < minSize) {
logger.error(`Invalid binary file size: ${stats.size} bytes. Expected at least ${minSize} bytes`);
return false;
}
// Check file permissions (should be executable)
const executableMode = 0o755;
if ((stats.mode & 0o777) !== executableMode) {
logger.error(`Invalid binary file permissions: ${stats.mode.toString(8)}. Expected: ${executableMode.toString(8)}`);
return false;
}
// Skip validation when cross compiling
if (!process.env.CROSS_COMPILING) {
const temBinInfo = await getMediaGetInfo(true);
logger.info('Execution result:', {
binPath: getBinPath(true),
arch: process.arch,
platform: process.platform,
temBinInfo
});
if (!temBinInfo || temBinInfo.versionInfo === "") {
logger.error('testing new bin failed. Details:', {
binExists: fs.existsSync(getBinPath(true)),
binPath: getBinPath(true),
error: temBinInfo === false ? 'Execution failed' : 'No version info'
});
return false;
}
}
const renameRet = await renameFile(getBinPath(true), getBinPath());
if (!renameRet) {
logger.error('rename failed');
return false;
}
return true;
} catch (err) {
logger.error('Failed to get binary stats:', err);
return false;
}
}
module.exports = {
getBinPath: getBinPath,
getMediaGetInfo: getMediaGetInfo,
getLatestMediaGetVersion: getLatestMediaGetVersion,
downloadTheLatestMediaGet: downloadTheLatestMediaGet,
}

View File

@@ -0,0 +1,52 @@
const logger = require('consola');
const { asyncHttpsGet } = require('../../utils/network');
const BaseUrl = 'https://music-dl.sayqz.com/api/';
function buildApiUrl(params = {}) {
const searchParams = new URLSearchParams(params);
return `${BaseUrl}?${searchParams.toString()}`;
}
async function fetchJson(params = {}) {
const url = buildApiUrl(params);
const raw = await asyncHttpsGet(url);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch (err) {
logger.error(`TuneHub 返回非 JSON: ${url}`);
return null;
}
}
async function getPlaylistDetail(source, playlistId) {
const response = await fetchJson({
source,
id: playlistId,
type: 'playlist',
});
if (!response || response.code !== 200 || !response.data) {
return false;
}
return response.data;
}
function buildSongUrl(source, songId, br) {
const params = {
source,
id: songId,
type: 'url',
};
if (br) {
params.br = br;
}
return buildApiUrl(params);
}
module.exports = {
getPlaylistDetail,
buildSongUrl,
};

View File

@@ -0,0 +1,312 @@
const logger = require('consola');
const {
cloud, cloudsearch, cloud_match, song_detail,
user_playlist, playlist_detail, user_account, playlist_track_all,
login_qr_check, login_qr_create, login_qr_key,
} = require('NeteaseCloudMusicApi');
const fs = require('fs');
const path = require('path');
const {requestApi} = require('./transport');
const { buildSongUrl } = require('../tunehub');
async function uploadSong(uid, filePath) {
const response = await safeRequest(uid, cloud, {
songFile: {
name: path.basename(filePath),
data: fs.readFileSync(filePath),
},
});
if (response === false) {
return false;
}
logger.debug('uploadSong\'s resonse: ', response)
if (!response.privateCloud) {
return false;
}
const songInfo = response.privateCloud.simpleSong;
return {
songId: songInfo.id,
matched: songInfo.ar[0].id !== 0 && songInfo.al.id !== 0, // It's matched the song on wyMusic if singer and album has info
};
}
async function searchSong(uid, songName, artist) {
const response = await safeRequest(uid, cloudsearch, {
keywords: `${songName} ${artist}`,
type: 1,
});
if (response === false) {
return false;
}
if (!response.result || response.result.songs.length === 0) {
return false;
}
return response.result.songs.map(song => {
let artists = [];
if (song.ar.length !== 0) {
song.ar.map(artist => {
artists.push(artist.name);
artist.alias && artists.push(...artist.alias);
artist.alia && artists.push(...artist.alia);
});
}
return {
songId: song.id,
songName: song.name,
album: song.al.name,
artists: artists.filter(a => a !== '' && a !== undefined),
};
})
}
async function matchAndFixCloudSong(uid, cloudSongId, wySongId) {
const response = await safeRequest(uid, cloud_match, {
sid: cloudSongId,
asid: wySongId,
});
if (response === false) {
return false;
}
if (response.code > 399) {
logger.warn(response);
return false;
}
return true;
}
async function getMyAccount(uid) {
const response = await safeRequest(uid, user_account, {
uid,
});
if (response === false) {
return false;
}
if (!response.profile) {
return false;
}
return {
userId: response.profile.userId,
nickname: response.profile.nickname,
avatarUrl: response.profile.avatarUrl,
};
}
async function getSongInfo(uid, id) {
const response = await safeRequest(uid, song_detail, {
ids: `"${id}"`,
});
if (response === false) {
return false;
}
if (!response.songs || response.songs.length === 0) {
return false;
}
const songInfo = response['songs'][0];
return {
songId: songInfo.id,
songName: songInfo.name,
artists: songInfo.ar.map(artist => artist.name),
duration: songInfo.dt / 1000,
album: songInfo.al.name,
cover: songInfo.al.picUrl,
};
}
async function getPlayUrl(uid, id, isLossless = false) {
const br = isLossless ? 'flac' : '320k';
return buildSongUrl('netease', id, br);
}
async function getUserAllPlaylist(uid) {
const wyAccount = await getMyAccount(uid);
if (wyAccount === false) {
logger.error(`uid(${uid}) get user's wycloud account failed.`);
return false;
}
const response = await safeRequest(uid, user_playlist, {
uid: wyAccount.userId,
});
if (response === false) {
return false;
}
if (!response.playlist || response.playlist.length === 0) {
return false;
}
return response.playlist.map(playlist => {
return {
id: playlist.id,
name: playlist.name,
cover: playlist.coverImgUrl,
trackCount: playlist.trackCount,
isCreatedByMe: playlist.creator.userId === wyAccount.userId,
};
});
}
async function getSongsFromPlaylist(uid, source, playlistId) {
const [detailResponse, songsResponse] = await Promise.all([
safeRequest(uid, playlist_detail, {
id: playlistId,
}),
safeRequest(uid, playlist_track_all, {
id: playlistId,
offset: 0,
limit: 1000,
}),
]);
if (detailResponse === false || songsResponse === false) {
return false;
}
if (!detailResponse.playlist || !songsResponse.songs || songsResponse.songs.length === 0) {
logger.error(`uid(${uid}) playlist(${playlistId}) has no songs.`, detailResponse, songsResponse);
return false;
}
// console.log(JSON.stringify(songsResponse, null, 4));
// ddd
if (songsResponse.songs.length >= 1000) {
const songsPage2Response = await safeRequest(uid, playlist_track_all, {
id: playlistId,
offset: 1000,
limit: 1000,
});
if (songsPage2Response !== false && songsPage2Response.songs) {
songsResponse.songs = songsResponse.songs.concat(songsPage2Response.songs);
songsResponse.privileges = songsResponse.privileges.concat(songsPage2Response.privileges);
}
}
let info = {
id: playlistId,
name: detailResponse.playlist.name,
cover: detailResponse.playlist.coverImgUrl,
songs: [],
};
const songsMap = {};
songsResponse.songs.map(song => {
songsMap[song.id] = song;
});
const isBlockedSong = (song, songInfo) => {
// the song has been added to cloud if the pc field is present
if (songInfo.pc) {
return false;
}
// 收费歌曲
if (song.fee === 1) {
if (song.realPayed === 1 || song.payed === 1) {
return false;
}
return true;
}
// subp 或 cp === 1 可能都表示有版权
// 免费歌曲
if (song.subp === 1) {
return false;
}
return true;
};
songsResponse.privileges.forEach(song => {
const songInfo = songsMap[song.id];
if (!songInfo) {
return;
}
const isBlocked = isBlockedSong(song, songInfo);
const isCloud = !!songInfo.pc;
info.songs.push({
songId: songInfo.id,
songName: songInfo.name,
artists: songInfo.ar.map(artist => artist.name),
artist: songInfo.ar.length > 0 ? songInfo.ar[0].name : '',
duration: songInfo.dt / 1000,
album: songInfo.al.name,
cover: songInfo.al.picUrl,
pageUrl: `https://music.163.com/song?id=${songInfo.id}`,
playUrl: !isBlocked && !isCloud ? `http://music.163.com/song/media/outer/url?id=${songInfo.id}.mp3` : '', // 不再建议使用这个 url建议每次都 Call API 获取
isBlocked,
isCloud,
});
});
return info;
}
async function getBlockedSongsFromPlaylist(uid, source, playlistId) {
const info = await getSongsFromPlaylist(uid, source, playlistId);
if (info === false) {
return false;
}
info.blockedSongs = info.songs.filter(song => song.isBlocked);
return info;
}
async function qrLoginCreate(uid) {
const keyResponse = await safeRequest(uid, login_qr_key, {}, false);
if (keyResponse === false || !keyResponse.data.unikey) {
logger.warn(`uid(${uid}) get qr login key failed.`);
return false;
}
const qrKey = keyResponse.data.unikey;
const qrCodeResponse = await safeRequest(uid, login_qr_create, {key: qrKey, qrimg: true}, false);
if (qrCodeResponse === false || !qrCodeResponse.data.qrimg) {
return false;
}
return {
qrKey,
qrCode: qrCodeResponse.data.qrimg,
};
}
async function qrLoginCheck(uid, qrKey) {
const response = await safeRequest(uid, login_qr_check, {key: qrKey, cookie: {
os: 'pc',
}}, false);
if (response === false) {
return false;
}
return {
code: response.code,
cookie: response.cookie,
};
}
async function verifyAccountStatus(uid) {
const account = await getMyAccount(uid);
return account !== false;
}
async function safeRequest(uid, moduleFunc, params, cookieRequired = true) {
try {
const response = await requestApi(uid, moduleFunc, params, cookieRequired);
if (response == false) {
logger.error(`request failed.`, response);
return false;
}
return response;
} catch (error) {
logger.error(`uid(${uid}) request failed.`, error);
return false;
}
}
module.exports = {
getMyAccount: getMyAccount,
uploadSong: uploadSong,
matchAndFixCloudSong: matchAndFixCloudSong,
searchSong: searchSong,
getBlockedSongsFromPlaylist: getBlockedSongsFromPlaylist,
getSongsFromPlaylist: getSongsFromPlaylist,
getUserAllPlaylist: getUserAllPlaylist,
getSongInfo: getSongInfo,
getPlayUrl: getPlayUrl,
qrLoginCreate: qrLoginCreate,
qrLoginCheck: qrLoginCheck,
verifyAccountStatus: verifyAccountStatus,
}

View File

@@ -0,0 +1,151 @@
const logger = require('consola');
const AccountService = require('../../account');
const { login_cellphone, login_refresh, login } = require('NeteaseCloudMusicApi');
const CookiePath = `${__dirname}/../../../../.profile/cookie/`;
const fs = require('fs');
const LoginTypePhone = 'phone';
const LoginTypeEmail = 'email';
const CookieMap = {};
async function requestApi(uid, moduleFunc, request = {}, cookieRequired = true) {
if (cookieRequired) {
let cookie = await getCookie(uid);
if (!cookie) {
logger.error(`uid(${uid}) get cookie failed`);
return false;
}
request.cookie = cookie;
}
let response = await requestWyyApi(moduleFunc, request);
// need refresh
if (response && response.status == 301) {
cookie = await getCookie(uid, true);
if (!cookie) {
logger.error(`uid(${uid}) refresh cookie failed. request api abort`);
return false;
}
// retry request
request.cookie = cookie;
response = await requestWyyApi(moduleFunc, request);
}
if (response && response.status == 200) {
return response.body;
}
logger.error(`requestWyyApi respond non 200, response: `, response);
return false;
}
async function requestWyyApi(moduleFunc, request) {
return moduleFunc(request).then(response => {
return response;
}).catch(err => {
console.log(err)
logger.error(`requestWyyApi failed: `, err);
if (typeof err == 'object' && err.status == '301') {
return err;
}
return false;
});
}
async function getCookie(uid, refresh = false) {
const account = AccountService.getAccount(uid);
if (!account) {
return false;
}
// fetch from cache
const cookieFromCache = fetchCookieFromCache(uid, account);
if (cookieFromCache) {
if (!refresh) {
return cookieFromCache;
}
logger.info('refresh cookie...', cookieFromCache);
const response = await requestWyyApi(login_refresh, {cookie: cookieFromCache});
if (response && response.status == 200 && response.cookie && response.cookie.length > 1) {
const cookie = response.cookie.map(line => line.replace('HTTPOnly', '')).join(';');
logger.info('refresh cookie succeed, ', cookie);
storeCookie(uid, account, cookie);
return cookie;
}
logger.info(`refresh failed, try login again`);
}
// login
logger.info(`uid(${uid}) login with ${account.countryCode} ${account.account} via ${account.loginType}`);
let result;
if (account.loginType === LoginTypePhone) {
result = await requestWyyApi(login_cellphone, {
countrycode: account.countrycode,
phone: account.account,
password: account.password,
});
} else if (account.loginType === LoginTypeEmail) {
result = await requestWyyApi(login, {
email: account.account,
password: account.password,
});
} else {
if (account.loginType === 'qrcode') {
logger.error(`uid(${uid})'s loginType(${account.loginType}) does not support auto login, please login in the browser page first`);
} else {
logger.error(`uid(${uid})'s loginType(${account.loginType}) does not support now`);
}
return false;
}
if (result && result.status == 200 && result.body && result.body.code == 200 && result.body.cookie) {
logger.info(`uid(${uid}) login succeed`)
storeCookie(uid, account, result.body.cookie);
return result.body.cookie;
}
logger.error(`fetch cookie from response failed, uid(${uid}) login failed`, result);
return false;
}
function storeCookie(uid, account, cookie) {
fs.writeFileSync(getCookieFilePath(uid, account), cookie);
CookieMap[getCookieMapKey(uid, account)] = cookie;
}
function fetchCookieFromCache(uid, account) {
const cacheKey = getCookieMapKey(uid, account);
if (CookieMap[cacheKey]) {
return CookieMap[cacheKey];
}
const CookieFile = getCookieFilePath(uid, account);
if (!fs.existsSync(CookieFile)) {
logger.info(`uid(${uid})'s cookie not found from .profile`);
return null;
}
const cookie = fs.readFileSync(CookieFile).toString();
CookieMap[cacheKey] = cookie;
return cookie;
}
function getCookieMapKey(uid, account) {
return `${uid}-${account.platform}-${account.account}`;
}
function getCookieFilePath(uid, account) {
return `${CookiePath}${uid}-${account.platform}-${account.account}`;
}
module.exports = {
requestApi,
storeCookie,
}

View File

@@ -0,0 +1,69 @@
const httpsGet = require('../../utils/network').asyncHttpsGet;
const logger = require('consola');
const configManager = require('../config_manager');
// Store best proxy in memory for performance
let cachedBestProxy = '';
async function validateGithubAccess(proxy = '') {
try {
const testUrl = proxy ? `${proxy}https://api.github.com/zen` : 'https://api.github.com/zen';
const response = await httpsGet(testUrl);
return response !== null;
} catch (err) {
return false;
}
}
async function findBestProxy(proxyList) {
// Always try direct access first
if (await validateGithubAccess()) {
cachedBestProxy = '';
return '';
}
// Try cached proxy if available
if (cachedBestProxy && await validateGithubAccess(cachedBestProxy)) {
return cachedBestProxy;
}
// Test each proxy in the list
for (const proxy of proxyList) {
if (proxy && await validateGithubAccess(proxy)) {
cachedBestProxy = proxy;
return proxy;
}
}
logger.warn('No working GitHub access found, either direct or via proxy');
return ''; // Return empty string if no working access found
}
async function getRemoteConfig() {
const fallbackConfig = {
githubProxy: ['', 'https://ghp.ci/'],
}
const remoteConfigUrl = 'https://foamzou.com/tools/melody-config.php?v=2';
const remoteConfig = await httpsGet(remoteConfigUrl);
let config = {};
if (remoteConfig === null) {
config = fallbackConfig;
} else {
config = JSON.parse(remoteConfig);
}
let bestGithubProxy = await findBestProxy(config.githubProxy);
if (bestGithubProxy !== '' && !bestGithubProxy.endsWith('/')) {
bestGithubProxy = bestGithubProxy + '/';
}
return {
bestGithubProxy,
}
}
module.exports = {
getRemoteConfig,
}

View File

@@ -0,0 +1,221 @@
const schedule = require('node-schedule');
const logger = require('consola');
const configManager = require('../config_manager');
const AccountService = require('../account');
const syncPlaylist = require('../sync_music/sync_playlist');
const unblockMusicInPlaylist = require('../sync_music/unblock_music_in_playlist');
const { consts: sourceConsts } = require('../../consts/source');
const { getUserAllPlaylist, verifyAccountStatus } = require('../music_platform/wycloud');
class SchedulerService {
constructor() {
this.jobs = new Map();
}
async start() {
await this.scheduleLocalSyncJobs();
await this.scheduleCloudSyncJobs();
// Log initial schedule info
const localNextRun = this.getLocalSyncNextRun();
if (localNextRun) {
logger.info(`Next local sync scheduled at: ${localNextRun.nextRunTime}, in ${Math.round(localNextRun.remainingMs / 1000 / 60)} minutes`);
}
const accounts = await AccountService.getAllAccounts();
for (const uid in accounts) {
const cloudNextRun = this.getCloudSyncNextRun(uid);
if (cloudNextRun) {
logger.info(`Next cloud sync for account ${uid} scheduled at: ${cloudNextRun.nextRunTime}, in ${Math.round(cloudNextRun.remainingMs / 1000 / 60)} minutes`);
}
}
logger.info('Scheduler service started');
}
async scheduleLocalSyncJobs() {
// 系统级别的本地同步任务
const config = await configManager.getGlobalConfig();
const syncAccounts = config.playlistSyncToLocal.syncAccounts || [];
if (!config.playlistSyncToLocal.autoSync.enable || syncAccounts.length === 0) {
return;
}
const frequency = config.playlistSyncToLocal.autoSync.frequency;
const unit = config.playlistSyncToLocal.autoSync.frequencyUnit;
const rule = this.buildScheduleRule(frequency, unit);
const jobKey = 'localSync';
const job = schedule.scheduleJob(rule, async () => {
logger.info('Start auto sync playlist to local');
for (const uid of syncAccounts) {
const isActive = await verifyAccountStatus(uid);
if (!isActive) {
logger.warn(`Account ${uid} is not active, skip local sync`);
continue;
}
const playlists = await getUserAllPlaylist(uid);
for (const playlist of playlists) {
logger.info(`Start sync playlist ${playlist.id} to local for account ${uid}`);
await syncPlaylist(uid, sourceConsts.Netease.code, playlist.id);
}
}
});
this.jobs.set(jobKey, job);
logger.info(`Schedule local sync job success, rule: ${this.formatScheduleRule(rule)}`);
}
async scheduleCloudSyncJobs() {
// 账号级别的云盘同步任务
const accounts = await AccountService.getAllAccounts();
for (const uid in accounts) {
const account = accounts[uid];
await this.scheduleCloudSyncJob(uid, account);
}
}
async scheduleCloudSyncJob(uid, account) {
if (!account.config?.playlistSyncToWyCloudDisk?.autoSync?.enable) {
return;
}
const isActive = await verifyAccountStatus(uid);
if (!isActive) {
logger.warn(`Account ${uid} is not active, skip cloud sync`);
return;
}
const frequency = account.config.playlistSyncToWyCloudDisk.autoSync.frequency;
const unit = account.config.playlistSyncToWyCloudDisk.autoSync.frequencyUnit;
const jobKey = `cloudSync_${uid}`;
const rule = this.buildScheduleRule(frequency, unit);
this.jobs.set(jobKey, schedule.scheduleJob(rule, async () => {
logger.info(`Start cloud sync for account ${uid}`);
const playlists = await getUserAllPlaylist(uid);
// Filter playlists based on user preference
const playlistsToSync = account.config.playlistSyncToWyCloudDisk.autoSync.onlyCreatedPlaylists
? playlists.filter(p => p.isCreatedByMe)
: playlists;
for (const playlist of playlistsToSync) {
logger.info(`Start sync playlist ${playlist.id} to cloud for account ${uid}`);
await unblockMusicInPlaylist(uid, sourceConsts.Netease.code, playlist.id, {
syncWySong: account.config.playlistSyncToWyCloudDisk.syncWySong,
syncNotWySong: account.config.playlistSyncToWyCloudDisk.syncNotWySong
});
}
}));
logger.info(`Schedule cloud sync job for account ${uid} success, rule: ${this.formatScheduleRule(rule)}`);
}
buildScheduleRule(frequency, unit) {
if (unit === 'minute') {
return `0 */${frequency} * * * *`;
} else if (unit === 'hour') {
return `0 0 */${frequency} * * *`;
} else {
return `0 0 0 */${frequency} * *`;
}
}
formatScheduleRule(rule) {
if (typeof rule === 'string') {
return rule;
}
return `每天 ${rule.hour.map(h => h.toString().padStart(2, '0') + ':00').join(', ')} 执行`;
}
async updateLocalSyncJob() {
logger.info('Update local sync job');
// 添加调试日志
logger.info('Before update:');
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
const localJob = this.jobs.get('localSync');
if (localJob) {
localJob.cancel();
this.jobs.delete('localSync');
}
// 添加调试日志
logger.info('After cancel:');
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
await this.scheduleLocalSyncJobs();
// 添加调试日志
logger.info('After reschedule:');
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
const newJob = this.jobs.get('localSync');
logger.info(`- New job created: ${!!newJob}`);
if (newJob) {
logger.info(`- New job next run: ${newJob.nextInvocation()}`);
}
logger.info('Update local sync job success');
}
async updateCloudSyncJob(uid) {
logger.info(`Update cloud sync job for account ${uid}`);
// 取消指定账号的云盘同步任务
const cloudJobKey = `cloudSync_${uid}`;
const cloudJob = this.jobs.get(cloudJobKey);
if (cloudJob) {
cloudJob.cancel();
this.jobs.delete(cloudJobKey);
logger.info(`Cancel cloud sync job for account ${uid}`);
}
// 重新调度指定账号的云盘同步任务
const account = (await AccountService.getAllAccounts())[uid];
if (account) {
await this.scheduleCloudSyncJob(uid, account);
}
logger.info(`Update cloud sync job for account ${uid} success`);
}
getNextRunInfo(job) {
if (!job) return null;
const nextRun = job.nextInvocation();
if (!nextRun) return null;
const now = new Date();
const remainingMs = nextRun.getTime() - now.getTime();
return {
nextRunTime: nextRun,
remainingMs: remainingMs
};
}
getLocalSyncNextRun() {
const job = this.jobs.get('localSync');
// 添加调试日志
logger.info('Debug getLocalSyncNextRun:');
logger.info(`- Has job: ${!!job}`);
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
if (job) {
logger.info(`- Job next invocation: ${job.nextInvocation()}`);
logger.info(`- Job scheduling info:`, job.scheduledJobs);
}
return this.getNextRunInfo(job);
}
getCloudSyncNextRun(uid) {
const job = this.jobs.get(`cloudSync_${uid}`);
return this.getNextRunInfo(job);
}
}
const schedulerService = new SchedulerService();
module.exports = schedulerService;

View File

@@ -0,0 +1,57 @@
const { searchSong, getSongInfo } = require('../music_platform/wycloud');
const logger = require('consola');
module.exports = async function findTheBestMatchFromWyCloud(uid, {songName, artist, album, musicPlatformSongId} = {}) {
if (musicPlatformSongId) {
const songInfo = await getSongInfo(uid, musicPlatformSongId);
if (songInfo) {
return songInfo;
}
if (songName && artist) {
return {
songId: musicPlatformSongId,
songName,
artists: [artist],
album,
};
}
return null;
}
if (songName === "" || artist === "") {
return null;
}
const searchLists = await searchSong(uid, songName, artist);
logger.info('searchLists', searchLists);
if (searchLists === false) {
logger.warn(`search song failed, no matter, go on`);
return null;
}
let matchSongAndArtist = null;
for (const searchItem of searchLists) {
let hitArtist = false;
for (const searchArtist of searchItem.artists) {
if (artist === searchArtist) {
hitArtist = true;
}
}
if (!hitArtist) {
continue;
}
if (searchItem.songName === songName) {
if (searchItem.album === album) {
logger.info('matched the best')
return searchItem;
}
if (!matchSongAndArtist) {
matchSongAndArtist = searchItem;
}
}
}
return matchSongAndArtist;
}

View File

@@ -0,0 +1,19 @@
const { searchSongFromAllPlatform } = require('../media_fetcher');
const searchSongsWithSongMeta = require('./search_songs_with_song_meta');
const findTheBestMatchFromWyCloud = require('./find_the_best_match_from_wycloud');
async function searchSongsWithKeyword(keyword) {
const searchList = await searchSongFromAllPlatform({keyword});
if (searchList === false || searchList.length === 0) {
return [];
}
return searchList;
}
module.exports = {
searchSongsWithSongMeta: searchSongsWithSongMeta,
searchSongsWithKeyword: searchSongsWithKeyword,
findTheBestMatchFromWyCloud: findTheBestMatchFromWyCloud,
}

View File

@@ -0,0 +1,131 @@
const logger = require('consola');
const { searchSongFromAllPlatform } = require('../media_fetcher');
module.exports = async function searchSongsWithSongMeta(songMeta = {
songName: '',
artist: '',
album: '',
duration: 0,
}, options = {
expectArtistAkas: [], // 歌手名字,有的歌手有很多别名的,给出这些信息能够更好地排序
allowSongsJustMatchDuration: false, // 关键信息不对的情况下,但 duration 很接近的歌曲,是否希望返回
allowSongsNotMatchMeta: false, // 关键的 meta 信息不匹配的歌曲,是否希望返回
}) {
// search song with the meta
const searchList = await searchSongFromAllPlatform({
songName:songMeta.songName,
artist: songMeta.artist,
album: songMeta.album
});
if (searchList === false || searchList.length === 0) {
logger.error(`search song failed, songMeta: ${JSON.stringify(songMeta)}`);
return false;
}
return sortOutTheSearchList(searchList, options.expectArtistAkas, {
songName: songMeta.songName,
duration: songMeta.duration,
}, {
allowSongsJustMatchDuration: options.allowSongsJustMatchDuration,
allowSongsNotMatchMeta: options.allowSongsNotMatchMeta,
});
}
function sortOutTheSearchList(searchList, expectArtistAkas, songMeta = {
songName: '',
duration: 0,
}, options = {
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
}) {
let searchListfilttered = [];
let searchListfiltterJustWithDuration = [];
let searchListNotMatchMeta = [];
// filter with song name, artist first
for (const searchItem of searchList) {
if (searchItem.cannotDownload) {
logger.info(`song cannot download, continue. searchItem: ${JSON.stringify(searchItem)}`);
searchListNotMatchMeta.push(searchItem);
continue;
}
const hasDuration = songMeta.duration > 0 && searchItem.duration > 0;
const durationDiff = hasDuration ? Math.abs(searchItem.duration - songMeta.duration) : 0;
searchItem.durationDiff = durationDiff;
if (hasDuration && durationDiff > 10) {
searchListNotMatchMeta.push(searchItem);
continue;
}
if (thereAreWordNotExistFromInputButInSearchResult(['cover', '伴奏', '翻唱', 'instrumental'], searchItem.songName, songMeta.songName)) {
logger.info(`there are word not exist from input but in search result, continue. searchItem.songName: ${searchItem.songName}, songMeta.songName: ${songMeta.songName}`);
searchListNotMatchMeta.push(searchItem);
continue;
}
if (hasDuration && durationDiff <= 5) {
searchListfiltterJustWithDuration.push(searchItem);
searchListNotMatchMeta.push(searchItem);
}
if (searchItem.songName.replace(' ', '').indexOf(songMeta.songName.replace(' ', '')) === -1) {
logger.info(`songName not matched, continue. ${searchItem.songName} vs ${songMeta.songName}`);
searchListNotMatchMeta.push(searchItem);
continue;
}
if (searchItem.fromMusicPlatform && expectArtistAkas.length > 0) {
logger.info(`should find the artist:${searchItem.artist} from ${expectArtistAkas.join(',')}`);
if (!expectArtistAkas.find(artist => artist === searchItem.artist)) {
logger.info(`artist not matched, continue.`);
searchListNotMatchMeta.push(searchItem);
continue;
}
}
searchListfilttered.push(searchItem);
}
if (options.allowSongsJustMatchDuration) {
searchListfiltterJustWithDuration = searchListfiltterJustWithDuration.sort((a, b) => a.durationDiff - b.durationDiff);
searchListfilttered.push(...searchListfiltterJustWithDuration);
}
if (options.allowSongsNotMatchMeta) {
searchListfilttered.push(...searchListNotMatchMeta);
}
// uniq with song url
const uniqedSearchList = [];
for (const searchItem of searchListfilttered) {
if (uniqedSearchList.find(item => item.url === searchItem.url)) {
continue;
}
uniqedSearchList.push(searchItem);
}
// stable sort。 resourceForbidden 排在后面
return uniqedSearchList.map((data, i) => {
return {i, data}
}).sort((a,b)=>{
if (a.data.resourceForbidden == b.data.resourceForbidden) {
return a.i-b.i;
} if (a.data.resourceForbidden) {
return 1;
}
return -1
}).map(d=> d.data)
}
function thereAreWordNotExistFromInputButInSearchResult(words, searchResultWord, inputWord) {
searchResultWord = searchResultWord.toLowerCase();
inputWord = inputWord.toLowerCase();
for (const word of words) {
if (searchResultWord.indexOf(word) !== -1 && inputWord.indexOf(word) === -1) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,14 @@
const { getPlayUrl } = require('../music_platform/wycloud');
async function getPlayUrlWithOptions(uid, source, songId) {
// Only support netease now
if (source !== 'netease') {
return '';
}
return await getPlayUrl(uid, songId);
}
module.exports = {
getPlayUrlWithOptions: getPlayUrlWithOptions,
}

View File

@@ -0,0 +1,64 @@
const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher');
const { uploadSong, searchSong, matchAndFixCloudSong } = require('../music_platform/wycloud');
const logger = require('consola');
const sleep = require('../../utils/sleep');
const configManager = require('../config_manager');
const fs = require('fs');
const libPath = require('path');
const utilFs = require('../../utils/fs');
module.exports = {
downloadFromLocalTmpPath: downloadFromLocalTmpPath,
buildDestFilename: buildDestFilename,
}
async function downloadFromLocalTmpPath(tmpPath, songInfo = {
songName: "",
artist: "",
album: "",
}, playlistName = '', collectResponse) {
const globalConfig = (await configManager.getGlobalConfig());
const downloadPath = globalConfig.downloadPath;
if (!downloadPath) {
logger.error(`download path not set`);
return "IOFailed";
}
const destPathAndFilename = buildDestFilename(globalConfig, songInfo, playlistName);
const destPath = libPath.dirname(destPathAndFilename);
// make sure the path is exist
await utilFs.asyncMkdir(destPath, {recursive: true});
try {
if (await utilFs.asyncFileExisted(destPathAndFilename)) {
logger.info(`file already exists, remove it: ${destPathAndFilename}`);
await utilFs.asyncUnlinkFile(destPathAndFilename)
}
await utilFs.asyncMoveFile(tmpPath, destPathAndFilename);
} catch (err) {
logger.error(`move file failed, ${tmpPath} -> ${destPathAndFilename}`, err);
return "IOFailed";
}
if (collectResponse !== undefined) {
try {
const md5Value = await utilFs.asyncMd5(destPathAndFilename);
collectResponse['md5Value'] = md5Value;
} catch (err) {
logger.error(`md5 failed, ${destPathAndFilename}`, err);
// don't return false, just log it
}
}
logger.info(`download song success, path: ${destPathAndFilename}`);
return true;
}
function buildDestFilename(globalConfig, songInfo, playlistName) {
const downloadPath = globalConfig.downloadPath;
let filename = (playlistName ? globalConfig.playlistSyncToLocal?.filenameFormat : globalConfig.filenameFormat)
.replace(/{artist}/g, songInfo.artist ? songInfo.artist : 'Unknown')
.replace(/{songName}/g, songInfo.songName ? songInfo.songName : 'Unknown')
.replace(/{playlistName}/g, playlistName ? playlistName : 'UnknownPlayList')
.replace(/{album}/g, songInfo.album ? songInfo.album : 'Unknown');
// remove the head / and \ in filename
filename = filename.replace(/^[\/\\]+/, '') + '.mp3';
return `${downloadPath}${libPath.sep}${filename}`
}

View File

@@ -0,0 +1,11 @@
const syncSingleSongWithUrl = require('./sync_single_song_with_url');
const unblockMusicInPlaylist = require('./unblock_music_in_playlist');
const unblockMusicWithSongId = require('./unblock_music_with_song_id');
const syncPlaylist = require('./sync_playlist');
module.exports = {
syncSingleSongWithUrl: syncSingleSongWithUrl,
unblockMusicInPlaylist: unblockMusicInPlaylist,
unblockMusicWithSongId: unblockMusicWithSongId,
syncPlaylist: syncPlaylist,
};

View File

@@ -0,0 +1,382 @@
const {
getSongsFromPlaylist,
getPlayUrl,
} = require("../music_platform/wycloud");
const syncSingleSongWithUrl = require("./sync_single_song_with_url");
const logger = require("consola");
const {
findTheBestMatchFromWyCloud,
searchSongsWithSongMeta,
} = require("../search_songs");
const JobManager = require("../job_manager");
const JobType = require("../../consts/job_type");
const JobStatus = require("../../consts/job_status");
const BusinessCode = require("../../consts/business_code");
const { downloadViaSourceUrl } = require("../media_fetcher/index");
const {
downloadFromLocalTmpPath,
buildDestFilename,
} = require("./download_to_local");
const KV = require("../kv");
const utilFs = require("../../utils/fs");
const configManager = require("../config_manager");
const path = require("path");
const { consts } = require("../../consts/source");
const soundQualityConst = require("../../consts/sound_quality");
module.exports = async function syncPlaylist(uid, source, playlistId) {
// step 1. get all the songs
const playlistInfo = await getSongsFromPlaylist(uid, source, playlistId);
if (playlistInfo === false) {
return false;
}
// calc the songs need to be synced
const songsNeedToSync = [];
const cacheForCalcSongsNeedToSync = {};
// 如果开启了删除功能,先执行删除
const globalConfig = await configManager.getGlobalConfig();
if (globalConfig.playlistSyncToLocal.deleteLocalFile) {
const currentSongIDs = playlistInfo.songs.map(song => song.songId);
await syncDeleteFiles(playlistId, currentSongIDs);
}
for (const song of playlistInfo.songs) {
const needToSync = await isNeedToSyncFile(playlistId, song.songId, cacheForCalcSongsNeedToSync);
if (!needToSync) {
continue;
}
songsNeedToSync.push(song);
}
if (songsNeedToSync.length === 0) {
logger.info(`[No need] all the songs in the playlist are already downloaded.`);
return BusinessCode.StatusJobNoNeedToCreate;
}
// create job
const args = `syncPlaylist: {"source":${source},"playlistId":${playlistId}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`syncPlaylist job is already running.`);
return BusinessCode.StatusJobAlreadyExisted;
}
const jobId = await JobManager.createJob(uid, {
name: `下载歌单到本地服务器:${playlistInfo.name}`,
args,
type: JobType.SyncThePlaylistToLocalService,
status: JobStatus.Pending,
desc: `${songsNeedToSync.length}首歌曲需要下载`,
progress: 0,
tip: "等待下载",
createdAt: Date.now(),
});
// async do the job
(async () => {
const songs = songsNeedToSync;
logger.info(`${jobId}: try to sync songs: ${playlistInfo.name}`);
await JobManager.updateJob(uid, jobId, {
status: JobStatus.InProgress,
});
const succeedList = [];
const failedList = [];
// step 2. download the songs
for (const song of songs) {
let tip = `[${succeedList.length + failedList.length + 1}/${
songs.length
}] 正在下载歌曲:${song.songName}`;
await JobManager.updateJob(uid, jobId, {
tip,
});
const syncSucceed = await syncSingleSong(uid, song, playlistInfo);
if (syncSucceed) {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 下载成功",
});
succeedList.push({ songName: song.songName, artist: song.artists[0] });
} else {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 下载失败",
});
failedList.push({ songName: song.songName, artist: song.artists[0] });
}
await JobManager.updateJob(uid, jobId, {
progress: (succeedList.length + failedList.length) / songs.length,
});
}
let tip = `任务完成,成功${succeedList.length}首,失败${failedList.length}`;
await JobManager.updateJob(uid, jobId, {
progress: 1,
status: succeedList.length > 0 ? JobStatus.Finished : JobStatus.Failed,
tip,
data: {
succeedList,
failedList,
},
});
})().catch(async (e) => {
logger.error(`${jobId}: ${e}`);
let tip = "遇到不可思议的错误了哦,任务终止";
await JobManager.updateJob(uid, jobId, {
status: JobStatus.Failed,
tip,
});
});
return jobId;
};
async function syncSingleSong(uid, wySongMeta, playlistInfo) {
const playlistName = playlistInfo.name;
const playlistID = playlistInfo.id;
logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`);
const globalConfig = await configManager.getGlobalConfig();
let isLossless = false;
if (globalConfig.playlistSyncToLocal.soundQualityPreference === soundQualityConst.Lossless) {
isLossless = true;
}
// 优先使用官方资源下载
const playUrl = await getPlayUrl(uid, wySongMeta.songId, isLossless);
if (playUrl) {
const tmpPath = await downloadViaSourceUrl(playUrl);
if (tmpPath) {
const collectRet = {};
const ret = await downloadFromLocalTmpPath(
tmpPath,
wySongMeta,
playlistName,
collectRet
);
if (ret === true) {
logger.info(`download from official succeed`, wySongMeta);
if (collectRet.md5Value) {
await recordSongIndex(playlistID, wySongMeta.songId, wySongMeta, playlistName, collectRet.md5Value);
}
return true;
}
}
}
// 从公开资源获取
logger.info(
`download from official failed, try to download from public resources`,
wySongMeta
);
const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
musicPlatformSongId: wySongMeta.songId,
});
// search songs with the meta
const searchListfilttered = await searchSongsWithSongMeta(
{
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
duration: wySongMeta.duration,
},
{
expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [],
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
}
);
logger.info(
`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`
);
if (searchListfilttered === false) {
return false;
}
// find the best match song
for (const searchItem of searchListfilttered) {
logger.info(`try to the search item: ${JSON.stringify(searchItem)}`);
const collectRet = {};
const isSucceed = await syncSingleSongWithUrl(
uid,
searchItem.url,
{
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
songFromWyCloud,
},
0,
JobType.SyncThePlaylistToLocalService,
playlistName,
collectRet
);
if (isSucceed === "IOFailed") {
logger.error(`not try others due to upload failed.`);
return false;
}
if (isSucceed) {
if (collectRet.md5Value) {
await recordSongIndex(playlistID, wySongMeta.songId, wySongMeta, playlistName, collectRet.md5Value);
}
return true;
}
}
return false;
}
const SourceWYPlaylist = "wycloudPlaylist";
function recordFileIndex(playlistID, songID, songInfo, playlistName, md5Value) {
const sourceID = `${playlistID}_${songID}`;
// no need to await to save time
KV.fileSyncMeta.set(SourceWYPlaylist, sourceID, {
songInfo,
playlistName,
md5Value,
createTime: Date.now(),
});
}
async function getRecordFileIndex(playlistID, songID) {
const sourceID = `${playlistID}_${songID}`;
return await KV.fileSyncMeta.get(SourceWYPlaylist, sourceID);
}
async function isNeedToSyncFile(playlistID, songID, cache) {
const record = await getRecordFileIndex(playlistID, songID);
if (!record) {
// logger.info(`no record for ${playlistID}_${songID}, need to sync`);
return true;
}
// use the latest setting to rebuild the dest filename
// then check:
// 1. if the file exists(don't check the md5), skip
// 2. if the file not exists, check if there is a same file under the destFile's dir with the same md5, skip
const globalConfig = await configManager.getGlobalConfig();
const destFilename = buildDestFilename(
globalConfig,
record.songInfo,
record.playlistName
);
if (await utilFs.asyncFileExisted(destFilename)) {
// logger.info(`file already exists, skip: ${destFilename}`);
return false;
}
try {
const dir = path.dirname(destFilename);
const files = await (async () => {
if (cache[`dir_files_${dir}`]) {
return cache[`dir_files_${dir}`];
}
const files = await utilFs.asyncReadDir(dir);
cache[`dir_files_${dir}`] = files;
return files;
})();
for (const file of files) {
const filename = path.join(dir, file);
const md5Value = await (async () => {
if (cache[`md5_${filename}`]) {
return cache[`md5_${filename}`];
}
const md5Value = await utilFs.asyncMd5(filename);
cache[`md5_${filename}`] = md5Value;
return md5Value;
})()
if (md5Value === record.md5Value) {
// logger.info(`file already exists with the same md5, skip: ${filename}`);
return false;
}
}
// logger.info(`no file with the same md5, need to sync: ${destFilename}`);
return true;
} catch (e) {
logger.error(e);
// logger.info(`error when check the file, need to sync: ${destFilename}`);
return true;
}
}
async function recordSongIndex(playlistID, songID, songInfo, playlistName, md5Value) {
try {
// 记录单曲信息
recordFileIndex(playlistID, songID, songInfo, playlistName, md5Value);
// 更新歌单元数据
const playlistMeta = await KV.fileSyncMeta.getPlaylistMeta(playlistID);
const songIDs = new Set(playlistMeta.songIDs || []);
songIDs.add(songID);
await KV.fileSyncMeta.setPlaylistMeta(playlistID, {
songIDs: Array.from(songIDs)
});
logger.info(`Updated playlist meta for song: ${songInfo.songName}`);
} catch (err) {
logger.error(`Failed to record song index: ${songInfo.songName}`, err);
// 不抛出错误,避免影响主流程
}
}
async function syncDeleteFiles(playlistId, currentSongIDs) {
try {
// 获取本地已下载的歌曲记录
const playlistMeta = await KV.fileSyncMeta.getPlaylistMeta(playlistId);
const localSongIDs = playlistMeta.songIDs || [];
// 找出需要删除的歌曲(在本地但不在云端的)
const needDeleteSongIDs = localSongIDs.filter(id => !currentSongIDs.includes(id));
if (needDeleteSongIDs.length === 0) {
logger.info(`No songs need to be deleted for playlist: ${playlistId}`);
return;
}
logger.info(`Found ${needDeleteSongIDs.length} songs to delete for playlist: ${playlistId}`);
// 记录删除结果
const deletedSongIDs = new Set();
for (const songID of needDeleteSongIDs) {
const record = await getRecordFileIndex(playlistId, songID);
if (!record) {
logger.warn(`No record found for song: ${songID}`);
continue;
}
const globalConfig = await configManager.getGlobalConfig();
const destFilename = buildDestFilename(globalConfig, record.songInfo, record.playlistName);
try {
if (await utilFs.asyncFileExisted(destFilename)) {
await utilFs.asyncUnlinkFile(destFilename);
logger.info(`Deleted file: ${destFilename}`);
} else {
logger.warn(`File not found: ${destFilename}`);
}
// 删除单曲记录
const sourceID = `${playlistId}_${songID}`;
await KV.fileSyncMeta.set(SourceWYPlaylist, sourceID, null);
deletedSongIDs.add(songID);
logger.info(`Deleted record for song: ${record.songInfo.songName}`);
} catch (err) {
logger.error(`Failed to delete song: ${record.songInfo.songName}`, err);
}
}
// 更新歌单元数据
const remainingSongIDs = localSongIDs.filter(id => !deletedSongIDs.has(id));
await KV.fileSyncMeta.setPlaylistMeta(playlistId, {
songIDs: remainingSongIDs
});
logger.info(`Successfully deleted ${deletedSongIDs.size} songs from playlist: ${playlistId}`);
} catch (err) {
logger.error(`Failed to sync delete files for playlist: ${playlistId}`, err);
}
}

View File

@@ -0,0 +1,83 @@
const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher');
const logger = require('consola');
const sleep = require('../../utils/sleep');
const findTheBestMatchFromWyCloud = require('../search_songs/find_the_best_match_from_wycloud');
const JobManager = require('../job_manager');
const JobStatus = require('../../consts/job_status');
const JobType = require('../../consts/job_type');
const configManager = require('../config_manager');
const fs = require('fs');
const libPath = require('path');
const utilFs = require('../../utils/fs');
const { downloadFromLocalTmpPath } = require('./download_to_local');
const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match');
module.exports = async function syncSingleSongWithUrl(uid, url, {
songName = "",
artist = "",
album = "",
songFromWyCloud = null
} = {}, jobId = 0, jobType = JobType.SyncSongFromUrl, playlistName = "", collectRet) {
// step 1. fetch song info
const songInfo = await getMetaWithUrl(url);
logger.info(songInfo);
if (songInfo === false || songInfo.isTrial) {
logger.error(`fetch song info failed or it's a trial song. ${JSON.stringify(songInfo)}`);
return false;
}
await updateJobIfNeed(uid, jobId, songInfo, jobType);
// step 2. find the best match from wycloud
if (songFromWyCloud === null) {
let findSongName, findArtist, findAlbum;
if (songName !== "" && artist !== "") {
logger.info(`use the user input song name and artist, ${songName}, ${artist}, ${album}`);
findSongName = songName;
findArtist = artist;
findAlbum = album;
} else if (songInfo.fromMusicPlatform) {
findSongName = songInfo.songName;
findArtist = songInfo.artist;
findAlbum = songInfo.album;
}
songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: findSongName,
artist: findArtist,
album: findAlbum,
});
} else {
logger.info(`use the songFromWyCloud by params`);
}
logger.info('songFromWyCloud:', songFromWyCloud);
// step 3. download
// should add meta tag if not matched song on wycloud
const path = await fetchWithUrl(url, {songName: songInfo.songName, addMediaTag: songFromWyCloud ? false : true});
if (path === false) {
return false;
}
// step 4. upload or download
logger.info(`handle song start: ${path}`);
if (jobType === JobType.DownloadSongFromUrl || jobType === JobType.SyncThePlaylistToLocalService) {
return await downloadFromLocalTmpPath(path, songInfo, playlistName, collectRet);
} else {
return await uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud);
}
}
async function updateJobIfNeed(uid, jobId, songInfo, jobType) {
if (!jobId) {
return;
}
const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载";
await JobManager.updateJob(uid, jobId, {
name: `${operation}歌曲:${songInfo.songName}`,
status: JobStatus.InProgress,
desc: `歌曲: ${songInfo.songName}`,
tip: "任务开始",
});
}

View File

@@ -0,0 +1,205 @@
const { getSongsFromPlaylist, getPlayUrl } = require('../music_platform/wycloud');
const syncSingleSongWithUrl = require('./sync_single_song_with_url');
const logger = require('consola');
const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs');
const JobManager = require('../job_manager');
const JobType = require('../../consts/job_type');
const JobStatus = require('../../consts/job_status');
const SoundQuality = require('../../consts/sound_quality');
const BusinessCode = require('../../consts/business_code');
const AccountService = require('../account');
const { downloadViaSourceUrl } = require("../media_fetcher/index");
const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match');
const asyncFS = require('../../utils/fs');
// scope:
// 1. for not wy song: download from network then upload to cloud disk
// 2. for wy song: download from wy then upload to cloud disk. (i.e. backup wy song to cloud disk)
module.exports = async function unblockMusicInPlaylist(uid, source, playlistId, options = {
syncWySong: false,
syncNotWySong: false,
asyncExecute: true,
}) {
// step 1. get songs
const songsInfo = await getSongsFromPlaylist(uid, source, playlistId);
if (songsInfo === false) {
return false;
}
if (songsInfo.songs.length === 0) {
return false;
}
const songsNeedToSync = [];
songsInfo.songs.forEach(song => {
if (song.isCloud) {
return
}
// block song
if (song.isBlocked) {
if (options.syncNotWySong) {
songsNeedToSync.push(song);
}
} else {
// wy song
if (options.syncWySong) {
songsNeedToSync.push(song);
}
}
});
if (songsNeedToSync.length === 0) {
return BusinessCode.StatusNoNeedToSync;
}
// create job
const args = `unblockMusicInPlaylist: {"source":${source},"playlistId":${playlistId}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`unblock music in playlist job is already running.`);
return BusinessCode.StatusJobAlreadyExisted;
}
const jobId = await JobManager.createJob(uid, {
name: `解锁歌单:${songsInfo.name}`,
args,
type: JobType.UnblockedPlaylist,
status: JobStatus.Pending,
desc: `${songsNeedToSync.length}首歌曲需要解锁`,
progress: 0,
tip: "等待解锁",
createdAt: Date.now()
});
// async do the job
const job = (async () => {
const songs = songsNeedToSync;
logger.info(`${jobId}: try to unblock songs: ${JSON.stringify(songs)}`);
await JobManager.updateJob(uid, jobId, {
status: JobStatus.InProgress,
});
const succeedList = [];
const failedList = [];
// step 2. download the songs and upload to cloud
for (const song of songs) {
let tip = `[${(succeedList.length + failedList.length + 1)}/${songs.length}] 正在解锁歌曲:${song.songName}`;
await JobManager.updateJob(uid, jobId, {
tip,
});
const syncSucceed = await syncSingleSongWithMeta(uid, song);
if (syncSucceed) {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 解锁成功",
});
succeedList.push({songName: song.songName, artist: song.artists[0]});
} else {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 解锁失败",
});
failedList.push({songName: song.songName, artist: song.artists[0]});
}
await JobManager.updateJob(uid, jobId, {
progress: (succeedList.length + failedList.length) / songs.length,
});
}
let tip = `任务完成,成功${succeedList.length}首,失败${failedList.length}`;
await JobManager.updateJob(uid, jobId, {
progress: 1,
status: succeedList.length > 0 ? JobStatus.Finished : JobStatus.Failed,
tip,
data: {
succeedList,
failedList,
}
});
})().catch(async e => {
logger.error(`${jobId}: ${e}`);
let tip = '遇到不可思议的错误了哦,任务终止';
await JobManager.updateJob(uid, jobId, {
status: JobStatus.Failed,
tip,
});
});
// For sync execution, wait for job completion
if (!options.asyncExecute) {
await job;
}
return jobId;
}
async function syncSingleSongWithMeta(uid, wySongMeta) {
logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`);
// 获取 wycloud 的歌曲信息,有 id 就直接 get没有就 search meta 选一个最匹配的
const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
musicPlatformSongId: wySongMeta.songId,
});
// Case 1: download the song from wy
if (!wySongMeta.isBlocked) {
const account = AccountService.getAccount(uid);
const playUrl = await getPlayUrl(uid, wySongMeta.songId, account.config.playlistSyncToWyCloudDisk.soundQualityPreference === SoundQuality.Lossless);
// if the playUrl is empty, we think the song is block as well. go through the search process
if (playUrl) {
const tmpPath = await downloadViaSourceUrl(playUrl);
// if download failed, we think due to network issue, just return false. It will retry in the next time
if (tmpPath === false) {
return false;
}
// add some magic
try {
await asyncFS.asyncAppendFile(tmpPath, '00000');
} catch (e) {
logger.error(`append file failed: ${tmpPath}`);
// 追加失败,可以继续
}
const isSucceed = await uploadWithRetryThenMatch(uid, tmpPath, null, songFromWyCloud);
if (isSucceed === true) {
return true;
}
return false;
}
}
// Case 2: search songs with the meta in the internet then upload to cloud
const searchListfilttered = await searchSongsWithSongMeta({
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
duration: wySongMeta.duration,
}, {
expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [],
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
});
logger.info(`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`);
if (searchListfilttered === false) {
return false;
}
// find the best match song
for (const searchItem of searchListfilttered) {
logger.info(`try to the search item: ${JSON.stringify(searchItem)}`);
const isUploadSucceed = await syncSingleSongWithUrl(uid, searchItem.url, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
songFromWyCloud,
});
if (isUploadSucceed === "IOFailed") {
logger.error(`not try others due to upload failed.`);
return false;
}
if (isUploadSucceed) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,102 @@
const { getSongInfo } = require('../music_platform/wycloud');
const syncSingleSongWithUrl = require('./sync_single_song_with_url');
const logger = require('consola');
const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs');
const JobManager = require('../job_manager');
const JobType = require('../../consts/job_type');
const JobStatus = require('../../consts/job_status');
const BusinessCode = require('../../consts/business_code');
module.exports = async function unblockMusiWithSongId(uid, source, songId) {
const songInfo = await getSongInfo(uid, songId);
if (songInfo === false) {
return false;
}
// create job
const args = `unblockMusicWithSongId: {"source":${source},"songId":${songId}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`unblock music with songID job is already running.`);
return BusinessCode.StatusJobAlreadyExisted;
}
const jobId = await JobManager.createJob(uid, {
name: `解锁歌曲:${songInfo.songName}`,
args,
type: JobType.UnblockedSong,
status: JobStatus.Pending,
desc: `${songInfo.songName} - ${songInfo.artists.join(',')}`,
progress: 0,
tip: "等待解锁",
createdAt: Date.now()
});
// async do the job
(async () => {
logger.info(`${jobId}: try to unblock song: ${JSON.stringify(songInfo)}`);
// download the songs and upload to cloud
const syncSucceed = await syncSingleSongWithMeta(uid, songInfo);
let tip = songInfo.songName + (syncSucceed ? ": 解锁成功" : ": 解锁失败");
await JobManager.updateJob(uid, jobId, {
progress: 1,
status: syncSucceed ? JobStatus.Finished : JobStatus.Failed,
tip,
data: {}
});
})().catch(async e => {
logger.error(`${jobId}: ${e}`);
let tip = '遇到不可思议的错误了哦,任务终止';
await JobManager.updateJob(uid, jobId, {
status: JobStatus.Failed,
tip,
});
})
return jobId;
}
async function syncSingleSongWithMeta(uid, wySongMeta) {
logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`);
const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
musicPlatformSongId: wySongMeta.songId,
});
// search songs with the meta
const searchListfilttered = await searchSongsWithSongMeta({
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
duration: wySongMeta.duration,
}, {
expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [],
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
});
logger.info(`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`);
if (searchListfilttered === false) {
return false;
}
// find the best match song
for (const searchItem of searchListfilttered) {
logger.info(`try to the search item: ${JSON.stringify(searchItem)}`);
const isUploadSucceed = await syncSingleSongWithUrl(uid, searchItem.url, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
songFromWyCloud,
});
if (isUploadSucceed === "IOFailed") {
logger.error(`not try others due to upload failed.`);
return false;
}
if (isUploadSucceed) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,50 @@
const { uploadSong, matchAndFixCloudSong } = require('../music_platform/wycloud');
const logger = require('consola');
const fs = require('fs');
const sleep = require('../../utils/sleep');
module.exports = async function uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud) {
const startTime = new Date();
let isHandleSucceed = false;
let uploadResult;
for (let tryCount = 0; tryCount < 5; tryCount++) {
if (tryCount !== 0) {
logger.info(`upload song failed, try again: ${path}`);
}
uploadResult = await uploadSong(uid, path);
if (uploadResult === false) {
logger.error(`upload song failed, uid: ${uid}, path: ${path}`);
await sleep(3000);
continue;
} else {
isHandleSucceed = true;
break;
}
}
// del file async
fs.unlink(path, () => {});
if (!isHandleSucceed) {
logger.error(`upload song failed, uid: ${uid}, path: ${path}`);
return "IOFailed";
}
const costSeconds = (new Date() - startTime) / 1000;
logger.info(`upload song success, uid: ${uid}, path: ${path}, cost: ${costSeconds}s`);
if (uploadResult.matched) {
logger.info(`matched song already, uid: ${uid}, songId: ${uploadResult.songId}. ignore.`);
return true;
}
// fix match manually IF not matched in music platform
if (!songFromWyCloud) {
logger.info(`would not try to match from wycloud!!! uid: ${uid}, ${JSON.stringify(songInfo)}`);
return true;
}
const matchResult = await matchAndFixCloudSong(uid, uploadResult.songId, songFromWyCloud.songId);
logger.info(`match song ${matchResult ? 'success' : 'failed'}, uid: ${uid}, songId: ${uploadResult.songId}, wySongId: ${songFromWyCloud.songId}`);
return true;
}

32
backend/src/utils/cmd.js Normal file
View File

@@ -0,0 +1,32 @@
const logger = require("consola");
const spawnAsync = require("child_process").spawn;
module.exports = function exec(exe, args) {
return new Promise(function (resolve, reject) {
console.log(exe, args);
const process = spawnAsync(exe, args);
let stdout = "";
let stderr = "";
process.stdout.on("data", function (data) {
stdout += data;
});
process.stderr.on("data", function (data) {
stderr += data;
});
process.on("close", function (code) {
resolve({
code: stderr ? code : 0,
message: stderr ? stderr: stdout,
});
});
process.on("error", function (error) {
logger.error('exec error: ', error);
resolve({
code: -1,
message: error.message,
});
});
});
}

View File

@@ -0,0 +1,20 @@
const got = require('got');
const fs = require('fs');
const pipeline = require('stream').pipeline;
const { promisify } = require('util');
const streamPipeline = promisify(pipeline);
module.exports = async function downloadFile(url, destination) {
try {
await streamPipeline(
got.stream(url),
fs.createWriteStream(destination)
);
return true;
} catch (error) {
console.error('download failed:', error);
return false;
}
}

124
backend/src/utils/fs.js Normal file
View File

@@ -0,0 +1,124 @@
const fs = require('fs');
const crypto = require('crypto');
function asyncReadFile(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
}
function asyncWriteFile(filePath, data) {
return new Promise((resolve, reject) => {
fs.writeFile(filePath, data, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function asyncFileExisted(filePath) {
return new Promise((resolve, reject) => {
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
resolve(false);
return;
}
resolve(true);
});
});
}
function asyncMkdir(dirPath, options) {
return new Promise((resolve, reject) => {
fs.mkdir(dirPath, options, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function asyncUnlinkFile(filePath) {
return new Promise((resolve, reject) => {
fs.unlink(filePath, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
const fsPromise = fs.promises;
async function asyncMoveFile(oldPath, newPath) {
await fsPromise.copyFile(oldPath, newPath)
await fsPromise.unlink(oldPath);
}
function asyncReadDir(dirPath) {
return new Promise((resolve, reject) => {
fs.readdir(dirPath, (err, files) => {
if (err) {
reject(err);
return;
}
resolve(files);
}
)});
}
async function asyncMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => {
hash.update(data);
});
stream.on('end', () => {
resolve(hash.digest('hex'));
});
stream.on('error', (error) => {
reject(error);
});
});
}
async function asyncAppendFile(filePath, str) {
return new Promise((resolve, reject) => {
fs.appendFile(filePath, str, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
module.exports = {
asyncReadFile,
asyncWriteFile,
asyncFileExisted,
asyncMkdir,
asyncUnlinkFile,
asyncMoveFile,
asyncReadDir,
asyncMd5,
asyncAppendFile,
};

View File

@@ -0,0 +1,25 @@
const https = require('https');
function asyncHttpsGet(url) {
return new Promise((resolve) => {
https.get(url, res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
resolve(data.toString());
});
}).on('error', err => {
console.error(err);
resolve(null);
});
});
}
module.exports = {
asyncHttpsGet
}

View File

@@ -0,0 +1,9 @@
module.exports = {
matchUrlFromStr: (str) => {
const matched = str.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/);
if (!matched) {
return false;
}
return matched[0];
}
}

View File

@@ -0,0 +1,30 @@
const lockMap = {};
const sleep = require('./sleep');
async function lock(key, expireSeconds = 20) {
let retryCount = 20;
while (--retryCount >= 0) {
if (!lockMap[key]) {
lockMap[key] = true;
setTimeout(() => {
delete lockMap[key];
}, expireSeconds * 1000);
return true;
}
await sleep(200);
}
return false;
}
function unlock(key) {
delete lockMap[key];
}
module.exports = {
lock: lock,
unlock: unlock,
};

View File

@@ -0,0 +1,3 @@
module.exports = function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,5 @@
const { v4: uuidv4 } = require('uuid');
module.exports = function () {
return uuidv4().replace(/-/g, '');
}