mioku-plugin-music 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mioku Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # mioku-plugin-music
2
+
3
+ ## 功能
4
+
5
+ - 点歌搜索:`点歌 晴天`
6
+ - 搜索结果截图列表
7
+ - 编号听歌:`听1`
8
+ - 关键词直听:`听晴天`
9
+ - AI skills:搜索、发送歌曲
10
+
11
+ ## 已适配 Provider
12
+
13
+ - `applemusic`
14
+
15
+ ## Provider 对接要求
16
+
17
+ `music` 插件通过“服务 API + provider 适配器”接入新音乐源。下面是完整接口约束和实现模板。
18
+
19
+ ### 1. 服务 API(`ctx.services.<provider>`)标准
20
+
21
+ 服务侧应暴露 `createClient(options)`,返回一个 client。推荐实现如下:
22
+
23
+ ```ts
24
+ export interface ProviderClientOptions {
25
+ token?: string;
26
+ timeoutMs?: number;
27
+ // 其他需要的内容
28
+ }
29
+
30
+ export interface ProviderSearchSongItem {
31
+ id: string;
32
+ name: string;
33
+ artistName: string;
34
+ albumName: string;
35
+ artworkUrl?: string;
36
+ previewUrl?: string;
37
+ durationInMillis?: number;
38
+ }
39
+
40
+ export interface ProviderSearchResult {
41
+ query: string;
42
+ songs: ProviderSearchSongItem[];
43
+ }
44
+
45
+ export interface ProviderSongDetail {
46
+ id: string;
47
+ name: string;
48
+ artistName: string;
49
+ albumName: string;
50
+ artworkUrl?: string;
51
+ releaseDate?: string;
52
+ durationInMillis?: number;
53
+ previewUrl?: string;
54
+ audioTraits?: string[];
55
+ }
56
+
57
+ export interface ProviderAlbumTrack {
58
+ id: string;
59
+ name: string;
60
+ artistName: string;
61
+ durationInMillis?: number;
62
+ }
63
+
64
+ export interface ProviderAlbumDetail {
65
+ id: string;
66
+ name: string;
67
+ artistName: string;
68
+ artworkUrl?: string;
69
+ releaseDate?: string;
70
+ tracks: ProviderAlbumTrack[];
71
+ }
72
+
73
+ export interface ProviderSongDownloadResult {
74
+ filePath: string; // 本地可读路径,供 record 发送
75
+ sourceType: "hls" | "preview";
76
+ }
77
+
78
+ export interface ProviderCoverDownloadResult {
79
+ filePath: string; // 本地可读路径
80
+ }
81
+
82
+ export interface ProviderClient {
83
+ searchSongs(options: {
84
+ query: string;
85
+ limit?: number;
86
+ offset?: number;
87
+ storefront?: string;
88
+ language?: string;
89
+ }): Promise<ProviderSearchResult>;
90
+
91
+ getSongDetail(options: {
92
+ songId: string;
93
+ storefront?: string;
94
+ language?: string;
95
+ }): Promise<ProviderSongDetail>;
96
+
97
+ getAlbumDetail(options: {
98
+ albumId: string;
99
+ storefront?: string;
100
+ language?: string;
101
+ }): Promise<ProviderAlbumDetail>;
102
+
103
+ downloadSongAac(options: {
104
+ songId: string;
105
+ outputDir?: string;
106
+ fileName?: string;
107
+ storefront?: string;
108
+ language?: string;
109
+ }): Promise<ProviderSongDownloadResult>;
110
+
111
+ downloadCover(options: {
112
+ artworkUrl: string;
113
+ outputDir?: string;
114
+ fileName?: string;
115
+ size?: string;
116
+ }): Promise<ProviderCoverDownloadResult>;
117
+ }
118
+
119
+ export interface ProviderServiceApi {
120
+ createClient(options?: ProviderClientOptions): ProviderClient;
121
+ }
122
+ ```
123
+
124
+ ### 2. 插件侧 Provider 适配器标准(`MusicProvider`)
125
+
126
+ 适配器负责把服务返回值映射成 music 插件统一结构,需满足:
127
+
128
+ ```ts
129
+ export interface MusicProvider {
130
+ readonly name: MusicProviderName;
131
+ searchSongs(query: string, limit?: number): Promise<MusicSearchResult>;
132
+ getSongDetail(songId: string): Promise<MusicSongDetail>;
133
+ getAlbumDetail(albumId: string): Promise<MusicAlbumDetail>;
134
+ downloadSong(songId: string): Promise<DownloadSongResult>;
135
+ downloadCover(coverUrl: string, fileName?: string): Promise<string>;
136
+ }
137
+ ```
138
+
139
+ 实现模板:
140
+
141
+ ```ts
142
+ export class XxxMusicProvider implements MusicProvider {
143
+ readonly name: MusicProviderName = "xxxmusic";
144
+ private readonly client: ProviderClient;
145
+
146
+ constructor(api: ProviderServiceApi, options?: MusicProviderClientOptions) {
147
+ this.client = api.createClient({
148
+ mediaUserToken: options?.mediaUserToken,
149
+ storefront: options?.storefront,
150
+ language: options?.language,
151
+ });
152
+ }
153
+
154
+ async searchSongs(query: string, limit = 15): Promise<MusicSearchResult> {
155
+ const result = await this.client.searchSongs({ query, limit });
156
+ return {
157
+ query,
158
+ provider: this.name,
159
+ tracks: result.songs.map((item) => ({
160
+ id: item.id,
161
+ provider: this.name,
162
+ title: item.name,
163
+ artist: item.artistName,
164
+ album: item.albumName,
165
+ coverUrl: item.artworkUrl,
166
+ durationMs: item.durationInMillis,
167
+ previewUrl: item.previewUrl,
168
+ })),
169
+ };
170
+ }
171
+
172
+ async getSongDetail(songId: string): Promise<MusicSongDetail> {
173
+ const detail = await this.client.getSongDetail({ songId });
174
+ return {
175
+ id: detail.id,
176
+ provider: this.name,
177
+ title: detail.name,
178
+ artist: detail.artistName,
179
+ album: detail.albumName,
180
+ coverUrl: detail.artworkUrl,
181
+ releaseDate: detail.releaseDate,
182
+ durationMs: detail.durationInMillis,
183
+ previewUrl: detail.previewUrl,
184
+ audioTraits: detail.audioTraits,
185
+ };
186
+ }
187
+
188
+ async getAlbumDetail(albumId: string): Promise<MusicAlbumDetail> {
189
+ const detail = await this.client.getAlbumDetail({ albumId });
190
+ return {
191
+ id: detail.id,
192
+ provider: this.name,
193
+ title: detail.name,
194
+ artist: detail.artistName,
195
+ coverUrl: detail.artworkUrl,
196
+ releaseDate: detail.releaseDate,
197
+ tracks: detail.tracks.map((track) => ({
198
+ id: track.id,
199
+ title: track.name,
200
+ artist: track.artistName,
201
+ durationMs: track.durationInMillis,
202
+ })),
203
+ };
204
+ }
205
+
206
+ async downloadSong(songId: string): Promise<DownloadSongResult> {
207
+ const result = await this.client.downloadSongAac({ songId });
208
+ return {
209
+ filePath: result.filePath,
210
+ sourceType: result.sourceType,
211
+ };
212
+ }
213
+
214
+ async downloadCover(coverUrl: string, fileName?: string): Promise<string> {
215
+ const result = await this.client.downloadCover({
216
+ artworkUrl: coverUrl,
217
+ fileName: fileName || "cover",
218
+ size: "1200x1200",
219
+ });
220
+ return result.filePath;
221
+ }
222
+ }
223
+ ```
224
+
225
+ ### 3. 错误与返回约束
226
+
227
+ - 搜索无结果时返回空数组,不要抛错。
228
+ - 参数非法、鉴权失败、网络失败、资源不存在时抛 `Error`,由上层统一提示。
229
+ - `downloadSong` 必须返回可读的本地音频路径;`sourceType` 只能是 `"hls"` 或 `"preview"`。
230
+ - 统一结构里的 `provider` 字段必须始终等于当前 provider 名称,不能留空或混用。
231
+ - 返回值中的可选字段允许缺省,但字段类型必须稳定(例如 `durationMs` 始终是 number 或 undefined)。
232
+
233
+ Provider 编写完成后可在本仓库提出issue请求适配,我们也欢迎积极的PR :)
package/config.md ADDED
@@ -0,0 +1,49 @@
1
+ ---
2
+ title: Music 插件配置
3
+ description: 在这里调整 music 插件的配置项目
4
+ fields:
5
+ - key: base.searchLimit
6
+ label: 默认搜索条数
7
+ type: number
8
+ description: 点歌时默认拉取的歌曲数量,建议 1-15
9
+ placeholder: 15
10
+
11
+ - key: base.defaultProvider
12
+ label: 默认音乐源
13
+ type: select
14
+ description: 需要安装对应的服务
15
+ options:
16
+ - value: applemusic
17
+ label: Apple Music
18
+
19
+ - key: base.applemusic.storefront
20
+ label: Apple Music Storefront
21
+ type: text
22
+ description: 默认地区代码,例如 cn、us、jp。
23
+ placeholder: cn
24
+
25
+ - key: base.applemusic.language
26
+ label: Apple Music 语言
27
+ type: text
28
+ description: 默认语言代码,例如 zh-CN、en-US。
29
+ placeholder: zh-CN
30
+
31
+ - key: base.applemusic.defaultMediaUserToken
32
+ label: 默认 Media User Token
33
+ type: secret
34
+ description: Apple Music 下载高质量 AAC 必需 token,无token仅能下载30s音频
35
+ placeholder: eyJ...
36
+ ---
37
+
38
+ ```mioku-fields
39
+ keys:
40
+ - base.searchLimit
41
+ - base.defaultProvider
42
+ ```
43
+
44
+ ```mioku-fields
45
+ keys:
46
+ - base.applemusic.storefront
47
+ - base.applemusic.language
48
+ - base.applemusic.defaultMediaUserToken
49
+ ```
package/config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { MusicBaseConfig } from "./types";
2
+
3
+ export const MUSIC_DEFAULTS: MusicBaseConfig = {
4
+ searchLimit: 15,
5
+ defaultProvider: "applemusic",
6
+ applemusic: {
7
+ storefront: "cn",
8
+ language: "zh-CN",
9
+ defaultMediaUserToken: "",
10
+ },
11
+ };
package/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { definePlugin } from "mioki";
2
+ import type { AIService } from "../../src/services/ai/types";
3
+ import type { ScreenshotService } from "../../src/services/screenshot/types";
4
+ import type { ConfigService } from "../../src/services/config/tpyes";
5
+ import type { AppleMusicServiceApi } from "../../src/services/applemusic/types";
6
+ import { resetMusicRuntimeState, setMusicRuntimeState } from "./runtime";
7
+ import { MusicPluginRuntime } from "./runtime-core/service";
8
+ import { MUSIC_DEFAULTS } from "./config";
9
+ import type { MusicBaseConfig } from "./types";
10
+
11
+ function cloneConfig<T>(value: T): T {
12
+ return JSON.parse(JSON.stringify(value)) as T;
13
+ }
14
+
15
+ export default definePlugin({
16
+ name: "music",
17
+ version: "1.0.0",
18
+ description: "点歌与听歌插件",
19
+ async setup(ctx) {
20
+ const configService = ctx.services?.config as ConfigService | undefined;
21
+ const aiService = ctx.services?.ai as AIService | undefined;
22
+ const screenshotService = ctx.services?.screenshot as
23
+ | ScreenshotService
24
+ | undefined;
25
+ const applemusicService = ctx.services?.applemusic as
26
+ | AppleMusicServiceApi
27
+ | undefined;
28
+ let baseConfig = cloneConfig(MUSIC_DEFAULTS);
29
+
30
+ if (configService) {
31
+ await configService.registerConfig("music", "base", baseConfig);
32
+ const nextBase = await configService.getConfig("music", "base");
33
+ if (nextBase) {
34
+ baseConfig = nextBase as MusicBaseConfig;
35
+ }
36
+ } else {
37
+ ctx.logger.warn("config-service 未加载,music 插件将使用默认配置");
38
+ }
39
+
40
+ const runtime = new MusicPluginRuntime({
41
+ logger: ctx.logger,
42
+ aiService,
43
+ screenshotService,
44
+ applemusicService,
45
+ });
46
+ runtime.updateConfig(baseConfig);
47
+
48
+ setMusicRuntimeState({ runtime });
49
+
50
+ const disposers: Array<() => void> = [];
51
+ if (configService) {
52
+ disposers.push(
53
+ configService.onConfigChange("music", "base", (next) => {
54
+ baseConfig = next as MusicBaseConfig;
55
+ runtime.updateConfig(baseConfig);
56
+ }),
57
+ );
58
+ }
59
+
60
+ ctx.handle("message", async (event: any) => {
61
+ await runtime.handleMessage(ctx, event);
62
+ });
63
+
64
+ return () => {
65
+ for (const dispose of disposers) {
66
+ dispose();
67
+ }
68
+ resetMusicRuntimeState();
69
+ };
70
+ },
71
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "mioku-plugin-music",
3
+ "version": "1.1.0",
4
+ "description": "音乐插件:点歌、搜索音乐、听歌语音发送",
5
+ "main": "index.ts",
6
+ "keywords": [
7
+ "mioku"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/mioku-lab/mioku-plugin-music.git"
12
+ },
13
+ "mioku": {
14
+ "services": [
15
+ "config",
16
+ "ai",
17
+ "screenshot"
18
+ ],
19
+ "help": {
20
+ "title": "音乐",
21
+ "description": "点歌并发送语音,支持搜索结果图片列表",
22
+ "commands": [
23
+ {
24
+ "cmd": "点歌 <歌曲名>",
25
+ "desc": "搜索歌曲/歌手/专辑,返回最多 15 条结果图片列表",
26
+ "usage": "/点歌 晴天",
27
+ "role": "member"
28
+ },
29
+ {
30
+ "cmd": "听 <编号>",
31
+ "desc": "发送上次搜索列表中的指定歌曲语音",
32
+ "usage": "听1",
33
+ "role": "member"
34
+ },
35
+ {
36
+ "cmd": "听 <关键词>",
37
+ "desc": "直接按关键词搜索并发送第一首歌曲语音",
38
+ "usage": "听晴天",
39
+ "role": "member"
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,101 @@
1
+ import type { AppleMusicServiceApi } from "../../../src/services/applemusic/types";
2
+ import type {
3
+ DownloadSongResult,
4
+ MusicAlbumDetail,
5
+ MusicProvider,
6
+ MusicProviderClientOptions,
7
+ MusicProviderName,
8
+ MusicSearchResult,
9
+ MusicSongDetail,
10
+ MusicTrack,
11
+ } from "../types";
12
+
13
+ function mapTrack(item: any): MusicTrack {
14
+ return {
15
+ id: item.id,
16
+ provider: "applemusic",
17
+ title: item.name,
18
+ artist: item.artistName,
19
+ album: item.albumName,
20
+ coverUrl: item.artworkUrl,
21
+ durationMs: item.durationInMillis,
22
+ previewUrl: item.previewUrl,
23
+ };
24
+ }
25
+
26
+ export class AppleMusicProvider implements MusicProvider {
27
+ readonly name: MusicProviderName = "applemusic";
28
+ private readonly client: ReturnType<AppleMusicServiceApi["createClient"]>;
29
+
30
+ constructor(api: AppleMusicServiceApi, options?: MusicProviderClientOptions) {
31
+ this.client = api.createClient({
32
+ mediaUserToken: options?.mediaUserToken,
33
+ storefront: options?.storefront || "cn",
34
+ language: options?.language || "zh-CN",
35
+ allowPreviewFallback: false,
36
+ });
37
+ }
38
+
39
+ async searchSongs(query: string, limit: number = 15): Promise<MusicSearchResult> {
40
+ const result = await this.client.searchSongs({
41
+ query,
42
+ limit,
43
+ });
44
+ return {
45
+ query,
46
+ provider: "applemusic",
47
+ tracks: result.songs.map(mapTrack),
48
+ };
49
+ }
50
+
51
+ async getSongDetail(songId: string): Promise<MusicSongDetail> {
52
+ const detail = await this.client.getSongDetail({ songId });
53
+ return {
54
+ id: detail.id,
55
+ provider: "applemusic",
56
+ title: detail.name,
57
+ artist: detail.artistName,
58
+ album: detail.albumName,
59
+ coverUrl: detail.artworkUrl,
60
+ releaseDate: detail.releaseDate,
61
+ durationMs: detail.durationInMillis,
62
+ previewUrl: detail.previewUrl,
63
+ audioTraits: detail.audioTraits,
64
+ };
65
+ }
66
+
67
+ async getAlbumDetail(albumId: string): Promise<MusicAlbumDetail> {
68
+ const detail = await this.client.getAlbumDetail({ albumId });
69
+ return {
70
+ id: detail.id,
71
+ provider: "applemusic",
72
+ title: detail.name,
73
+ artist: detail.artistName,
74
+ coverUrl: detail.artworkUrl,
75
+ releaseDate: detail.releaseDate,
76
+ tracks: detail.tracks.map((track) => ({
77
+ id: track.id,
78
+ title: track.name,
79
+ artist: track.artistName,
80
+ durationMs: track.durationInMillis,
81
+ })),
82
+ };
83
+ }
84
+
85
+ async downloadSong(songId: string): Promise<DownloadSongResult> {
86
+ const result = await this.client.downloadSongAac({ songId });
87
+ return {
88
+ filePath: result.filePath,
89
+ sourceType: result.sourceType,
90
+ };
91
+ }
92
+
93
+ async downloadCover(coverUrl: string, fileName?: string): Promise<string> {
94
+ const result = await this.client.downloadCover({
95
+ artworkUrl: coverUrl,
96
+ fileName: fileName || "cover",
97
+ size: "1200x1200",
98
+ });
99
+ return result.filePath;
100
+ }
101
+ }
@@ -0,0 +1,81 @@
1
+ import type { AppleMusicServiceApi } from "../../../src/services/applemusic/types";
2
+ import { AppleMusicProvider } from "./applemusic-provider";
3
+ import {
4
+ type MusicProvider,
5
+ type MusicProviderClientOptions,
6
+ type MusicProviderName,
7
+ } from "../types";
8
+
9
+ export interface MusicProviderFactoryOptions {
10
+ applemusic?: AppleMusicServiceApi;
11
+ }
12
+
13
+ interface MusicProviderRegistryItem {
14
+ readonly name: MusicProviderName;
15
+ readonly serviceName: string;
16
+ isAvailable(services: MusicProviderFactoryOptions): boolean;
17
+ create(
18
+ services: MusicProviderFactoryOptions,
19
+ clientOptions?: MusicProviderClientOptions,
20
+ ): MusicProvider;
21
+ }
22
+
23
+ const MUSIC_PROVIDER_REGISTRY: MusicProviderRegistryItem[] = [
24
+ {
25
+ name: "applemusic",
26
+ serviceName: "applemusic",
27
+ isAvailable: (services) => Boolean(services.applemusic),
28
+ create: (services, clientOptions) => {
29
+ if (!services.applemusic) {
30
+ throw new Error("applemusic 服务未加载");
31
+ }
32
+ return new AppleMusicProvider(services.applemusic, clientOptions);
33
+ },
34
+ },
35
+ ];
36
+
37
+ function getProviderRegistryItem(
38
+ providerName: string,
39
+ ): MusicProviderRegistryItem | undefined {
40
+ const normalized = String(providerName || "").trim();
41
+ return MUSIC_PROVIDER_REGISTRY.find((item) => item.name === normalized);
42
+ }
43
+
44
+ export function resolveMusicProviderName(
45
+ preferredProviderName: unknown,
46
+ services: MusicProviderFactoryOptions,
47
+ ): MusicProviderName | null {
48
+ const preferred = String(preferredProviderName || "").trim();
49
+ if (preferred) {
50
+ const preferredItem = getProviderRegistryItem(preferred);
51
+ if (preferredItem?.isAvailable(services)) {
52
+ return preferredItem.name;
53
+ }
54
+ }
55
+
56
+ const fallbackItem = MUSIC_PROVIDER_REGISTRY.find((item) =>
57
+ item.isAvailable(services),
58
+ );
59
+ return fallbackItem?.name || null;
60
+ }
61
+
62
+ export function getMusicProviderCandidates(): MusicProviderName[] {
63
+ return MUSIC_PROVIDER_REGISTRY.map((item) => item.name);
64
+ }
65
+
66
+ export function createMusicProvider(
67
+ providerName: MusicProviderName,
68
+ services: MusicProviderFactoryOptions,
69
+ clientOptions?: MusicProviderClientOptions,
70
+ ): MusicProvider {
71
+ const item = getProviderRegistryItem(providerName);
72
+ if (!item) {
73
+ throw new Error(`不支持的音乐源: ${providerName}`);
74
+ }
75
+
76
+ if (!item.isAvailable(services)) {
77
+ throw new Error(`${item.serviceName} 服务未加载`);
78
+ }
79
+
80
+ return item.create(services, clientOptions);
81
+ }
@@ -0,0 +1,10 @@
1
+ import type { MusicProviderName } from "../types";
2
+
3
+ export const MUSIC_PROVIDER_LABELS: Record<MusicProviderName, string> = {
4
+ applemusic: "Apple Music",
5
+ };
6
+
7
+ export function getMusicProviderLabel(provider: MusicProviderName | string): string {
8
+ const key = String(provider || "").trim() as MusicProviderName;
9
+ return MUSIC_PROVIDER_LABELS[key] || key || "Unknown";
10
+ }
@@ -0,0 +1,73 @@
1
+ import type { MusicSearchResult } from "../types";
2
+ import { getMusicProviderLabel } from "../providers/provider-labels";
3
+
4
+ function escapeHtml(value: string): string {
5
+ const source = String(value || "");
6
+ return source
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;")
11
+ .replace(/'/g, "&#039;");
12
+ }
13
+
14
+ export function renderMusicSearchListHtml(search: MusicSearchResult): string {
15
+ const providerLabel = getMusicProviderLabel(search.provider);
16
+ const items = search.tracks
17
+ .map((track, index) => {
18
+ const coverUrl = track.coverUrl
19
+ ? escapeHtml(track.coverUrl.replace("{w}x{h}", "240x240"))
20
+ : "";
21
+ const cover = coverUrl
22
+ ? `<img src="${coverUrl}" alt="cover" class="h-14 w-14 rounded-2xl object-cover ring-1 ring-teal-200/70 dark:ring-teal-300/20" />`
23
+ : `<div class="h-14 w-14 rounded-2xl bg-teal-100/70 text-2xl text-teal-600 dark:bg-teal-400/10 dark:text-teal-300 flex items-center justify-center">♪</div>`;
24
+
25
+ return `
26
+ <div class="group relative overflow-hidden rounded-2xl border border-teal-200/60 bg-white/80 p-3 shadow-[0_10px_28px_rgba(6,49,57,0.12)] backdrop-blur-sm transition dark:border-teal-300/20 dark:bg-slate-900/70">
27
+ <div class="pointer-events-none absolute inset-0 opacity-0 transition group-hover:opacity-100 bg-gradient-to-r from-teal-300/10 via-transparent to-cyan-300/10"></div>
28
+ <div class="relative grid grid-cols-[40px_56px_minmax(0,1fr)] items-center gap-3">
29
+ <div class="h-9 w-9 rounded-full bg-teal-500/12 text-[16px] text-teal-700 dark:text-teal-200 dark:bg-teal-300/15 flex items-center justify-center font-extrabold">${index + 1}</div>
30
+ ${cover}
31
+ <div class="min-w-0">
32
+ <div class="truncate text-[18px] font-extrabold tracking-[0.01em] text-slate-900 dark:text-slate-100">${escapeHtml(track.title)}</div>
33
+ <div class="mt-1 truncate text-[15px] text-slate-600 dark:text-slate-300/90">
34
+ <span>${escapeHtml(track.artist)}</span>
35
+ <span class="mx-1.5 text-slate-400 dark:text-slate-500">·</span>
36
+ <span>${escapeHtml(track.album)}</span>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ `;
42
+ })
43
+ .join("");
44
+
45
+ return `
46
+ <style>
47
+ html, body {
48
+ margin: 0;
49
+ padding: 0;
50
+ width: auto;
51
+ height: auto;
52
+ min-width: 0;
53
+ min-height: 0;
54
+ overflow: visible;
55
+ }
56
+ body {
57
+ display: inline-block;
58
+ background: transparent;
59
+ }
60
+ </style>
61
+ <div class="w-[860px] p-7 font-['Noto_Sans_SC','PingFang_SC','Hiragino_Sans_GB',sans-serif] bg-[radial-gradient(circle_at_0%_0%,rgba(53,210,200,0.22),transparent_40%),radial-gradient(circle_at_92%_14%,rgba(14,165,160,0.2),transparent_38%)] bg-teal-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
62
+ <div class="mb-5 rounded-3xl border border-teal-200/70 bg-white/85 px-7 py-6 shadow-[0_18px_46px_rgba(6,49,57,0.16)] backdrop-blur-sm dark:border-teal-300/20 dark:bg-slate-900/75">
63
+ <div class="text-center text-[36px] leading-[1.08] font-black tracking-[0.01em]">音乐搜索结果</div>
64
+ <div class="mt-2 text-center text-[17px] text-slate-600 dark:text-slate-300">检索歌曲:${escapeHtml(search.query)} · 共 ${search.tracks.length} 条</div>
65
+ </div>
66
+ <div class="grid gap-3">${items}</div>
67
+ <div class="mt-5 flex items-center justify-between text-[16px] text-slate-600 dark:text-slate-300">
68
+ <div>音源:${escapeHtml(providerLabel)}</div>
69
+ <div>发送「听1」播放第一首歌曲</div>
70
+ </div>
71
+ </div>
72
+ `;
73
+ }