初始化提交
This commit is contained in:
1
backend/.nvmrc
Normal file
1
backend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v16.13.0
|
||||
14
backend/accounts.sample.json
Normal file
14
backend/accounts.sample.json
Normal 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
0
backend/bin/.gitkeep
Normal file
33
backend/package.json
Normal file
33
backend/package.json
Normal 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
1699
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
backend/src/consts/business_code.js
Normal file
5
backend/src/consts/business_code.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
StatusJobAlreadyExisted: 40010,
|
||||
StatusJobNoNeedToCreate: 40011,
|
||||
StatusNoNeedToSync: 40012,
|
||||
}
|
||||
6
backend/src/consts/job_status.js
Normal file
6
backend/src/consts/job_status.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
Pending: "待开始",
|
||||
InProgress: "进行中",
|
||||
Failed: "失败",
|
||||
Finished: "已完成",
|
||||
}
|
||||
7
backend/src/consts/job_type.js
Normal file
7
backend/src/consts/job_type.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
UnblockedPlaylist: "UnblockedPlaylist",
|
||||
UnblockedSong: "UnblockedSong",
|
||||
SyncSongFromUrl: "SyncSongFromUrl",
|
||||
DownloadSongFromUrl: "DownloadSongFromUrl",
|
||||
SyncThePlaylistToLocalService: "SyncThePlaylistToLocalService",
|
||||
}
|
||||
4
backend/src/consts/sound_quality.js
Normal file
4
backend/src/consts/sound_quality.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
High: "high",
|
||||
Lossless: "lossless",
|
||||
}
|
||||
40
backend/src/consts/source.js
Normal file
40
backend/src/consts/source.js
Normal 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歌',
|
||||
},
|
||||
},
|
||||
}
|
||||
6
backend/src/errors/account_not_existed.js
Normal file
6
backend/src/errors/account_not_existed.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = class AccountNotExisted extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'AccountNotExisted';
|
||||
}
|
||||
}
|
||||
108
backend/src/handler/account.js
Normal file
108
backend/src/handler/account.js
Normal 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,
|
||||
}
|
||||
23
backend/src/handler/config.js
Normal file
23
backend/src/handler/config.js
Normal 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,
|
||||
}
|
||||
41
backend/src/handler/media_fetcher_lib.js
Normal file
41
backend/src/handler/media_fetcher_lib.js
Normal 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,
|
||||
}
|
||||
92
backend/src/handler/playlists.js
Normal file
92
backend/src/handler/playlists.js
Normal 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,
|
||||
}
|
||||
46
backend/src/handler/proxy.js
Normal file
46
backend/src/handler/proxy.js
Normal 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
|
||||
};
|
||||
27
backend/src/handler/scheduler.js
Normal file
27
backend/src/handler/scheduler.js
Normal 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
|
||||
};
|
||||
31
backend/src/handler/song_meta.js
Normal file
31
backend/src/handler/song_meta.js
Normal 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
|
||||
}
|
||||
76
backend/src/handler/songs.js
Normal file
76
backend/src/handler/songs.js
Normal 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
|
||||
}
|
||||
183
backend/src/handler/sync_jobs.js
Normal file
183
backend/src/handler/sync_jobs.js
Normal 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
39
backend/src/index.js
Normal 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
48
backend/src/init_app.js
Normal 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
|
||||
}
|
||||
19
backend/src/middleware/auth.js
Normal file
19
backend/src/middleware/auth.js
Normal 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()
|
||||
}
|
||||
19
backend/src/middleware/handle_error.js
Normal file
19
backend/src/middleware/handle_error.js
Normal 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
42
backend/src/router.js
Normal 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;
|
||||
|
||||
117
backend/src/service/account.js
Normal file
117
backend/src/service/account.js
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
85
backend/src/service/config_manager/index.js
Normal file
85
backend/src/service/config_manager/index.js
Normal 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,
|
||||
}
|
||||
0
backend/src/service/cronjob/index.js
Normal file
0
backend/src/service/cronjob/index.js
Normal file
189
backend/src/service/job_manager/index.js
Normal file
189
backend/src/service/job_manager/index.js
Normal 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,
|
||||
}
|
||||
88
backend/src/service/kv/index.js
Normal file
88
backend/src/service/kv/index.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
180
backend/src/service/media_fetcher/index.js
Normal file
180
backend/src/service/media_fetcher/index.js
Normal 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,
|
||||
}
|
||||
228
backend/src/service/media_fetcher/media_get.js
Normal file
228
backend/src/service/media_fetcher/media_get.js
Normal 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,
|
||||
}
|
||||
52
backend/src/service/music_platform/tunehub.js
Normal file
52
backend/src/service/music_platform/tunehub.js
Normal 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,
|
||||
};
|
||||
312
backend/src/service/music_platform/wycloud/index.js
Normal file
312
backend/src/service/music_platform/wycloud/index.js
Normal 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,
|
||||
}
|
||||
151
backend/src/service/music_platform/wycloud/transport.js
Normal file
151
backend/src/service/music_platform/wycloud/transport.js
Normal 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,
|
||||
}
|
||||
69
backend/src/service/remote_config/index.js
Normal file
69
backend/src/service/remote_config/index.js
Normal 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,
|
||||
}
|
||||
221
backend/src/service/scheduler/index.js
Normal file
221
backend/src/service/scheduler/index.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
19
backend/src/service/search_songs/index.js
Normal file
19
backend/src/service/search_songs/index.js
Normal 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,
|
||||
}
|
||||
131
backend/src/service/search_songs/search_songs_with_song_meta.js
Normal file
131
backend/src/service/search_songs/search_songs_with_song_meta.js
Normal 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;
|
||||
}
|
||||
14
backend/src/service/songs_info/index.js
Normal file
14
backend/src/service/songs_info/index.js
Normal 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,
|
||||
}
|
||||
64
backend/src/service/sync_music/download_to_local.js
Normal file
64
backend/src/service/sync_music/download_to_local.js
Normal 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}`
|
||||
}
|
||||
11
backend/src/service/sync_music/index.js
Normal file
11
backend/src/service/sync_music/index.js
Normal 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,
|
||||
};
|
||||
382
backend/src/service/sync_music/sync_playlist.js
Normal file
382
backend/src/service/sync_music/sync_playlist.js
Normal 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);
|
||||
}
|
||||
}
|
||||
83
backend/src/service/sync_music/sync_single_song_with_url.js
Normal file
83
backend/src/service/sync_music/sync_single_song_with_url.js
Normal 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: "任务开始",
|
||||
});
|
||||
}
|
||||
205
backend/src/service/sync_music/unblock_music_in_playlist.js
Normal file
205
backend/src/service/sync_music/unblock_music_in_playlist.js
Normal 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;
|
||||
}
|
||||
102
backend/src/service/sync_music/unblock_music_with_song_id.js
Normal file
102
backend/src/service/sync_music/unblock_music_with_song_id.js
Normal 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;
|
||||
}
|
||||
@@ -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
32
backend/src/utils/cmd.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
20
backend/src/utils/download.js
Normal file
20
backend/src/utils/download.js
Normal 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
124
backend/src/utils/fs.js
Normal 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,
|
||||
};
|
||||
25
backend/src/utils/network.js
Normal file
25
backend/src/utils/network.js
Normal 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
|
||||
}
|
||||
9
backend/src/utils/regex.js
Normal file
9
backend/src/utils/regex.js
Normal 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];
|
||||
}
|
||||
}
|
||||
30
backend/src/utils/simple_locker.js
Normal file
30
backend/src/utils/simple_locker.js
Normal 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,
|
||||
};
|
||||
3
backend/src/utils/sleep.js
Normal file
3
backend/src/utils/sleep.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
5
backend/src/utils/uuid.js
Normal file
5
backend/src/utils/uuid.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = function () {
|
||||
return uuidv4().replace(/-/g, '');
|
||||
}
|
||||
Reference in New Issue
Block a user