Files
Ensemble/lib/services/recently_played_service.dart
Your NameandClaude Opus 4.5 79e3862895 Fix critical audit issues: secure storage, memory leaks, race conditions
Security:
- Add flutter_secure_storage dependency for credential protection
- Create SecureStorageService for encrypted credential storage
- Migrate auth tokens, passwords, API tokens from SharedPreferences
- Add automatic migration for existing users in main.dart

Memory Leaks:
- Fix audio session listener disposal in MassivAudioHandler
- Store all 4 stream subscriptions and add dispose() method
- Store connection state subscription in MusicAssistantProvider
- Fix StreamController disposal in HardwareVolumeService

Race Conditions:
- Add reentrancy guard to selectPlayer() to prevent concurrent calls

Code Quality:
- Fix 7 silent exception handlers across codebase
- Log errors with context instead of swallowing silently
- Fix double opacity bug in player_controls.dart shuffle/repeat buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:28:49 +00:00

260 lines
7.9 KiB
Dart

import 'dart:convert';
import 'database_service.dart';
import 'debug_logger.dart';
import '../database/database.dart';
import '../models/media_item.dart';
/// Service for tracking recently played items locally, per-profile
/// This supplements MA's server-side tracking with instant local data
class RecentlyPlayedService {
static RecentlyPlayedService? _instance;
static final _logger = DebugLogger();
RecentlyPlayedService._();
/// Get the singleton instance
static RecentlyPlayedService get instance {
_instance ??= RecentlyPlayedService._();
return _instance!;
}
DatabaseService get _db => DatabaseService.instance;
/// Record an album being played
Future<void> recordAlbumPlayed(Album album) async {
if (!_db.isInitialized) return;
try {
// Store images metadata for instant artwork display
final images = album.metadata?['images'];
await _db.addRecentlyPlayed(
mediaId: album.itemId,
mediaType: 'album',
name: album.name,
artistName: album.artistsString,
metadata: {
'provider': album.provider,
'uri': album.uri,
if (images != null) 'images': images,
},
);
_logger.log('📝 Recorded album play: ${album.name}');
} catch (e) {
_logger.log('⚠️ Failed to record album play: $e');
}
}
/// Record an artist being played (e.g., artist radio)
Future<void> recordArtistPlayed(Artist artist) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: artist.itemId,
mediaType: 'artist',
name: artist.name,
metadata: {
'provider': artist.provider,
'uri': artist.uri,
},
);
_logger.log('📝 Recorded artist play: ${artist.name}');
} catch (e) {
_logger.log('⚠️ Failed to record artist play: $e');
}
}
/// Record a track being played
Future<void> recordTrackPlayed(Track track) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: track.itemId,
mediaType: 'track',
name: track.name,
artistName: track.artistsString,
metadata: {
'provider': track.provider,
'uri': track.uri,
'albumName': track.album?.name,
},
);
_logger.log('📝 Recorded track play: ${track.name}');
} catch (e) {
_logger.log('⚠️ Failed to record track play: $e');
}
}
/// Record a playlist being played
Future<void> recordPlaylistPlayed(Playlist playlist) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: playlist.itemId,
mediaType: 'playlist',
name: playlist.name,
metadata: {
'provider': playlist.provider,
'uri': playlist.uri,
},
);
_logger.log('📝 Recorded playlist play: ${playlist.name}');
} catch (e) {
_logger.log('⚠️ Failed to record playlist play: $e');
}
}
/// Record an audiobook being played
Future<void> recordAudiobookPlayed(Audiobook audiobook) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: audiobook.itemId,
mediaType: 'audiobook',
name: audiobook.name,
artistName: audiobook.authorsString,
metadata: {
'provider': audiobook.provider,
'uri': audiobook.uri,
'narrators': audiobook.narratorsString,
'duration': audiobook.duration?.inSeconds,
'resumePositionMs': audiobook.resumePositionMs,
},
);
_logger.log('📝 Recorded audiobook play: ${audiobook.name}');
} catch (e) {
_logger.log('⚠️ Failed to record audiobook play: $e');
}
}
/// Get recently played albums for the current profile
/// Returns Album objects reconstructed from local data
Future<List<Album>> getRecentAlbums({int limit = 20}) async {
if (!_db.isInitialized) return [];
try {
final items = await _db.getRecentlyPlayed(limit: limit * 2); // Get more to filter
// Filter for albums and convert to Album objects
final albums = <Album>[];
final seenIds = <String>{};
for (final item in items) {
if (item.mediaType != 'album') continue;
if (seenIds.contains(item.mediaId)) continue;
seenIds.add(item.mediaId);
Map<String, dynamic>? metadata;
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (e) {
// Corrupted metadata JSON - log and continue with null metadata
_logger.log('⚠️ Error parsing album metadata for ${item.mediaId}: $e');
}
}
// Build metadata with images if available (for instant artwork display)
Map<String, dynamic>? albumMetadata;
if (metadata?['images'] != null) {
albumMetadata = {'images': metadata!['images']};
}
albums.add(Album(
itemId: item.mediaId,
provider: metadata?['provider'] ?? 'library',
name: item.name,
uri: metadata?['uri'],
// Create minimal artist info from stored name
artists: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
metadata: albumMetadata,
));
if (albums.length >= limit) break;
}
return albums;
} catch (e) {
_logger.log('⚠️ Failed to get recent albums: $e');
return [];
}
}
/// Get recently played audiobooks for the current profile
/// Returns Audiobook objects reconstructed from local data
Future<List<Audiobook>> getRecentAudiobooks({int limit = 20}) async {
if (!_db.isInitialized) return [];
try {
final items = await _db.getRecentlyPlayed(limit: limit * 2); // Get more to filter
// Filter for audiobooks and convert to Audiobook objects
final audiobooks = <Audiobook>[];
final seenIds = <String>{};
for (final item in items) {
if (item.mediaType != 'audiobook') continue;
if (seenIds.contains(item.mediaId)) continue;
seenIds.add(item.mediaId);
Map<String, dynamic>? metadata;
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (e) {
// Corrupted metadata JSON - log and continue with null metadata
_logger.log('⚠️ Error parsing audiobook metadata for ${item.mediaId}: $e');
}
}
audiobooks.add(Audiobook(
itemId: item.mediaId,
provider: metadata?['provider'] ?? 'library',
name: item.name,
uri: metadata?['uri'],
// Create minimal author info from stored name
authors: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
duration: metadata?['duration'] != null
? Duration(seconds: metadata!['duration'] as int)
: null,
resumePositionMs: metadata?['resumePositionMs'] as int?,
));
if (audiobooks.length >= limit) break;
}
return audiobooks;
} catch (e) {
_logger.log('⚠️ Failed to get recent audiobooks: $e');
return [];
}
}
/// Get all recently played items for the current profile
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) async {
if (!_db.isInitialized) return [];
return _db.getRecentlyPlayed(limit: limit);
}
/// Check if we have local recently played data
Future<bool> hasLocalData() async {
if (!_db.isInitialized) return false;
final items = await _db.getRecentlyPlayed(limit: 1);
return items.isNotEmpty;
}
/// Clear recently played for current profile
Future<void> clear() async {
if (!_db.isInitialized) return;
await _db.clearRecentlyPlayed();
_logger.log('🗑️ Cleared recently played');
}
}