feat(播放): 添加支持直接使用playUrl播放歌曲的功能

- 在SearchResultTable和SearchResultList组件中添加playTheSongWithPlayUrl方法
- 修改播放逻辑,优先使用playUrl进行播放
- 在Mobile.vue中添加playUrl处理逻辑
- 新增tunehub服务的getSongInfo和searchSongs接口
- 重构歌曲搜索处理逻辑,使用tunehub服务替代原有实现
This commit is contained in:
史悦
2026-01-07 17:10:32 +08:00
parent 0dbb36be9d
commit 6fa4f1a72e
9 changed files with 242 additions and 51 deletions

View File

@@ -1,8 +1,51 @@
const logger = require('consola');
const { searchSongsWithKeyword, searchSongsWithSongMeta } = require('../service/search_songs');
const { getPlayUrlWithOptions } = require('../service/songs_info'); const { getPlayUrlWithOptions } = require('../service/songs_info');
const { getMetaWithUrl } = require('../service/media_fetcher');
const { matchUrlFromStr } = require('../utils/regex'); const { matchUrlFromStr } = require('../utils/regex');
const { searchSongs, getSongInfo, buildSongUrl } = require('../service/music_platform/tunehub');
const configManager = require('../service/config_manager');
function buildPageUrl(source, songId) {
if (!source || !songId) {
return '';
}
if (source === 'netease') {
return `https://music.163.com/song?id=${songId}`;
}
if (source === 'qq') {
return `https://y.qq.com/n/ryqq/songDetail/${songId}`;
}
return '';
}
function mapTunehubResult(item) {
const playUrl = item.url || buildSongUrl(item.platform, item.id);
const pageUrl = buildPageUrl(item.platform, item.id);
return {
songName: item.name || '',
artist: item.artist || '',
album: item.album || '',
duration: 0,
url: pageUrl || playUrl || '',
playUrl: playUrl || '',
pageUrl: pageUrl || '',
coverUrl: item.pic || '',
resourceForbidden: false,
source: item.platform || '',
fromMusicPlatform: true,
score: 0,
};
}
function parseNeteaseSongId(url) {
const match = url.match(/song\\?id=(\\d+)/);
if (match && match[1]) {
return match[1];
}
const altMatch = url.match(/\\bid=(\\d+)/);
if (altMatch && altMatch[1]) {
return altMatch[1];
}
return '';
}
async function search(req, res) { async function search(req, res) {
const query = req.query; const query = req.query;
@@ -16,30 +59,49 @@ async function search(req, res) {
}); });
return; return;
} }
let songs = []; let keyword = keywordOrUrl;
const url = matchUrlFromStr(keywordOrUrl); const url = matchUrlFromStr(keywordOrUrl);
if (!url) { if (url) {
songs = await searchSongsWithKeyword(keywordOrUrl); const neteaseId = parseNeteaseSongId(url);
} else { if (neteaseId) {
const songMeta = await getMetaWithUrl(url); const info = await getSongInfo('netease', neteaseId);
if (!songMeta) { if (info && info.name && info.artist) {
keyword = `${info.name} ${info.artist}`;
}
}
}
const limit = query.limit ? parseInt(query.limit, 10) : 20;
const requestSource = query.source || '';
const searchData = await searchSongs(keyword, {
source: requestSource,
limit: Number.isNaN(limit) ? 20 : limit,
aggregate: !requestSource,
});
if (searchData === false || !searchData.results) {
res.send({ res.send({
status: 2, status: 0,
message: "can not get song meta with this url", data: {
songs: [],
}
}); });
return; return;
} }
songs = await searchSongsWithSongMeta({
songName: songMeta.songName, const globalConfig = await configManager.getGlobalConfig();
artist: songMeta.artist, const enabledSources = globalConfig && Array.isArray(globalConfig.sources) ? globalConfig.sources : [];
album: songMeta.album, const songs = searchData.results
duration: songMeta.duration, .filter(item => {
}, { if (!item.platform) {
expectArtistAkas: [], return false;
allowSongsJustMatchDuration: true,
allowSongsNotMatchMeta: true,
});
} }
if (enabledSources.length === 0) {
return true;
}
return enabledSources.includes(item.platform);
})
.map(mapTunehubResult)
.filter(song => song.songName.length > 0);
res.send({ res.send({
status: 0, status: 0,

View File

@@ -34,6 +34,40 @@ async function getPlaylistDetail(source, playlistId) {
return response.data; return response.data;
} }
async function getSongInfo(source, songId) {
const response = await fetchJson({
source,
id: songId,
type: 'info',
});
if (!response || response.code !== 200 || !response.data) {
return false;
}
return response.data;
}
async function searchSongs(keyword, {
source = '',
limit = 20,
aggregate = true,
} = {}) {
const params = {
keyword,
limit,
};
if (aggregate) {
params.type = 'aggregateSearch';
} else {
params.type = 'search';
params.source = source;
}
const response = await fetchJson(params);
if (!response || response.code !== 200 || !response.data) {
return false;
}
return response.data;
}
function buildSongUrl(source, songId, br) { function buildSongUrl(source, songId, br) {
const params = { const params = {
source, source,
@@ -48,5 +82,7 @@ function buildSongUrl(source, songId, br) {
module.exports = { module.exports = {
getPlaylistDetail, getPlaylistDetail,
getSongInfo,
searchSongs,
buildSongUrl, buildSongUrl,
}; };

View File

@@ -187,6 +187,13 @@ export default {
} }
playOption.playUrl = playUrlRet.data.playUrl; playOption.playUrl = playUrlRet.data.playUrl;
} }
if (playOption.playUrl) {
playOption.playUrl = getProperPlayUrl(
playOption.source,
playOption.playUrl,
playOption.pageUrl
);
}
this.songInfos.push(playOption); this.songInfos.push(playOption);
this.currentSongIndex = this.songInfos.length - 1; this.currentSongIndex = this.songInfos.length - 1;
this.changedTime = new Date().getTime(); this.changedTime = new Date().getTime();

View File

@@ -9,7 +9,7 @@
<van-col span="23" offset="1"> <van-col span="23" offset="1">
<van-row> <van-row>
<van-col span="22"> <van-col span="22">
<van-row @click="play(null, item.url, i)"> <van-row @click="play(item, i)">
<van-col style="font-size: 16px"> <van-col style="font-size: 16px">
<i <i
v-if="item.resourceForbidden" v-if="item.resourceForbidden"
@@ -115,6 +115,10 @@ export default {
type: Function, type: Function,
required: true, required: true,
}, },
playTheSongWithPlayUrl: {
type: Function,
required: false,
},
suggestMatchSongId: { suggestMatchSongId: {
type: String, type: String,
required: false, required: false,
@@ -137,11 +141,15 @@ export default {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => { const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId); props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
}; };
const playTheSongWithPlayUrl = (playOption) => {
props.playTheSongWithPlayUrl && props.playTheSongWithPlayUrl(playOption);
};
const showPopover = ref([]); const showPopover = ref([]);
return { return {
playTheSong, playTheSong,
playTheSongWithPlayUrl,
showPopover, showPopover,
ellipsis, ellipsis,
}; };
@@ -158,6 +166,12 @@ export default {
}, },
}, },
methods: { methods: {
getSourceUrl(item) {
if (!item) {
return "";
}
return item.pageUrl || item.url || "";
},
async uploadToCloud(pageUrl) { async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob( const ret = await createSyncSongFromUrlJob(
pageUrl, pageUrl,
@@ -180,25 +194,47 @@ export default {
startTaskListener(ret.data.jobId); startTaskListener(ret.data.jobId);
} }
}, },
play(songMeta, pageUrl, index) { play(item, index) {
if (this.currentSongIndex === index) { if (this.currentSongIndex === index) {
return; return;
} }
this.currentSongIndex = index; this.currentSongIndex = index;
this.playTheSong(songMeta, pageUrl, this.suggestMatchSongId); const pageUrl = this.getSourceUrl(item);
if (this.playTheSongWithPlayUrl && item && item.playUrl) {
this.playTheSongWithPlayUrl({
playUrl: item.playUrl,
coverUrl: item.coverUrl,
songName: item.songName,
artist: item.artist,
pageUrl,
source: item.source,
});
return;
}
this.playTheSong(null, pageUrl, this.suggestMatchSongId);
}, },
async onSelect(actionItem) { async onSelect(actionItem) {
const currentSong = this.searchResult[actionItem.songIndex]; const currentSong = this.searchResult[actionItem.songIndex];
const pageUrl = this.getSourceUrl(currentSong);
console.log(currentSong); console.log(currentSong);
switch (actionItem.action) { switch (actionItem.action) {
case ActionUpload: case ActionUpload:
this.uploadToCloud(currentSong.url); this.uploadToCloud(pageUrl);
break; break;
case ActionDownloadToLocalService: case ActionDownloadToLocalService:
this.downloadToLocalService(currentSong.url); this.downloadToLocalService(pageUrl);
break; break;
case ActionDownload: case ActionDownload:
const ret = await getSongsMeta({ url: currentSong.url }); if (currentSong.playUrl) {
const a = document.createElement("a");
a.href = currentSong.playUrl;
a.download = `${currentSong.songName}-${currentSong.artist}.mp3`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
} else {
const ret = await getSongsMeta({ url: pageUrl });
const info = ret.data.songMeta; const info = ret.data.songMeta;
console.log(ret); console.log(ret);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -208,9 +244,10 @@ export default {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
}
break; break;
case ActionOpenRef: case ActionOpenRef:
window.open(currentSong.url, "_blank").focus(); window.open(pageUrl || currentSong.url, "_blank").focus();
break; break;
} }
}, },

View File

@@ -73,7 +73,7 @@
<el-tooltip content="播放歌曲" placement="top" v-else> <el-tooltip content="播放歌曲" placement="top" v-else>
<el-button <el-button
@click="play(null, scope.row.url)" @click="play(scope.row)"
type="primary" type="primary"
circle circle
:disabled="scope.row.url.indexOf('youtube') >= 0" :disabled="scope.row.url.indexOf('youtube') >= 0"
@@ -94,7 +94,7 @@
<el-button <el-button
type="success" type="success"
circle circle
@click="uploadToCloud(scope.row.url)" @click="uploadToCloud(scope.row)"
:disabled="!wyAccount" :disabled="!wyAccount"
class="operation-btn" class="operation-btn"
> >
@@ -113,7 +113,7 @@
<el-button <el-button
type="primary" type="primary"
circle circle
@click="downloadToLocalService(scope.row.url)" @click="downloadToLocalService(scope.row)"
:disabled="!globalConfig.downloadPathExisted" :disabled="!globalConfig.downloadPathExisted"
class="operation-btn" class="operation-btn"
> >
@@ -126,7 +126,7 @@
type="warning" type="warning"
circle circle
class="operation-btn" class="operation-btn"
@click="openSourceUrl(scope.row.url)" @click="openSourceUrl(scope.row)"
> >
<i class="bi bi-box-arrow-up-right"></i> <i class="bi bi-box-arrow-up-right"></i>
</el-button> </el-button>
@@ -229,6 +229,10 @@ export default {
type: Function, type: Function,
required: true, required: true,
}, },
playTheSongWithPlayUrl: {
type: Function,
required: false,
},
abortTheSong: { abortTheSong: {
type: Function, type: Function,
required: true, required: true,
@@ -250,11 +254,15 @@ export default {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => { const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId); props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
}; };
const playTheSongWithPlayUrl = (playOption) => {
props.playTheSongWithPlayUrl && props.playTheSongWithPlayUrl(playOption);
};
const abortTheSong = () => { const abortTheSong = () => {
props.abortTheSong(); props.abortTheSong();
}; };
return { return {
playTheSong, playTheSong,
playTheSongWithPlayUrl,
abortTheSong, abortTheSong,
}; };
}, },
@@ -267,7 +275,14 @@ export default {
}, },
}, },
methods: { methods: {
async uploadToCloud(pageUrl) { getSourceUrl(row) {
if (!row) {
return "";
}
return row.pageUrl || row.url || "";
},
async uploadToCloud(row) {
const pageUrl = this.getSourceUrl(row);
const ret = await createSyncSongFromUrlJob( const ret = await createSyncSongFromUrlJob(
pageUrl, pageUrl,
this.suggestMatchSongId this.suggestMatchSongId
@@ -278,7 +293,8 @@ export default {
startTaskListener(ret.data.jobId); startTaskListener(ret.data.jobId);
} }
}, },
async downloadToLocalService(pageUrl) { async downloadToLocalService(row) {
const pageUrl = this.getSourceUrl(row);
const ret = await createDownloadSongFromUrlJob( const ret = await createDownloadSongFromUrlJob(
pageUrl, pageUrl,
this.suggestMatchSongId this.suggestMatchSongId
@@ -295,15 +311,28 @@ export default {
this.globalConfig = globalConfig.data; this.globalConfig = globalConfig.data;
} }
}, },
play(songMeta, pageUrl) { play(row) {
this.currentSongUrl = pageUrl; const pageUrl = this.getSourceUrl(row);
this.playTheSong(songMeta, pageUrl, this.suggestMatchSongId); this.currentSongUrl = row && row.url ? row.url : pageUrl;
if (this.playTheSongWithPlayUrl && row && row.playUrl) {
this.playTheSongWithPlayUrl({
playUrl: row.playUrl,
coverUrl: row.coverUrl,
songName: row.songName,
artist: row.artist,
pageUrl,
source: row.source,
});
return;
}
this.playTheSong(null, pageUrl, this.suggestMatchSongId);
}, },
abort() { abort() {
this.currentSongUrl = -1; this.currentSongUrl = -1;
this.abortTheSong(); this.abortTheSong();
}, },
openSourceUrl(url) { openSourceUrl(row) {
const url = this.getSourceUrl(row);
try { try {
if (typeof window !== "undefined" && window?.open) { if (typeof window !== "undefined" && window?.open) {
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");

View File

@@ -128,6 +128,7 @@
<van-row style="margin-top: 10px"> <van-row style="margin-top: 10px">
<SearchResultList <SearchResultList
:playTheSong="playTheSong" :playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
:suggestMatchSongId="suggestMatchSongId" :suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult" :searchResult="searchResult"
> >
@@ -412,6 +413,10 @@ export default {
type: Function, type: Function,
required: true, required: true,
}, },
playTheSongWithPlayUrl: {
type: Function,
required: false,
},
}, },
mounted() { mounted() {
this.wyAccount = storage.get("wyAccount"); this.wyAccount = storage.get("wyAccount");
@@ -425,8 +430,12 @@ export default {
const playTheSong = (songMeta, pageUrl) => { const playTheSong = (songMeta, pageUrl) => {
props.playTheSong(songMeta, pageUrl); props.playTheSong(songMeta, pageUrl);
}; };
const playTheSongWithPlayUrl = (playOption) => {
props.playTheSongWithPlayUrl && props.playTheSongWithPlayUrl(playOption);
};
return { return {
playTheSong, playTheSong,
playTheSongWithPlayUrl,
ellipsis, ellipsis,
}; };
}, },

View File

@@ -18,6 +18,7 @@
<van-row style="margin-top: 30px" v-if="searchResult.length > 0"> <van-row style="margin-top: 30px" v-if="searchResult.length > 0">
<SearchResultList <SearchResultList
:playTheSong="playTheSong" :playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
:suggestMatchSongId="suggestMatchSongId" :suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult" :searchResult="searchResult"
> >

View File

@@ -215,6 +215,7 @@
<el-row v-if="searchResult.length > 0" class="search-result-container"> <el-row v-if="searchResult.length > 0" class="search-result-container">
<SearchResultTable <SearchResultTable
:playTheSong="playTheSong" :playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
:abortTheSong="abortTheSong" :abortTheSong="abortTheSong"
:suggestMatchSongId="suggestMatchSongId" :suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult" :searchResult="searchResult"
@@ -269,6 +270,10 @@ export default {
type: Function, type: Function,
required: true, required: true,
}, },
playTheSongWithPlayUrl: {
type: Function,
required: false,
},
}, },
async mounted() { async mounted() {
this.wyAccount = storage.get("wyAccount"); this.wyAccount = storage.get("wyAccount");
@@ -286,12 +291,16 @@ export default {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => { const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId); props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
}; };
const playTheSongWithPlayUrl = (playOption) => {
props.playTheSongWithPlayUrl && props.playTheSongWithPlayUrl(playOption);
};
const abortTheSong = () => { const abortTheSong = () => {
props.abortTheSong(); props.abortTheSong();
}; };
return { return {
abortTheSong, abortTheSong,
playTheSong, playTheSong,
playTheSongWithPlayUrl,
}; };
}, },
components: { components: {

View File

@@ -28,6 +28,7 @@
</p> </p>
<SearchResultTable <SearchResultTable
:playTheSong="playTheSong" :playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
:abortTheSong="abortTheSong" :abortTheSong="abortTheSong"
:suggestMatchSongId="suggestMatchSongId" :suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult" :searchResult="searchResult"