summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 14:05:21 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 14:05:21 +0300
commitb7b2d404d035f4d83a5f3a2dc66055551dd022d3 (patch)
treefee3ac53bab0ed6fa294bb90334022393e97e624
parent67c90f9b9a7a861e0f6ab0f199d66cd411948fab (diff)
Fix PlayerApiClient const constructor and Media.tags deserialization (d9+e9)
- Remove const from PlayerApiClient constructor (Uri is not const-constructable) - Replace .cast<String>() with .whereType<String>().toList() in Media.fromJson to silently drop non-string and null tag elements instead of throwing TypeError - Add regression test: tags [1, 'valid', null] deserialized as ['valid'] Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-rw-r--r--player-android/lib/api/player_api_client.dart4
-rw-r--r--player-android/lib/models/media.dart5
-rw-r--r--player-android/test/models_test.dart9
3 files changed, 16 insertions, 2 deletions
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart
index 3031c73..eb438f4 100644
--- a/player-android/lib/api/player_api_client.dart
+++ b/player-android/lib/api/player_api_client.dart
@@ -6,7 +6,9 @@ class PlayerApiClient {
final Uri baseUrl;
final String bearerToken;
- const PlayerApiClient({required this.baseUrl, required this.bearerToken});
+ // Normal (non-const) constructor: Uri is not const-constructable, so the
+ // constructor must not be declared const.
+ PlayerApiClient({required this.baseUrl, required this.bearerToken});
Future<User> bootstrap({required String username, required String password}) => throw UnimplementedError();
Future<User> login({required String username, required String password}) => throw UnimplementedError();
diff --git a/player-android/lib/models/media.dart b/player-android/lib/models/media.dart
index 7330bca..19c57b8 100644
--- a/player-android/lib/models/media.dart
+++ b/player-android/lib/models/media.dart
@@ -10,7 +10,10 @@ class Media {
const Media({required this.id, required this.setId, required this.relPath, required this.fileName, required this.absPath, required this.type, required this.duration, required this.codec, required this.resolution, required this.bitrate, required this.fileSizeBytes, required this.width, required this.height, required this.thumbnailPath, required this.playCount, this.favorite = false, this.tags = const [], this.deletedAt, this.createdAt});
- factory Media.fromJson(Map<String, dynamic> json) => Media(id: json['id'] as int? ?? 0, setId: json['set_id'] as int? ?? 0, relPath: json['rel_path'] as String? ?? '', fileName: json['file_name'] as String? ?? '', absPath: json['abs_path'] as String? ?? '', type: json['type'] as String? ?? '', duration: (json['duration'] as num?)?.toDouble() ?? 0, codec: json['codec'] as String? ?? '', resolution: json['resolution'] as String? ?? '', bitrate: json['bitrate'] as int? ?? 0, fileSizeBytes: json['file_size_bytes'] as int? ?? 0, width: json['width'] as int? ?? 0, height: json['height'] as int? ?? 0, thumbnailPath: json['thumbnail_path'] as String? ?? '', playCount: json['play_count'] as int? ?? 0, favorite: json['favorite'] as bool? ?? false, tags: (json['tags'] as List<dynamic>? ?? const []).cast<String>(), deletedAt: dateTimeFromJson(json['deleted_at']), createdAt: dateTimeFromJson(json['created_at']));
+ factory Media.fromJson(Map<String, dynamic> json) => Media(id: json['id'] as int? ?? 0, setId: json['set_id'] as int? ?? 0, relPath: json['rel_path'] as String? ?? '', fileName: json['file_name'] as String? ?? '', absPath: json['abs_path'] as String? ?? '', type: json['type'] as String? ?? '', duration: (json['duration'] as num?)?.toDouble() ?? 0, codec: json['codec'] as String? ?? '', resolution: json['resolution'] as String? ?? '', bitrate: json['bitrate'] as int? ?? 0, fileSizeBytes: json['file_size_bytes'] as int? ?? 0, width: json['width'] as int? ?? 0, height: json['height'] as int? ?? 0, thumbnailPath: json['thumbnail_path'] as String? ?? '', playCount: json['play_count'] as int? ?? 0, favorite: json['favorite'] as bool? ?? false, // Safe tag deserialization: keep only elements that are already Strings,
+// silently dropping ints, nulls, or other unexpected types. This tolerates
+// malformed server responses without throwing a TypeError at runtime.
+tags: (json['tags'] as List<dynamic>? ?? []).whereType<String>().toList(), deletedAt: dateTimeFromJson(json['deleted_at']), createdAt: dateTimeFromJson(json['created_at']));
Map<String, dynamic> toJson() => {'id': id, 'set_id': setId, 'rel_path': relPath, 'file_name': fileName, 'abs_path': absPath, 'type': type, 'duration': duration, 'codec': codec, 'resolution': resolution, 'bitrate': bitrate, 'file_size_bytes': fileSizeBytes, 'width': width, 'height': height, 'thumbnail_path': thumbnailPath, 'play_count': playCount, 'favorite': favorite, 'tags': tags, 'deleted_at': dateTimeToJson(deletedAt), 'created_at': dateTimeToJson(createdAt)};
}
diff --git a/player-android/test/models_test.dart b/player-android/test/models_test.dart
index 22dc190..84fbf62 100644
--- a/player-android/test/models_test.dart
+++ b/player-android/test/models_test.dart
@@ -126,6 +126,15 @@ void main() {
expect(media.deletedAt, isNull);
expect(media.createdAt, isNull);
});
+
+ // Regression test for e9: non-string and null elements in the tags list
+ // must be silently dropped rather than causing a TypeError at runtime.
+ test('fromJson drops non-string and null tag elements', () {
+ final media = Media.fromJson({'tags': [1, 'valid', null]});
+
+ // Only 'valid' survives: the integer 1 and null are filtered out.
+ expect(media.tags, ['valid']);
+ });
});
// ---------------------------------------------------------------------------