1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
//! [`Server`]:audio engine + scheduler + PlayerCore + PCM puller 的单进程收纳容器。
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::persistence::ServerStore;
use mineral_audio::{AudioHandle, AudioMode};
use mineral_channel_core::MusicChannel;
use mineral_playback::PlaybackRegistry;
use mineral_protocol::{Event, PlayMode};
use mineral_task::Scheduler;
use tokio::net::UnixListener;
use tokio::sync::{Notify, broadcast};
use crate::client::ClientHandle;
use crate::config::ServerConfig;
use crate::ipc::{ConnRegistry, SessionServices};
use crate::media_cache::MediaCache;
use crate::pcm_relay::PcmRelay;
use crate::player::PlayerCore;
use crate::publisher::DomainPublishers;
/// Catalog channels and playback providers assembled for one server process.
#[non_exhaustive]
#[derive(typed_builder::TypedBuilder)]
pub struct SourceBackends {
/// Catalog, library, and user-data connectors.
pub(crate) channels: Vec<Arc<dyn MusicChannel>>,
/// Playback resource providers keyed by source identity.
pub(crate) playback: PlaybackRegistry,
}
impl SourceBackends {
/// Registered catalog channels, used to seed script capabilities before server startup.
pub fn channels(&self) -> &[Arc<dyn MusicChannel>] {
&self.channels
}
}
/// 后台 server。`spawn` 启动 audio engine + scheduler + PlayerCore + PCM puller,
/// 投递初始任务,对外发 [`ClientHandle`]。
pub struct Server {
/// PlayerCore — 业务状态 + 自治 auto-next + events 中继。
player: PlayerCore,
/// 领域发布器 — 会话订阅的共享快照源。
publishers: DomainPublishers,
/// 在线连接注册表,与 accept loop 共享:心跳据此报在线 client 数。
connections: Arc<ConnRegistry>,
/// Event 推送 hub:daemon 内生产者经
/// [`Server::event_sink`] 拿发送端;会话按订阅集过滤下发。
events: broadcast::Sender<Event>,
/// daemon 级关停通知:client 经 IPC 发 `Request::Shutdown` 时唤醒,daemon
/// 入口经 [`Server::shutdown_requested`] 等待。
shutdown: Arc<Notify>,
}
impl Server {
/// 启动 audio engine + scheduler + PlayerCore + PCM puller,投递初始任务。
///
/// # Params:
/// - `sources`: Catalog channels and playback providers. Empty sets are valid.
/// - `audio_mode`: 音频后端选择(env / config resolve 后的最终值);无设备时 `Auto` 降级而非失败。
/// - `persist`: 持久化句柄,透传给 `PlayerCore::spawn`(收藏 / 歌曲元数据等经它落库)。
/// - `config`: daemon 配置切片(引擎参数 / 音质 / 缓存容量 / 各间隔)。
/// - `config_tree`: 已校验的 daemon 私有配置底树,供 session 覆盖与重载使用。
/// - `script`: 脚本线程投递句柄(daemon 启用脚本时传 `Some`,其余 `None`)。
pub async fn spawn(
sources: SourceBackends,
audio_mode: AudioMode,
persist: ServerStore,
config: ServerConfig,
config_tree: serde_json::Value,
script: Option<mineral_script::ScriptSender>,
stats: crate::StatsRecorder,
) -> Result<Self, crate::Error> {
let channels = &sources.channels;
mineral_log::debug!(target: "server", channels = channels.len(), "spawning server components");
let scheduler = Scheduler::new(channels, *config.channel_workers_per());
let (audio, spectrum_tap) = AudioHandle::spawn(audio_mode, config.engine().clone())?;
mineral_log::debug!(target: "server", "audio engine ready");
let media_cache = open_media_cache(&persist, *config.audio_cache_capacity()).await;
let music_dir = crate::download::open_env(config.download().dir().as_deref());
// 每个订阅者最多保留 256 条 advisory event;落后的 event_pump 收 Lagged 并跳过旧帧。
let (events, _) = broadcast::channel::<Event>(/*capacity*/ 256);
let notify = crate::notify::Notifier::new(events.clone(), script);
let player = PlayerCore::spawn(
audio,
scheduler,
sources,
crate::player::PlayerStorage {
persist,
media_cache,
music_dir,
},
crate::player::SpawnConfig {
slices: &config,
tree: config_tree,
},
crate::player::Sinks { notify, stats },
)?;
// 读回上次会话:恢复播放模式(其余字段仅打日志,不自动恢复队列/进度)。
// 同步 await:保证在 serve / 首次 PlayerSync 之前生效,client 一连上看到的就是恢复后的模式。
let session_restored = restore_last_session(&player).await;
// 埋点:daemon 启动(app_lifecycle;audio 已 spawn 故后端已定,会话恢复已知)。
// daemon 总要发,recorder 降级 disabled 时此调用静默 no-op。
let audio_backend = match player.audio_snapshot().backend {
mineral_audio::AudioBackend::Device => mineral_stats::AudioBackend::Device,
mineral_audio::AudioBackend::Null => mineral_stats::AudioBackend::Null,
};
player
.inner
.stats
.daemon_started(audio_backend, session_restored);
// 第一次 initial loads — 为「daemon 起来无 client 也能后台 prefetch」考虑。
player.refresh_initial_loads();
let pcm = PcmRelay::spawn(spectrum_tap, player.audio().clone());
let publishers = crate::publisher::spawn(&player, events.clone(), pcm);
let connections = Arc::new(ConnRegistry::new());
tokio::spawn(heartbeat(
player.clone(),
Arc::clone(&connections),
*config.timing().heartbeat_secs(),
));
mineral_log::debug!(target: "server", "server components ready");
Ok(Self {
player,
publishers,
connections,
events,
shutdown: Arc::new(Notify::new()),
})
}
/// Event 推送 hub 的发送端(clone 廉价)。生产者(脚本运行时 / 测试)往里
/// send,所有已连接且订阅了对应类别的 client 都会收到;无 client 时 send
/// 返回 `Err`,按 advisory 语义丢弃即可。
pub fn event_sink(&self) -> broadcast::Sender<Event> {
self.events.clone()
}
/// 接上脚本双泵(命令 → player 执行面;推送 → event hub)。daemon 入口在
/// [`Server::spawn`] 之后调一次;无脚本时泵闲置无害。
///
/// # Params:
/// - `pumps`: [`crate::ScriptParts::spawn_runtime`] 拆出的泵接线件
pub fn attach_script_pumps(&self, pumps: crate::ScriptPumps) -> crate::ScriptReloadParts {
pumps.start(self.player.clone(), self.events.clone())
}
/// 拿一个 client handle。clone 廉价(全 Arc 内部),可任意复制给多处调用。
pub fn client(&self) -> ClientHandle {
ClientHandle::new(self.player.clone())
}
/// 接入系统媒体服务(Linux MPRIS):上报当前播放、响应媒体键 / 桌面控件。
///
/// 仅 daemon 模式调用 —— 控制的是常驻播放。注册失败(无 D-Bus session 等)
/// 返回 `Err`,调用方应降级而非中止 daemon。
pub fn start_media_service(&self) -> Result<(), crate::Error> {
crate::media::start(self.player.clone()).map_err(crate::Error::from)
}
/// 消费 server 句柄。daemon 关停路径在 accept loop 结束后调用,
/// 随后由 Tokio runtime 退出终止其余后台任务。
pub async fn shutdown(self) {
self.player.shutdown_downloads().await;
drop(self);
}
/// 等待 client 经 IPC 发来的 daemon 关停请求([`mineral_protocol::Request::Shutdown`])。
/// daemon 入口把它与 accept loop / 信号一起 select,任一唤醒即走 graceful 收尾。
/// `Notify` 带 permit:请求先于本调用到达也不丢。
pub async fn shutdown_requested(&self) {
self.shutdown.notified().await;
}
/// IPC accept loop:每条新 connection 走会话握手 + 结构化消息管线(请求合批
/// 配对 + 按订阅主题的状态发布)。多 client:连接数不设限,每条独立 task /
/// writer / 订阅泵。
///
/// 每条新 connection 接受后,内部重跑 `PlayerCore::refresh_initial_loads`
/// ——数据现状已由订阅初始快照兜底,这里是连接时顺手保鲜远端数据。
pub async fn serve(&self, listener: UnixListener) -> Result<(), crate::Error> {
let player = self.player.clone();
let on_connect = move || player.refresh_initial_loads();
let services = Arc::new(SessionServices {
client: self.client(),
registry: Arc::clone(&self.connections),
publishers: self.publishers.clone(),
shutdown: Arc::clone(&self.shutdown),
on_connect: Arc::new(on_connect),
});
crate::ipc::run(listener, services).await
}
}
/// 打开音频本体缓存(`audio_cache` 表落 `persist` 的 `mineral.db`);目录解析 / open 失败时
/// `warn` 并降级到 [`MediaCache::disabled`](不阻断 daemon 启动)。
///
/// # Params:
/// - `persist`: 持久化句柄
/// - `capacity`: 缓存容量上限(字节,配置 `cache.audio_capacity`)
async fn open_media_cache(persist: &ServerStore, capacity: u64) -> MediaCache {
let dir = match mineral_paths::audio_cache_dir() {
Ok(d) => d,
Err(e) => {
mineral_log::warn!(target: "server", error = mineral_log::chain(&e), "音频缓存目录解析失败,降级禁用");
return MediaCache::disabled();
}
};
match MediaCache::open(persist, dir, capacity).await {
Ok(cache) => cache,
Err(e) => {
mineral_log::warn!(target: "server", error = mineral_log::chain(&e), "音频缓存打开失败,降级禁用");
MediaCache::disabled()
}
}
}
/// 启动时读回上次会话:**恢复播放模式**,其余字段(队列 / 进度 / 音量)仅打日志不应用
/// (不自动恢复播放)。模式名解析不出(脏数据)warn 后保持默认;读不到走 debug;
/// 读出错仅 warn,不影响 daemon 启动。
///
/// # Return:
/// 是否读到并采纳了上次会话(app_lifecycle.session_restored 用):`Ok(Some)` 且模式
/// 可解析为 `true`;无历史 / 脏模式 / 读出错为 `false`(均视为「未恢复」)。
async fn restore_last_session(player: &PlayerCore) -> bool {
match player.load_session().await {
Ok(Some(snap)) => {
let restored = match PlayMode::from_name(&snap.play_mode) {
Some(mode) => {
player.restore_play_mode(mode);
true
}
None => {
mineral_log::warn!(
target: "session",
play_mode = %snap.play_mode,
"上次会话播放模式无法解析,保持默认"
);
false
}
};
mineral_log::info!(
target: "session",
queue_len = snap.queue.len(),
position_ms = snap.position_ms,
play_mode = %snap.play_mode,
restored_play_mode = restored,
"读到上次会话"
);
restored
}
Ok(None) => {
mineral_log::debug!(target: "session", "无历史会话");
false
}
Err(e) => {
mineral_log::warn!(target: "session", error = mineral_log::chain(&e), "读取上次会话失败");
false
}
}
}
/// 心跳:每 `interval_secs` 秒把内部状态快照打一条 info,供事后回溯间歇性问题
/// (出问题时往往没提前开 debug,有心跳就能看到那个时间点系统在干嘛)。
///
/// # Params:
/// - `interval_secs`: 心跳间隔(秒,配置 `heartbeat_secs`)
async fn heartbeat(player: PlayerCore, connections: Arc<ConnRegistry>, interval_secs: u64) {
let start = Instant::now();
let mut tick = tokio::time::interval(Duration::from_secs(interval_secs));
loop {
tick.tick().await;
// in-process 直读 State 摘日志字段,不走含整队列 clone 的全量快照。
let (song_id, play_mode, queue_len, cursor, format, bitrate_kbps, lyrics_loaded) = player
.with_state(|st| {
(
st.current_song
.as_ref()
.map_or_else(|| "-".to_owned(), |s| s.id.as_str().to_owned()),
st.play_mode,
st.queue.len(),
st.cursor,
st.media_info
.as_ref()
.and_then(|info| info.format.as_ref())
.map_or_else(|| "-".to_owned(), |f| f.as_str().to_owned()),
st.media_info
.as_ref()
.and_then(|info| info.bitrate_bps)
.map_or_else(|| "-".to_owned(), |b| (b / 1000).to_string()),
st.current_lyrics.is_some(),
)
});
let audio = player.audio().snapshot();
let tasks = player.task_snapshot();
mineral_log::info!(
target: "heartbeat",
uptime_s = start.elapsed().as_secs(),
clients_online = connections.online(),
playing = audio.playing,
position_ms = audio.position_ms,
duration_ms = audio.duration_ms,
volume_pct = audio.volume_pct,
song_id,
play_mode = ?play_mode,
queue_len,
cursor = ?cursor,
format,
bitrate_kbps,
lyrics_loaded,
prefetched = player.prefetched_ready(),
tasks_running = tasks.running,
tasks_by_kind = ?tasks.by_kind,
"status"
);
}
}