Skip to content

Commit 75348d2

Browse files
committed
🐞 fix: 修复 Jellyfin 列表及封面获取失败
1 parent 19eef84 commit 75348d2

6 files changed

Lines changed: 145 additions & 50 deletions

File tree

src/api/streaming/jellyfin.ts

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,22 @@ import type {
1717
* 获取请求头
1818
*/
1919
const getHeaders = (config: StreamingServerConfig): HeadersInit => {
20-
const headers: HeadersInit = {
21-
"Content-Type": "application/json",
22-
"X-Emby-Client": "SPlayer",
23-
"X-Emby-Client-Version": "1.0.0",
24-
"X-Emby-Device-Name": "SPlayer Web",
25-
"X-Emby-Device-Id": "splayer-web-client",
26-
};
20+
// Jellyfin 需要 X-Emby-Authorization 头
21+
const authParts = [
22+
`MediaBrowser Client="SPlayer"`,
23+
`Version="1.0.0"`,
24+
`Device="SPlayer Web"`,
25+
`DeviceId="splayer-web-client"`,
26+
];
2727

2828
if (config.accessToken) {
29-
headers["X-Emby-Token"] = config.accessToken;
29+
authParts.push(`Token="${config.accessToken}"`);
3030
}
3131

32-
return headers;
32+
return {
33+
"Content-Type": "application/json",
34+
"X-Emby-Authorization": authParts.join(", "),
35+
};
3336
};
3437

3538
/**
@@ -76,11 +79,18 @@ export const getImageUrl = (
7679
config: StreamingServerConfig,
7780
itemId: string,
7881
imageType: "Primary" | "Backdrop" | "Banner" = "Primary",
79-
maxWidth: number = 300,
82+
maxHeight?: number,
83+
tag?: string,
8084
): string => {
8185
if (!itemId) return "";
8286
const baseUrl = config.url.endsWith("/") ? config.url.slice(0, -1) : config.url;
83-
return `${baseUrl}/Items/${itemId}/Images/${imageType}?maxWidth=${maxWidth}&quality=90`;
87+
const params = new URLSearchParams({
88+
quality: "100",
89+
});
90+
if (maxHeight) params.append("maxHeight", maxHeight.toString());
91+
if (tag) params.append("tag", tag);
92+
if (config.accessToken) params.append("api_key", config.accessToken);
93+
return `${baseUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
8494
};
8595

8696
/**
@@ -89,8 +99,18 @@ export const getImageUrl = (
8999
export const getAudioStreamUrl = (config: StreamingServerConfig, itemId: string): string => {
90100
const baseUrl = config.url.endsWith("/") ? config.url.slice(0, -1) : config.url;
91101
const params = new URLSearchParams({
92-
static: "true",
102+
UserId: config.userId || "",
103+
DeviceId: "splayer-web-client",
104+
MaxStreamingBitrate: "140000000", // High bitrate to prefer direct play/high quality
105+
Container: "opus,webm|opus,ts|mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
106+
TranscodingContainer: "ts",
107+
TranscodingProtocol: "hls",
108+
AudioCodec: "aac",
109+
PlaySessionId: Date.now().toString(),
93110
api_key: config.accessToken || "",
111+
StartTimeTicks: "0",
112+
EnableRedirection: "true",
113+
EnableRemoteMedia: "true",
94114
});
95115
return `${baseUrl}/Audio/${itemId}/universal?${params.toString()}`;
96116
};
@@ -116,15 +136,22 @@ export const convertJellyfinSong = (
116136
config: StreamingServerConfig,
117137
): SongType => {
118138
const artists = item.Artists?.join(", ") || item.AlbumArtist || "未知艺术家";
119-
const albumImageId = item.AlbumId || item.Id;
139+
const imageId = item.Id;
140+
const imageTag = item.ImageTags?.Primary;
120141

121142
return {
122143
id: stringToNumericId(item.Id),
123144
originalId: item.Id,
124145
name: item.Name,
125146
artists,
126147
album: item.Album || "未知专辑",
127-
cover: getImageUrl(config, albumImageId),
148+
cover: getImageUrl(config, imageId, "Primary", 300, imageTag),
149+
coverSize: {
150+
s: getImageUrl(config, imageId, "Primary", 100, imageTag),
151+
m: getImageUrl(config, imageId, "Primary", 300, imageTag),
152+
l: getImageUrl(config, imageId, "Primary", 1024, imageTag),
153+
xl: getImageUrl(config, imageId, "Primary", undefined, imageTag),
154+
},
128155
duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000) : 0, // 转换为毫秒
129156
size: 0,
130157
free: 0,
@@ -145,13 +172,20 @@ export const convertJellyfinAlbum = (
145172
config: StreamingServerConfig,
146173
): StreamingAlbumType => {
147174
const artistId = item.AlbumArtists?.[0]?.Id || item.ArtistItems?.[0]?.Id;
175+
const imageTag = item.ImageTags?.Primary;
148176

149177
return {
150178
id: item.Id,
151179
name: item.Name,
152180
artist: item.AlbumArtist || item.AlbumArtists?.[0]?.Name,
153181
artistId,
154-
cover: getImageUrl(config, item.Id),
182+
cover: getImageUrl(config, item.Id, "Primary", undefined, imageTag),
183+
coverSize: {
184+
s: getImageUrl(config, item.Id, "Primary", 100, imageTag),
185+
m: getImageUrl(config, item.Id, "Primary", 300, imageTag),
186+
l: getImageUrl(config, item.Id, "Primary", 1024, imageTag),
187+
xl: getImageUrl(config, item.Id, "Primary", undefined, imageTag),
188+
},
155189
songCount: item.SongCount || item.ChildCount,
156190
year: item.ProductionYear,
157191
serverId: config.id,
@@ -166,10 +200,17 @@ export const convertJellyfinArtist = (
166200
item: JellyfinItem,
167201
config: StreamingServerConfig,
168202
): StreamingArtistType => {
203+
const imageTag = item.ImageTags?.Primary;
169204
return {
170205
id: item.Id,
171206
name: item.Name,
172-
cover: getImageUrl(config, item.Id),
207+
cover: getImageUrl(config, item.Id, "Primary", undefined, imageTag),
208+
coverSize: {
209+
s: getImageUrl(config, item.Id, "Primary", 100, imageTag),
210+
m: getImageUrl(config, item.Id, "Primary", 300, imageTag),
211+
l: getImageUrl(config, item.Id, "Primary", 1024, imageTag),
212+
xl: getImageUrl(config, item.Id, "Primary", undefined, imageTag),
213+
},
173214
albumCount: item.ChildCount,
174215
serverId: config.id,
175216
serverType: config.type,
@@ -183,11 +224,18 @@ export const convertJellyfinPlaylist = (
183224
item: JellyfinItem,
184225
config: StreamingServerConfig,
185226
): StreamingPlaylistType => {
227+
const imageTag = item.ImageTags?.Primary;
186228
return {
187229
id: item.Id,
188230
name: item.Name,
189231
description: item.Overview,
190-
cover: getImageUrl(config, item.Id),
232+
cover: getImageUrl(config, item.Id, "Primary", undefined, imageTag),
233+
coverSize: {
234+
s: getImageUrl(config, item.Id, "Primary", 100, imageTag),
235+
m: getImageUrl(config, item.Id, "Primary", 300, imageTag),
236+
l: getImageUrl(config, item.Id, "Primary", 1024, imageTag),
237+
xl: getImageUrl(config, item.Id, "Primary", undefined, imageTag),
238+
},
191239
songCount: item.ChildCount,
192240
serverId: config.id,
193241
serverType: config.type,
@@ -433,6 +481,37 @@ export const getSongs = async (
433481
return result.Items.map((item) => convertJellyfinSong(item, config));
434482
};
435483

484+
/**
485+
* 获取歌词
486+
*/
487+
export const getLyrics = async (config: StreamingServerConfig, itemId: string): Promise<string> => {
488+
if (!itemId) return "";
489+
try {
490+
const result = await request<{ Lyrics: { Text: string; Start: number }[] }>(
491+
config,
492+
`Audio/${itemId}/Lyrics`,
493+
);
494+
if (result && Array.isArray(result.Lyrics)) {
495+
return result.Lyrics.map((l) => {
496+
const totalSeconds = l.Start / 10000000;
497+
const minutes = Math.floor(totalSeconds / 60);
498+
const seconds = Math.floor(totalSeconds % 60);
499+
const milliseconds = Math.floor((totalSeconds % 1) * 100);
500+
501+
const mm = minutes.toString().padStart(2, "0");
502+
const ss = seconds.toString().padStart(2, "0");
503+
const xx = milliseconds.toString().padStart(2, "0");
504+
505+
return `[${mm}:${ss}.${xx}]${l.Text}`;
506+
}).join("\n");
507+
}
508+
return "";
509+
} catch (error) {
510+
console.warn("Failed to fetch lyrics from Jellyfin:", error);
511+
return "";
512+
}
513+
};
514+
436515
export default {
437516
authenticate,
438517
ping,
@@ -446,4 +525,5 @@ export default {
446525
search,
447526
getImageUrl,
448527
getAudioStreamUrl,
528+
getLyrics,
449529
};

src/components/Setting/StreamingSetting.vue

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
<div class="label">
3838
<n-flex align="center" :size="8">
3939
<n-text class="name">{{ server.name }}</n-text>
40-
<n-tag size="small" :type="getServerTagType(server.type)" round>
40+
<n-tag size="small" type="primary" round>
4141
{{ getServerTypeLabel(server.type) }}
4242
</n-tag>
4343
<n-tag
@@ -119,16 +119,6 @@ const getServerTypeLabel = (type: StreamingServerType): string => {
119119
return labels[type] || type;
120120
};
121121
122-
// 获取服务器类型标签颜色
123-
const getServerTagType = (type: StreamingServerType): "default" | "info" | "success" => {
124-
const types: Record<StreamingServerType, "default" | "info" | "success"> = {
125-
navidrome: "info",
126-
jellyfin: "success",
127-
opensubsonic: "default",
128-
};
129-
return types[type] || "default";
130-
};
131-
132122
// 添加服务器
133123
const handleAdd = () => {
134124
openStreamingServerConfig(null, async (config) => {

src/core/player/SongManager.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { personalFm, personalFmToTrash } from "@/api/rec";
22
import { songUrl, unlockSongUrl } from "@/api/song";
3-
import { useDataStore, useMusicStore, useSettingStore, useStatusStore } from "@/stores";
3+
import {
4+
useDataStore,
5+
useMusicStore,
6+
useSettingStore,
7+
useStatusStore,
8+
useStreamingStore,
9+
} from "@/stores";
410
import { QualityType, type SongType } from "@/types/main";
511
import { isLogin } from "@/utils/auth";
612
import { isElectron } from "@/utils/env";
@@ -307,9 +313,12 @@ class SongManager {
307313

308314
// Stream songs (Subsonic / Jellyfin)
309315
if (song.type === "streaming" && song.streamUrl) {
316+
const streamingStore = useStreamingStore();
317+
const finalUrl = streamingStore.getSongUrl(song);
318+
console.log(`🔄 [${song.id}] Stream URL:`, finalUrl);
310319
return {
311320
id: song.id,
312-
url: song.streamUrl,
321+
url: finalUrl,
313322
isUnlocked: false,
314323
quality: song.quality || QualityType.SQ,
315324
};

src/stores/streaming.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,8 @@ const createStreamingStore = () => {
7171
}
7272

7373
// 自动连接
74-
if (servers.value.length > 0) {
75-
const targetId = activeServerId.value || servers.value[0].id;
76-
connectToServer(targetId);
74+
if (servers.value.length > 0 && activeServerId.value) {
75+
connectToServer(activeServerId.value);
7776
}
7877
} catch (error) {
7978
console.error("Failed to load streaming servers:", error);
@@ -467,8 +466,8 @@ const createStreamingStore = () => {
467466
if (!server || !isConnected.value) return "";
468467

469468
try {
470-
if (server.type === "jellyfin") {
471-
return "";
469+
if (server.type === "jellyfin" && song.originalId) {
470+
return await jellyfin.getLyrics(server, song.originalId);
472471
} else {
473472
// 优先使用 ID 获取
474473
if (song.originalId) {
@@ -483,6 +482,22 @@ const createStreamingStore = () => {
483482
}
484483
};
485484

485+
/**
486+
* 获取流媒体歌曲播放地址
487+
*/
488+
const getSongUrl = (song: SongType): string => {
489+
if (song.type !== "streaming" || !song.serverId) return song.streamUrl || "";
490+
491+
const server = servers.value.find((s) => s.id === song.serverId);
492+
if (!server) return song.streamUrl || "";
493+
494+
if (server.type === "jellyfin" && server.accessToken && song.originalId) {
495+
return jellyfin.getAudioStreamUrl(server, song.originalId);
496+
}
497+
498+
return song.streamUrl || "";
499+
};
500+
486501
// 初始化:加载保存的配置
487502
loadServers();
488503

@@ -519,6 +534,7 @@ const createStreamingStore = () => {
519534
fetchPlaylistSongs,
520535
search,
521536
fetchLyrics,
537+
getSongUrl,
522538
};
523539
};
524540

src/types/streaming.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export interface JellyfinItem {
162162
Overview?: string;
163163
ChildCount?: number;
164164
SongCount?: number;
165+
AlbumPrimaryImageTag?: string;
165166
}
166167

167168
/**

src/views/Streaming/layout.vue

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
<n-number-animation :from="0" :to="songsCount" /> 首歌曲
1010
</n-text>
1111
<n-text class="item server-info">
12-
<SvgIcon name="Cloud" :depth="3" />
13-
{{ serverName }}
12+
<SvgIcon name="Stream" :depth="3" />
13+
{{ serverType }}
1414
</n-text>
1515
</n-flex>
1616
</div>
@@ -153,8 +153,17 @@ const isConnected = computed<boolean>(() => streamingStore.isConnected.value);
153153
// 歌曲数量(用于模板)
154154
const songsCount = computed<number>(() => streamingStore.songs.value?.length || 0);
155155
156-
// 服务器名称(用于模板)
157-
const serverName = computed<string>(() => streamingStore.connectionStatus.value?.serverName || "");
156+
// 服务器类型
157+
const serverType = computed<string>(() => {
158+
const type = streamingStore.activeServer.value?.type;
159+
if (!type) return "";
160+
const typeMap: Record<string, string> = {
161+
jellyfin: "Jellyfin",
162+
navidrome: "Navidrome",
163+
opensubsonic: "Subsonic",
164+
};
165+
return typeMap[type] || type;
166+
});
158167
159168
// Tab 状态
160169
const tabsDisabled = computed<boolean>(() => !streamingStore.isConnected.value);
@@ -362,17 +371,7 @@ watch(
362371
363372
// 初始化
364373
onMounted(async () => {
365-
// 尝试连接到已保存的服务器
366-
if (streamingStore.servers.value.length > 0 && !streamingStore.isConnected.value) {
367-
const lastServer =
368-
streamingStore.servers.value.find((s) => s.lastConnected) || streamingStore.servers.value[0];
369-
const success = await streamingStore.connectToServer(lastServer.id);
370-
if (success) {
371-
await loadData();
372-
} else if (streamingStore.connectionStatus.value.error) {
373-
window.$message.error("连接失败:" + streamingStore.connectionStatus.value.error);
374-
}
375-
} else if (streamingStore.isConnected.value) {
374+
if (streamingStore.isConnected.value) {
376375
await loadData();
377376
}
378377
});

0 commit comments

Comments
 (0)