import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/video_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; final officialMusicRegex = RegExp( r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", caseSensitive: false, ); class YoutubeSourcedTrack extends SourcedTrack { YoutubeSourcedTrack({ required super.source, required super.siblings, required super.info, required super.query, required super.sources, required super.ref, }); static Future fetchFromTrack({ required TrackSourceQuery query, required Ref ref, }) async { final audioSource = ref.read(userPreferencesProvider).audioSource; final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(query.id)) ..limit(1) ..orderBy([ (s) => OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), ])) .get() .then((s) => s.firstOrNull); if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { final siblings = await fetchSiblings(ref: ref, query: query); if (siblings.isEmpty) { throw TrackNotFoundError(query); } await database.into(database.sourceMatchTable).insert( SourceMatchTableCompanion.insert( trackId: query.id, sourceId: siblings.first.info.id, sourceType: const Value(SourceType.youtube), ), ); return YoutubeSourcedTrack( ref: ref, siblings: siblings.map((s) => s.info).skip(1).toList(), info: siblings.first.info, source: audioSource, sources: siblings.first.source ?? [], query: query, ); } final (item, manifest) = await ref .read(youtubeEngineProvider) .getVideoWithStreamInfo(cachedSource.sourceId); final sourcedTrack = YoutubeSourcedTrack( ref: ref, siblings: [], sources: toTrackSources(manifest), info: TrackSourceInfo( id: item.id.value, artists: item.author, pageUrl: item.url, thumbnail: item.thumbnails.highResUrl, title: item.title, durationMs: item.duration?.inMilliseconds ?? 0, ), query: query, source: audioSource, ); AppLogger.log.i("${query.title}: ${sourcedTrack.url}"); return sourcedTrack; } static List toTrackSources(StreamManifest manifest) { return manifest.audioOnly.map((streamInfo) { return TrackSource( url: streamInfo.url.toString(), quality: switch (streamInfo.qualityLabel) { "medium" => SourceQualities.medium, "high" => SourceQualities.high, "low" => SourceQualities.low, _ => SourceQualities.high, }, codec: streamInfo.codec.mimeType == "audio/webm" ? SourceCodecs.weba : SourceCodecs.m4a, bitrate: streamInfo.bitrate.bitsPerSecond.toString(), ); }).toList(); } static Future toSiblingType( int index, YoutubeVideoInfo item, dynamic ref, ) async { assert(ref is WidgetRef || ref is Ref, "Invalid ref type"); List? sourceMap; if (index == 0) { final manifest = await ref.read(youtubeEngineProvider).getStreamManifest(item.id); sourceMap = toTrackSources(manifest); } final SiblingType sibling = ( info: TrackSourceInfo( id: item.id, artists: item.channelName, pageUrl: "https://www.youtube.com/watch?v=${item.id}", thumbnail: item.thumbnailUrl, title: item.title, durationMs: item.duration.inMilliseconds, ), source: sourceMap, ); return sibling; } static List rankResults( List results, TrackSourceQuery track) { return results .sorted((a, b) => b.views.compareTo(a.views)) .map((sibling) { int score = 0; for (final artist in track.artists) { final isSameChannelArtist = sibling.channelName.toLowerCase() == artist.toLowerCase(); final channelContainsArtist = sibling.channelName .toLowerCase() .contains(artist.toLowerCase()); if (isSameChannelArtist || channelContainsArtist) { score += 1; } final titleContainsArtist = sibling.title.toLowerCase().contains(artist.toLowerCase()); if (titleContainsArtist) { score += 1; } } final titleContainsTrackName = sibling.title.toLowerCase().contains(track.title.toLowerCase()); final hasOfficialFlag = officialMusicRegex.hasMatch(sibling.title.toLowerCase()); if (titleContainsTrackName) { score += 3; } if (hasOfficialFlag) { score += 1; } if (hasOfficialFlag && titleContainsTrackName) { score += 2; } return (sibling: sibling, score: score); }) .sorted((a, b) => b.score.compareTo(a.score)) .map((e) => e.sibling) .toList(); } static Future> fetchFromIsrc({ required TrackSourceQuery track, required Ref ref, }) async { final isrcResults = []; final isrc = track.isrc; if (isrc.isNotEmpty) { final searchedVideos = await ref.read(youtubeEngineProvider).searchVideos(isrc.toString()); if (searchedVideos.isNotEmpty) { AppLogger.log .d("${track.title} ISRC $isrc Total ${searchedVideos.length}"); final stringBuffer = StringBuffer(); final filteredMatches = searchedVideos .map(YoutubeVideoInfo.fromVideo) .map((YoutubeVideoInfo videoInfo) { final ytWords = videoInfo.title .toLowerCase() .replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '') .split(RegExp(r'\p{Z}+', unicode: true)) .where((item) => item.isNotEmpty); final spWords = track.title .toLowerCase() .replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '') .split(RegExp(r'\p{Z}+', unicode: true)) .where((item) => item.isNotEmpty); // Single word and duration match with 3 second tolerance if (ytWords.any((word) => spWords.contains(word)) && (videoInfo.duration - Duration(milliseconds: track.durationMs)) .abs() .inMilliseconds <= 3000) { stringBuffer.writeln( "ISRC MATCH: ${videoInfo.id} ${videoInfo.title} by ${videoInfo.channelName} ${videoInfo.duration}", ); return videoInfo; } return null; }) .nonNulls .toList(); AppLogger.log.d(stringBuffer.toString()); isrcResults.addAll(filteredMatches); } } return isrcResults; } static Future> fetchSiblings({ required TrackSourceQuery query, required Ref ref, }) async { final videoResults = []; if (query is! SourcedTrack) { final isrcResults = await fetchFromIsrc( track: query, ref: ref, ); videoResults.addAll(isrcResults); if (isrcResults.isEmpty) { AppLogger.log.w("No ISRC results found, falling back to SongLink"); final links = await SongLinkService.links(query.id); final stringBuffer = links.fold( StringBuffer(), (previousValue, element) { previousValue.writeln( "SongLink ${query.id} ${element.platform} ${element.url}"); return previousValue; }, ); AppLogger.log.d(stringBuffer.toString()); final ytLink = links.firstWhereOrNull( (link) => link.platform == "youtube", ); if (ytLink?.url != null) { try { videoResults.add( YoutubeVideoInfo.fromVideo(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); } } else { AppLogger.log.w("No YouTube link found in SongLink results"); } } } final searchQuery = SourcedTrack.getSearchTerm(query); final searchResults = await ref.read(youtubeEngineProvider).searchVideos(searchQuery); if (ServiceUtils.onlyContainsEnglish(searchQuery)) { videoResults .addAll(searchResults.map(YoutubeVideoInfo.fromVideo).toList()); } else { videoResults.addAll(rankResults( searchResults.map(YoutubeVideoInfo.fromVideo).toList(), query, )); } final seenIds = {}; int index = 0; return await Future.wait( 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 Future swapWithSibling(TrackSourceInfo sibling) async { if (sibling.id == info.id) { return null; } // a sibling source that was fetched from the search results final isStepSibling = siblings.none((s) => s.id == sibling.id); final newSourceInfo = isStepSibling ? sibling : siblings.firstWhere((s) => s.id == sibling.id); final newSiblings = siblings.where((s) => s.id != sibling.id).toList() ..insert(0, info); final manifest = await ref .read(youtubeEngineProvider) .getStreamManifest(newSourceInfo.id); final database = ref.read(databaseProvider); await database.into(database.sourceMatchTable).insert( SourceMatchTableCompanion.insert( trackId: query.id, sourceId: newSourceInfo.id, sourceType: const Value(SourceType.youtube), // Because we're sorting by createdAt in the query // we have to update it to indicate priority createdAt: Value(DateTime.now()), ), mode: InsertMode.replace, ); return YoutubeSourcedTrack( ref: ref, source: source, siblings: newSiblings, sources: toTrackSources(manifest), info: newSourceInfo, query: query, ); } @override Future copyWithSibling() async { if (siblings.isNotEmpty) { return this; } final fetchedSiblings = await fetchSiblings(ref: ref, query: query); return YoutubeSourcedTrack( ref: ref, siblings: fetchedSiblings .where((s) => s.info.id != info.id) .map((s) => s.info) .toList(), source: source, sources: sources, info: info, query: query, ); } @override Future refreshStream() async { List validStreams = []; final stringBuffer = StringBuffer(); for (final source in sources) { final res = await globalDio.head( source.url, options: Options(validateStatus: (status) => status != null && status < 500), ); stringBuffer.writeln( "[${query.id}] ${res.statusCode} ${source.quality} ${source.codec} ${source.bitrate}", ); if (res.statusCode! < 400) { validStreams.add(source); } } AppLogger.log.d(stringBuffer.toString()); if (validStreams.isEmpty) { final manifest = await ref.read(youtubeEngineProvider).getStreamManifest(info.id); validStreams = toTrackSources(manifest); } final sourcedTrack = YoutubeSourcedTrack( ref: ref, siblings: siblings, source: source, sources: validStreams, info: info, query: query, ); AppLogger.log.i("Refreshing ${query.title}: ${sourcedTrack.url}"); return sourcedTrack; } }