♻️ Refactor lyrics fetching

This commit is contained in:
2025-12-19 00:16:23 +08:00
parent a37d762b1b
commit e820fb08de
8 changed files with 53 additions and 263 deletions

View File

@@ -1,201 +0,0 @@
# GroovyBox Track Repository Refactor Summary
## Overview
Successfully refactored the `lib/data/track_repository.dart` to support more settings including in-place indexing and folder watching functionality.
## Key Changes Made
### 1. Database Schema Updates (`lib/data/db.dart`)
- Added `WatchFolders` table for managing watch folders
- Added `AppSettings` table for storing app preferences
- Updated database schema version to 6
- Added proper migrations for new tables
### 2. Settings Provider (`lib/providers/settings_provider.dart`)
- Created comprehensive settings management with Riverpod
- Added `ImportMode` enum (Copy vs In-place)
- Added auto-scan and watch-for-changes settings
- Added supported audio formats configuration
- Persistent storage using SharedPreferences
### 3. Watch Folder Provider (`lib/providers/watch_folder_provider.dart`)
- Created service for managing watch folders
- Added database operations for CRUD operations
- Simplified implementation avoiding complex watcher issues
- Added folder scanning functionality
- Added missing track cleanup
### 4. Track Repository Refactor (`lib/data/track_repository.dart`)
- **Major Changes:**
- Split `importFiles` into mode-specific methods
- `_importFilesWithCopy`: Original behavior (copies files to internal storage)
- `_importFilesInPlace`: New behavior (indexes files in original location)
- Added `scanDirectory` method for folder scanning
- Added `scanWatchFolders` method for bulk scanning
- Added file event handlers (`addFileFromWatch`, `removeFileFromWatch`, `updateFileFromWatch`)
- Added `cleanupMissingTracks` for maintaining database integrity
- Updated `deleteTrack` to handle in-place vs copied files correctly
### 5. Settings UI (`lib/ui/screens/settings_screen.dart`)
- Created comprehensive settings interface
- Import mode selection (Copy vs In-place)
- Auto-scan and watch-for-changes toggles
- Watch folders management section
- Supported formats display
- Integration with new providers
### 6. Dependencies (`pubspec.yaml`)
- Added `watcher: ^1.2.0` for file system monitoring
- Added `shared_preferences: ^2.3.5` for settings persistence
## New Functionality
### Import Modes
1. **Copy Mode (Default):**
- Original behavior maintained
- Files copied to internal music directory
- Safe file management
- Suitable for mobile devices
2. **In-place Mode:**
- Files indexed in original location
- No additional storage usage
- Preserves original file organization
- Suitable for desktop/storage-rich environments
### Watch Folder Features
- Add/remove watch folders
- Toggle active/inactive status
- Bulk scanning of all active folders
- Automatic cleanup of missing tracks
- Support for recursive scanning
### Settings Management
- Persistent storage of user preferences
- Auto-scan scheduling
- File change monitoring toggle
- Configurable audio formats
## Usage Examples
### Switch to In-place Indexing
```dart
// Update settings to use in-place indexing
ref.read(settingsProvider.notifier).setImportMode(ImportMode.inplace);
```
### Add Watch Folder
```dart
// Add a folder to watch list
final watchService = ref.read(watchFolderServiceProvider);
await watchService.addWatchFolder('/path/to/music', name: 'My Music');
```
### Scan Watch Folders
```dart
// Scan all active watch folders
final trackRepo = ref.read(trackRepositoryProvider);
await trackRepo.scanWatchFolders();
```
### Cleanup Missing Tracks
```dart
// Remove tracks that no longer exist
final trackRepo = ref.read(trackRepositoryProvider);
await trackRepo.cleanupMissingTracks();
```
## Benefits
### User Experience
- Flexible import options for different use cases
- Automatic library maintenance
- Real-time folder monitoring capabilities
- Preserved file organization when desired
### Performance
- Efficient database operations
- Selective file scanning
- Proper resource cleanup
- Minimal storage impact for in-place mode
### Maintainability
- Clear separation of concerns
- Modular provider architecture
- Comprehensive error handling
- Extensible design for future features
## Future Enhancements
### Potential Additions
1. Real-time file watching implementation
2. Advanced file format detection
3. Folder exclusion/inclusion patterns
4. Metadata caching for performance
5. Batch operations optimization
6. Conflict resolution for duplicate files
### UI Improvements
1. Watch folder management interface
2. Import progress indicators
3. Folder scanning status
4. Settings organization and search
5. Conflict resolution dialogs
## Migration Guide
### For Existing Users
- Current behavior preserved (copy mode by default)
- Manual switch to in-place mode available
- Existing copied files unaffected
- Gradual migration possible
### Recommended Workflow
1. Start with copy mode for testing
2. Add watch folders in in-place mode
3. Enable auto-scan when comfortable
4. Use cleanup to maintain library
## Technical Notes
### Database Considerations
- Unique path constraint ensures no duplicates
- Cascade deletion maintains referential integrity
- Proper indexing on path for performance
- Migration handles existing installations
### File System Safety
- Existence checks before operations
- Graceful error handling
- Safe disposal of file watchers
- Album art always stored internally
### Memory Management
- Lazy loading of watch folders
- Efficient streaming for large libraries
- Proper cleanup of resources
- Minimal memory footprint
## Testing Recommendations
### Unit Tests
- Test import mode switching
- Test watch folder operations
- Test file event handling
- Test cleanup functionality
- Test settings persistence
### Integration Tests
- Test full import workflows
- Test settings changes
- Test database migrations
- Test file system scenarios
### Edge Cases
- Large file collections
- Network storage scenarios
- Permission denials
- File system errors
- Corrupted metadata
This refactor provides a solid foundation for enhanced music library management while maintaining backward compatibility and enabling powerful new features.

