diff --git a/src/music/netease.ts b/src/music/netease.ts index b4f487b..e01772e 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -54,10 +54,21 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] { return lines.sort((a, b) => a.time - b.time); } +// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster +export const NETEASE_QUALITY_LEVELS = [ + { value: "standard", label: "标准 (128kbps)", bitrate: 128 }, + { value: "higher", label: "较高 (192kbps)", bitrate: 192 }, + { value: "exhigh", label: "极高 (320kbps)", bitrate: 320 }, + { value: "lossless", label: "无损 (FLAC)", bitrate: 900 }, + { value: "hires", label: "Hi-Res", bitrate: 1500 }, + { value: "jymaster", label: "超清母带", bitrate: 4000 }, +] as const; + export class NeteaseProvider implements MusicProvider { readonly platform = "netease" as const; private api: AxiosInstance; private cookie = ""; + private quality = "exhigh"; constructor(baseUrl: string) { this.api = axios.create({ @@ -66,6 +77,14 @@ export class NeteaseProvider implements MusicProvider { }); } + setQuality(quality: string): void { + this.quality = quality; + } + + getQuality(): string { + return this.quality; + } + private get cookieParams(): Record { return this.cookie ? { cookie: this.cookie } : {}; } @@ -110,9 +129,10 @@ export class NeteaseProvider implements MusicProvider { return { songs, playlists, albums: [] }; } - async getSongUrl(songId: string): Promise { + async getSongUrl(songId: string, quality?: string): Promise { + const level = quality ?? this.quality; const res = await this.api.get("/song/url/v1", { - params: { id: songId, level: "exhigh", ...this.cookieParams }, + params: { id: songId, level, ...this.cookieParams }, }); return res.data?.data?.[0]?.url ?? null; } diff --git a/src/music/provider.ts b/src/music/provider.ts index 7f8adad..ee81d00 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -57,7 +57,9 @@ export interface MusicProvider { readonly platform: "netease" | "qq"; search(query: string, limit?: number): Promise; - getSongUrl(songId: string): Promise; + getSongUrl(songId: string, quality?: string): Promise; + setQuality(quality: string): void; + getQuality(): string; getSongDetail(songId: string): Promise; getPlaylistSongs(playlistId: string): Promise; getRecommendPlaylists(): Promise; diff --git a/src/music/qq.ts b/src/music/qq.ts index c0e44cb..4b9d9e7 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -14,6 +14,7 @@ export class QQMusicProvider implements MusicProvider { readonly platform = "qq" as const; private api: AxiosInstance; private cookie = ""; + private quality = "exhigh"; constructor(baseUrl: string) { this.api = axios.create({ @@ -22,6 +23,14 @@ export class QQMusicProvider implements MusicProvider { }); } + setQuality(quality: string): void { + this.quality = quality; + } + + getQuality(): string { + return this.quality; + } + private get cookieParams(): Record { return this.cookie ? { cookie: this.cookie } : {}; } @@ -48,7 +57,7 @@ export class QQMusicProvider implements MusicProvider { return { songs, playlists: [], albums: [] }; } - async getSongUrl(songId: string): Promise { + async getSongUrl(songId: string, _quality?: string): Promise { const res = await this.api.get("/getMusicPlay", { params: { songmid: songId, ...this.cookieParams }, }); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index bde06f5..e064956 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -193,5 +193,30 @@ export function createMusicRouter( } }); + // Get current quality + router.get("/quality", (_req, res) => { + res.json({ + netease: neteaseProvider.getQuality(), + qq: qqProvider.getQuality(), + }); + }); + + // Set quality + router.post("/quality", (req, res) => { + const { quality, platform } = req.body; + if (!quality) { + res.status(400).json({ error: "quality is required" }); + return; + } + if (!platform || platform === "netease") { + neteaseProvider.setQuality(quality); + } + if (!platform || platform === "qq") { + qqProvider.setQuality(quality); + } + logger.info({ quality, platform }, "Audio quality changed"); + res.json({ success: true, quality }); + }); + return router; } diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 7988520..270a683 100644 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -195,6 +195,29 @@ + +
+

音质设置

+
+
+ + 音源质量 +
+
+ +
+
+
+

命令设置

@@ -227,6 +250,31 @@ const neteaseCookie = ref(''); const qqCookie = ref(''); const commandPrefix = ref('!'); +// Audio quality +const currentQuality = ref('exhigh'); +const qualityLevels = [ + { value: 'standard', label: '标准', desc: '128kbps MP3' }, + { value: 'higher', label: '较高', desc: '192kbps MP3' }, + { value: 'exhigh', label: '极高', desc: '320kbps MP3' }, + { value: 'lossless', label: '无损', desc: 'FLAC' }, + { value: 'hires', label: 'Hi-Res', desc: '高解析度' }, + { value: 'jymaster', label: '超清母带', desc: '最高质量' }, +]; + +async function loadQuality() { + try { + const res = await axios.get('/api/music/quality'); + currentQuality.value = res.data.netease || 'exhigh'; + } catch { /* ignore */ } +} + +async function setQuality(q: string) { + currentQuality.value = q; + try { + await axios.post('/api/music/quality', { quality: q }); + } catch { /* ignore */ } +} + // Login mode: 'qr' | 'cookie' | null const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null); const qqLoginMode = ref<'qr' | 'cookie' | null>(null); @@ -380,6 +428,7 @@ async function savePrefix() { onMounted(() => { checkAuthStatus(); + loadQuality(); }); onUnmounted(() => { @@ -653,6 +702,39 @@ onUnmounted(() => { &:focus { border-color: var(--color-primary); } } +.quality-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.quality-btn { + padding: 12px; + background: var(--hover-bg); + border: 2px solid transparent; + border-radius: var(--radius-md); + text-align: center; + transition: all var(--transition-fast); + cursor: pointer; + + &:hover { border-color: var(--border-color); } + &.active { + border-color: var(--color-primary); + background: rgba(51, 94, 234, 0.1); + } +} + +.quality-name { + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; +} + +.quality-desc { + font-size: 11px; + color: var(--text-tertiary); +} + .prefix-input-wrap { display: flex; gap: 8px;