improved ranking system for matched youtube search results

synced lyrics initial implementation using rentanadviser
This commit is contained in:
Kingkor Roy Tirtho 2022-04-19 12:59:12 +06:00
parent 4efd600d13
commit e92f107e55
9 changed files with 258 additions and 25 deletions

View File

@ -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(),
],
),
),

View File

@ -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,
);

View 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),
],
),
);
}
}

View File

@ -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,
);

View File

@ -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)

View 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));
}
}

View File

@ -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,

View File

@ -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;
}

View 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;
}