mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Jellyfin Integration. Assisted by Claude Fable 5.
This commit is contained in:
1 parent
b9767a7471
commit
f637ba0191
32 files changed
+2802
-171
No files matched your search
@@ -7,8 +7,8 @@
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span>
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : song.platform === 'jellyfin' ? 'badge-jellyfin' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : song.platform === 'jellyfin' ? 'Jellyfin' : '网易云' }}</span>
|
||||
<span
|
||||
v-if="song.requestedBy"
|
||||
class="requester-badge"
|
||||
@@ -175,6 +175,11 @@ function formatDuration(seconds: number): string {
|
||||
color: var(--brand-spotify);
|
||||
}
|
||||
|
||||
.badge-jellyfin {
|
||||
background: var(--brand-jellyfin-12);
|
||||
color: var(--brand-jellyfin);
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import type { Source } from '../stores/player.js';
|
||||
|
||||
const LABELS: Record<Source, string> = {
|
||||
jellyfin: 'Jellyfin',
|
||||
netease: '网易云',
|
||||
qq: 'QQ',
|
||||
kugou: '酷狗',
|
||||
|
||||
+137
-22
@@ -10,12 +10,21 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify';
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify' | 'jellyfin';
|
||||
requestedBy?: string;
|
||||
playedAt?: string;
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||
|
||||
export interface AlbumItem {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface BotStatus {
|
||||
id: string;
|
||||
@@ -101,12 +110,22 @@ export const usePlayerStore = defineStore('player', {
|
||||
timings: {} as Record<string, TimingState>,
|
||||
theme: 'dark' as 'dark' | 'light',
|
||||
|
||||
// Which sources the server has enabled (GET /api/music/providers) and the
|
||||
// configured default. Empty until fetched — sections stay hidden briefly.
|
||||
enabledProviders: [] as string[],
|
||||
defaultSource: 'jellyfin' as string,
|
||||
|
||||
// Home page cache, split by source
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||
userPlaylists: { jellyfin: [] as PlaylistItem[], netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||
bilibiliPopular: [] as Song[],
|
||||
authStatus: { netease: false, qq: false, kugou: false, spotify: false },
|
||||
// Jellyfin home sections (primary source)
|
||||
jellyfinLatestAlbums: [] as AlbumItem[],
|
||||
jellyfinMostPlayed: [] as Song[],
|
||||
jellyfinFavorites: [] as Song[],
|
||||
jellyfinGenres: [] as { id: string; name: string }[],
|
||||
authStatus: { jellyfin: false, netease: false, qq: false, kugou: false, spotify: false },
|
||||
lastFetchTime: 0,
|
||||
|
||||
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||
@@ -154,15 +173,20 @@ export const usePlayerStore = defineStore('player', {
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
/** Sources that are currently logged in. Order: netease before qq. */
|
||||
/** Sources that are currently logged in. Jellyfin (the primary source) leads. */
|
||||
availableSources(): Source[] {
|
||||
const s: Source[] = [];
|
||||
if (this.authStatus.jellyfin) s.push('jellyfin');
|
||||
if (this.authStatus.netease) s.push('netease');
|
||||
if (this.authStatus.qq) s.push('qq');
|
||||
if (this.authStatus.kugou) s.push('kugou');
|
||||
if (this.authStatus.spotify) s.push('spotify');
|
||||
return s;
|
||||
},
|
||||
/** Whether a source is enabled server-side (enabledProviders gate). */
|
||||
sourceEnabled(): (platform: string) => boolean {
|
||||
return (platform: string) => this.enabledProviders.includes(platform);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -571,27 +595,50 @@ export const usePlayerStore = defineStore('player', {
|
||||
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
||||
},
|
||||
|
||||
/** Load the server-side source gate (enabledProviders + default). */
|
||||
async fetchProviders() {
|
||||
try {
|
||||
const res = await axios.get('/api/music/providers');
|
||||
this.enabledProviders = res.data.enabled ?? [];
|
||||
if (typeof res.data.default === 'string') this.defaultSource = res.data.default;
|
||||
} catch {
|
||||
// keep previous values (backend briefly unavailable)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchHomeData() {
|
||||
// The provider gate decides which sources to even ask about — disabled
|
||||
// sources are skipped entirely (their sidecars may not be running).
|
||||
await this.fetchProviders();
|
||||
const has = (p: string) => this.enabledProviders.includes(p);
|
||||
const skip = Promise.reject(new Error('provider disabled'));
|
||||
// Pre-rejected sentinel: mark handled so it never surfaces as unhandled.
|
||||
skip.catch(() => {});
|
||||
|
||||
// Always check auth status first — if it changed since the cached
|
||||
// fetch (e.g., user logged in/out as a different account), the
|
||||
// cached playlists belong to a different user and we MUST refetch.
|
||||
const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
|
||||
const [jfAuthRes, neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
|
||||
has('jellyfin') ? axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }) : skip,
|
||||
has('netease') ? axios.get('/api/auth/status', { params: { platform: 'netease' } }) : skip,
|
||||
has('qq') ? axios.get('/api/auth/status', { params: { platform: 'qq' } }) : skip,
|
||||
has('kugou') ? axios.get('/api/auth/status', { params: { platform: 'kugou' } }) : skip,
|
||||
has('spotify') ? axios.get('/api/auth/status', { params: { platform: 'spotify' } }) : skip,
|
||||
]);
|
||||
const newAuth = {
|
||||
jellyfin: jfAuthRes.status === 'fulfilled' && !!jfAuthRes.value.data?.loggedIn,
|
||||
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
||||
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
||||
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
|
||||
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
|
||||
};
|
||||
const authChanged =
|
||||
newAuth.jellyfin !== this.authStatus.jellyfin ||
|
||||
newAuth.netease !== this.authStatus.netease ||
|
||||
newAuth.qq !== this.authStatus.qq ||
|
||||
newAuth.kugou !== this.authStatus.kugou ||
|
||||
newAuth.spotify !== this.authStatus.spotify;
|
||||
this.authStatus.jellyfin = newAuth.jellyfin;
|
||||
this.authStatus.netease = newAuth.netease;
|
||||
this.authStatus.qq = newAuth.qq;
|
||||
this.authStatus.kugou = newAuth.kugou;
|
||||
@@ -612,17 +659,23 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
// 2. NetEase data: recommend playlists work anonymously; daily/user
|
||||
// playlists need login but Promise.allSettled isolates failures.
|
||||
const neteasePromises = [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
|
||||
];
|
||||
const emptyPlaylists = { data: { playlists: [] } };
|
||||
const emptySongs = { data: { songs: [] } };
|
||||
const neteasePromises = has('netease')
|
||||
? [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
|
||||
]
|
||||
: [
|
||||
Promise.resolve(emptyPlaylists),
|
||||
Promise.resolve(emptySongs),
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
|
||||
// resolve to empty payloads so the same indexed handling works.
|
||||
const emptyPlaylists = { data: { playlists: [] } };
|
||||
const emptySongs = { data: { songs: [] } };
|
||||
const qqPromises = this.authStatus.qq
|
||||
const qqPromises = has('qq') && this.authStatus.qq
|
||||
? [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
|
||||
@@ -636,7 +689,7 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
// 4. Kugou data: every section (incl. recommend playlists) needs login,
|
||||
// so gate all three on auth like QQ.
|
||||
const kugouPromises = this.authStatus.kugou
|
||||
const kugouPromises = has('kugou') && this.authStatus.kugou
|
||||
? [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
|
||||
@@ -648,16 +701,49 @@ export const usePlayerStore = defineStore('player', {
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
|
||||
const biliPromise = has('bilibili')
|
||||
? axios.get('/api/music/bilibili/popular?limit=12')
|
||||
: Promise.resolve(emptySongs);
|
||||
|
||||
// 5. Jellyfin home sections. Favorites are guest-blocked server-side;
|
||||
// allSettled turns that 403 into an empty section.
|
||||
const jellyfinPromises = has('jellyfin')
|
||||
? [
|
||||
axios.get('/api/music/jellyfin/latest-albums', { params: { limit: 12 } }),
|
||||
axios.get('/api/music/jellyfin/most-played', { params: { limit: 12 } }),
|
||||
axios.get('/api/music/jellyfin/favorites', { params: { limit: 12 } }),
|
||||
axios.get('/api/music/jellyfin/genres'),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'jellyfin' } }),
|
||||
]
|
||||
: [
|
||||
Promise.resolve({ data: { albums: [] } }),
|
||||
Promise.resolve(emptySongs),
|
||||
Promise.resolve(emptySongs),
|
||||
Promise.resolve({ data: { genres: [] } }),
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
...neteasePromises,
|
||||
...qqPromises,
|
||||
...kugouPromises,
|
||||
biliPromise,
|
||||
...jellyfinPromises,
|
||||
]);
|
||||
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results;
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili,
|
||||
jfLatest, jfMost, jfFav, jfGenres, jfUserPL] = results;
|
||||
|
||||
this.jellyfinLatestAlbums =
|
||||
jfLatest.status === 'fulfilled' ? (jfLatest.value.data.albums ?? []) : [];
|
||||
this.jellyfinMostPlayed =
|
||||
jfMost.status === 'fulfilled' ? (jfMost.value.data.songs ?? []) : [];
|
||||
this.jellyfinFavorites =
|
||||
jfFav.status === 'fulfilled' ? (jfFav.value.data.songs ?? []) : [];
|
||||
this.jellyfinGenres =
|
||||
jfGenres.status === 'fulfilled' ? (jfGenres.value.data.genres ?? []) : [];
|
||||
this.userPlaylists.jellyfin =
|
||||
jfUserPL.status === 'fulfilled' ? (jfUserPL.value.data.playlists ?? []) : [];
|
||||
|
||||
this.recommendPlaylists.netease =
|
||||
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
|
||||
@@ -686,8 +772,10 @@ export const usePlayerStore = defineStore('player', {
|
||||
// Only mark as fetched if at least the auth-status calls succeeded —
|
||||
// a fully failed fetch (network blip / server down) should NOT be
|
||||
// cached for 5 minutes, otherwise the user has to hard-reload to
|
||||
// recover when connectivity returns.
|
||||
// recover when connectivity returns. (Disabled providers' checks are
|
||||
// pre-rejected sentinels, so only enabled ones can vouch here.)
|
||||
const authOk =
|
||||
jfAuthRes.status === 'fulfilled' ||
|
||||
neAuthRes.status === 'fulfilled' ||
|
||||
qqAuthRes.status === 'fulfilled' ||
|
||||
kugouAuthRes.status === 'fulfilled';
|
||||
@@ -695,5 +783,32 @@ export const usePlayerStore = defineStore('player', {
|
||||
this.lastFetchTime = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Play a whole Jellyfin genre: fetch its tracks, start the first, queue
|
||||
* the rest. There is no bulk-queue endpoint, so the remainder is appended
|
||||
* sequentially — capped at 30 tracks to keep the request burst small.
|
||||
*/
|
||||
async playJellyfinGenre(genreId: string) {
|
||||
if (!this.activeBotId) return;
|
||||
try {
|
||||
const res = await axios.get(`/api/music/jellyfin/genre/${genreId}/songs`, {
|
||||
params: { limit: 30 },
|
||||
});
|
||||
const songs: Song[] = res.data.songs ?? [];
|
||||
if (songs.length === 0) {
|
||||
this.notify('该流派下没有曲目', 'info');
|
||||
return;
|
||||
}
|
||||
await this.playSong(songs[0]);
|
||||
for (const song of songs.slice(1)) {
|
||||
await this.addSong(song);
|
||||
}
|
||||
this.notify(`已加入 ${songs.length} 首`, 'info');
|
||||
this.fetchQueue();
|
||||
} catch {
|
||||
this.notify('播放流派失败', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
|
||||
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
||||
const all = readAll();
|
||||
const v = all[key];
|
||||
return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
|
||||
return v === 'jellyfin' || v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
|
||||
}
|
||||
|
||||
export function saveTabSource(key: TabKey, value: Source): void {
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
--brand-kugou-12: rgba(44, 162, 249, 0.12);
|
||||
--brand-spotify: #1DB954;
|
||||
--brand-spotify-12: rgba(29, 185, 84, 0.12);
|
||||
--brand-jellyfin: #aa5cc3;
|
||||
--brand-jellyfin-12: rgba(170, 92, 195, 0.12);
|
||||
}
|
||||
|
||||
// Dark theme (default)
|
||||
|
||||
+144
-18
@@ -18,10 +18,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 私人FM -->
|
||||
<section class="section">
|
||||
<!-- 私人FM(音源按 enabledProviders 门控;Jellyfin 电台优先) -->
|
||||
<section v-if="fmCardCount > 0" class="section">
|
||||
<h2 class="section-title">私人FM</h2>
|
||||
<div class="fm-card hover-scale" @click="playFm('netease')">
|
||||
<div v-if="enabled('jellyfin')" class="fm-card hover-scale" @click="playFm('jellyfin')">
|
||||
<div class="fm-icon-wrapper jellyfin">
|
||||
<Icon icon="mdi:jellyfish" class="fm-icon" />
|
||||
</div>
|
||||
<div class="fm-info">
|
||||
<div class="fm-title">Jellyfin 电台</div>
|
||||
<div class="fm-desc">从收藏出发的 Instant Mix 歌曲流</div>
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="enabled('netease')" class="fm-card hover-scale" @click="playFm('netease')">
|
||||
<div class="fm-icon-wrapper">
|
||||
<Icon icon="mdi:radio" class="fm-icon" />
|
||||
</div>
|
||||
@@ -31,7 +41,7 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||
<div v-if="enabled('qq') && store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||
<div class="fm-icon-wrapper qq">
|
||||
<Icon icon="mdi:radar" class="fm-icon" />
|
||||
</div>
|
||||
@@ -41,7 +51,7 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div v-if="enabled('kugou') && store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div class="fm-icon-wrapper kugou">
|
||||
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
||||
</div>
|
||||
@@ -53,6 +63,76 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 最近添加 -->
|
||||
<section class="section" v-if="store.jellyfinLatestAlbums.length > 0">
|
||||
<h2 class="section-title">最近添加</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="al in store.jellyfinLatestAlbums"
|
||||
:key="al.id"
|
||||
:to="`/album/${al.id}?platform=jellyfin`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ al.name }}</div>
|
||||
<div class="playlist-count">{{ al.artist }}</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 播放最多 -->
|
||||
<section class="section" v-if="store.jellyfinMostPlayed.length > 0">
|
||||
<h2 class="section-title">播放最多</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in store.jellyfinMostPlayed"
|
||||
:key="song.id"
|
||||
class="daily-card hover-scale"
|
||||
@click="store.playSong(song)"
|
||||
>
|
||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||
<div class="daily-name">{{ song.name }}</div>
|
||||
<div class="daily-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 收藏曲目 -->
|
||||
<section class="section" v-if="store.jellyfinFavorites.length > 0">
|
||||
<h2 class="section-title">
|
||||
<Icon icon="mdi:star" style="color: var(--brand-jellyfin)" />
|
||||
Jellyfin 收藏
|
||||
</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in store.jellyfinFavorites"
|
||||
:key="song.id"
|
||||
class="daily-card hover-scale"
|
||||
@click="store.playSong(song)"
|
||||
>
|
||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||
<div class="daily-name">{{ song.name }}</div>
|
||||
<div class="daily-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 流派 -->
|
||||
<section class="section" v-if="store.jellyfinGenres.length > 0">
|
||||
<h2 class="section-title">流派</h2>
|
||||
<div class="genre-chips">
|
||||
<button
|
||||
v-for="g in store.jellyfinGenres"
|
||||
:key="g.id"
|
||||
class="genre-chip"
|
||||
@click="store.playJellyfinGenre(g.id)"
|
||||
>
|
||||
<Icon icon="mdi:music-note" />
|
||||
{{ g.name }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailyAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
@@ -177,16 +257,31 @@ const store = usePlayerStore();
|
||||
const USER_PLAYLIST_LIMIT = 20;
|
||||
const userPlaylistsExpanded = ref(false);
|
||||
|
||||
// Available sources per section.
|
||||
// Recommend playlists work anonymously on netease, so it's always available;
|
||||
// Server-side source gate (enabledProviders).
|
||||
const enabled = (p: string) => store.enabledProviders.includes(p);
|
||||
const fmCardCount = computed(() =>
|
||||
['jellyfin', 'netease'].filter(enabled).length +
|
||||
(enabled('qq') && store.authStatus.qq ? 1 : 0) +
|
||||
(enabled('kugou') && store.authStatus.kugou ? 1 : 0),
|
||||
);
|
||||
|
||||
// Available sources per section. Jellyfin has no daily/recommend concept —
|
||||
// it gets its own home sections instead — so those two tab bars only ever
|
||||
// carry the legacy sources (which is also what their store maps are keyed by).
|
||||
type LegacySource = Exclude<Source, 'jellyfin'>;
|
||||
|
||||
// Recommend playlists work anonymously on netease (when enabled);
|
||||
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
|
||||
const recommendAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = ['netease'];
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
if (store.authStatus.kugou) s.push('kugou');
|
||||
const recommendAvailable = computed<LegacySource[]>(() => {
|
||||
const s: LegacySource[] = [];
|
||||
if (enabled('netease')) s.push('netease');
|
||||
if (enabled('qq') && store.authStatus.qq) s.push('qq');
|
||||
if (enabled('kugou') && store.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
});
|
||||
const dailyAvailable = computed<Source[]>(() => store.availableSources);
|
||||
const dailyAvailable = computed<LegacySource[]>(() =>
|
||||
store.availableSources.filter((s): s is LegacySource => s !== 'jellyfin'),
|
||||
);
|
||||
const userAvailable = computed<Source[]>(() => store.availableSources);
|
||||
|
||||
// Persisted active source per section.
|
||||
@@ -199,14 +294,14 @@ watch(dailySource, (v) => saveTabSource('home.daily', v));
|
||||
watch(userSource, (v) => saveTabSource('home.user', v));
|
||||
|
||||
// Fallback when persisted source is no longer available.
|
||||
const recommendSourceSafe = computed<Source>(() =>
|
||||
recommendAvailable.value.includes(recommendSource.value)
|
||||
? recommendSource.value
|
||||
const recommendSourceSafe = computed<LegacySource>(() =>
|
||||
(recommendAvailable.value as Source[]).includes(recommendSource.value)
|
||||
? (recommendSource.value as LegacySource)
|
||||
: recommendAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
const dailySourceSafe = computed<Source>(() =>
|
||||
dailyAvailable.value.includes(dailySource.value)
|
||||
? dailySource.value
|
||||
const dailySourceSafe = computed<LegacySource>(() =>
|
||||
(dailyAvailable.value as Source[]).includes(dailySource.value)
|
||||
? (dailySource.value as LegacySource)
|
||||
: dailyAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
const userSourceSafe = computed<Source>(() =>
|
||||
@@ -373,6 +468,11 @@ onMounted(() => {
|
||||
&.kugou {
|
||||
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
||||
}
|
||||
|
||||
// Jellyfin's brand gradient (purple → blue).
|
||||
&.jellyfin {
|
||||
background: linear-gradient(135deg, var(--brand-jellyfin), #00a4dc);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-icon {
|
||||
@@ -472,4 +572,30 @@ onMounted(() => {
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// Jellyfin 流派
|
||||
.genre-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.genre-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-jellyfin);
|
||||
background: var(--brand-jellyfin-12);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -56,23 +56,35 @@
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
||||
— the primary source — comes first. -->
|
||||
<div class="source-bar">
|
||||
<button
|
||||
v-if="sourceEnabled('jellyfin')"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'jellyfin' }"
|
||||
@click="selectedSource = 'jellyfin'"
|
||||
>Jellyfin</button>
|
||||
<button
|
||||
v-if="sourceEnabled('netease')"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'netease' }"
|
||||
@click="selectedSource = 'netease'"
|
||||
>网易云</button>
|
||||
<button
|
||||
v-if="sourceEnabled('qq')"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'qq' }"
|
||||
@click="selectedSource = 'qq'"
|
||||
>QQ</button>
|
||||
<button
|
||||
v-if="sourceEnabled('bilibili')"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'bilibili' }"
|
||||
@click="selectedSource = 'bilibili'"
|
||||
>B站</button>
|
||||
<button
|
||||
v-if="sourceEnabled('kugou')"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'kugou' }"
|
||||
@click="selectedSource = 'kugou'"
|
||||
@@ -197,14 +209,16 @@ const router = useRouter();
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'search-source';
|
||||
|
||||
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
|
||||
type SearchSource = 'jellyfin' | 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
|
||||
|
||||
const SEARCH_SOURCES: SearchSource[] = ['jellyfin', 'netease', 'qq', 'bilibili', 'local', 'kugou'];
|
||||
|
||||
function loadSource(): SearchSource {
|
||||
try {
|
||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored;
|
||||
if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'netease';
|
||||
return 'jellyfin';
|
||||
}
|
||||
|
||||
type TabType = 'songs' | 'albums' | 'playlists';
|
||||
@@ -245,6 +259,22 @@ const filteredPlaylists = computed(() =>
|
||||
|
||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||
|
||||
// Server-side source gate (enabledProviders). Until /providers loads, the
|
||||
// store list is empty — treat that as "everything shown" so a slow request
|
||||
// doesn't blank the bar.
|
||||
function sourceEnabled(p: string): boolean {
|
||||
return store.enabledProviders.length === 0 || store.enabledProviders.includes(p);
|
||||
}
|
||||
|
||||
/** Snap a disabled/stale selection to the configured default (or first enabled). */
|
||||
function fixupSelectedSource() {
|
||||
if (selectedSource.value === 'local' || sourceEnabled(selectedSource.value)) return;
|
||||
const def = store.defaultSource as SearchSource;
|
||||
selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def)
|
||||
? def
|
||||
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'jellyfin';
|
||||
}
|
||||
|
||||
// ---- 分页 / 加载更多 ----
|
||||
function pageKey(type: TabType, source: string): string {
|
||||
return `${type}:${source}`;
|
||||
@@ -451,6 +481,7 @@ function badgeLabel(platform: string): string {
|
||||
if (platform === 'youtube') return 'YouTube';
|
||||
if (platform === 'local') return '本地';
|
||||
if (platform === 'kugou') return '酷狗';
|
||||
if (platform === 'jellyfin') return 'Jellyfin';
|
||||
return '网易云';
|
||||
}
|
||||
|
||||
@@ -460,6 +491,7 @@ function badgeClass(platform: string): string {
|
||||
if (platform === 'youtube') return 'badge-youtube';
|
||||
if (platform === 'local') return 'badge-local';
|
||||
if (platform === 'kugou') return 'badge-kugou';
|
||||
if (platform === 'jellyfin') return 'badge-jellyfin';
|
||||
return 'badge-netease';
|
||||
}
|
||||
|
||||
@@ -475,9 +507,11 @@ async function loadLocalAudioSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadLocalAudioSetting();
|
||||
if (query.value) doSearch();
|
||||
await store.fetchProviders();
|
||||
fixupSelectedSource();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -790,6 +824,11 @@ onMounted(() => {
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
|
||||
.badge-jellyfin {
|
||||
background: var(--brand-jellyfin-12);
|
||||
color: var(--brand-jellyfin);
|
||||
}
|
||||
|
||||
.fav-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
+243
-20
@@ -165,12 +165,106 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
|
||||
<section v-if="can('platform.auth')" class="settings-section">
|
||||
<!-- Jellyfin — the primary music source. Admin-configured connection
|
||||
(server URL + credentials), no QR flow. -->
|
||||
<section v-if="can('platform.auth') && providerOn('jellyfin')" class="settings-section">
|
||||
<h2 class="section-title">Jellyfin 音乐库</h2>
|
||||
|
||||
<div class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:jellyfish" class="account-icon jellyfin-icon" />
|
||||
<div class="account-info">
|
||||
<div class="account-name">Jellyfin</div>
|
||||
<div class="account-status" :class="{ logged: jellyfinAuth.loggedIn }">
|
||||
{{ jellyfinAuth.loggedIn ? `已连接: ${jellyfinAuth.nickname}` : '未连接' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="jellyfinForm.serverUrl" class="input" autocomplete="off" placeholder="https://jellyfin.example.com" />
|
||||
</div>
|
||||
|
||||
<div class="setting-row spotify-row">
|
||||
<div class="setting-label">认证方式</div>
|
||||
<div class="spotify-btn-group">
|
||||
<button
|
||||
class="spotify-choice"
|
||||
:class="{ active: jellyfinForm.authMode === 'userpass' }"
|
||||
@click="jellyfinForm.authMode = 'userpass'"
|
||||
>账号密码</button>
|
||||
<button
|
||||
class="spotify-choice"
|
||||
:class="{ active: jellyfinForm.authMode === 'apikey' }"
|
||||
@click="jellyfinForm.authMode = 'apikey'"
|
||||
>API Key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="jellyfinForm.authMode === 'userpass'">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="jellyfinForm.username" class="input" autocomplete="off" placeholder="Jellyfin 用户名" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
密码
|
||||
<span class="spotify-secret-hint" :class="{ set: jellyfinHasPassword }">
|
||||
{{ jellyfinHasPassword ? '已设置' : '未设置' }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="jellyfinForm.password"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="留空表示不修改已保存的密码"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
API Key
|
||||
<span class="spotify-secret-hint" :class="{ set: jellyfinHasApiKey }">
|
||||
{{ jellyfinHasApiKey ? '已设置' : '未设置' }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="jellyfinForm.apiKey"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="留空表示不修改已保存的 API Key"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户 ID</label>
|
||||
<input v-model="jellyfinForm.userId" class="input" autocomplete="off" placeholder="该 Key 对应使用的 Jellyfin 用户 ID" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="spotify-actions">
|
||||
<button class="btn-secondary" :disabled="jellyfinTesting" @click="testJellyfin">
|
||||
{{ jellyfinTesting ? '测试中…' : '测试连接' }}
|
||||
</button>
|
||||
<button class="btn-primary" :disabled="jellyfinSaving" @click="saveJellyfin">
|
||||
{{ jellyfinSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="jellyfinMessage" class="spotify-message" :class="`tone-${jellyfinMessageTone}`">{{ jellyfinMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Music Account - QR Code Login (platform auth) requires platform.auth.
|
||||
Cards for sources disabled via enabledProviders are hidden. -->
|
||||
<section v-if="can('platform.auth') && (providerOn('netease') || providerOn('qq') || providerOn('bilibili') || providerOn('kugou'))" class="settings-section">
|
||||
<h2 class="section-title">音乐账号</h2>
|
||||
|
||||
<!-- NetEase -->
|
||||
<div class="account-card">
|
||||
<div v-if="providerOn('netease')" class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:cloud-outline" class="account-icon" />
|
||||
<div class="account-info">
|
||||
@@ -240,7 +334,7 @@
|
||||
</div>
|
||||
|
||||
<!-- QQ Music -->
|
||||
<div class="account-card">
|
||||
<div v-if="providerOn('qq')" class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:music-circle-outline" class="account-icon" />
|
||||
<div class="account-info">
|
||||
@@ -309,7 +403,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- BiliBili -->
|
||||
<div class="account-card">
|
||||
<div v-if="providerOn('bilibili')" class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:video-outline" class="account-icon bilibili-icon" />
|
||||
<div class="account-info">
|
||||
@@ -379,7 +473,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Kugou -->
|
||||
<div class="account-card">
|
||||
<div v-if="providerOn('kugou')" class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
|
||||
<div class="account-info">
|
||||
@@ -552,7 +646,25 @@
|
||||
<!-- Audio Quality requires quality -->
|
||||
<section v-if="can('quality')" class="settings-section">
|
||||
<h2 class="section-title">音质设置</h2>
|
||||
<div class="setting-row">
|
||||
<div v-if="providerOn('jellyfin')" class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:jellyfish" class="setting-icon" />
|
||||
Jellyfin 音质
|
||||
</div>
|
||||
<div class="quality-options">
|
||||
<button
|
||||
v-for="q in jellyfinQualityLevels"
|
||||
:key="q.value"
|
||||
class="quality-btn"
|
||||
:class="{ active: jellyfinQuality === q.value }"
|
||||
@click="setJellyfinQuality(q.value)"
|
||||
>
|
||||
<div class="quality-name">{{ q.label }}</div>
|
||||
<div class="quality-desc">{{ q.desc }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="providerOn('netease') || providerOn('qq') || providerOn('kugou') || providerOn('bilibili')" class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:music-note-eighth" class="setting-icon" />
|
||||
音源质量
|
||||
@@ -984,10 +1096,21 @@ const qualityLevels = [
|
||||
{ value: 'jymaster', label: '超清母带', desc: '最高质量' },
|
||||
];
|
||||
|
||||
// Jellyfin quality tiers (direct = untranscoded original; the rest are
|
||||
// server-side transcodes for low-bandwidth Jellyfin links).
|
||||
const jellyfinQuality = ref('direct');
|
||||
const jellyfinQualityLevels = [
|
||||
{ value: 'direct', label: '原始直传', desc: 'Direct(无转码)' },
|
||||
{ value: '320', label: '320kbps', desc: '服务器转码' },
|
||||
{ value: '192', label: '192kbps', desc: '服务器转码' },
|
||||
{ value: '128', label: '128kbps', desc: '服务器转码' },
|
||||
];
|
||||
|
||||
async function loadQuality() {
|
||||
try {
|
||||
const res = await axios.get('/api/music/quality');
|
||||
currentQuality.value = res.data.netease || 'exhigh';
|
||||
jellyfinQuality.value = res.data.jellyfin || 'direct';
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -998,6 +1121,13 @@ async function setQuality(q: string) {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function setJellyfinQuality(q: string) {
|
||||
jellyfinQuality.value = q;
|
||||
try {
|
||||
await axios.post('/api/music/quality', { quality: q, platform: 'jellyfin' });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Login mode: 'qr' | 'cookie' | null
|
||||
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
@@ -1009,6 +1139,88 @@ const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const jellyfinAuth = reactive({ loggedIn: false, nickname: '' });
|
||||
|
||||
// Server-side source gate (enabledProviders): cards for disabled sources are
|
||||
// hidden. Empty until GET /api/bot/settings responds — treat as all-on so a
|
||||
// slow load doesn't blank the page.
|
||||
const enabledProviders = ref<string[]>([]);
|
||||
function providerOn(p: string): boolean {
|
||||
return enabledProviders.value.length === 0 || enabledProviders.value.includes(p);
|
||||
}
|
||||
|
||||
// --- Jellyfin connection (admin-configured; password/apiKey are write-only) ---
|
||||
const jellyfinForm = reactive({
|
||||
serverUrl: '',
|
||||
authMode: 'userpass' as 'userpass' | 'apikey',
|
||||
username: '',
|
||||
password: '',
|
||||
apiKey: '',
|
||||
userId: '',
|
||||
});
|
||||
const jellyfinHasPassword = ref(false);
|
||||
const jellyfinHasApiKey = ref(false);
|
||||
const jellyfinSaving = ref(false);
|
||||
const jellyfinTesting = ref(false);
|
||||
const jellyfinMessage = ref('');
|
||||
const jellyfinMessageTone = ref<'ok' | 'warn'>('ok');
|
||||
|
||||
// Populate from the masked GET /api/bot/settings response — secrets never
|
||||
// round-trip, only hasPassword/hasApiKey.
|
||||
function applyJellyfinConfig(jf: any) {
|
||||
if (!jf || typeof jf !== 'object') return;
|
||||
jellyfinForm.serverUrl = typeof jf.serverUrl === 'string' ? jf.serverUrl : '';
|
||||
if (jf.authMode === 'userpass' || jf.authMode === 'apikey') jellyfinForm.authMode = jf.authMode;
|
||||
jellyfinForm.username = typeof jf.username === 'string' ? jf.username : '';
|
||||
jellyfinForm.userId = typeof jf.userId === 'string' ? jf.userId : '';
|
||||
jellyfinForm.password = ''; // blank on load; blank on save = unchanged
|
||||
jellyfinForm.apiKey = '';
|
||||
jellyfinHasPassword.value = Boolean(jf.hasPassword);
|
||||
jellyfinHasApiKey.value = Boolean(jf.hasApiKey);
|
||||
}
|
||||
|
||||
async function saveJellyfin() {
|
||||
jellyfinSaving.value = true;
|
||||
jellyfinMessage.value = '';
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', { jellyfin: { ...jellyfinForm } });
|
||||
applyJellyfinConfig(res.data?.jellyfin);
|
||||
jellyfinMessageTone.value = 'ok';
|
||||
jellyfinMessage.value = '已保存';
|
||||
await checkAuthStatus();
|
||||
} catch (err: any) {
|
||||
jellyfinMessageTone.value = 'warn';
|
||||
jellyfinMessage.value = err?.response?.status === 403
|
||||
? '没有权限修改设置(需要 bot.manage)'
|
||||
: '保存失败,请稍后重试';
|
||||
} finally {
|
||||
jellyfinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trips /System/Info with the CURRENT form values (empty secret fields
|
||||
// fall back to the stored ones server-side), without touching saved config.
|
||||
async function testJellyfin() {
|
||||
jellyfinTesting.value = true;
|
||||
jellyfinMessage.value = '';
|
||||
try {
|
||||
const res = await axios.post('/api/auth/jellyfin/test', { ...jellyfinForm });
|
||||
if (res.data?.ok) {
|
||||
jellyfinMessageTone.value = 'ok';
|
||||
jellyfinMessage.value = `连接成功:${res.data.serverName ?? 'Jellyfin'} ${res.data.version ?? ''}`;
|
||||
} else {
|
||||
jellyfinMessageTone.value = 'warn';
|
||||
jellyfinMessage.value = `连接失败:${res.data?.error ?? '未知错误'}`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
jellyfinMessageTone.value = 'warn';
|
||||
jellyfinMessage.value = err?.response?.status === 403
|
||||
? '没有权限执行平台登录(需要 platform.auth)'
|
||||
: '测试请求失败,请稍后重试';
|
||||
} finally {
|
||||
jellyfinTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// QR state
|
||||
interface QrState {
|
||||
@@ -1039,19 +1251,22 @@ function getQrState(platform: string): QrState {
|
||||
}
|
||||
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const [nRes, qRes, bRes, kRes] = await Promise.all([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
]);
|
||||
Object.assign(neteaseAuth, nRes.data);
|
||||
Object.assign(qqAuth, qRes.data);
|
||||
Object.assign(bilibiliAuth, bRes.data);
|
||||
Object.assign(kugouAuth, kRes.data);
|
||||
} catch {
|
||||
// API not ready
|
||||
// allSettled: one slow/unconfigured platform must not hide the others'
|
||||
// status (jellyfin probes its server with a real HTTP round-trip).
|
||||
const [nRes, qRes, bRes, kRes, jRes] = await Promise.allSettled([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }),
|
||||
]);
|
||||
if (nRes.status === 'fulfilled') Object.assign(neteaseAuth, nRes.value.data);
|
||||
if (qRes.status === 'fulfilled') Object.assign(qqAuth, qRes.value.data);
|
||||
if (bRes.status === 'fulfilled') Object.assign(bilibiliAuth, bRes.value.data);
|
||||
if (kRes.status === 'fulfilled') Object.assign(kugouAuth, kRes.value.data);
|
||||
if (jRes.status === 'fulfilled') {
|
||||
jellyfinAuth.loggedIn = Boolean(jRes.value.data?.loggedIn);
|
||||
jellyfinAuth.nickname = jRes.value.data?.nickname ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,6 +1468,10 @@ async function loadIdleTimeout() {
|
||||
applyGuestModeFromServer(res.data.guestMode);
|
||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||
applySpotifyConfig(res.data.spotify);
|
||||
applyJellyfinConfig(res.data.jellyfin);
|
||||
if (Array.isArray(res.data.enabledProviders)) {
|
||||
enabledProviders.value = res.data.enabledProviders;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1989,6 +2208,10 @@ onUnmounted(() => {
|
||||
&.kugou-icon {
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
|
||||
&.jellyfin-icon {
|
||||
color: var(--brand-jellyfin);
|
||||
}
|
||||
}
|
||||
|
||||
.account-name {
|
||||
|
||||
+95
-5
@@ -60,12 +60,49 @@
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 2" class="step-content">
|
||||
<h2>登录音乐账号 (可选)</h2>
|
||||
<p class="subtitle">登录后可播放 VIP/付费歌曲,跳过则只能播放免费歌曲</p>
|
||||
<h2>连接 Jellyfin (可选)</h2>
|
||||
<p class="subtitle">连接自建 Jellyfin 服务器作为主音源;也可稍后在「设置」中配置</p>
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>认证方式</label>
|
||||
<select v-model="jellyfin.authMode" class="input">
|
||||
<option value="userpass">账号密码</option>
|
||||
<option value="apikey">API Key</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="jellyfin.authMode === 'userpass'">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="jellyfin.username" placeholder="Jellyfin 用户名" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="jellyfin.password" type="password" placeholder="Jellyfin 密码" class="input" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<input v-model="jellyfin.apiKey" type="password" placeholder="Jellyfin API Key" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户 ID</label>
|
||||
<input v-model="jellyfin.userId" placeholder="该 Key 使用的用户 ID" class="input" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="jellyfinTestMessage" class="test-message" :class="{ ok: jellyfinTestOk }">{{ jellyfinTestMessage }}</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" @click="currentStep = 1">上一步</button>
|
||||
<button class="btn-secondary" @click="currentStep = 3">跳过</button>
|
||||
<button class="btn-primary" @click="currentStep = 3">完成登录</button>
|
||||
<button class="btn-secondary" :disabled="jellyfinTesting" @click="testJellyfin">
|
||||
{{ jellyfinTesting ? '测试中…' : '测试连接' }}
|
||||
</button>
|
||||
<button class="btn-primary" :disabled="jellyfinSaving" @click="saveJellyfinAndNext">
|
||||
{{ jellyfinSaving ? '保存中…' : '保存并继续' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,11 +115,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, reactive } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const currentStep = ref(0);
|
||||
const stepLabels = ['欢迎', 'TS 服务器', '音乐账号', '完成'];
|
||||
const stepLabels = ['欢迎', 'TS 服务器', 'Jellyfin', '完成'];
|
||||
|
||||
const adminPassword = ref('');
|
||||
const locale = ref('zh');
|
||||
@@ -93,6 +130,21 @@ const nickname = ref('MusicBot');
|
||||
const defaultChannel = ref('');
|
||||
const channelId = ref('');
|
||||
|
||||
// Jellyfin — the primary music source. Optional: skipping leaves it
|
||||
// configurable later in Settings.
|
||||
const jellyfin = reactive({
|
||||
serverUrl: '',
|
||||
authMode: 'userpass' as 'userpass' | 'apikey',
|
||||
username: '',
|
||||
password: '',
|
||||
apiKey: '',
|
||||
userId: '',
|
||||
});
|
||||
const jellyfinTesting = ref(false);
|
||||
const jellyfinSaving = ref(false);
|
||||
const jellyfinTestOk = ref(false);
|
||||
const jellyfinTestMessage = ref('');
|
||||
|
||||
async function createBotAndNext() {
|
||||
try {
|
||||
await axios.post('/api/bot', {
|
||||
@@ -109,6 +161,36 @@ async function createBotAndNext() {
|
||||
alert('Failed to create bot: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testJellyfin() {
|
||||
jellyfinTesting.value = true;
|
||||
jellyfinTestMessage.value = '';
|
||||
try {
|
||||
const res = await axios.post('/api/auth/jellyfin/test', { ...jellyfin });
|
||||
jellyfinTestOk.value = Boolean(res.data?.ok);
|
||||
jellyfinTestMessage.value = res.data?.ok
|
||||
? `连接成功:${res.data.serverName ?? 'Jellyfin'} ${res.data.version ?? ''}`
|
||||
: `连接失败:${res.data?.error ?? '未知错误'}`;
|
||||
} catch {
|
||||
jellyfinTestOk.value = false;
|
||||
jellyfinTestMessage.value = '测试请求失败,请检查地址后重试';
|
||||
} finally {
|
||||
jellyfinTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJellyfinAndNext() {
|
||||
jellyfinSaving.value = true;
|
||||
try {
|
||||
await axios.post('/api/bot/settings', { jellyfin: { ...jellyfin } });
|
||||
currentStep.value = 3;
|
||||
} catch {
|
||||
jellyfinTestOk.value = false;
|
||||
jellyfinTestMessage.value = '保存失败,请稍后在「设置」中重试';
|
||||
} finally {
|
||||
jellyfinSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -232,6 +314,14 @@ async function createBotAndNext() {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 13px;
|
||||
color: #e67e22;
|
||||
margin-top: -6px;
|
||||
|
||||
&.ok { color: var(--color-online, #22c55e); }
|
||||
}
|
||||
|
||||
.done-step {
|
||||
text-align: center;
|
||||
padding-top: 60px;
|
||||
|
||||
Reference in new issue
Block a user