Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e8cce43
新增 doudou-server 同步服务器包
HttpAnimation Jul 20, 2026
2fd0712
新增 doudou 客户端集成 doudou-server
HttpAnimation Jul 20, 2026
fb70d71
CI 新增 doudou-server 桌面端构建任务
HttpAnimation Jul 20, 2026
76c525c
修复 CodeRabbit 反馈: 移除未使用的 _activeClients, 改用 listen 并发处理请求, 修复 JSON 读取…
HttpAnimation Jul 20, 2026
f0864d3
修复 CodeRabbit 反馈: deleteMusicServer 用事务包裹级联删除, 防止快照残留
HttpAnimation Jul 20, 2026
37204c6
修复 CodeRabbit 反馈: Windows 跳过 SIGTERM 订阅, sigterm 取消改为空安全
HttpAnimation Jul 20, 2026
fd22415
修复 CodeRabbit 反馈: Windows 移除首帧 Show 调用保持无窗口, macOS 添加网络服务器/客户端权限
HttpAnimation Jul 20, 2026
6222a17
修复 CodeRabbit 反馈: README 代码块添加 shell 语言标记
HttpAnimation Jul 20, 2026
50ebf3a
修复 CodeRabbit 反馈: CI build_runner 加 --delete-conflicting-outputs, Win…
HttpAnimation Jul 20, 2026
f659224
新增 flutter_secure_storage、uuid、crypto 依赖用于 doudou-server 客户端集成
HttpAnimation Jul 20, 2026
e6c115a
修复 CodeRabbit 反馈: 共享密码改用 flutter_secure_storage 存储, 客户端 ID 用 UUID, re…
HttpAnimation Jul 20, 2026
2e92b42
修复 CodeRabbit 反馈: doudou-server 对话框改为 StatefulWidget 添加内联校验反馈, config…
HttpAnimation Jul 20, 2026
fd92e91
修复 CodeRabbit 反馈: DoudouServerSyncService 改为 Get.put 立即注册, onInit 在启动时运行
HttpAnimation Jul 20, 2026
5d93127
同步服务新增每服务器同步状态追踪
HttpAnimation Jul 20, 2026
f2b24c8
设置页服务器列表新增 doudou-server 同步徽章
HttpAnimation Jul 20, 2026
263eaa8
同步服务支持 YouTube Music 和单服务器同步
HttpAnimation Jul 20, 2026
fbf6700
补提交 flutter_secure_storage 各平台插件注册文件
HttpAnimation Jul 20, 2026
bc2eb86
添加服务器对话框新增 doudou-server 同步按钮, YouTube Music 走对话框流程
HttpAnimation Jul 20, 2026
07e7bcb
YouTube Music 添加对话框用同步状态徽章替代同步按钮
HttpAnimation Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ jobs:
os: macos-latest
- target: ios
os: macos-latest
- target: doudou-server-linux
os: ubuntu-latest
- target: doudou-server-windows
os: windows-latest
- target: doudou-server-macos
os: macos-latest

steps:
- uses: actions/checkout@v4
Expand All @@ -63,7 +69,7 @@ jobs:
run: flutter doctor -v

- name: Install Linux build dependencies
if: matrix.target == 'linux'
if: matrix.target == 'linux' || matrix.target == 'doudou-server-linux'
run: |
sudo apt-get update -y
sudo apt-get install -y \
Expand All @@ -77,6 +83,13 @@ jobs:
- name: Install dependencies
run: flutter pub get

- name: Install doudou-server dependencies
if: startsWith(matrix.target, 'doudou-server-')
working-directory: packages/doudou_server
run: |
flutter pub get
dart run build_runner build --delete-conflicting-outputs

