mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55: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/Category/CategoryCard.dart';
|
||||||
import 'package:spotube/components/Home/Sidebar.dart';
|
import 'package:spotube/components/Home/Sidebar.dart';
|
||||||
import 'package:spotube/components/Home/SpotubeNavigationBar.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/Lyrics.dart';
|
||||||
import 'package:spotube/components/Search/Search.dart';
|
import 'package:spotube/components/Search/Search.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
@ -170,7 +171,7 @@ class Home extends HookConsumerWidget {
|
|||||||
}, [localStorage]);
|
}, [localStorage]);
|
||||||
|
|
||||||
final titleBarContents = Container(
|
final titleBarContents = Container(
|
||||||
color: Theme.of(context).backgroundColor,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -222,7 +223,7 @@ class Home extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (_selectedIndex.value == 1) const Search(),
|
if (_selectedIndex.value == 1) const Search(),
|
||||||
if (_selectedIndex.value == 2) const UserLibrary(),
|
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/hooks/useBreakpoints.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
class Lyrics extends HookConsumerWidget {
|
class Lyrics extends HookConsumerWidget {
|
||||||
const Lyrics({Key? key}) : super(key: key);
|
const Lyrics({Key? key}) : super(key: key);
|
||||||
@ -26,7 +27,11 @@ class Lyrics extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
return getLyrics(
|
return getLyrics(
|
||||||
playback.currentTrack!.name!,
|
playback.currentTrack!.name!,
|
||||||
artistsToString<Artist>(playback.currentTrack!.artists ?? []),
|
playback.currentTrack!.artists
|
||||||
|
?.map((s) => s.name)
|
||||||
|
.whereNotNull()
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
apiKey: userPreferences.geniusAccessToken,
|
apiKey: userPreferences.geniusAccessToken,
|
||||||
optimizeQuery: true,
|
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_provider/path_provider.dart' as path_provider;
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
enum TrackStatus { downloading, idle, done }
|
enum TrackStatus { downloading, idle, done }
|
||||||
|
|
||||||
@ -118,7 +119,11 @@ class DownloadTrackButton extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
final lyrics = await getLyrics(
|
final lyrics = await getLyrics(
|
||||||
playback.currentTrack!.name!,
|
playback.currentTrack!.name!,
|
||||||
artistsToString<Artist>(playback.currentTrack!.artists ?? []),
|
playback.currentTrack!.artists
|
||||||
|
?.map((s) => s.name)
|
||||||
|
.whereNotNull()
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
apiKey: preferences.geniusAccessToken,
|
apiKey: preferences.geniusAccessToken,
|
||||||
optimizeQuery: true,
|
optimizeQuery: true,
|
||||||
);
|
);
|
||||||
|
@ -71,7 +71,7 @@ class PageWindowTitleBar extends StatelessWidget
|
|||||||
}
|
}
|
||||||
return WindowTitleBarBox(
|
return WindowTitleBarBox(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Theme.of(context).backgroundColor,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (Platform.isMacOS)
|
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");
|
final logger = getLogger("GetLyrics");
|
||||||
|
|
||||||
String getTitle(String title, String artist) {
|
String clearArtistsOfTitle(String title, List<String> artists) {
|
||||||
return "$title $artist"
|
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()
|
.toLowerCase()
|
||||||
.replaceAll(RegExp(" *\\([^)]*\\) *"), '')
|
.replaceAll(RegExp(" *\\([^)]*\\) *"), '')
|
||||||
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
|
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
|
||||||
@ -50,7 +56,7 @@ Future<String?> extractLyrics(Uri url) async {
|
|||||||
|
|
||||||
Future<List?> searchSong(
|
Future<List?> searchSong(
|
||||||
String title,
|
String title,
|
||||||
String artist, {
|
List<String> artist, {
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
bool optimizeQuery = false,
|
bool optimizeQuery = false,
|
||||||
bool authHeader = false,
|
bool authHeader = false,
|
||||||
@ -60,7 +66,9 @@ Future<List?> searchSong(
|
|||||||
apiKey = getRandomElement(lyricsSecrets);
|
apiKey = getRandomElement(lyricsSecrets);
|
||||||
}
|
}
|
||||||
const searchUrl = 'https://api.genius.com/search?q=';
|
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)}";
|
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
|
||||||
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
|
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
|
||||||
@ -87,13 +95,13 @@ Future<List?> searchSong(
|
|||||||
|
|
||||||
Future<String?> getLyrics(
|
Future<String?> getLyrics(
|
||||||
String title,
|
String title,
|
||||||
String artist, {
|
List<String> artist, {
|
||||||
required String apiKey,
|
required String apiKey,
|
||||||
bool optimizeQuery = false,
|
bool optimizeQuery = false,
|
||||||
bool authHeader = false,
|
bool authHeader = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
var results = await searchSong(
|
final results = await searchSong(
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
|
@ -3,16 +3,20 @@ import 'package:spotify/spotify.dart';
|
|||||||
import 'package:spotube/helpers/getLyrics.dart';
|
import 'package:spotube/helpers/getLyrics.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
import 'package:spotube/models/Logger.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.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");
|
final logger = getLogger("toYoutubeTrack");
|
||||||
Future<Track> toYoutubeTrack(
|
Future<Track> toYoutubeTrack(
|
||||||
YoutubeExplode youtube, Track track, String format) async {
|
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");
|
logger.v("[Track Search Artists] $artistsName");
|
||||||
final mainArtist = artistsName.first ?? "";
|
final mainArtist = artistsName.first;
|
||||||
final featuredArtists =
|
final featuredArtists =
|
||||||
artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : "";
|
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");
|
logger.v("[Track Search Title] $title");
|
||||||
final queryString = format
|
final queryString = format
|
||||||
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
||||||
@ -22,18 +26,37 @@ Future<Track> toYoutubeTrack(
|
|||||||
|
|
||||||
SearchList videos = await youtube.search.getVideos(queryString);
|
SearchList videos = await youtube.search.getVideos(queryString);
|
||||||
|
|
||||||
List<Video> matchedVideos = videos.where((video) {
|
List<Map> ratedRankedVideos = videos
|
||||||
|
.map((video) {
|
||||||
// the find should be lazy thus everything case insensitive
|
// the find should be lazy thus everything case insensitive
|
||||||
return video.title.toLowerCase().contains(track.name!.toLowerCase()) &&
|
final ytTitle = video.title.toLowerCase();
|
||||||
(track.artists?.every((artist) => video.title
|
final bool hasTitle = ytTitle.contains(title);
|
||||||
.toLowerCase()
|
final bool hasAllArtists = track.artists?.every(
|
||||||
.contains(artist.name!.toLowerCase())) ??
|
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
|
||||||
false);
|
) ??
|
||||||
}).toList();
|
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
|
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
|
||||||
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
|
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
|
||||||
@ -46,6 +69,7 @@ Future<Track> toYoutubeTrack(
|
|||||||
.url
|
.url
|
||||||
.toString();
|
.toString();
|
||||||
track.href = ytVideo.url;
|
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;
|
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