feat(admin+server): 酷狗对接 - 扫码绑定管理员账号 / 按排期自动创建酷狗日歌单 + 同步歌曲
数据模型:SystemSettings 新增 kugouApiBaseUrl / kugouAdminCookie / kugouAdminToken / kugouAdminUser / kugouAdminBoundAt 5 字段;新建 KuGouPlaylistSync 同步历史表(12 字段 + playDate 倒序索引)。迁移脚本:app/drizzle/migrations/20260919143000_add_kugou_integration.sql 后端 Server:新增 server/services/kugouService.ts 封装 KuGouMusicApi sidecar 转发(自动拼 Cookie / Token header);新增 6 个 admin/kugou 路由:status / qr-create / qr-check / unbind / sync-schedule-playlists / sync-history。同步核心:逐日出非草稿排期 → kgHash 直取优先 → 失败用 cloudsearch/search 按标题+歌手三级召回 → /playlist/create + /playlist/tracks op=add 每 100 首分批;写同步历史(status、added/missing、歌单 URL)。 后台 UI:新建 Admin/KuGouImporter.client.vue 三栏布局——左栏 sidecar 健康卡+部署说明+扫码绑定/解绑弹窗;中栏多选排期日期+逐日出排期预览卡+按日同步;下栏最近 100 条同步历史(7 种状态 badge + 歌单链接跳转)。dashboard.vue L105 playlist-card 挂载点由 PlaylistCardPage 替换为 KuGouImporter;权限 permissions.js 5 个 ADMIN 族角色开放 playlist-card 页面访问(接口内部绑定/同步再强校验 ADMIN/SUPER_ADMIN)。 侧car要求:部署 KuGouMusicApi(参考文档 https://kugoumusicapi-docs.4everland.app),默认本机 http://127.0.0.1:3000,可通过 SystemSettings.kugouApiBaseUrl 或环境变量 KUGOU_API_BASE 覆盖。
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import { and, asc, eq, sql } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs, systemSettings, kuGouPlaylistSyncs } from '~/drizzle/schema'
|
||||
import { kuGouFetch } from '~~/server/services/kugouService'
|
||||
|
||||
const WEEKDAY_ZH = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
|
||||
function pad2(n: number) {
|
||||
return n < 10 ? '0' + n : '' + n
|
||||
}
|
||||
function dayKey(d: Date) {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
}
|
||||
function parseDate(s: string | Date) {
|
||||
const d = s instanceof Date ? new Date(s) : new Date(s)
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
async function readSettings() {
|
||||
const rows = await db.select().from(systemSettings).limit(1)
|
||||
return rows[0] as any
|
||||
}
|
||||
|
||||
async function fetchScheduleForDate(dayStart: Date) {
|
||||
const dayEnd = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate() + 1)
|
||||
return await db
|
||||
.select({
|
||||
scheduleId: schedules.id,
|
||||
sequence: schedules.sequence,
|
||||
songId: schedules.songId,
|
||||
playDate: schedules.playDate,
|
||||
title: songs.title,
|
||||
singer: songs.singer,
|
||||
artist: songs.artist,
|
||||
kgHash: songs.kgHash,
|
||||
musicId: songs.musicId,
|
||||
coverUrl: songs.coverUrl,
|
||||
cover: songs.cover,
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(songs.id, schedules.songId))
|
||||
.where(and(sql`${schedules.playDate} >= ${dayStart}`, sql`${schedules.playDate} < ${dayEnd}`, eq(schedules.isDraft, false)))
|
||||
.orderBy(asc(schedules.sequence), asc(schedules.playDate))
|
||||
}
|
||||
|
||||
async function ensureLogin(settings: any) {
|
||||
if (!settings?.kugouAdminToken && !settings?.kugouAdminCookie) {
|
||||
throw createError({
|
||||
statusCode: 412,
|
||||
message: '尚未绑定酷狗管理员账号,请先在本页扫码绑定',
|
||||
})
|
||||
}
|
||||
try {
|
||||
const res = await kuGouFetch('/login/status', { timestamp: Date.now() })
|
||||
const ok = Number((res as any)?.status ?? 0) === 1 || !!(res as any)?.data?.profile || !!(res as any)?.data?.account
|
||||
if (!ok) {
|
||||
throw new Error('酷狗账号未登录或已过期,请重新扫码绑定')
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw createError({
|
||||
statusCode: 412,
|
||||
message: '酷狗登录状态无效:' + (e?.message || String(e)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function searchKugouTrackFor(item: { title: string; singer?: string | null; artist?: string | null; kgHash?: string | null }) {
|
||||
const q = [item.title, item.singer || item.artist].filter(Boolean).join(' - ')
|
||||
const res = await kuGouFetch<any>('/cloudsearch', {
|
||||
keywords: q,
|
||||
limit: 10,
|
||||
timestamp: Date.now(),
|
||||
}).catch(() =>
|
||||
kuGouFetch<any>('/search', {
|
||||
keywords: q,
|
||||
limit: 10,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
)
|
||||
const list: any[] =
|
||||
(res as any)?.result?.songs ||
|
||||
(res as any)?.data?.info ||
|
||||
(res as any)?.songs ||
|
||||
[]
|
||||
if (!list.length) return null
|
||||
const targetL = item.title.trim().toLowerCase()
|
||||
const targetArtist = (item.singer || item.artist || '').trim().toLowerCase()
|
||||
const best =
|
||||
list.find((it) => {
|
||||
const nameL = (it.name || it.fileName || it.songname || '').toString().trim().toLowerCase()
|
||||
const artistL = ((it.ar && it.ar[0]?.name) || it.singerName || it.singername || '')
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return nameL === targetL && (!targetArtist || artistL.includes(targetArtist))
|
||||
}) || list[0]
|
||||
const name = best.name || best.fileName || best.songname || item.title
|
||||
const hash = best.hash || best.fileHash || ''
|
||||
const albumId = best.album?.id ?? best.album_id ?? best.albumId ?? ''
|
||||
const audioId = best.id ?? best.album_audio_id ?? best.albumAudioId ?? ''
|
||||
return {
|
||||
name,
|
||||
hash,
|
||||
albumId: String(albumId || ''),
|
||||
audioId: String(audioId || ''),
|
||||
raw: best,
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) throw createError({ statusCode: 401, message: '未登录' })
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
|
||||
throw createError({ statusCode: 403, message: '仅管理员可同步排期' })
|
||||
|
||||
const body = await readBody(event)
|
||||
const rawDates: (string | Date)[] = Array.isArray(body?.dates) && body.dates.length ? body.dates : []
|
||||
const playlistUrlTemplate = body.playlistUrlTemplate || 'https://www.kugou.com/yy/special/single/{listid}.html'
|
||||
if (!rawDates.length) throw createError({ statusCode: 400, message: '请选择至少一个排期日期' })
|
||||
|
||||
const settings = await readSettings()
|
||||
await ensureLogin(settings)
|
||||
|
||||
const results: any[] = []
|
||||
for (const d of rawDates) {
|
||||
const dayStart = parseDate(d as string)
|
||||
const key = dayKey(dayStart)
|
||||
const playlistName = `BYW广播 ${key} (${WEEKDAY_ZH[dayStart.getDay()]})`
|
||||
const scheduled = await fetchScheduleForDate(dayStart)
|
||||
const totalSongs = scheduled.length
|
||||
let playlistId = ''
|
||||
let playlistUrl = ''
|
||||
const missing: any[] = []
|
||||
const addBatches: string[] = []
|
||||
let added = 0
|
||||
let status = 'pending'
|
||||
let error = ''
|
||||
let rawCreate: any = null
|
||||
let rawAdd: any = null
|
||||
|
||||
try {
|
||||
if (!totalSongs) {
|
||||
status = 'skipped_empty'
|
||||
error = '当日没有已发布排期的歌曲'
|
||||
} else {
|
||||
const createRes = await kuGouFetch<any>('/playlist/create', {
|
||||
name: playlistName,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
rawCreate = createRes
|
||||
const createOk = Number((createRes as any)?.code ?? 0) === 200 || Number((createRes as any)?.status ?? 0) === 1
|
||||
const listid =
|
||||
(createRes as any)?.playlist?.id ||
|
||||
(createRes as any)?.data?.id ||
|
||||
(createRes as any)?.id ||
|
||||
(createRes as any)?.listid ||
|
||||
''
|
||||
if (!createOk || !listid) {
|
||||
status = 'failed_create'
|
||||
error =
|
||||
(createRes as any)?.message ||
|
||||
(createRes as any)?.error ||
|
||||
'歌单创建失败,请检查 sidecar / 酷狗账号权限'
|
||||
} else {
|
||||
playlistId = String(listid)
|
||||
playlistUrl = playlistUrlTemplate.replace('{listid}', playlistId)
|
||||
const tracks: string[] = []
|
||||
for (const s of scheduled) {
|
||||
// 先直接用 kgHash / musicId 构造 track;没有就搜索
|
||||
const hash = s.kgHash || ''
|
||||
const name = s.title
|
||||
const albId = ''
|
||||
const audioId = s.musicId || ''
|
||||
if (hash) {
|
||||
tracks.push([name, hash, albId, audioId].join('|'))
|
||||
} else {
|
||||
const hit = await searchKugouTrackFor(s)
|
||||
if (hit && hit.hash) tracks.push([hit.name, hit.hash, hit.albumId, hit.audioId].join('|'))
|
||||
else
|
||||
missing.push({
|
||||
songId: s.songId,
|
||||
title: s.title,
|
||||
singer: s.singer || s.artist,
|
||||
reason: '酷狗未匹配到可加歌曲',
|
||||
})
|
||||
}
|
||||
}
|
||||
added = tracks.length
|
||||
const CHUNK = 100
|
||||
for (let i = 0; i < tracks.length; i += CHUNK) {
|
||||
const data = tracks.slice(i, i + CHUNK).join(',')
|
||||
const r = await kuGouFetch<any>('/playlist/tracks', {
|
||||
op: 'add',
|
||||
pid: playlistId,
|
||||
trackIds: tracks[i],
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
rawAdd = rawAdd || []
|
||||
rawAdd.push(r)
|
||||
const ok = Number((r as any)?.code ?? 0) === 200 || Number((r as any)?.status ?? 0) === 1
|
||||
if (!ok) {
|
||||
status = 'partial_add_failed'
|
||||
error = (r as any)?.message || (r as any)?.error || '歌单添加歌曲失败'
|
||||
break
|
||||
}
|
||||
addBatches.push(`${i + 1}-${Math.min(i + CHUNK, tracks.length)}`)
|
||||
}
|
||||
if (status === 'pending') status = missing.length ? 'partial' : 'success'
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
status = 'error'
|
||||
error = e?.message || String(e)
|
||||
}
|
||||
|
||||
const inserted = (
|
||||
await db
|
||||
.insert(kuGouPlaylistSyncs)
|
||||
.values({
|
||||
playDate: dayStart,
|
||||
playlistName,
|
||||
playlistId: playlistId || null,
|
||||
playlistUrl: playlistUrl || null,
|
||||
totalSongs,
|
||||
addedSongs: added,
|
||||
missingSongs: missing as any,
|
||||
status,
|
||||
error: error || null,
|
||||
triggeredByUserId: Number(user.id) || null,
|
||||
rawResponse: {
|
||||
create: rawCreate,
|
||||
add: rawAdd,
|
||||
batches: addBatches,
|
||||
} as any,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
)[0]
|
||||
results.push({
|
||||
id: inserted.id,
|
||||
date: key,
|
||||
playlistName,
|
||||
playlistId,
|
||||
playlistUrl,
|
||||
totalSongs,
|
||||
addedSongs: added,
|
||||
missingSongs: missing,
|
||||
status,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
results,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user