数据模型: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 覆盖。
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
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,
|
|
}
|
|
})
|