新增 doudou-server 同步服务器#9
Conversation
添加 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 数据库代码。
| String _remoteIdFor(SettingsServer s) { | ||
| final raw = '${s.type.name}:${s.serverUrl ?? ""}'; | ||
| return raw.hashCode.toRadixString(16); | ||
| } |
There was a problem hiding this comment.
🔴 多设备之间的音乐库同步实际上无法工作
同一个音乐服务器在不同设备(甚至同一设备重启后)会被算出不同的标识 (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,拉取时(_pullRemoteLibraries 的 client.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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| } else if (path == '/api/v1/snapshots' && method == 'PUT') { | ||
| await _pushSnapshot(req); | ||
| } else if (path == '/api/v1/snapshots' && method == 'GET') { | ||
| await _listSnapshots(req); |
There was a problem hiding this comment.
🟡 服务器路由里有一段永远执行不到的重复分支
获取快照列表的处理分支被重复写了两次 (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 链会先命中第一个,第二段是不可达代码。
| } 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); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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"'); |
There was a problem hiding this comment.
🟡 在部分系统上健康检查会误报服务器已停止
健康检查会去连接状态文件里记录的监听地址 (_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 通过 _probe 用 http://$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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| try { | ||
| await _dispatch(req); | ||
| } catch (e, st) { | ||
| _error(req, HttpStatus.internalServerError, '$e\n$st'); |
There was a problem hiding this comment.
| final provided = req.headers.value('x-doudou-key') ?? | ||
| req.uri.queryParameters['key']; |
| Future<void> setPassword(String password) async { | ||
| final hash = sha256.convert(utf8.encode(password)).toString(); | ||
| await _db.putSetting('auth.passwordHash', hash); |
| } | ||
| final expected = await _db.getSetting('auth.passwordHash'); | ||
| final hash = sha256.convert(utf8.encode(provided)).toString(); | ||
| if (expected == null || hash != expected) { |
…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。同步按钮只在需要凭据的服务器类型显示。
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.