import 'dart:convert'; 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/extensions/string.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/utils/service_utils.dart'; final officialMusicRegex = RegExp( r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", caseSensitive: false, ); class SourcedTrack extends BasicSourcedTrack { final Ref ref; SourcedTrack({ required this.ref, required super.info, required super.query, required super.source, required super.siblings, required super.sources, }); static String getSearchTerm(SpotubeFullTrackObject track) { final title = ServiceUtils.getTitle( track.name, artists: track.artists.map((e) => e.name).toList(), onlyCleanArtist: true, ).trim(); assert(title.trim().isNotEmpty, "Title should not be empty"); return "$title - ${track.artists.join(", ")}"; } static Future fetchFromQuery({ required SpotubeFullTrackObject query, required Ref ref, }) async { final audioSource = await ref.read(audioSourcePluginProvider.future); final audioSourceConfig = await ref.read(metadataPluginsProvider .selectAsync((data) => data.defaultAudioSourcePluginConfig)); if (audioSource == null || audioSourceConfig == null) { throw Exception("Dude wat?"); } final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(query.id) & s.sourceType.equals(audioSourceConfig.slug)) ..limit(1) ..orderBy([ (s) => OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), ])) .get() .then((s) => s.firstOrNull); if (cachedSource == null) { 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, source: jsonEncode(siblings.first), sourceType: Value(audioSourceConfig.slug), ), ); return SourcedTrack( ref: ref, siblings: siblings.map((s) => s.info).skip(1).toList(), info: siblings.first.info, source: audioSourceConfig.slug, sources: siblings.first.source ?? [], query: query, ); } final item = SpotubeAudioSourceMatchObject.fromJson(jsonDecode(cachedSource.source)); final manifest = await audioSource.audioSource.streams(item); final sourcedTrack = SourcedTrack( ref: ref, siblings: [], sources: manifest, info: item, query: query, source: audioSourceConfig.slug, ); AppLogger.log.i("${query.name}: ${sourcedTrack.url}"); return sourcedTrack; } static List rankResults( List results, SpotubeFullTrackObject track, ) { return results .map((sibling) { int score = 0; for (final artist in track.artists) { final isSameChannelArtist = sibling.artists.any((a) => a.toLowerCase() == artist.name); if (isSameChannelArtist) { score += 1; } final titleContainsArtist = sibling.title.toLowerCase().contains(artist.name.toLowerCase()); if (titleContainsArtist) { score += 1; } } final titleContainsTrackName = sibling.title.toLowerCase().contains(track.name.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> fetchSiblings({ required SpotubeFullTrackObject query, required Ref ref, }) async { final audioSource = await ref.read(audioSourcePluginProvider.future); if (audioSource == null) { throw Exception("Dude wat?"); } final videoResults = []; final searchResults = await audioSource.audioSource.matches(query); if (ServiceUtils.onlyContainsEnglish(query.name)) { videoResults.addAll(searchResults); } else { videoResults.addAll(rankResults(searchResults, query)); } return videoResults.toSet().toList(); } Future copyWithSibling() async { if (siblings.isNotEmpty) { return this; } final fetchedSiblings = await fetchSiblings(ref: ref, query: query); return SourcedTrack( ref: ref, siblings: fetchedSiblings.where((s) => s.id != info.id).toList(), source: source, sources: sources, info: info, query: query, ); } Future swapWithSibling( SpotubeAudioSourceMatchObject sibling) async { if (sibling.id == info.id) { return null; } final audioSource = await ref.read(audioSourcePluginProvider.future); final audioSourceConfig = await ref.read(metadataPluginsProvider .selectAsync((data) => data.defaultAudioSourcePluginConfig)); if (audioSource == null || audioSourceConfig == null) { throw Exception("Dude wat?"); } // 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 audioSource.audioSource.streams(newSourceInfo); final database = ref.read(databaseProvider); await database.into(database.sourceMatchTable).insert( SourceMatchTableCompanion.insert( trackId: query.id, source: jsonEncode(siblings.first), sourceType: Value(audioSourceConfig.slug), createdAt: Value(DateTime.now()), ), mode: InsertMode.replace, ); return SourcedTrack( ref: ref, source: source, siblings: newSiblings, sources: manifest, info: newSourceInfo, query: query, ); } Future swapWithSiblingOfIndex(int index) { return swapWithSibling(siblings[index]); } Future refreshStream() async { final audioSource = await ref.read(audioSourcePluginProvider.future); final audioSourceConfig = await ref.read(metadataPluginsProvider .selectAsync((data) => data.defaultAudioSourcePluginConfig)); if (audioSource == null || audioSourceConfig == null) { throw Exception("Dude wat?"); } 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.container} ${source.codec} ${source.bitrate}", ); if (res.statusCode! < 400) { validStreams.add(source); } } AppLogger.log.d(stringBuffer.toString()); if (validStreams.isEmpty) { validStreams = await audioSource.audioSource.streams(info); } final sourcedTrack = SourcedTrack( ref: ref, siblings: siblings, source: source, sources: validStreams, info: info, query: query, ); AppLogger.log.i("Refreshing ${query.name}: ${sourcedTrack.url}"); return sourcedTrack; } String? get url { final preferences = ref.read(userPreferencesProvider); final codec = preferences.audioSource == AudioSource.jiosaavn ? SourceCodecs.m4a : preferences.streamMusicCodec; return getUrlOfCodec(codec); } /// Returns the URL of the track based on the codec and quality preferences. /// If an exact match is not found, it will return the closest match based on /// the user's audio quality preference. /// /// If no sources match the codec, it will return the first or last source /// based on the user's audio quality preference. SpotubeAudioSourceStreamObject? getStreamOfQuality( SpotubeAudioSourceContainerPreset preset, int qualityIndex, ) { final quality = preset.qualities[qualityIndex]; final exactMatch = sources.firstWhereOrNull( (source) { if (source.container != preset.name) return false; if (quality case SpotubeAudioLosslessContainerQuality()) { return source.sampleRate == quality.sampleRate && source.bitDepth == quality.bitDepth; } else { return source.bitrate == (preset as SpotubeAudioLossyContainerQuality).bitrate; } }, ); if (exactMatch != null) { return exactMatch; } // Find the closest to preset SpotubeAudioSourceStreamObject? closest; for (final source in sources) { if (source.container != preset.name) continue; if (quality case SpotubeAudioLosslessContainerQuality()) { final sourceBps = (source.bitDepth ?? 0) * (source.sampleRate ?? 0); final qualityBps = quality.bitDepth * quality.sampleRate; final closestBps = (closest?.bitDepth ?? 0) * (closest?.sampleRate ?? 0); if (sourceBps == qualityBps) { closest = source; break; } final closestDiff = (closestBps - qualityBps).abs(); final sourceDiff = (sourceBps - qualityBps).abs(); if (sourceDiff < closestDiff) { closest = source; } } else { final presetBitrate = (preset as SpotubeAudioLossyContainerQuality).bitrate; if (presetBitrate == source.bitrate) { closest = source; break; } final closestDiff = (closest?.bitrate ?? 0) - presetBitrate; final sourceDiff = (source.bitrate ?? 0) - presetBitrate; if (sourceDiff < closestDiff) { closest = source; } } } return closest; } String? getUrlOfQuality( SpotubeAudioSourceContainerPreset preset, int qualityIndex, ) { return getStreamOfQuality(preset, qualityIndex)?.url; } }