Skip to content

新增 doudou-server 同步服务器#9

Merged
openlyst merged 19 commits into
mainfrom
feature/doudou-server
Jul 20, 2026
Merged

新增 doudou-server 同步服务器#9
openlyst merged 19 commits into
mainfrom
feature/doudou-server

Conversation

@openlyst

@openlyst openlyst commented Jul 20, 2026

Copy link
Copy Markdown
Owner

GitLab MR: !89

Original: https://gitlab.com/Openlyst/doudou/-/merge_requests/89

概述 新增 doudou-server,一个独立的 headless 同步服务器,用于在多个 doudou 客户端(手机、桌面、电视)之间同步音乐库数据。服务器只存储音乐服务器 URL 和库快照,不存储任何媒体服务器登录凭据,这样即使 doudou-server 被攻破也不会泄露 Jellyfin/Subsonic/Plex 的登录信息。 ## 包含内容 ### doudou-server 服务器 (packages/doudou_server) 独立的 Flutter 桌面应用,可在 Linux、macOS、Windows 上运行。使用 drift + SQLite 存储数据,HTTP REST API 提供同步接口。 CLI 命令: - -start 启动服务器(前台运行) - -stop 停止运行中的服务器 - -clients 查看已同步的客户端 - -set login <user> <pass> 设置共享密码 - -status 查看服务器状态 - -health 健康检查 REST API(/api/v1 下,需 X-Doudou-Key 头认证): - GET /health 健康检查(无需认证) - GET/PUT /servers 音乐服务器列表 - DELETE /servers/{id} 删除音乐服务器 - GET/PUT /snapshots 库快照推送/拉取 - POST /clients/register 客户端注册 ### doudou 客户端集成 - DoudouServerConfig 模型存储 doudou-server 连接配置 - DoudouServerClient 封装 REST API HTTP 调用 - DoudouServerSyncService 定期(30分钟)+ 启动时 + 手动同步本地库和 doudou-server - 设置页面新增 doudou-server 配置入口 ### CI GitHub Actions 构建矩阵新增 doudou-server-linux、doudou-server-windows、doudou-server-macos 三个目标。 ## 安全设计 - 音乐服务器凭据只保存在每个 doudou 客户端本地,永远不经过 doudou-server - doudou-server 只存储音乐服务器 URL 和库快照数据 - 共享密码以 sha256 哈希存储,客户端用明文密码认证 ## 测试计划 - [x] doudou-server 在 Linux 上构建通过 - [x] doudou 客户端在 Linux 上构建通过 - [x] CLI 命令手动测试: -set login-start-health-status-stop-clients - [x] REST API 手动测试: 健康检查、服务器列表、快照推送/拉取、客户端注册 - [ ] doudou 客户端 UI 添加 doudou-server 并触发同步 - [ ] macOS/Windows 构建验证 ## Summary by CodeRabbit * New Features * Added optional doudou-server integration for syncing music server settings and library snapshots (including scheduled sync and “sync on resume” behavior). * Added doudou-server configuration, per-server sync status, and manual sync controls to Settings (with add/edit and removal). * Added a standalone doudou-server app with health checks, authenticated REST endpoints, client registration, and snapshot storage. * Added Linux, Windows, and macOS build outputs, with CI packaging for new server targets. * Documentation * Added setup, CLI usage, build, and API documentation for doudou-server.

HttpAnimations added 3 commits July 19, 2026 23:48
添加 packages/doudou_server 作为独立的 Flutter 桌面应用,用于在多个 doudou 客户端之间同步音乐库数据。

服务器只存储音乐服务器 URL 和库快照,不存储任何媒体服务器登录凭据,这样即使 doudou-server 被攻破也不会泄露 Jellyfin/Subsonic/Plex 的登录信息。

CLI 支持 -start -stop -clients -set login -status -health 命令,可在 Linux/macOS/Windows 上运行。使用 drift + SQLite 存储数据,HTTP REST API 提供同步接口。
添加 DoudouServerConfig 模型存储 doudou-server 连接配置(URL + 共享密码),密码仅保存在本地用于向 doudou-server 认证,不会发送到任何音乐服务器。

DoudouServerClient 封装 doudou-server REST API 的 HTTP 调用,包括健康检查、音乐服务器列表、库快照推送/拉取、客户端注册。

DoudouServerSyncService 负责在本地库和 doudou-server 之间同步:定期(30分钟)、应用启动时、手动触发。推送本地库缓存到服务器,拉取其他设备的库快照到本地。音乐服务器 URL 双向同步,但凭据永远不经过 doudou-server。

设置页面新增 doudou-server 配置入口,可添加/编辑/移除 doudou-server 连接,显示同步状态和上次同步时间。
在 GitHub Actions 构建矩阵中添加 doudou-server-linux、doudou-server-windows、doudou-server-macos 三个目标,从 packages/doudou_server 目录构建 doudou-server CLI 二进制文件。

