Jellyfin Integration. Assisted by Claude Fable 5.

This commit is contained in:
itsericrao committed 2026-07-06 23:07:20 +08:00
1 parent b9767a7471
commit f637ba0191
32 files changed
+2802 -171

No files matched your search

+144 -18
View File
@@ -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>