mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
improved ranking system for matched youtube search results
synced lyrics initial implementation using rentanadviser
This commit is contained in:
parent
4efd600d13
commit
e92f107e55
@ -11,6 +11,7 @@ import 'package:spotify/spotify.dart' hide Image, Player, Search;
|
||||
import 'package:spotube/components/Category/CategoryCard.dart';
|
||||
import 'package:spotube/components/Home/Sidebar.dart';
|
||||
import 'package:spotube/components/Home/SpotubeNavigationBar.dart';
|
||||
import 'package:spotube/components/Lyrics/SyncedLyrics.dart';
|
||||
import 'package:spotube/components/Lyrics.dart';
|
||||
import 'package:spotube/components/Search/Search.dart';
|
||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||
@ -170,7 +171,7 @@ class Home extends HookConsumerWidget {
|
||||
}, [localStorage]);
|
||||
|
||||
final titleBarContents = Container(
|
||||
color: Theme.of(context).backgroundColor,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@ -222,7 +223,7 @@ class Home extends HookConsumerWidget {
|
||||
),
|
||||
if (_selectedIndex.value == 1) const Search(),
|
||||
if (_selectedIndex.value == 2) const UserLibrary(),
|
||||
if (_selectedIndex.value == 3) const Lyrics(),
|
||||
if (_selectedIndex.value == 3) const SyncedLyrics(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -7,6 +7,7 @@ import 'package:spotube/helpers/getLyrics.dart';
|
||||
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||
import 'package:spotube/provider/Playback.dart';
|
||||
import 'package:spotube/provider/UserPreferences.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
class Lyrics extends HookConsumerWidget {
|
||||
const Lyrics({Key? key}) : super(key: key);
|
||||
@ -26,7 +27,11 @@ class Lyrics extends HookConsumerWidget {
|
||||
}
|
||||
return getLyrics(
|
||||
playback.currentTrack!.name!,
|
||||
artistsToString<Artist>(playback.currentTrack!.artists ?? []),
|
||||
playback.currentTrack!.artists
|
||||
?.map((s) => s.name)
|
||||
.whereNotNull()
|
||||
.toList() ??
|
||||
[],
|
||||
apiKey: userPreferences.geniusAccessToken,
|
||||
optimizeQuery: true,
|
||||
);
|
||||
|
56
lib/components/Lyrics/SyncedLyrics.dart
Normal file
56
lib/components/Lyrics/SyncedLyrics.dart
Normal file
@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:spotube/helpers/timed-lyrics.dart';
|
||||
import 'package:spotube/provider/AudioPlayer.dart';
|
||||
import 'package:spotube/provider/Playback.dart';
|
||||
|
||||
class SyncedLyrics extends HookConsumerWidget {
|
||||
const SyncedLyrics({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
Playback playback = ref.watch(playbackProvider);
|
||||
AudioPlayer player = ref.watch(audioPlayerProvider);
|
||||
final timedLyrics = useMemoized(() {
|
||||
if (playback.currentTrack == null) return null;
|
||||
return getTimedLyrics(playback.currentTrack!);
|
||||
}, [playback.currentTrack]);
|
||||
final lyricsSnapshot = useFuture(timedLyrics);
|
||||
final stream = useStream(
|
||||
player.positionStream.isBroadcast
|
||||
? player.positionStream
|
||||
: player.positionStream.asBroadcastStream(),
|
||||
);
|
||||
|
||||
final lyricsMap = useMemoized(
|
||||
() =>
|
||||
lyricsSnapshot.data?.lyrics
|
||||
.map((lyric) => {lyric.time.inSeconds: lyric.text})
|
||||
.reduce((a, b) => {...a, ...b}) ??
|
||||
{},
|
||||
[lyricsSnapshot.data],
|
||||
);
|
||||
|
||||
print(lyricsSnapshot.data?.name);
|
||||
|
||||
final currentLyric = useState("");
|
||||
|
||||
useEffect(() {
|
||||
if (stream.hasData && lyricsMap.containsKey(stream.data!.inSeconds)) {
|
||||
currentLyric.value = lyricsMap[stream.data!.inSeconds]!;
|
||||
}
|
||||
return null;
|
||||
}, [stream.data, stream.hasData]);
|
||||
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("Lyrics"),
|
||||
Text(currentLyric.value),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
import 'package:path_provider/path_provider.dart' as path_provider;
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
enum TrackStatus { downloading, idle, done }
|
||||
|
||||
@ -118,7 +119,11 @@ class DownloadTrackButton extends HookConsumerWidget {
|
||||
}
|
||||
final lyrics = await getLyrics(
|
||||
playback.currentTrack!.name!,
|
||||
artistsToString<Artist>(playback.currentTrack!.artists ?? []),
|
||||
playback.currentTrack!.artists
|
||||
?.map((s) => s.name)
|
||||
.whereNotNull()
|
||||
.toList() ??
|
||||
[],
|
||||
apiKey: preferences.geniusAccessToken,
|
||||
optimizeQuery: true,
|
||||
);
|
||||
|
@ -71,7 +71,7 @@ class PageWindowTitleBar extends StatelessWidget
|
||||
}
|
||||
return WindowTitleBarBox(
|
||||
child: Container(
|
||||
color: Theme.of(context).backgroundColor,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
if (Platform.isMacOS)
|
||||
|
48
lib/extensions/list-sort-multiple.dart
Normal file
48
lib/extensions/list-sort-multiple.dart
Normal file
@ -0,0 +1,48 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
extension MultiSortListMap on List<Map> {
|
||||
/// [preference] - List of properties in which you want to sort the list
|
||||
/// i.e.
|
||||
/// ```
|
||||
/// List<String> preference = ['property1','property2'];
|
||||
/// ```
|
||||
/// This will first sort the list by property1 then by property2
|
||||
///
|
||||
/// [criteria] - List of booleans that specifies the criteria of sort
|
||||
/// i.e., For ascending order `true` and for descending order `false`.
|
||||
/// ```
|
||||
/// List<bool> criteria = [true. false];
|
||||
/// ```
|
||||
List<Map> sortByProperties(List<bool> criteria, List<String> preference) {
|
||||
if (preference.isEmpty || criteria.isEmpty || isEmpty) {
|
||||
return this;
|
||||
}
|
||||
if (preference.length != criteria.length) {
|
||||
print('Criteria length is not equal to preference');
|
||||
return this;
|
||||
}
|
||||
|
||||
int compare(int i, Map a, Map b) {
|
||||
if (a[preference[i]] == b[preference[i]]) {
|
||||
return 0;
|
||||
} else if (a[preference[i]] > b[preference[i]]) {
|
||||
return criteria[i] ? 1 : -1;
|
||||
} else {
|
||||
return criteria[i] ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
int sortAll(Map a, Map b) {
|
||||
int i = 0;
|
||||
int result = 0;
|
||||
while (i < preference.length) {
|
||||
result = compare(i, a, b);
|
||||
if (result != 0) break;
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return sorted((a, b) => sortAll(a, b));
|
||||
}
|
||||
}
|
@ -8,8 +8,14 @@ import 'package:spotube/models/generated_secrets.dart';
|
||||
|
||||
final logger = getLogger("GetLyrics");
|
||||
|
||||
String getTitle(String title, String artist) {
|
||||
return "$title $artist"
|
||||
String clearArtistsOfTitle(String title, List<String> artists) {
|
||||
return title
|
||||
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
String getTitle(String title, [List<String> artists = const []]) {
|
||||
return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}"
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(" *\\([^)]*\\) *"), '')
|
||||
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
|
||||
@ -50,7 +56,7 @@ Future<String?> extractLyrics(Uri url) async {
|
||||
|
||||
Future<List?> searchSong(
|
||||
String title,
|
||||
String artist, {
|
||||
List<String> artist, {
|
||||
String? apiKey,
|
||||
bool optimizeQuery = false,
|
||||
bool authHeader = false,
|
||||
@ -60,7 +66,9 @@ Future<List?> searchSong(
|
||||
apiKey = getRandomElement(lyricsSecrets);
|
||||
}
|
||||
const searchUrl = 'https://api.genius.com/search?q=';
|
||||
String song = optimizeQuery ? getTitle(title, artist) : "$title $artist";
|
||||
String song = optimizeQuery
|
||||
? getTitle(clearArtistsOfTitle(title, artist), artist)
|
||||
: "$title $artist";
|
||||
|
||||
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
|
||||
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
|
||||
@ -87,13 +95,13 @@ Future<List?> searchSong(
|
||||
|
||||
Future<String?> getLyrics(
|
||||
String title,
|
||||
String artist, {
|
||||
List<String> artist, {
|
||||
required String apiKey,
|
||||
bool optimizeQuery = false,
|
||||
bool authHeader = false,
|
||||
}) async {
|
||||
try {
|
||||
var results = await searchSong(
|
||||
final results = await searchSong(
|
||||
title,
|
||||
artist,
|
||||
apiKey: apiKey,
|
||||
|
@ -3,16 +3,20 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/helpers/getLyrics.dart';
|
||||
import 'package:spotube/models/Logger.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:spotube/extensions/list-sort-multiple.dart';
|
||||
|
||||
final logger = getLogger("toYoutubeTrack");
|
||||
Future<Track> toYoutubeTrack(
|
||||
YoutubeExplode youtube, Track track, String format) async {
|
||||
final artistsName = track.artists?.map((ar) => ar.name).toList() ?? [];
|
||||
final artistsName =
|
||||
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ??
|
||||
[];
|
||||
logger.v("[Track Search Artists] $artistsName");
|
||||
final mainArtist = artistsName.first ?? "";
|
||||
final mainArtist = artistsName.first;
|
||||
final featuredArtists =
|
||||
artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : "";
|
||||
final title = getTitle(track.name!, "").trim();
|
||||
final title = getTitle(clearArtistsOfTitle(track.name!, artistsName)).trim();
|
||||
logger.v("[Track Search Title] $title");
|
||||
final queryString = format
|
||||
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
||||
@ -22,18 +26,37 @@ Future<Track> toYoutubeTrack(
|
||||
|
||||
SearchList videos = await youtube.search.getVideos(queryString);
|
||||
|
||||
List<Video> matchedVideos = videos.where((video) {
|
||||
// the find should be lazy thus everything case insensitive
|
||||
return video.title.toLowerCase().contains(track.name!.toLowerCase()) &&
|
||||
(track.artists?.every((artist) => video.title
|
||||
.toLowerCase()
|
||||
.contains(artist.name!.toLowerCase())) ??
|
||||
false);
|
||||
}).toList();
|
||||
List<Map> ratedRankedVideos = videos
|
||||
.map((video) {
|
||||
// the find should be lazy thus everything case insensitive
|
||||
final ytTitle = video.title.toLowerCase();
|
||||
final bool hasTitle = ytTitle.contains(title);
|
||||
final bool hasAllArtists = track.artists?.every(
|
||||
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
|
||||
) ??
|
||||
false;
|
||||
final bool authorIsArtist = track.artists
|
||||
?.any((artist) => artist.name?.toLowerCase() == video.author) ??
|
||||
false;
|
||||
int rate = 0;
|
||||
for (final el in [hasTitle, hasAllArtists, authorIsArtist]) {
|
||||
if (el) rate++;
|
||||
}
|
||||
return {
|
||||
"video": video,
|
||||
"points": rate,
|
||||
"views": video.engagement.viewCount,
|
||||
};
|
||||
})
|
||||
.toList()
|
||||
.sortByProperties(
|
||||
[false, false],
|
||||
["points", "views"],
|
||||
);
|
||||
|
||||
Video ytVideo = matchedVideos.isNotEmpty ? matchedVideos.first : videos.first;
|
||||
final ytVideo = ratedRankedVideos.first["video"] as Video;
|
||||
|
||||
var trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
|
||||
final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
|
||||
|
||||
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
|
||||
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
|
||||
@ -46,6 +69,7 @@ Future<Track> toYoutubeTrack(
|
||||
.url
|
||||
.toString();
|
||||
track.href = ytVideo.url;
|
||||
logger.v("[YouTube Matched Track] ${ytVideo.title} - ${track.href}");
|
||||
logger.v(
|
||||
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${track.href}");
|
||||
return track;
|
||||
}
|
||||
|
86
lib/helpers/timed-lyrics.dart
Normal file
86
lib/helpers/timed-lyrics.dart
Normal file
@ -0,0 +1,86 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/helpers/getLyrics.dart';
|
||||
|
||||
class SubtitleSimple {
|
||||
Uri uri;
|
||||
String name;
|
||||
List<LyricSlice> lyrics;
|
||||
SubtitleSimple({
|
||||
required this.uri,
|
||||
required this.name,
|
||||
required this.lyrics,
|
||||
});
|
||||
}
|
||||
|
||||
class LyricSlice {
|
||||
Duration time;
|
||||
String text;
|
||||
|
||||
LyricSlice({required this.time, required this.text});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "LyricsSlice({time: $time, text: $text})";
|
||||
}
|
||||
}
|
||||
|
||||
const baseUri = "https://www.rentanadviser.com/subtitles";
|
||||
|
||||
Future<SubtitleSimple?> getTimedLyrics(Track track) async {
|
||||
final artistNames =
|
||||
track.artists?.map((artist) => artist.name!).toList() ?? [];
|
||||
final query = getTitle(
|
||||
clearArtistsOfTitle(track.name!, artistNames),
|
||||
artistNames,
|
||||
);
|
||||
final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace(
|
||||
queryParameters: {"q": query},
|
||||
);
|
||||
|
||||
final res = await http.get(searchUri);
|
||||
final document = parse(res.body);
|
||||
final topResult =
|
||||
document.querySelector("#tablecontainer table tbody tr td a");
|
||||
|
||||
if (topResult == null) return null;
|
||||
|
||||
final subtitleUri =
|
||||
Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc");
|
||||
|
||||
final lrcDocument = parse((await http.get(subtitleUri)).body);
|
||||
final lrcList = lrcDocument
|
||||
.querySelector("#ctl00_ContentPlaceHolder1_lbllyrics")
|
||||
?.innerHtml
|
||||
.replaceAll(RegExp(r'<h3>.*</h3>'), "")
|
||||
.split("<br>")
|
||||
.map((e) {
|
||||
e = e.trim();
|
||||
final regexp = RegExp(r'\[.*\]');
|
||||
final timeStr = regexp
|
||||
.firstMatch(e)
|
||||
?.group(0)
|
||||
?.replaceAll(RegExp(r'\[|\]'), "")
|
||||
.trim()
|
||||
.split(":");
|
||||
final minuteSeconds = timeStr?.last.split(".");
|
||||
|
||||
return LyricSlice(
|
||||
time: Duration(
|
||||
minutes: int.parse(timeStr?.first ?? "0"),
|
||||
seconds: int.parse(minuteSeconds?.first ?? "0"),
|
||||
milliseconds: int.parse(minuteSeconds?.last ?? "0"),
|
||||
),
|
||||
text: e.split(regexp).last);
|
||||
}).toList() ??
|
||||
[];
|
||||
|
||||
final subtitle = SubtitleSimple(
|
||||
name: topResult.text.trim(),
|
||||
uri: subtitleUri,
|
||||
lyrics: lrcList,
|
||||
);
|
||||
|
||||
return subtitle;
|
||||
}
|
Loading…
Reference in New Issue
Block a user