View File

@@ -1,5 +1,5 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:dio/dio.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart' as path_provider;
import 'dart:io';
@@ -16,10 +16,10 @@ class Lyrics {
/// Abstract base class for LRC providers
abstract class LrcProvider {
late final http.Client session;
late final Dio session;
LrcProvider() {
session = http.Client();
session = Dio();
}
String get name;
@@ -41,7 +41,7 @@ class MusixmatchProvider extends LrcProvider {
@override
String get name => 'Musixmatch';
Future<http.Response> _get(
Future<Response> _get(
String action,
List<MapEntry<String, String>> query,
) async {
@@ -55,7 +55,7 @@ class MusixmatchProvider extends LrcProvider {
final t = DateTime.now().millisecondsSinceEpoch.toString();
query.add(MapEntry("t", t));
final url = rootUrl + action;
return await session.get(Uri.parse(url), headers: Map.fromEntries(query));
return await session.get(url, queryParameters: Map.fromEntries(query));
}
Future<void> _getToken() async {
@@ -84,7 +84,7 @@ class MusixmatchProvider extends LrcProvider {
await Future.delayed(Duration(seconds: 10));
return await _getToken();
}
final newToken = jsonDecode(d.body)["message"]["body"]["user_token"];
final newToken = jsonDecode(d.data)["message"]["body"]["user_token"];
final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expirationTime = currentTime + 600; // 10 minutes
token = newToken;
@@ -105,7 +105,7 @@ class MusixmatchProvider extends LrcProvider {
MapEntry("translation_fields_set", "minimal"),
MapEntry("selected_language", lang!),
]);
final bodyTr = jsonDecode(rTr.body)["message"]["body"];
final bodyTr = jsonDecode(rTr.data)["message"]["body"];
if (bodyTr["translations_list"] == null ||
(bodyTr["translations_list"] as List).isEmpty) {
throw Exception("Couldn't find translations");
@@ -113,7 +113,7 @@ class MusixmatchProvider extends LrcProvider {
// Translation handling would need full implementation
}
if (r.statusCode != 200) return null;
final body = jsonDecode(r.body)["message"]["body"];
final body = jsonDecode(r.data)["message"]["body"];
if (body == null) return null;
final lrcStr = body["subtitle"]["subtitle_body"];
final lrc = Lyrics(synced: lrcStr);
@@ -124,9 +124,9 @@ class MusixmatchProvider extends LrcProvider {
var lrc = Lyrics();
final r = await _get("track.richsync.get", [MapEntry("track_id", trackId)]);
if (r.statusCode == 200 &&
jsonDecode(r.body)["message"]["header"]["status_code"] == 200) {
jsonDecode(r.data)["message"]["header"]["status_code"] == 200) {
final lrcRaw = jsonDecode(
r.body,
r.data,
)["message"]["body"]["richsync"]["richsync_body"];
final data = jsonDecode(lrcRaw);
String lrcStr = "";
@@ -157,9 +157,9 @@ class MusixmatchProvider extends LrcProvider {
MapEntry("page_size", "5"),
MapEntry("page", "1"),
]);
final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"];
final statusCode = jsonDecode(r.data)["message"]["header"]["status_code"];
if (statusCode != 200) return null;
final body = jsonDecode(r.body)["message"]["body"];
final body = jsonDecode(r.data)["message"]["body"];
if (body == null || body is! Map) return null;
final tracks = body["track_list"];
if (tracks == null || tracks is! List || tracks.isEmpty) return null;
@@ -194,12 +194,13 @@ class NetEaseProvider extends LrcProvider {
Future<Map<String, dynamic>?> searchTrack(String searchTerm) async {
final params = {"limit": "10", "type": "1", "offset": "0", "s": searchTerm};
final response = await session.get(
Uri.parse(apiEndpointMetadata).replace(queryParameters: params),
headers: {"cookie": cookie},
apiEndpointMetadata,
queryParameters: params,
options: Options(headers: {"cookie": cookie}),
);
// Update the session cookies from the new sent cookies for the next request.
// In http package, we can set it, but for simplicity, pass to next call
final results = jsonDecode(response.body)["result"]["songs"];
final results = jsonDecode(response.data)["result"]["songs"];
if (results == null || results.isEmpty) return null;
// Simple best match - first track
return results[0];
@@ -208,10 +209,11 @@ class NetEaseProvider extends LrcProvider {
Future<Lyrics?> getLrcById(String trackId) async {
final params = {"id": trackId, "lv": "1"};
final response = await session.get(
Uri.parse(apiEndpointLyrics).replace(queryParameters: params),
headers: {"cookie": cookie},
apiEndpointLyrics,
queryParameters: params,
options: Options(headers: {"cookie": cookie}),
);
final data = jsonDecode(response.body);
final data = jsonDecode(response.data);
final lrc = Lyrics(synced: data["lrc"]["lyric"]);
return lrc;
}

View File

@@ -1,11 +1,11 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:riverpod/riverpod.dart';
import 'package:groovybox/data/db.dart';
import 'package:groovybox/data/track_repository.dart';
import 'package:groovybox/providers/db_provider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:drift/drift.dart';
import '../data/db.dart';
import '../data/track_repository.dart';
import '../providers/db_provider.dart';
// Simple watch folder provider using Riverpod
final watchFoldersProvider = FutureProvider<List<WatchFolder>>((ref) async {

View File

@@ -732,7 +732,7 @@ class LibraryScreen extends HookConsumerWidget {
if (await file.exists()) {
final stat = await file.stat();
final sizeInMB = (stat.size / (1024 * 1024)).toStringAsFixed(2);
fileSize = '${sizeInMB} MB';
fileSize = '$sizeInMB MB';
dateAdded = stat.modified.toString().split(
' ',
)[0]; // Just the date part
@@ -752,7 +752,9 @@ class LibraryScreen extends HookConsumerWidget {
}
});
final screenSize = MediaQuery.of(context).size;
if (!context.mounted) return;
final screenSize = MediaQuery.sizeOf(context);
showDialog(
context: context,

View File

@@ -371,7 +371,12 @@ class _CoverView extends StatelessWidget {
child: Column(
children: [
Expanded(
child: Center(child: _PlayerCoverArt(metadataAsync: metadataAsync)),
child: Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Center(
child: _PlayerCoverArt(metadataAsync: metadataAsync),
),
),
),
_PlayerControls(
player: player,
@@ -456,7 +461,7 @@ class _PlayerCoverArt extends StatelessWidget {
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Center(

View File

@@ -160,40 +160,6 @@ class SettingsScreen extends ConsumerWidget {
),
),
),
const SizedBox(height: 16),
// Supported Formats Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Supported Formats',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: settings.supportedFormats.map((format) {
return Chip(
label: Text(format.toUpperCase()),
backgroundColor: Theme.of(
context,
).primaryColor.withOpacity(0.1),
);
}).toList(),
),
],
),
),
),
],
),
),

View File

@@ -305,6 +305,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: "direct main"
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.dev"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
drift:
dependency: "direct main"
description:
@@ -505,7 +521,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View File

@@ -50,7 +50,7 @@ dependencies:
gap: ^3.0.1
styled_widget: ^0.4.1
super_sliver_list: ^0.4.1
http: ^1.0.0
dio: ^5.0.0
audio_service: ^0.18.18
palette_generator: ^0.3.3+4
watcher: ^1.2.0