From 2c4cc949853ce6d849b6861540eb3cd33f15b97b Mon Sep 17 00:00:00 2001 From: Seungmin Kim <8457324+ehfd@users.noreply.github.com> Date: Fri, 28 Mar 2025 22:10:54 +0900 Subject: [PATCH] feat: add ISRC track search for YouTube (#2594) * Add ISRC track search for YouTube * Do not probe Song.Link when ISRC results are valid, fix rate limit --- lib/collections/fake.dart | 1 + lib/extensions/track.dart | 57 +++++++---- lib/modules/album/album_card.dart | 2 +- lib/provider/spotify/album/tracks.dart | 2 +- .../sourced_track/sources/youtube.dart | 96 ++++++++++++++----- 5 files changed, 110 insertions(+), 48 deletions(-) diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 31f97e0c..8af40e71 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -94,6 +94,7 @@ abstract class FakeData { ..trackNumber = 1 ..type = "type" ..uri = "uri" + ..externalIds = externalIds ..isPlayable = true ..explicit = false ..linkedFrom = trackLink; diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 215a5ab2..92d8b0da 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -4,7 +4,9 @@ import 'dart:typed_data'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; extension TrackExtensions on Track { Track fromFile( @@ -67,27 +69,40 @@ extension TrackExtensions on Track { } } -extension TrackSimpleExtensions on TrackSimple { - Track asTrack(AlbumSimple album) { - Track track = Track(); - track.name = name; - track.album = album; - track.artists = artists; - track.availableMarkets = availableMarkets; - track.discNumber = discNumber; - track.durationMs = durationMs; - track.explicit = explicit; - track.externalUrls = externalUrls; - track.href = href; - track.id = id; - track.isPlayable = isPlayable; - track.linkedFrom = linkedFrom; - track.name = name; - track.previewUrl = previewUrl; - track.trackNumber = trackNumber; - track.type = type; - track.uri = uri; - return track; +extension IterableTrackSimpleExtensions on Iterable { + Future> asTracks(AlbumSimple album, ref) async { + try { + final spotify = ref.read(spotifyProvider); + final tracks = await spotify.invoke( + (api) => api.tracks.list(map((trackSimple) => trackSimple.id!).toList())); + return tracks.toList(); + } catch (e, stack) { + // Ignore errors and create the track locally + AppLogger.reportError(e, stack); + + List tracks = []; + for (final trackSimple in this) { + Track track = Track(); + track.album = album; + track.name = trackSimple.name; + track.artists = trackSimple.artists; + track.availableMarkets = trackSimple.availableMarkets; + track.discNumber = trackSimple.discNumber; + track.durationMs = trackSimple.durationMs; + track.explicit = trackSimple.explicit; + track.externalUrls = trackSimple.externalUrls; + track.href = trackSimple.href; + track.id = trackSimple.id; + track.isPlayable = trackSimple.isPlayable; + track.linkedFrom = trackSimple.linkedFrom; + track.previewUrl = trackSimple.previewUrl; + track.trackNumber = trackSimple.trackNumber; + track.type = trackSimple.type; + track.uri = trackSimple.uri; + tracks.add(track); + } + return tracks; + } } } diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index 84106594..5fee9cc4 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -54,7 +54,7 @@ class AlbumCard extends HookConsumerWidget { Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { - return album.tracks!.map((track) => track.asTrack(album)).toList(); + return album.tracks!.asTracks(album, ref); } await ref.read(albumTracksProvider(album).future); return ref.read(albumTracksProvider(album).notifier).fetchAll(); diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart index d886d180..13c48886 100644 --- a/lib/provider/spotify/album/tracks.dart +++ b/lib/provider/spotify/album/tracks.dart @@ -33,7 +33,7 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier api.albums.tracks(arg.id!).getPage(limit, offset), ); - final items = tracks.items?.map((e) => e.asTrack(arg)).toList() ?? []; + final items = await tracks.items!.asTracks(arg, ref); return ( items: items, diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index c4881051..2dc0c815 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -236,29 +236,67 @@ class YoutubeSourcedTrack extends SourcedTrack { .toList(); } + static Future> fetchFromIsrc({ + required Track track, + required Provider provider, + required Ref ref, + }) async { + final isrcResults = []; + final isrc = track.externalIds?.isrc; + if (isrc != null && isrc.isNotEmpty) { + final searchedVideos = await ref + .read(provider) + .searchVideos(isrc.toString()); + if (searchedVideos.isNotEmpty) { + isrcResults.addAll(searchedVideos + .map(YoutubeVideoInfo.fromVideo) + .map((YoutubeVideoInfo videoInfo) { + final ytWords = + videoInfo.title + .toLowerCase() + .replaceAll(RegExp(r'[^a-zA-Z0-9\s]+'), '') + .split(RegExp(r'\s+')) + .where((item) => item.isNotEmpty); + final spWords = + track.name! + .toLowerCase() + .replaceAll(RegExp(r'\((.*)\)'), '') + .replaceAll(RegExp(r'[^a-zA-Z0-9\s]+'), '') + .split(RegExp(r'\s+')) + .where((item) => item.isNotEmpty); + // Word match to filter out unrelated results + final matchCount = + ytWords.where((word) => spWords.contains(word)).length; + if (matchCount > spWords.length ~/ 2) { + return videoInfo; + } + return null; + } + ).whereType().toList()); + } + } + return isrcResults; + } + static Future> fetchSiblings({ required Track track, required Ref ref, }) async { + final videoResults = []; + + final isrcResults = await fetchFromIsrc(track: track, provider: youtubeEngineProvider, ref: ref); + videoResults.addAll(isrcResults); + final links = await SongLinkService.links(track.id!); final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); - if (ytLink?.url != null - // allows to fetch siblings more results for already sourced track - && - track is! SourcedTrack) { + if (isrcResults.isEmpty && ytLink?.url != null) { try { - return [ - await toSiblingType( - 0, + videoResults.add( YoutubeVideoInfo.fromVideo( - await ref.read(youtubeEngineProvider).getVideo( - Uri.parse(ytLink!.url!).queryParameters["v"]!, - ), - ), - ref, - ) - ]; + await ref.read(youtubeEngineProvider) + .getVideo(Uri.parse(ytLink!.url!).queryParameters["v"]!) + )); } on VideoUnplayableException catch (e, stack) { // Ignore this error and continue with the search AppLogger.reportError(e, stack); @@ -271,20 +309,28 @@ class YoutubeSourcedTrack extends SourcedTrack { await ref.read(youtubeEngineProvider).searchVideos(query); if (ServiceUtils.onlyContainsEnglish(query)) { - return await Future.wait(searchResults - .map(YoutubeVideoInfo.fromVideo) - .mapIndexed((index, info) => toSiblingType(index, info, ref))); + videoResults.addAll( + searchResults.map(YoutubeVideoInfo.fromVideo).toList() + ); + } else { + videoResults.addAll(rankResults( + searchResults.map(YoutubeVideoInfo.fromVideo).toList(), + track, + )); } - final rankedSiblings = rankResults( - searchResults.map(YoutubeVideoInfo.fromVideo).toList(), - track, - ); - + final seenIds = {}; + int index = 0; return await Future.wait( - rankedSiblings - .mapIndexed((index, info) => toSiblingType(index, info, ref)), - ); + videoResults.map((videoResult) async { + // Deduplicate results + if (!seenIds.contains(videoResult.id)) { + seenIds.add(videoResult.id); + return await toSiblingType(index++, videoResult, ref); + } + return null; + }), + ).then((s) => s.whereType().toList()); } @override