From 2c5cd8d505afbf45c5d998339c4033bc7e98c133 Mon Sep 17 00:00:00 2001 From: Seungmin Kim <8457324+ehfd@users.noreply.github.com> Date: Mon, 24 Mar 2025 00:51:33 +0900 Subject: [PATCH] Add ISRC track search for YouTube --- lib/collections/fake.dart | 1 + lib/extensions/track.dart | 50 ++++++---- lib/modules/album/album_card.dart | 3 +- lib/provider/spotify/album/tracks.dart | 2 +- .../sourced_track/sources/youtube.dart | 95 ++++++++++++++----- 5 files changed, 104 insertions(+), 47 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..f333f859 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( @@ -68,26 +70,34 @@ 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; + Future asTrack(AlbumSimple album, ref) async { + try { + final spotify = ref.read(spotifyProvider); + return await spotify.invoke((api) => api.tracks.get(id!)); + } catch (e, stack) { + // Ignore errors and create the track locally + AppLogger.reportError(e, stack); + + 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; + } } } diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index 84106594..43e94242 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -54,7 +54,8 @@ class AlbumCard extends HookConsumerWidget { Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { - return album.tracks!.map((track) => track.asTrack(album)).toList(); + return await Future.wait( + album.tracks!.map((track) => track.asTrack(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..c69dc0e9 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 List items = await Future.wait(tracks.items?.map((e) => e.asTrack(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..13ead3ef 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -236,29 +236,66 @@ 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 = []; + + videoResults.addAll(await fetchFromIsrc(track: track, provider: youtubeEngineProvider, ref: ref)); + 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 (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 +308,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