- name: Build debug + release
shell: bash
run: |
Expand Down Expand Up @@ -145,6 +158,24 @@ jobs:
flutter build ios --release --no-codesign
(cd build/ios/iphoneos && zip -ry "$OLDPWD/artifacts/doudou-ios-release-${suffix}.zip" ./*.app)
;;
doudou-server-linux)
(cd packages/doudou_server && flutter build linux --debug)
tar -czf "artifacts/doudou-server-linux-debug-${suffix}.tar.gz" -C packages/doudou_server/build/linux/x64/debug/bundle .
(cd packages/doudou_server && flutter build linux --release)
tar -czf "artifacts/doudou-server-linux-release-${suffix}.tar.gz" -C packages/doudou_server/build/linux/x64/release/bundle .
;;
doudou-server-windows)
(cd packages/doudou_server && flutter build windows --debug)
(cd packages/doudou_server/build/windows/x64/runner/Debug && 7z a "$OLDPWD/artifacts/doudou-server-windows-debug-${suffix}.zip" "*")
(cd packages/doudou_server && flutter build windows --release)
(cd packages/doudou_server/build/windows/x64/runner/Release && 7z a "$OLDPWD/artifacts/doudou-server-windows-release-${suffix}.zip" "*")
;;
doudou-server-macos)
(cd packages/doudou_server && flutter build macos --debug)
(cd packages/doudou_server/build/macos/Build/Products/Debug && zip -ry "$OLDPWD/artifacts/doudou-server-macos-debug-${suffix}.zip" ./*.app)
(cd packages/doudou_server && flutter build macos --release)
(cd packages/doudou_server/build/macos/Build/Products/Release && zip -ry "$OLDPWD/artifacts/doudou-server-macos-release-${suffix}.zip" ./*.app)
;;
*)
echo "Unknown target: ${{ matrix.target }}" >&2
exit 1
Expand Down
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '/l10n/app_localizations.dart';
import '/ui/screens/Search/search_screen_controller.dart';
import '/services/downloader.dart';
import '/services/library_sync_service.dart';
import '/services/doudou_server_sync_service.dart';
import '/services/piped_service.dart';
import '/services/playback_diagnostics_service.dart';
import 'utils/app_link_controller.dart';
Expand Down Expand Up @@ -146,6 +147,9 @@ Future<void> startApplicationServices() async {
Get.lazyPut(() => SettingsScreenController(), fenix: true);
Get.lazyPut(() => Downloader(), fenix: true);
Get.lazyPut(() => LibrarySyncService(), fenix: true);
// Eager registration so onInit runs at launch and the periodic sync starts
// immediately, even before the settings screen is opened.
Get.put(DoudouServerSyncService(), permanent: true);
Get.lazyPut(() => SearchScreenController(), fenix: true);
Get.lazyPut(() => PlaybackDiagnosticsService(), fenix: true);
if (GetPlatform.isAndroid) {
Expand Down
42 changes: 42 additions & 0 deletions lib/models/doudou_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/// Configuration for a doudou-server instance. Stored locally on each client.
///
/// The [key] is the shared password the server operator set via
/// `doudou-server -set login`. It is stored locally so the client can
/// authenticate, but it is never sent to any music server and never leaves the
/// client except when talking to this doudou-server.
class DoudouServerConfig {
DoudouServerConfig({
required this.url,
required this.key,
this.deviceName,
});

/// Base URL of the doudou-server, e.g. `http://192.168.1.20:7427`.
final String url;
final String key;
final String? deviceName;

Map<String, dynamic> toMap() => {
'url': url,
'key': key,
'deviceName': deviceName,
};

factory DoudouServerConfig.fromMap(Map<String, dynamic> map) =>
DoudouServerConfig(
url: map['url'] as String? ?? '',
key: map['key'] as String? ?? '',
deviceName: map['deviceName'] as String?,
);

DoudouServerConfig copyWith({
String? url,
String? key,
String? deviceName,
}) =>
DoudouServerConfig(
url: url ?? this.url,
key: key ?? this.key,
deviceName: deviceName ?? this.deviceName,
);
}
154 changes: 154 additions & 0 deletions lib/services/doudou_server_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import 'dart:convert';

import 'package:dio/dio.dart';

import '/models/doudou_server.dart';

/// One music server entry as mirrored from doudou-server. Same shape the
/// server stores, minus credentials.
class RemoteMusicServer {
RemoteMusicServer({
required this.id,
required this.name,
required this.type,
required this.url,
this.isDefault = false,
});

final String id;
final String name;
final String type;
final String url;
final bool isDefault;

factory RemoteMusicServer.fromJson(Map<String, dynamic> json) =>
RemoteMusicServer(
id: json['id'] as String,
name: json['name'] as String,
type: json['type'] as String,
url: json['url'] as String,
isDefault: json['isDefault'] as bool? ?? false,
);
}

class RemoteSnapshot {
RemoteSnapshot({
required this.musicServerId,
required this.kind,
required this.version,
required this.updatedAtMs,
required this.payload,
});

final String musicServerId;
final String kind;
final int version;
final int updatedAtMs;
final String payload;

factory RemoteSnapshot.fromJson(Map<String, dynamic> json) => RemoteSnapshot(
musicServerId: json['musicServerId'] as String,
kind: json['kind'] as String,
version: json['version'] as int? ?? 0,
updatedAtMs: json['updatedAtMs'] as int? ?? 0,
payload: json['payload'] as String? ?? '[]',
);
}

/// Thin HTTP client for the doudou-server REST API.
class DoudouServerClient {
DoudouServerClient(this._config) : _dio = Dio(BaseOptions(
baseUrl: _config.url.replaceAll(RegExp(r'/+$'), ''),
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 30),
headers: {'X-Doudou-Key': _config.key},
));

final DoudouServerConfig _config;
final Dio _dio;

DoudouServerConfig get config => _config;

Future<bool> health() async {
try {
final res = await _dio.get('/api/v1/health');
return res.statusCode == 200 &&
(res.data as Map<String, dynamic>)['status'] == 'ok';
} catch (_) {
return false;
}
}

Future<List<RemoteMusicServer>> listServers() async {
final res = await _dio.get('/api/v1/servers');
final list = res.data as List<dynamic>;
return list
.map((e) => RemoteMusicServer.fromJson(e as Map<String, dynamic>))
.toList();
}

Future<void> upsertServer(RemoteMusicServer server) async {
await _dio.put('/api/v1/servers', data: {
'id': server.id,
'name': server.name,
'type': server.type,
'url': server.url,
'isDefault': server.isDefault,
});
}

Future<void> deleteServer(String id) async {
await _dio.delete('/api/v1/servers/$id');
}

Future<List<RemoteSnapshot>> listSnapshots(String musicServerId) async {
final res = await _dio.get('/api/v1/snapshots',
queryParameters: {'musicServerId': musicServerId});
final list = res.data as List<dynamic>;
return list
.map((e) => RemoteSnapshot.fromJson(e as Map<String, dynamic>))
.toList();
}

/// Pushes a library snapshot. Returns true if the server accepted it.
Future<bool> pushSnapshot({
required String musicServerId,
required String kind,
required int version,
required String payload,
}) async {
final res = await _dio.put('/api/v1/snapshots', data: {
'musicServerId': musicServerId,
'kind': kind,
'version': version,
'payload': payload,
});
final data = res.data as Map<String, dynamic>;
return data['accepted'] == true;
}

Future<void> registerClient(String clientId, String name) async {
await _dio.post('/api/v1/clients/register', data: {
'id': clientId,
'name': name,
});
}

void close() => _dio.close();
}

/// Encodes a list of maps into the JSON string the server expects as a
/// snapshot payload.
String encodeSnapshotPayload(List<Map<String, dynamic>> items) =>
jsonEncode(items);

List<Map<String, dynamic>> decodeSnapshotPayload(String payload) {
if (payload.isEmpty) return [];
final decoded = jsonDecode(payload);
if (decoded is List) {
return decoded
.map((e) => e is Map<String, dynamic> ? e : Map<String, dynamic>.from(e as Map))
.toList();
}
return [];
}
Loading
Loading