doudou-server-linux 复用现有的 Linux 桌面构建依赖安装步骤,构建前运行 build_runner 生成 drift 数据库代码。

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 7 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +195 to +198
String _remoteIdFor(SettingsServer s) {
final raw = '${s.type.name}:${s.serverUrl ?? ""}';
return raw.hashCode.toRadixString(16);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 多设备之间的音乐库同步实际上无法工作

同一个音乐服务器在不同设备(甚至同一设备重启后)会被算出不同的标识 (raw.hashCode.toRadixString(16) at lib/services/doudou_server_sync_service.dart:197),所以一台设备上传到 doudou-server 的库快照,别的设备根本匹配不到、也就拉不下来。
Impact: 号称的跨设备库同步核心功能失效,设备之间看不到彼此同步上去的音乐库快照。

标识依赖不稳定的 String.hashCode

_remoteIdFor'${type.name}:${serverUrl}'.hashCode 生成快照的 musicServerId。上传时(_syncServerUrls/_pushLocalLibraries)用它作为服务器 id 和快照 key,拉取时(_pullRemoteLibrariesclient.listSnapshots(remoteId),lib/services/doudou_server_sync_service.dart:235-236)也用同样的算法重新计算并去服务器匹配。

Dart 的 String.hashCode 并不保证在不同的进程/隔离区之间稳定(VM 出于防哈希碰撞使用随机种子),因此设备 A 计算的 id 与设备 B 计算的 id 对同一个 URL 通常不一致,listSnapshots 返回空,同步链路断开。即便偶然一致,同一设备重启后 id 变化也会让此前上传的快照变成孤儿数据。要让跨设备匹配成立,这里必须使用内容确定性的哈希(如 sha1/sha256 的十六进制)或直接用规范化 URL 作为 id。

Prompt for agents
In lib/services/doudou_server_sync_service.dart, _remoteIdFor generates a per-music-server identifier using Dart's String.hashCode ('${s.type.name}:${s.serverUrl ?? ""}'.hashCode.toRadixString(16)). This id is used as the cross-device key for snapshots (pushed in _syncServerUrls/_pushLocalLibraries and re-derived in _pullRemoteLibraries via client.listSnapshots). Dart's String.hashCode is not stable across processes/devices/restarts, so different devices compute different ids for the same server URL and snapshot matching fails, breaking cross-device sync entirely. Replace the hashCode-based id with a deterministic, content-based identifier — e.g. a sha1/sha256 hex digest of the normalized 'type:url' string (the crypto package is already a dependency elsewhere), or use the normalized URL directly. Ensure the exact same derivation is used on the push side and the pull side.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +79 to +82
} else if (path == '/api/v1/snapshots' && method == 'PUT') {
await _pushSnapshot(req);
} else if (path == '/api/v1/snapshots' && method == 'GET') {
await _listSnapshots(req);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 服务器路由里有一段永远执行不到的重复分支

获取快照列表的处理分支被重复写了两次 (else if (path == '/api/v1/snapshots' && method == 'GET') at packages/doudou_server/lib/src/server/http_server.dart:81),第二段永远不会被执行到。
Impact: 属于死代码,虽然目前不影响运行,但可能掩盖本应放在该位置的其它路由(比如后续新增的路径),容易造成维护误解。

重复的分发分支

_dispatch 中,/api/v1/snapshots + GET 已在 http_server.dart:77-78 处理;http_server.dart:81-82 又写了完全相同的条件,由于 if/else-if 链会先命中第一个,第二段是不可达代码。

Suggested change
} else if (path == '/api/v1/snapshots' && method == 'PUT') {
await _pushSnapshot(req);
} else if (path == '/api/v1/snapshots' && method == 'GET') {
await _listSnapshots(req);
} else if (path == '/api/v1/snapshots' && method == 'PUT') {
await _pushSnapshot(req);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +141 to +148
Future<bool> _probe(String host, int port) async {
try {
final client = HttpClient();
final req = await client.getUrl(Uri.parse('http://$host:$port/api/v1/health'));
final res = await req.close();
final body = await res.transform(utf8.decoder).join();
client.close(force: true);
return res.statusCode == 200 && body.contains('"status":"ok"');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 在部分系统上健康检查会误报服务器已停止

健康检查会去连接状态文件里记录的监听地址 (_probe(state.host, state.port) at packages/doudou_server/lib/src/cli/commands.dart:118),而该地址默认是绑定用的 0.0.0.0,在 Windows 等系统上作为客户端连接目标是无效的。
Impact: 服务器明明在运行,-health 会误报为不健康;同样地重复启动保护也可能失效,导致意外起了第二个实例。

把绑定地址当作连接地址

启动时 host 默认取 cmd.host ?? '0.0.0.0'(commands.dart:50),daemon 把这个 host 原样写进状态文件(packages/doudou_server/lib/src/daemon/daemon.dart:84)。随后 _health/_start 通过 _probehttp://$host:$port 去连接。0.0.0.0 是通配绑定地址,作为连接目标在 Windows 上会失败(macOS/Linux 通常会回落到本地环回,但不可依赖)。应当在探活/连接时把 0.0.0.0(或 ::)映射为 127.0.0.1 再连接。

Prompt for agents
In packages/doudou_server, the daemon writes the bind host (default '0.0.0.0') into the state file (_writeState in daemon.dart), and the CLI health/liveness checks connect to that host via _probe in commands.dart using http://$host:$port. Connecting to the wildcard bind address 0.0.0.0 (or ::) is not a valid client target on Windows and is unreliable elsewhere. Fix _probe (or the caller) to translate a wildcard bind address to a loopback address (0.0.0.0 -> 127.0.0.1, :: -> ::1) before constructing the connection URL, so -health and the duplicate-start guard work across all desktop platforms.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

try {
await _dispatch(req);
} catch (e, st) {
_error(req, HttpStatus.internalServerError, '$e\n$st');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 内部错误把异常信息和堆栈追踪直接返回给客户端

packages/doudou_server/lib/src/server/http_server.dart:51,任何未捕获异常都会通过 _error(req, HttpStatus.internalServerError, '$e\n$st') 把完整的异常消息和堆栈追踪写回 HTTP 响应体。攻击者只需发送能触发异常的请求(如非法 JSON 体)就能获得服务器内部文件路径、代码结构等信息。

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +33 to +34
final provided = req.headers.value('x-doudou-key') ??
req.uri.queryParameters['key'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 共享密码允许通过 URL 查询参数传递

packages/doudou_server/lib/src/server/auth.dart:33-34,认证密钥除了可以放在 X-Doudou-Key 头,还可以通过 ?key= 查询参数传递。共享密码出现在 URL 中会被记录到服务器/代理访问日志、浏览器历史及中间设备中。

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +20 to +22
Future<void> setPassword(String password) async {
final hash = sha256.convert(utf8.encode(password)).toString();
await _db.putSetting('auth.passwordHash', hash);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 共享密码使用无盐的单次 SHA-256 存储

packages/doudou_server/lib/src/server/auth.dart:21,共享密码以无盐、无迭代的单次 sha256.convert(utf8.encode(password)) 形式存储。这种快速哈希对口令抵御暴力/彩虹表攻击很弱,一旦数据库文件泄露,密码很容易被反推。

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
final expected = await _db.getSetting('auth.passwordHash');
final hash = sha256.convert(utf8.encode(provided)).toString();
if (expected == null || hash != expected) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 认证密钥比较非恒定时间

packages/doudou_server/lib/src/server/auth.dart:41,将请求提供的密码哈希与存储值用普通的 hash != expected 字符串比较,不是恒定时间比较。

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

HttpAnimations added 16 commits July 20, 2026 00:11
…dows zip 在构建目录内打包避免深层嵌套
…moteIdFor 用 sha1 稳定哈希, 每服务器独立 try/catch, 快照版本按服务器+类型独立追踪, config 改为 Rx 可观察, 复用 encode/decode 辅助函数
添加 syncingServerIds 和 syncedServerIds 两个可观察集合,追踪哪些音乐服务器正在与 doudou-server 同步、哪些已经完成过同步。syncedServerIds 持久化到 Hive,重启后恢复。移除 doudou-server 配置时清空这两个集合。
每个音乐服务器旁边显示同步状态徽章:正在同步时显示 cloud_sync 图标和 syncing 文字,已同步时显示 cloud_done 图标和 synced 文字。未配置 doudou-server 或该服务器未参与同步时不显示徽章。徽章颜色用主题色,带 Tooltip 说明。
remoteIdFor 改为接受可空 URL,YouTube Music 没有 URL 时用类型名作为 key。push/pull 不再跳过没有 URL 的服务器,YouTube Music 的本地库缓存现在也会同步到 doudou-server。

_syncServerUrls 推送和拉取都支持没有 URL 的 YTM 条目,拉取时按类型去重。

新增 syncServer 方法,只同步单个音乐服务器,供添加/编辑服务器对话框的同步按钮使用。
添加/编辑服务器对话框底部新增 Sync with doudou-server 按钮, 仅在 doudou-server 已配置时显示。点击后编辑现有服务器直接同步, 新增服务器先添加再后台同步, 同步中显示进度状态禁用按钮。

YouTube Music 不再跳过对话框直接添加, 改为和其他服务器一样打开对话框, 显示无需登录说明并支持同步按钮。修复添加 YTM 时硬编码类型的问题, 改用 widget.serverType。
YTM 没有凭据和 URL 需要填写, 同步按钮意义不大。改为在无需登录说明下方显示徽章: syncing/synced/will sync 三种状态, 配合 cloud 图标和 Tooltip。同步按钮只在需要凭据的服务器类型显示。
@openlyst
openlyst merged commit 07e7bcb into main Jul 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant