lrc fetcher

This commit is contained in:
2025-12-15 01:13:28 +08:00
parent 4a5b7c73e4
commit 9f44786d6d
9 changed files with 708 additions and 28 deletions

View File

@@ -0,0 +1,240 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart' as path_provider;
import 'dart:io';
/// Represents lyrics data
class Lyrics {
final String? synced; // LRC format synced lyrics
final String? plain; // Plain text lyrics
Lyrics({this.synced, this.plain});
Lyrics withSynced(String? s) => Lyrics(synced: s, plain: plain);
}
/// Abstract base class for LRC providers
abstract class LRCProvider {
late final http.Client session;
LRCProvider() {
session = http.Client();
}
String get name;
/// Search and retrieve lyrics by search term (usually title + artist)
Future<Lyrics?> getLrc(String searchTerm);
}
/// Musixmatch LRC provider
class MusixmatchProvider extends LRCProvider {
static const String rootUrl = "https://apic-desktop.musixmatch.com/ws/1.1/";
final String? lang;
final bool enhanced;
String? token;
MusixmatchProvider({this.lang, this.enhanced = false});
String get name => 'Musixmatch';
Future<http.Response> _get(
String action,
List<MapEntry<String, String>> query,
) async {
if (action != "token.get" && token == null) {
await _getToken();
}
query.add(MapEntry("app_id", "web-desktop-app-v1.0"));
if (token != null) {
query.add(MapEntry("usertoken", token!));
}
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));
}
Future<void> _getToken() async {
final dir = await path_provider.getApplicationSupportDirectory();
final tokenPath = path.join(
dir.path,
"syncedlyrics",
"musixmatch_token.json",
);
final file = File(tokenPath);
if (file.existsSync()) {
final data = jsonDecode(file.readAsStringSync());
final cachedToken = data['token'];
final expirationTime = data['expiration_time'];
final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if (cachedToken != null &&
expirationTime != null &&
currentTime < expirationTime) {
token = cachedToken;
return;
}
}
// Token not cached or expired, fetch new token
final d = await _get("token.get", [MapEntry("user_language", "en")]);
if (d.statusCode == 401) {
await Future.delayed(Duration(seconds: 10));
return await _getToken();
}
final newToken = jsonDecode(d.body)["message"]["body"]["user_token"];
final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expirationTime = currentTime + 600; // 10 minutes
token = newToken;
final tokenData = {"token": newToken, "expiration_time": expirationTime};
file.createSync(recursive: true);
file.writeAsStringSync(jsonEncode(tokenData));
}
Future<Lyrics?> getLrcById(String trackId) async {
var r = await _get("track.subtitle.get", [
MapEntry("track_id", trackId),
MapEntry("subtitle_format", "lrc"),
]);
if (lang != null) {
final rTr = await _get("crowd.track.translations.get", [
MapEntry("track_id", trackId),
MapEntry("subtitle_format", "lrc"),
MapEntry("translation_fields_set", "minimal"),
MapEntry("selected_language", lang!),
]);
final bodyTr = jsonDecode(rTr.body)["message"]["body"];
if (bodyTr["translations_list"] == null ||
(bodyTr["translations_list"] as List).isEmpty) {
throw Exception("Couldn't find translations");
}
// Translation handling would need full implementation
}
if (r.statusCode != 200) return null;
final body = jsonDecode(r.body)["message"]["body"];
if (body == null) return null;
final lrcStr = body["subtitle"]["subtitle_body"];
final lrc = Lyrics(synced: lrcStr);
return lrc;
}
Future<Lyrics?> getLrcWordByWord(String trackId) async {
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) {
final lrcRaw = jsonDecode(
r.body,
)["message"]["body"]["richsync"]["richsync_body"];
final data = jsonDecode(lrcRaw);
String lrcStr = "";
if (data is List) {
for (final i in data) {
lrcStr += "[${formatTime(i['ts'])}] ";
if (i['l'] is List) {
for (final l in i['l']) {
final t = formatTime(
double.parse(i['ts'].toString()) +
double.parse(l['o'].toString()),
);
lrcStr += "<$t> ${l['c']} ";
}
}
lrcStr += "\n";
}
}
lrc = lrc.withSynced(lrcStr);
}
return lrc;
}
@override
Future<Lyrics?> getLrc(String searchTerm) async {
final r = await _get("track.search", [
MapEntry("q", searchTerm),
MapEntry("page_size", "5"),
MapEntry("page", "1"),
]);
final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"];
if (statusCode != 200) return null;
final body = jsonDecode(r.body)["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;
// Simple "best match" - first track
final track = tracks.firstWhere((t) => true, orElse: () => null);
if (track == null) return null;
final trackId = track["track"]["track_id"];
if (enhanced) {
final lrc = await getLrcWordByWord(trackId);
if (lrc != null && lrc.synced != null) {
return lrc;
}
}
return await getLrcById(trackId);
}
}
/// NetEase provider
class NetEaseProvider extends LRCProvider {
static const String apiEndpointMetadata =
"https://music.163.com/api/search/pc";
static const String apiEndpointLyrics =
"https://music.163.com/api/song/lyric";
static const String cookie =
"NMTID=00OAVK3xqDG726ITU6jopU6jF2yMk0AAAGCO8l1BA; JSESSIONID-WYYY=8KQo11YK2GZP45RMlz8Kn80vHZ9%2FGvwzRKQXXy0iQoFKycWdBlQjbfT0MJrFa6hwRfmpfBYKeHliUPH287JC3hNW99WQjrh9b9RmKT%2Fg1Exc2VwHZcsqi7ITxQgfEiee50po28x5xTTZXKoP%2FRMctN2jpDeg57kdZrXz%2FD%2FWghb%5C4DuZ%3A1659124633932; _iuqxldmzr_=32; _ntes_nnid=0db6667097883aa9596ecfe7f188c3ec,1659122833973; _ntes_nuid=0db6667097883aa9596ecfe7f188c3ec; WNMCID=xygast.1659122837568.01.0; WEVNSM=1.0.0; WM_NI=CwbjWAFbcIzPX3dsLP%2F52VB%2Bxr572gmqAYwvN9KU5X5f1nRzBYl0SNf%2BV9FTmmYZy%2FoJLADaZS0Q8TrKfNSBNOt0HLB8rRJh9DsvMOT7%2BCGCQLbvlWAcJBJeXb1P8yZ3RHA%3D; WM_NIKE=9ca17ae2e6ffcda170e2e6ee90c65b85ae87b9aa5483ef8ab3d14a939e9a83c459959caeadce47e991fbaee82af0fea7c3b92a81a9ae8aabb64b86beadaaf95c9c437e2a3; WM_TID=AAkRFnl03RdABEBEQFOBWHCPOeMra4IL; playerid=94262567";
@override
String get name => 'NetEase';
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},
);
// 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"];
if (results == null || results.isEmpty) return null;
// Simple best match - first track
return results[0];
}
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},
);
final data = jsonDecode(response.body);
final lrc = Lyrics(plain: data["lrc"]["lyric"]);
return lrc;
}
@override
Future<Lyrics?> getLrc(String searchTerm) async {
final track = await searchTrack(searchTerm);
if (track == null) return null;
return await getLrcById(track["id"].toString());
}
}
// Utility function
String formatTime(dynamic time) {
final seconds = time.toInt();
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
final centiseconds = ((time - seconds) * 100).toInt();
return '$minutes:$remainingSeconds.${centiseconds.toString().padLeft(2, '0')}';
}
// Extension for List<MapEntry>
extension MapEntryList on List<MapEntry<String, String>> {
Map<String, String> toMap() {
return Map.fromEntries(this);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:groovybox/data/db.dart' as db;
import 'package:drift/drift.dart' as drift;
import 'package:groovybox/logic/lrc_providers.dart';
import 'package:groovybox/logic/lyrics_parser.dart';
import 'package:groovybox/providers/db_provider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'lrc_fetcher_provider.g.dart';
@riverpod
class LyricsFetcher extends _$LyricsFetcher {
@override
LyricsFetcherState build() {
return LyricsFetcherState();
}
Future<void> fetchLyricsForTrack({
required int trackId,
required String searchTerm,
required LRCProvider provider,
required String trackPath,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final lyrics = await provider.getLrc(searchTerm);
if (lyrics == null) {
state = state.copyWith(
isLoading: false,
error: 'No lyrics found from ${provider.name}',
);
return;
}
// Parse the lyrics into LyricsData format
String? lyricsJson;
if (lyrics.synced != null) {
// It's LRC format
final lyricsData = LyricsParser.parseLrc(lyrics.synced!);
lyricsJson = lyricsData.toJsonString();
} else if (lyrics.plain != null) {
// Plain text
final lyricsData = LyricsParser.parsePlaintext(lyrics.plain!);
lyricsJson = lyricsData.toJsonString();
}
if (lyricsJson != null) {
// Update the track in the database
final database = ref.read(databaseProvider);
await (database.update(database.tracks)
..where((t) => t.id.equals(trackId)))
.write(db.TracksCompanion(lyrics: drift.Value(lyricsJson)));
state = state.copyWith(
isLoading: false,
successMessage: 'Lyrics fetched from ${provider.name}',
);
} else {
state = state.copyWith(
isLoading: false,
error: 'Failed to parse lyrics',
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Error fetching lyrics: $e',
);
}
}
}
class LyricsFetcherState {
final bool isLoading;
final String? error;
final String? successMessage;
LyricsFetcherState({this.isLoading = false, this.error, this.successMessage});
LyricsFetcherState copyWith({
bool? isLoading,
String? error,
String? successMessage,
}) {
return LyricsFetcherState(
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
successMessage: successMessage ?? this.successMessage,
);
}
}
// Providers for each LRC provider
final musixmatchProvider = Provider((ref) => MusixmatchProvider());
final neteaseProvider = Provider((ref) => NetEaseProvider());

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'lrc_fetcher_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(LyricsFetcher)
const lyricsFetcherProvider = LyricsFetcherProvider._();
final class LyricsFetcherProvider
extends $NotifierProvider<LyricsFetcher, LyricsFetcherState> {
const LyricsFetcherProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'lyricsFetcherProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$lyricsFetcherHash();
@$internal
@override
LyricsFetcher create() => LyricsFetcher();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(LyricsFetcherState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<LyricsFetcherState>(value),
);
}
}
String _$lyricsFetcherHash() => r'49468a75e00ab1533368acb52328b059831836d3';
abstract class _$LyricsFetcher extends $Notifier<LyricsFetcherState> {
LyricsFetcherState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<LyricsFetcherState, LyricsFetcherState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<LyricsFetcherState, LyricsFetcherState>,
LyricsFetcherState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:drift/drift.dart' as drift;
import 'package:groovybox/data/db.dart' as db;
import 'package:groovybox/logic/lyrics_parser.dart';
import 'package:groovybox/logic/metadata_service.dart';
import 'package:groovybox/providers/audio_provider.dart';
import 'package:groovybox/providers/db_provider.dart';
import 'package:groovybox/providers/lrc_fetcher_provider.dart';
import 'package:groovybox/ui/widgets/mini_player.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -296,16 +298,71 @@ class _PlayerLyrics extends HookConsumerWidget {
? ref.watch(_trackByPathProvider(trackPath!))
: const AsyncValue<db.Track?>.data(null);
final metadataAsync = trackPath != null
? ref.watch(trackMetadataProvider(trackPath!))
: const AsyncValue<TrackMetadata?>.data(null);
final lyricsFetcher = ref.watch(lyricsFetcherProvider);
final musixmatchProviderInstance = ref.watch(musixmatchProvider);
final neteaseProviderInstance = ref.watch(neteaseProvider);
return trackAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (track) {
if (track == null || track.lyrics == null) {
return const Center(
child: Text(
'No Lyrics Available',
style: TextStyle(fontStyle: FontStyle.italic),
),
// Show fetch lyrics UI
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'No Lyrics Available',
style: TextStyle(fontStyle: FontStyle.italic, fontSize: 18),
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Fetch Lyrics'),
onPressed: track != null && trackPath != null
? () => _showFetchLyricsDialog(
context,
ref,
track,
trackPath!,
metadataAsync.value,
musixmatchProviderInstance,
neteaseProviderInstance,
)
: null,
),
if (lyricsFetcher.isLoading)
Padding(
padding: const EdgeInsets.all(16.0),
child: LinearProgressIndicator(),
),
if (lyricsFetcher.error != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
lyricsFetcher.error!,
style: TextStyle(color: Colors.red, fontSize: 12),
textAlign: TextAlign.center,
),
),
if (lyricsFetcher.successMessage != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
lyricsFetcher.successMessage!,
style: TextStyle(
color: Colors.green,
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
);
}
@@ -313,22 +370,32 @@ class _PlayerLyrics extends HookConsumerWidget {
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
if (lyricsData.type == 'timed') {
return _TimedLyricsView(lyrics: lyricsData, player: player);
return Stack(
children: [
_TimedLyricsView(lyrics: lyricsData, player: player),
_LyricsRefreshButton(trackPath: trackPath!),
],
);
} else {
// Plain text lyrics
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: lyricsData.lines.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
lyricsData.lines[index].text,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
);
},
return Stack(
children: [
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: lyricsData.lines.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
lyricsData.lines[index].text,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
);
},
),
_LyricsRefreshButton(trackPath: trackPath!),
],
);
}
} catch (e) {
@@ -337,6 +404,210 @@ class _PlayerLyrics extends HookConsumerWidget {
},
);
}
void _showFetchLyricsDialog(
BuildContext context,
WidgetRef ref,
db.Track track,
String trackPath,
dynamic metadataObj,
musixmatchProvider,
neteaseProvider,
) {
final metadata = metadataObj as TrackMetadata?;
final searchTerm =
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
.trim();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Fetch Lyrics'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Search term: $searchTerm'),
const SizedBox(height: 16),
Text('Choose a provider:'),
const SizedBox(height: 8),
Row(
children: [
_ProviderButton(
name: 'Musixmatch',
onPressed: () async {
Navigator.of(context).pop();
await ref
.read(lyricsFetcherProvider.notifier)
.fetchLyricsForTrack(
trackId: track.id,
searchTerm: searchTerm,
provider: musixmatchProvider,
trackPath: trackPath,
);
},
),
const SizedBox(width: 8),
_ProviderButton(
name: 'NetEase',
onPressed: () async {
Navigator.of(context).pop();
await ref
.read(lyricsFetcherProvider.notifier)
.fetchLyricsForTrack(
trackId: track.id,
searchTerm: searchTerm,
provider: neteaseProvider,
trackPath: trackPath,
);
},
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
),
);
}
}
class _ProviderButton extends StatelessWidget {
final String name;
final VoidCallback onPressed;
const _ProviderButton({required this.name, required this.onPressed});
@override
Widget build(BuildContext context) {
return Expanded(
child: ElevatedButton(onPressed: onPressed, child: Text(name)),
);
}
}
class _LyricsRefreshButton extends HookConsumerWidget {
final String trackPath;
const _LyricsRefreshButton({required this.trackPath});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trackAsync = ref.watch(_trackByPathProvider(trackPath));
final metadataAsync = ref.watch(trackMetadataProvider(trackPath));
final musixmatchProviderInstance = ref.watch(musixmatchProvider);
final neteaseProviderInstance = ref.watch(neteaseProvider);
return Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
child: IconButton(
icon: const Icon(Icons.refresh),
iconSize: 24,
tooltip: 'Refresh Lyrics',
onPressed: () => _showLyricsRefreshDialog(
context,
ref,
trackAsync,
metadataAsync,
musixmatchProviderInstance,
neteaseProviderInstance,
),
padding: EdgeInsets.zero,
),
);
}
void _showLyricsRefreshDialog(
BuildContext context,
WidgetRef ref,
AsyncValue<db.Track?> trackAsync,
AsyncValue<TrackMetadata> metadataAsync,
musixmatchProvider,
neteaseProvider,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Refresh Lyrics'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Choose an action:'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Re-fetch'),
onPressed: trackAsync.maybeWhen(
data: (track) => track != null
? () async {
Navigator.of(context).pop();
final metadata = metadataAsync.value;
final searchTerm =
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
.trim();
await ref
.read(lyricsFetcherProvider.notifier)
.fetchLyricsForTrack(
trackId: track.id,
searchTerm: searchTerm,
provider:
musixmatchProvider, // Default to Musixmatch
trackPath: trackPath,
);
}
: null,
orElse: () => null,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.clear),
label: const Text('Clear'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: trackAsync.maybeWhen(
data: (track) => track != null
? () async {
Navigator.of(context).pop();
final database = ref.read(databaseProvider);
await (database.update(
database.tracks,
)..where((t) => t.id.equals(track.id))).write(
db.TracksCompanion(
lyrics: const drift.Value.absent(),
),
);
}
: null,
orElse: () => null,
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
),
);
}
}
// Provider to fetch a single track by path
@@ -504,7 +775,7 @@ class _TimedLyricsView extends HookWidget {
).colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
child: Text(line.text, textAlign: TextAlign.center),
child: Text(line.text),
),
),
);