import 'package:collection/collection.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/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:invidious/invidious.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; final invidiousProvider = Provider( (ref) { final invidiousInstance = ref.watch( userPreferencesProvider.select((s) => s.invidiousInstance), ); return InvidiousClient(server: invidiousInstance); }, ); class InvidiousSourcedTrack extends SourcedTrack { InvidiousSourcedTrack({ required super.ref, required super.source, required super.siblings, required super.info, required super.query, required super.sources, }); 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), ])) .getSingleOrNull(); final invidiousClient = ref.read(invidiousProvider); 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, sourceId: siblings.first.info.id, sourceType: const Value(SourceType.youtube), ), ); return InvidiousSourcedTrack( ref: ref, siblings: siblings.map((s) => s.info).skip(1).toList(), sources: siblings.first.source as List, info: siblings.first.info, query: query, source: audioSource, ); } else { final manifest = await invidiousClient.videos.get(cachedSource.sourceId, local: true); return InvidiousSourcedTrack( ref: ref, siblings: [], sources: toSources(manifest), info: TrackSourceInfo( id: manifest.videoId, artists: manifest.author, pageUrl: "https://www.youtube.com/watch?v=${manifest.videoId}", thumbnail: manifest.videoThumbnails.first.url, title: manifest.title, durationMs: Duration(seconds: manifest.lengthSeconds).inMilliseconds, ), query: query, source: audioSource, ); } } static List toSources(InvidiousVideoResponse manifest) { return manifest.adaptiveFormats.map((stream) { return TrackSource( url: stream.url.toString(), quality: switch (stream.qualityLabel) { "high" => SourceQualities.high, "medium" => SourceQualities.medium, _ => SourceQualities.low, }, codec: stream.type.contains("audio/webm") ? SourceCodecs.weba : SourceCodecs.m4a, bitrate: stream.bitrate, ); }).toList(); } static Future toSiblingType( int index, YoutubeVideoInfo item, InvidiousClient invidiousClient, ) async { List? sourceMap; if (index == 0) { final manifest = await invidiousClient.videos.get(item.id, local: true); sourceMap = toSources(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 Future> fetchSiblings({ required TrackSourceQuery query, required Ref ref, }) async { final invidiousClient = ref.read(invidiousProvider); final preference = ref.read(userPreferencesProvider); final searchQuery = SourcedTrack.getSearchTerm(query); final searchResults = await invidiousClient.search.list( searchQuery, type: InvidiousSearchType.video, ); if (ServiceUtils.onlyContainsEnglish(searchQuery)) { return await Future.wait( searchResults .whereType() .map( (result) => YoutubeVideoInfo.fromSearchResponse( result, preference.searchMode, ), ) .mapIndexed((i, r) => toSiblingType(i, r, invidiousClient)), ); } final rankedSiblings = YoutubeSourcedTrack.rankResults( searchResults .whereType() .map( (result) => YoutubeVideoInfo.fromSearchResponse( result, preference.searchMode, ), ) .toList(), query, ); return await Future.wait( rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, invidiousClient)), ); } @override Future copyWithSibling() async { if (siblings.isNotEmpty) { return this; } final fetchedSiblings = await fetchSiblings(ref: ref, query: query); return InvidiousSourcedTrack( ref: ref, siblings: fetchedSiblings .where((s) => s.info.id != info.id) .map((s) => s.info) .toList(), source: source, info: info, query: query, sources: sources, ); } @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 pipedClient = ref.read(invidiousProvider); final manifest = await pipedClient.videos.get(newSourceInfo.id, local: true); 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 InvidiousSourcedTrack( ref: ref, siblings: newSiblings, sources: toSources(manifest), info: newSourceInfo, query: query, source: source, ); } @override Future refreshStream() async { final manifest = await ref.read(invidiousProvider).videos.get(info.id, local: true); return InvidiousSourcedTrack( ref: ref, siblings: siblings, sources: toSources(manifest), info: info, query: query, source: source, ); } }