From 6a162830bc7c2d219bcdf09c766d53f2cb5920c2 Mon Sep 17 00:00:00 2001 From: Alex Ayers Date: Wed, 4 Mar 2026 06:04:04 -0500 Subject: [PATCH] updates --- .claude/CLAUDE.md | 4 + .github/scripts/pr_summary.py | 106 ++++ .github/workflows/pr-summary.yml | 25 + .../audio_generation_task_repository.dart | 17 +- .../lib/src/audio/audio_service.dart | 11 +- .../lib/src/audio/midi/midi_client.dart | 5 +- .../lib/src/audio/task_status_values.dart | 7 + .../lib/configuration/configuration_base.dart | 6 - .../studio_ui/lib/models/task_status.dart | 15 +- packages/studio_ui/lib/pages/create_page.dart | 14 +- .../studio_ui/lib/services/api_client.dart | 483 +++--------------- 11 files changed, 262 insertions(+), 431 deletions(-) create mode 100644 .github/scripts/pr_summary.py create mode 100644 .github/workflows/pr-summary.yml create mode 100644 packages/studio_backend/lib/src/audio/task_status_values.dart diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2adb579..e8c376f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,5 +1,9 @@ # Studio Project Rules +## Branch Safety + +- **Before making ANY changes**, always check that the user is NOT on the `main` or `stable` branch. Run `git branch --show-current` first. If on `main` or `stable`, STOP and tell the user to create/switch to a feature branch before proceeding. + ## UI / Flutter - Never use mobile-style animations or transitions in the Flutter app. This is a desktop application. Avoid `AnimatedContainer`, `AnimatedCrossFade`, `AnimatedRotation`, `AnimatedSwitcher`, `AnimatedOpacity`, `SlideTransition`, `FadeTransition`, swipe gestures, and similar animated widgets. Use instant state changes (e.g. `if`/`switch` conditionals, `Container`, `Transform.rotate`) instead. diff --git a/.github/scripts/pr_summary.py b/.github/scripts/pr_summary.py new file mode 100644 index 0000000..7d77cfa --- /dev/null +++ b/.github/scripts/pr_summary.py @@ -0,0 +1,106 @@ +"""Generate an AI summary for a pull request and update its description.""" + +import json +import os +import re +import subprocess +import urllib.request + +ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] +PR_NUMBER = os.environ["PR_NUMBER"] +BASE_REF = os.environ["BASE_REF"] + +START_MARKER = "" +END_MARKER = "" + +MAX_DIFF_CHARS = 80_000 + +PROMPT = ( + "Summarize this pull request diff. Output a concise markdown summary with:\n" + "- A one-line overall summary\n" + "- A bullet list of key changes grouped by area\n\n" + "Keep it short and useful for code reviewers. " + "Do not include any preamble, just output the summary.\n\n" + "Diff:\n" +) + + +def get_diff() -> str: + result = subprocess.run( + ["git", "diff", f"origin/{BASE_REF}...HEAD", "--", ".", ":!*.lock", ":!*.sum"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout[:MAX_DIFF_CHARS] + + +def call_claude(diff: str) -> str: + payload = json.dumps({ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 1024, + "messages": [{"role": "user", "content": PROMPT + diff}], + }).encode() + + req = urllib.request.Request( + "https://api.anthropic.com/v1/messages", + data=payload, + headers={ + "Content-Type": "application/json", + "X-Api-Key": ANTHROPIC_API_KEY, + "Anthropic-Version": "2023-06-01", + }, + ) + + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + + return body["content"][0]["text"] + + +def get_pr_body() -> str: + result = subprocess.run( + ["gh", "pr", "view", PR_NUMBER, "--json", "body", "-q", ".body"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def update_pr_body(new_body: str) -> None: + subprocess.run( + ["gh", "pr", "edit", PR_NUMBER, "--body", new_body], + check=True, + ) + + +def main() -> None: + diff = get_diff() + if not diff.strip(): + print("No diff found, skipping summary.") + return + + print("Calling Claude API...") + summary = call_claude(diff) + + ai_section = f"{START_MARKER}\n## Summary (AI-generated)\n{summary}\n{END_MARKER}" + + current_body = get_pr_body() + + pattern = re.compile( + re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER), + re.DOTALL, + ) + + if pattern.search(current_body): + new_body = pattern.sub(ai_section, current_body) + else: + new_body = f"{ai_section}\n\n{current_body}" if current_body else ai_section + + update_pr_body(new_body) + print("PR description updated with AI summary.") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml new file mode 100644 index 0000000..231dd51 --- /dev/null +++ b/.github/workflows/pr-summary.yml @@ -0,0 +1,25 @@ +name: PR Summary + +on: + pull_request: + types: [opened, synchronize] + +permissions: + pull-requests: write + contents: read + +jobs: + summarize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate summary + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: python3 .github/scripts/pr_summary.py diff --git a/packages/studio_backend/lib/src/audio/audio_generation_task_repository.dart b/packages/studio_backend/lib/src/audio/audio_generation_task_repository.dart index 1eb7d5f..3fe614e 100644 --- a/packages/studio_backend/lib/src/audio/audio_generation_task_repository.dart +++ b/packages/studio_backend/lib/src/audio/audio_generation_task_repository.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:studio_backend/src/audio/dto/audio_generate_request.dart'; +import 'package:studio_backend/src/audio/task_status_values.dart'; import 'package:studio_backend/src/database/database.dart'; import 'package:studio_backend/src/database/postgres.dart'; import 'package:studio_backend/src/utils/cursor_pagination.dart'; @@ -38,7 +39,7 @@ class AudioGenerationTaskRepositoryImpl taskId: Value(taskId), model: Value(request.model), taskType: Value(request.taskType), - status: const Value('processing'), + status: const Value(TaskStatusValues.processing), prompt: Value(request.prompt), lyrics: Value(request.lyrics), negativePrompt: Value(request.negativePrompt), @@ -85,7 +86,7 @@ class AudioGenerationTaskRepositoryImpl taskId: taskId, model: request.model, taskType: request.taskType, - status: 'processing', + status: TaskStatusValues.processing, title: Value(request.title), prompt: Value(request.prompt), lyrics: Value(request.lyrics), @@ -132,7 +133,7 @@ class AudioGenerationTaskRepositoryImpl _database.audioGenerationTask, )..where((t) => t.taskId.equals(taskId))).write( AudioGenerationTaskCompanion( - status: const Value('complete'), + status: const Value(TaskStatusValues.complete), result: Value(jsonEncode(result)), completedAt: Value(DateTime.now().toPgDateTime()), ), @@ -148,7 +149,7 @@ class AudioGenerationTaskRepositoryImpl _database.audioGenerationTask, )..where((t) => t.taskId.equals(taskId))).write( AudioGenerationTaskCompanion( - status: const Value('failed'), + status: const Value(TaskStatusValues.failed), error: Value(error), completedAt: Value(DateTime.now().toPgDateTime()), ), @@ -169,7 +170,7 @@ class AudioGenerationTaskRepositoryImpl ..where( (t) { var clause = t.userId.equals(userId) & - t.status.equals('complete') & + t.status.equals(TaskStatusValues.complete) & buildCursorWhereClause(t.createdAt, t.id, cursor, descending: descending); if (rating != null) { @@ -207,7 +208,7 @@ class AudioGenerationTaskRepositoryImpl taskId: taskId, model: 'upload', taskType: 'upload', - status: 'uploading', + status: TaskStatusValues.uploading, srcAudioPath: Value(objectPath), workspaceId: Value(workspaceId), ), @@ -298,7 +299,7 @@ class AudioGenerationTaskRepositoryImpl ..where((t) => t.lyricSheetId.equals(lyricSheetId) & t.userId.equals(userId) & - t.status.equals('complete')) + t.status.equals(TaskStatusValues.complete)) ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) .get(); } @@ -337,7 +338,7 @@ class AudioGenerationTaskRepositoryImpl final q = _database.select(_database.audioGenerationTask) ..where((t) { var clause = t.userId.equals(userId) & - t.status.equals('complete') & + t.status.equals(TaskStatusValues.complete) & t.lyrics.lower().like(pattern.toLowerCase()) & buildCursorWhereClause(t.createdAt, t.id, cursor, descending: descending); diff --git a/packages/studio_backend/lib/src/audio/audio_service.dart b/packages/studio_backend/lib/src/audio/audio_service.dart index f09d81e..060755f 100644 --- a/packages/studio_backend/lib/src/audio/audio_service.dart +++ b/packages/studio_backend/lib/src/audio/audio_service.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:studio_backend/src/audio/audio_client.dart'; +import 'package:studio_backend/src/audio/task_status_values.dart'; import 'package:studio_backend/src/audio/audio_generation_task_repository.dart'; import 'package:studio_backend/src/audio/audio_model_client.dart'; import 'package:studio_backend/src/audio/dto/audio_generate_request.dart'; @@ -695,7 +696,7 @@ class AudioService { await _setTask( _TaskStatus( taskId: taskId, - status: 'processing', + status: TaskStatusValues.processing, taskType: generateRequest.taskType, model: generateRequest.model, ), @@ -740,10 +741,10 @@ class AudioService { if (task.model != null) 'model': task.model, }; - if (task.status == 'complete' && task.result != null) { + if (task.status == TaskStatusValues.complete && task.result != null) { response['result'] = task.result; } - if (task.status == 'failed' && task.error != null) { + if (task.status == TaskStatusValues.failed && task.error != null) { response['error'] = task.error; } @@ -765,7 +766,7 @@ class AudioService { await _setTask( _TaskStatus( taskId: taskId, - status: 'complete', + status: TaskStatusValues.complete, taskType: prev?.taskType ?? 'unknown', model: prev?.model, result: result, @@ -787,7 +788,7 @@ class AudioService { await _setTask( _TaskStatus( taskId: taskId, - status: 'failed', + status: TaskStatusValues.failed, taskType: prev?.taskType ?? 'unknown', model: prev?.model, error: 'Generation failed', diff --git a/packages/studio_backend/lib/src/audio/midi/midi_client.dart b/packages/studio_backend/lib/src/audio/midi/midi_client.dart index 50a4a8a..b67388f 100644 --- a/packages/studio_backend/lib/src/audio/midi/midi_client.dart +++ b/packages/studio_backend/lib/src/audio/midi/midi_client.dart @@ -1,4 +1,5 @@ import 'package:studio_backend/src/audio/audio_model_client.dart'; +import 'package:studio_backend/src/audio/task_status_values.dart'; /// Anticorruption layer for the MIDI generation model. /// @@ -94,7 +95,7 @@ class MidiClient extends AudioModelClient { final response = await getRequest('/api/tasks/$taskId/'); final status = response['status'] as String?; - if (status == 'complete') { + if (status == TaskStatusValues.complete) { final results = >[]; final downloadUrl = response['download_url'] as String?; final mp3DownloadUrl = response['mp3_download_url'] as String?; @@ -111,7 +112,7 @@ class MidiClient extends AudioModelClient { return {'results': results}; } - if (status == 'failed') { + if (status == TaskStatusValues.failed) { throw AudioModelException( 500, 'MIDI task $taskId failed: ' diff --git a/packages/studio_backend/lib/src/audio/task_status_values.dart b/packages/studio_backend/lib/src/audio/task_status_values.dart new file mode 100644 index 0000000..f339d51 --- /dev/null +++ b/packages/studio_backend/lib/src/audio/task_status_values.dart @@ -0,0 +1,7 @@ +/// Canonical status strings for audio generation tasks. +abstract final class TaskStatusValues { + static const processing = 'processing'; + static const uploading = 'uploading'; + static const complete = 'complete'; + static const failed = 'failed'; +} diff --git a/packages/studio_ui/lib/configuration/configuration_base.dart b/packages/studio_ui/lib/configuration/configuration_base.dart index e1d95a3..1170d32 100644 --- a/packages/studio_ui/lib/configuration/configuration_base.dart +++ b/packages/studio_ui/lib/configuration/configuration_base.dart @@ -11,12 +11,6 @@ class Configuration { final bool secure; final String applicationId; - Uri buildUri(String path, [Map? query]) { - return secure - ? Uri.https(apiHost, path, query) - : Uri.http(apiHost, path, query); - } - static String environmentLookup() { const envFromDefine = String.fromEnvironment('BUILD_ENV'); if (envFromDefine.isNotEmpty) return envFromDefine; diff --git a/packages/studio_ui/lib/models/task_status.dart b/packages/studio_ui/lib/models/task_status.dart index 32e99a2..f15452b 100644 --- a/packages/studio_ui/lib/models/task_status.dart +++ b/packages/studio_ui/lib/models/task_status.dart @@ -1,4 +1,9 @@ class TaskStatus { + static const statusProcessing = 'processing'; + static const statusUploading = 'uploading'; + static const statusComplete = 'complete'; + static const statusFailed = 'failed'; + TaskStatus({ required this.taskId, required this.status, @@ -28,7 +33,7 @@ class TaskStatus { factory TaskStatus.fromSongJson(Map json) => TaskStatus( taskId: json['task_id'] as String, - status: json['status'] as String? ?? 'complete', + status: json['status'] as String? ?? statusComplete, taskType: json['task_type'] as String? ?? 'unknown', result: json['result'] as Map?, prompt: json['prompt'] as String?, @@ -57,10 +62,10 @@ class TaskStatus { final DateTime? createdAt; final Map? parameters; - bool get isProcessing => status == 'processing'; - bool get isUploading => status == 'uploading'; - bool get isComplete => status == 'complete'; - bool get isFailed => status == 'failed'; + bool get isProcessing => status == statusProcessing; + bool get isUploading => status == statusUploading; + bool get isComplete => status == statusComplete; + bool get isFailed => status == statusFailed; /// Whether this task is still active and should be polled. bool get isActive => isProcessing || isUploading; diff --git a/packages/studio_ui/lib/pages/create_page.dart b/packages/studio_ui/lib/pages/create_page.dart index 84ed629..73d0a3b 100644 --- a/packages/studio_ui/lib/pages/create_page.dart +++ b/packages/studio_ui/lib/pages/create_page.dart @@ -952,7 +952,7 @@ class _CreatePageState extends State { final taskId = await client.submitTask(body); final status = TaskStatus( taskId: taskId, - status: 'processing', + status: TaskStatus.statusProcessing, taskType: _taskType, model: _model, ); @@ -1008,7 +1008,7 @@ class _CreatePageState extends State { setState(() { _tasks.insert( 0, - TaskStatus(taskId: fileId, status: 'uploading', taskType: 'upload'), + TaskStatus(taskId: fileId, status: TaskStatus.statusUploading, taskType: 'upload'), ); _uploadProgress = 0.2; }); @@ -1034,7 +1034,7 @@ class _CreatePageState extends State { setState(() { _tasks[index] = TaskStatus( taskId: fileId, - status: 'complete', + status: TaskStatus.statusComplete, taskType: 'upload', ); _pickedFile = null; @@ -5146,10 +5146,10 @@ class _TaskCardState extends State<_TaskCard> Widget _statusBadge() { final s = S.of(context); final (color, label) = switch (widget.task.status) { - 'processing' => (AppColors.controlPink, s.statusProcessing), - 'uploading' => (Colors.orange, s.statusUploading), - 'complete' => (Colors.green, s.statusComplete), - 'failed' => (Colors.redAccent, s.statusFailed), + TaskStatus.statusProcessing => (AppColors.controlPink, s.statusProcessing), + TaskStatus.statusUploading => (Colors.orange, s.statusUploading), + TaskStatus.statusComplete => (Colors.green, s.statusComplete), + TaskStatus.statusFailed => (Colors.redAccent, s.statusFailed), _ => (AppColors.textMuted, widget.task.status), }; diff --git a/packages/studio_ui/lib/services/api_client.dart b/packages/studio_ui/lib/services/api_client.dart index c42893d..80fc97e 100644 --- a/packages/studio_ui/lib/services/api_client.dart +++ b/packages/studio_ui/lib/services/api_client.dart @@ -81,7 +81,9 @@ class ApiClient { /// Login -- standalone raw HTTP call (pre-auth, no DiskRotHttpClient needed). Future login(String email, String password) async { - final uri = config.buildUri('/v1/authentication/diskrot-login'); + final uri = config.secure + ? Uri.https(config.apiHost, '/v1/authentication/diskrot-login') + : Uri.http(config.apiHost, '/v1/authentication/diskrot-login'); final client = http.Client(); try { final response = await client.post( @@ -90,15 +92,7 @@ class ApiClient { body: jsonEncode({'email': email, 'password': password}), ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Login failed', - ); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Login failed'); return LoginResponse( idToken: body['idToken'] as String, refreshToken: body['refreshToken'] as String, @@ -113,12 +107,7 @@ class ApiClient { /// Fetch the server-assigned external user ID. Future getUserId() async { final response = await httpClient.get(endpoint: '/users/me'); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get user ID'); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get user ID'); return body['user_id'] as String; } @@ -127,16 +116,7 @@ class ApiClient { endpoint: '/audio/generate', data: taskBody, ); - - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Task submission failed', - ); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Task submission failed'); return body['task_id'] as String; } @@ -147,11 +127,7 @@ class ApiClient { throw ApiException(404, 'Task not found'); } - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get task status'); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get task status'); return TaskStatus.fromJson(body); } @@ -176,11 +152,7 @@ class ApiClient { query: query, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get songs'); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get songs'); final data = (body['data'] as List?) ?? []; final songs = data .map((e) => TaskStatus.fromSongJson(e as Map)) @@ -195,17 +167,15 @@ class ApiClient { Future> healthCheck() async { final response = await httpClient.get(endpoint: '/audio/health'); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Health check failed'); - } - - return jsonDecode(response.body) as Map; + return _ok(response, 'Health check failed'); } /// Build the full URL for the server-side song download endpoint. String songDownloadUrl(String taskId) { - return config.buildUri('/v1/audio/songs/$taskId/download').toString(); + final uri = config.secure + ? Uri.https(config.apiHost, '/v1/audio/songs/$taskId/download') + : Uri.http(config.apiHost, '/v1/audio/songs/$taskId/download'); + return uri.toString(); } /// Download audio bytes from an internal API URL via the authenticated client. @@ -243,16 +213,7 @@ class ApiClient { endpoint: '/audio/upload', data: {'filename': filename, 'contentType': contentType, 'size': size}, ); - - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to create upload', - ); - } - - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to create upload'); } /// Finalize (or continue) a resumable upload by sending bytes through the @@ -295,10 +256,7 @@ class ApiClient { endpoint: '/audio/songs/$taskId', data: body, ); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to update song'); - } + _ok(response, 'Failed to update song'); } Future moveSong({ @@ -309,18 +267,12 @@ class ApiClient { endpoint: '/audio/songs/$taskId', data: {'workspace_id': workspaceId}, ); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to move song'); - } + _ok(response, 'Failed to move song'); } Future deleteSong({required String taskId}) async { final response = await httpClient.delete(endpoint: '/audio/songs/$taskId'); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to delete song'); - } + _ok(response, 'Failed to delete song'); } Future batchDeleteSongs({required List taskIds}) async { @@ -328,23 +280,13 @@ class ApiClient { endpoint: '/audio/songs/batch-delete', data: {'task_ids': taskIds}, ); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to batch delete songs'); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to batch delete songs'); return body['deleted'] as int? ?? 0; } Future getSongDetails(String taskId) async { final response = await httpClient.get(endpoint: '/audio/songs/$taskId'); - - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get song details'); - } - - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get song details'); return TaskStatus.fromSongJson(body); } @@ -354,10 +296,7 @@ class ApiClient { Future>> getLogs() async { final response = await httpClient.get(endpoint: '/logs'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get logs'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get logs'); return (body['data'] as List).cast>(); } @@ -369,10 +308,7 @@ class ApiClient { final response = await httpClient.get( endpoint: '/settings', ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get settings'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get settings'); return body.map((k, v) => MapEntry(k, v as String)); } @@ -381,13 +317,7 @@ class ApiClient { endpoint: '/settings', data: settings, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to update settings', - ); - } + _ok(response, 'Failed to update settings'); } // --------------------------------------------------------------------------- @@ -398,10 +328,7 @@ class ApiClient { final response = await httpClient.get( endpoint: '/server-backends', ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get server backends'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get server backends'); return (body['data'] as List).cast>(); } @@ -414,14 +341,7 @@ class ApiClient { endpoint: '/server-backends', data: {'name': name, 'api_host': apiHost, 'secure': secure}, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to create server backend', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to create server backend'); } Future updateServerBackend({ @@ -438,26 +358,14 @@ class ApiClient { 'secure': ?secure, }, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to update server backend', - ); - } + _ok(response, 'Failed to update server backend'); } Future deleteServerBackend(String id) async { final response = await httpClient.delete( endpoint: '/server-backends/$id', ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to delete server backend', - ); - } + _ok(response, 'Failed to delete server backend'); } Future activateServerBackend(String id) async { @@ -465,12 +373,7 @@ class ApiClient { endpoint: '/server-backends/$id/activate', data: {}, ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to activate server backend', - ); - } + _ok(response, 'Failed to activate server backend'); } /// Test whether a remote host is healthy before adding it as a backend. @@ -501,10 +404,7 @@ class ApiClient { Future>> getPeers() async { final response = await httpClient.get(endpoint: '/peers'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get peers'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get peers'); return (body['data'] as List).cast>(); } @@ -513,9 +413,7 @@ class ApiClient { endpoint: '/peers/$id/block', data: {}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to block peer'); - } + _ok(response, 'Failed to block peer'); } Future unblockPeer(String id) async { @@ -523,9 +421,7 @@ class ApiClient { endpoint: '/peers/$id/unblock', data: {}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to unblock peer'); - } + _ok(response, 'Failed to unblock peer'); } // --------------------------------------------------------------------------- @@ -534,10 +430,7 @@ class ApiClient { Future> getWorkspaces() async { final response = await httpClient.get(endpoint: '/workspaces'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get workspaces'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get workspaces'); return (body['data'] as List) .map((e) => Workspace.fromJson(e as Map)) .toList(); @@ -548,16 +441,8 @@ class ApiClient { endpoint: '/workspaces', data: {'name': name}, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to create workspace', - ); - } - return Workspace.fromJson( - jsonDecode(response.body) as Map, - ); + final body = _ok(response, 'Failed to create workspace'); + return Workspace.fromJson(body); } Future renameWorkspace(String id, String name) async { @@ -565,20 +450,12 @@ class ApiClient { endpoint: '/workspaces/$id', data: {'name': name}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to rename workspace'); - } + _ok(response, 'Failed to rename workspace'); } Future deleteWorkspace(String id) async { final response = await httpClient.delete(endpoint: '/workspaces/$id'); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to delete workspace', - ); - } + _ok(response, 'Failed to delete workspace'); } /// Loads workspaces from the backend and sets the active one. @@ -601,10 +478,7 @@ class ApiClient { Future> getLyricSheets() async { final response = await httpClient.get(endpoint: '/lyric-book'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to load lyric sheets'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to load lyric sheets'); final list = body['data'] as List; return list .map((e) => LyricSheet.fromJson(e as Map)) @@ -619,26 +493,13 @@ class ApiClient { endpoint: '/lyric-book', data: {'title': title, 'content': content}, ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to create lyric sheet', - ); - } - return LyricSheet.fromJson( - jsonDecode(response.body) as Map, - ); + final body = _ok(response, 'Failed to create lyric sheet'); + return LyricSheet.fromJson(body); } Future> getLyricSheetDetail(String id) async { final response = await httpClient.get(endpoint: '/lyric-book/$id'); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to load lyric sheet', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to load lyric sheet'); } Future updateLyricSheet( @@ -653,35 +514,19 @@ class ApiClient { endpoint: '/lyric-book/$id', data: data, ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to update lyric sheet', - ); - } + _ok(response, 'Failed to update lyric sheet'); } Future deleteLyricSheet(String id) async { final response = await httpClient.delete(endpoint: '/lyric-book/$id'); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to delete lyric sheet', - ); - } + _ok(response, 'Failed to delete lyric sheet'); } Future> searchLyricSheets(String query) async { final response = await httpClient.get( endpoint: '/lyric-book/search?q=${Uri.encodeQueryComponent(query)}', ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to search lyric sheets', - ); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to search lyric sheets'); final list = body['data'] as List; return list .map((e) => LyricSheet.fromJson(e as Map)) @@ -694,12 +539,7 @@ class ApiClient { endpoint: '/audio/songs/$taskId', data: data, ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to link song to lyric sheet', - ); - } + _ok(response, 'Failed to link song to lyric sheet'); } // --------------------------------------------------------------------------- @@ -719,14 +559,7 @@ class ApiClient { 'audio_model': ?audioModel, }, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to generate lyrics', - ); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to generate lyrics'); return body['lyrics'] as String; } @@ -743,14 +576,7 @@ class ApiClient { 'audio_model': ?audioModel, }, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to generate prompt', - ); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to generate prompt'); return body['prompt'] as String; } @@ -760,10 +586,7 @@ class ApiClient { Future> getModelDefaults(String model) async { final response = await httpClient.get(endpoint: '/audio/$model/defaults'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get model defaults'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get model defaults'); return (body['data'] as Map?) ?? body; } @@ -774,11 +597,7 @@ class ApiClient { Future getModelCapabilities(String model) async { final response = await httpClient.get(endpoint: '/audio/$model/capabilities'); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, 'Failed to get model capabilities'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get model capabilities'); final data = (body['data'] as Map?) ?? body; return ModelCapabilities.fromJson(data); } @@ -789,10 +608,7 @@ class ApiClient { Future>> getLoraList() async { final response = await httpClient.get(endpoint: '/audio/lora/list'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to list LoRAs'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to list LoRAs'); // The endpoint wraps the payload in a `data` key – unwrap it. final data = (body['data'] as Map?) ?? body; return (data['loras'] as List?)?.cast>() ?? @@ -801,10 +617,7 @@ class ApiClient { Future> getLoraStatus() async { final response = await httpClient.get(endpoint: '/audio/lora/status'); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to get LoRA status'); - } - final body = jsonDecode(response.body) as Map; + final body = _ok(response, 'Failed to get LoRA status'); // The Modal endpoint wraps the payload in a `data` key – unwrap it. return (body['data'] as Map?) ?? body; } @@ -817,14 +630,7 @@ class ApiClient { endpoint: '/audio/lora/load', data: {'lora_path': loraPath, 'adapter_name': ?adapterName}, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to load LoRA', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to load LoRA'); } Future> unloadLora() async { @@ -832,10 +638,7 @@ class ApiClient { endpoint: '/audio/lora/unload', data: {}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to unload LoRA'); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to unload LoRA'); } Future> toggleLora(bool useLora) async { @@ -843,10 +646,7 @@ class ApiClient { endpoint: '/audio/lora/toggle', data: {'use_lora': useLora}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to toggle LoRA'); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to toggle LoRA'); } Future> setLoraScale( @@ -857,10 +657,7 @@ class ApiClient { endpoint: '/audio/lora/scale', data: {'scale': scale, 'adapter_name': ?adapterName}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to set LoRA scale'); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to set LoRA scale'); } // --------------------------------------------------------------------------- @@ -872,14 +669,7 @@ class ApiClient { endpoint: '/training/load_tensor_info', data: {'tensor_dir': tensorDir}, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to load tensor info', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to load tensor info'); } Future> startTraining( @@ -889,14 +679,7 @@ class ApiClient { endpoint: '/training/start', data: params, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to start training', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to start training'); } Future> startLoKRTraining( @@ -906,25 +689,12 @@ class ApiClient { endpoint: '/training/start_lokr', data: params, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to start LoKR training', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to start LoKR training'); } Future> getTrainingStatus() async { final response = await httpClient.get(endpoint: '/training/status'); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to get training status', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to get training status'); } Future stopTraining() async { @@ -932,9 +702,7 @@ class ApiClient { endpoint: '/training/stop', data: {}, ); - if (response.statusCode != 200) { - throw ApiException(response.statusCode, 'Failed to stop training'); - } + _ok(response, 'Failed to stop training'); } Future> exportLora({ @@ -945,14 +713,7 @@ class ApiClient { endpoint: '/training/export', data: {'export_path': exportPath, 'lora_output_dir': loraOutputDir}, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to export LoRA', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to export LoRA'); } // --------------------------------------------------------------------------- @@ -979,16 +740,7 @@ class ApiClient { 'all_instrumental': allInstrumental.toString(), }, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['error'] as String? ?? - body['message'] as String? ?? - 'Failed to upload dataset zip', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to upload dataset zip'); } Future> loadDataset(String datasetPath) async { @@ -996,16 +748,7 @@ class ApiClient { endpoint: '/dataset/load', data: {'dataset_path': datasetPath}, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['error'] as String? ?? - body['message'] as String? ?? - 'Failed to load dataset', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to load dataset'); } Future> startAutoLabel( @@ -1015,42 +758,21 @@ class ApiClient { endpoint: '/dataset/auto_label_async', data: params, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['error'] as String? ?? - body['message'] as String? ?? - 'Failed to start auto-labeling', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to start auto-labeling'); } Future> getAutoLabelStatus() async { final response = await httpClient.get( endpoint: '/dataset/auto_label_status', ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to get auto-label status', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to get auto-label status'); } Future> getAutoLabelTaskStatus(String taskId) async { final response = await httpClient.get( endpoint: '/dataset/auto_label_status/$taskId', ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to get auto-label task status', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to get auto-label task status'); } Future> saveDataset( @@ -1060,16 +782,7 @@ class ApiClient { endpoint: '/dataset/save', data: params, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['error'] as String? ?? - body['message'] as String? ?? - 'Failed to save dataset', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to save dataset'); } Future> startPreprocess( @@ -1079,53 +792,26 @@ class ApiClient { endpoint: '/dataset/preprocess_async', data: params, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['error'] as String? ?? - body['message'] as String? ?? - 'Failed to start preprocessing', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to start preprocessing'); } Future> getPreprocessStatus() async { final response = await httpClient.get( endpoint: '/dataset/preprocess_status', ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to get preprocess status', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to get preprocess status'); } Future> getPreprocessTaskStatus(String taskId) async { final response = await httpClient.get( endpoint: '/dataset/preprocess_status/$taskId', ); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to get preprocess task status', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to get preprocess task status'); } Future> getDatasetSamples() async { final response = await httpClient.get(endpoint: '/dataset/samples'); - if (response.statusCode != 200) { - throw ApiException( - response.statusCode, - 'Failed to get dataset samples', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to get dataset samples'); } Future> updateDatasetSample( @@ -1136,16 +822,7 @@ class ApiClient { endpoint: '/dataset/sample/$idx', data: data, ); - if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['error'] as String? ?? - body['message'] as String? ?? - 'Failed to update dataset sample', - ); - } - return jsonDecode(response.body) as Map; + return _ok(response, 'Failed to update dataset sample'); } // --------------------------------------------------------------------------- @@ -1163,12 +840,22 @@ class ApiClient { endpoint: '/browse', data: data, ); + return _ok(response, 'Failed to browse directory'); + } + + /// Asserts [response] has status 200, decodes the JSON body and returns it. + /// On non-200 responses, tries to extract an error message from the body + /// before throwing [ApiException]. + Map _ok(http.Response response, String fallback) { if (response.statusCode != 200) { - final body = jsonDecode(response.body) as Map; - throw ApiException( - response.statusCode, - body['message'] as String? ?? 'Failed to browse directory', - ); + String message = fallback; + try { + final body = jsonDecode(response.body) as Map; + message = body['error'] as String? ?? + body['message'] as String? ?? + fallback; + } catch (_) {} + throw ApiException(response.statusCode, message); } return jsonDecode(response.body) as Map; }