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/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:jiosaavn/jiosaavn.dart'; import 'package:spotube/extensions/string.dart'; final jiosaavnClient = JioSaavnClient(); class JioSaavnSourcedTrack extends SourcedTrack { JioSaavnSourcedTrack({ 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, bool weakMatch = false, }) async { 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(); if (cachedSource == null || cachedSource.sourceType != SourceType.jiosaavn) { final siblings = await fetchSiblings(ref: ref, query: query, weakMatch: weakMatch); 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.jiosaavn), ), ); return JioSaavnSourcedTrack( ref: ref, siblings: siblings.map((s) => s.info).skip(1).toList(), sources: siblings.first.source!, info: siblings.first.info, query: query, source: AudioSource.jiosaavn, ); } final [item] = await jiosaavnClient.songs.detailsById([cachedSource.sourceId]); final (:info, :source) = toSiblingType(item); return JioSaavnSourcedTrack( ref: ref, siblings: [], sources: source!, query: query, info: info, source: AudioSource.jiosaavn, ); } static SiblingType toSiblingType(SongResponse result) { final SiblingType sibling = ( info: TrackSourceInfo( artists: [ result.primaryArtists, if (result.featuredArtists.isNotEmpty) ", ", result.featuredArtists ].join("").unescapeHtml(), durationMs: Duration(seconds: int.parse(result.duration)).inMilliseconds, id: result.id, pageUrl: result.url, thumbnail: result.image?.last.link ?? "", title: result.name!.unescapeHtml(), ), source: result.downloadUrl!.map((link) { return TrackSource( url: link.link, quality: link.quality == "320kbps" ? SourceQualities.high : link.quality == "160kbps" ? SourceQualities.medium : SourceQualities.low, codec: SourceCodecs.m4a, bitrate: link.quality, ); }).toList() ); return sibling; } static Future> fetchSiblings({ required TrackSourceQuery query, required Ref ref, bool weakMatch = false, }) async { final searchQuery = SourcedTrack.getSearchTerm(query); final SongSearchResponse(:results) = await jiosaavnClient.search.songs(searchQuery, limit: 20); final trackArtistNames = query.artists; final matchedResults = results .where( (s) { s.name?.unescapeHtml().contains(query.title) ?? false; final sameName = s.name?.unescapeHtml() == query.title; final artistNames = [ s.primaryArtists, if (s.featuredArtists.isNotEmpty) ", ", s.featuredArtists ].join("").unescapeHtml(); final sameArtists = artistNames.split(", ").any( (artist) => trackArtistNames.any((ar) => artist == ar), ); if (weakMatch) { final containsName = s.name?.unescapeHtml().contains(query.title) ?? false; final containsPrimaryArtist = s.primaryArtists .unescapeHtml() .contains(trackArtistNames.first); return containsName && containsPrimaryArtist; } return sameName && sameArtists; }, ) .map(toSiblingType) .toList(); if (weakMatch && matchedResults.isEmpty) { return results.map(toSiblingType).toList(); } return matchedResults; } @override Future copyWithSibling() async { if (siblings.isNotEmpty) { return this; } final fetchedSiblings = await fetchSiblings(ref: ref, query: query); return JioSaavnSourcedTrack( 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 == this.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, this.info); final [item] = await jiosaavnClient.songs.detailsById([newSourceInfo.id]); final (:info, :source) = toSiblingType(item); final database = ref.read(databaseProvider); await database.into(database.sourceMatchTable).insert( SourceMatchTableCompanion.insert( trackId: query.id, sourceId: info.id, sourceType: const Value(SourceType.jiosaavn), // 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 JioSaavnSourcedTrack( ref: ref, siblings: newSiblings, sources: source!, info: newSourceInfo, query: query, source: AudioSource.jiosaavn, ); } @override Future refreshStream() async { // There's no need to refresh the stream for JioSaavnSourcedTrack return this; } }