import 'dart:typed_data'; import 'package:auto_route/auto_route.dart'; import 'package:dio/dio.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:html/dom.dart' hide Text; import 'package:shadcn_flutter/shadcn_flutter.dart' hide Element; import 'package:spotify/spotify.dart'; import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; import 'package:spotube/modules/root/update_dialog.dart'; import 'package:spotube/models/lyrics.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; import 'package:html/parser.dart' as parser; import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:spotube/collections/env.dart'; import 'package:version/version.dart'; abstract class ServiceUtils { static final _englishMatcherRegex = RegExp( "^[a-zA-Z0-9\\s!\"#\$%&\\'()*+,-.\\/:;<=>?@\\[\\]^_`{|}~]*\$", ); static bool onlyContainsEnglish(String text) { return _englishMatcherRegex.hasMatch(text); } static String clearArtistsOfTitle(String title, List artists) { return title .replaceAll(RegExp(artists.join("|"), caseSensitive: false), "") .trim(); } static String getTitle( String title, { List artists = const [], bool onlyCleanArtist = false, }) { final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0); final artistInBracket = artists.any((artist) => match?.contains(artist) ?? false); if (artistInBracket) { title = title.replaceAll( RegExp(" *\\([^)]*\\) *"), '', ); } title = clearArtistsOfTitle(title, artists); if (onlyCleanArtist) { artists = []; } return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}" .toLowerCase() .replaceAll(RegExp(r"\s*\[[^\]]*]"), ' ') .replaceAll(RegExp(r"\sfeat\.|\sft\."), ' ') .replaceAll(RegExp(r"\s+"), ' ') .trim(); } static Future extractLyrics(Uri url) async { final response = await globalDio.getUri( url, options: Options(responseType: ResponseType.plain), ); Document document = parser.parse(response.data); String? lyrics = document.querySelector('div.lyrics')?.text.trim(); if (lyrics == null) { lyrics = ""; document .querySelectorAll("div[class^=\"Lyrics__Container\"]") .forEach((element) { if (element.text.trim().isNotEmpty) { final snippet = element.innerHtml.replaceAll("
", "\n").replaceAll( RegExp("<(?!\\s*br\\s*\\/?)[^>]+>", caseSensitive: false), "", ); final el = document.createElement("textarea"); el.innerHtml = snippet; lyrics = "$lyrics${el.text.trim()}\n\n"; } }); } return lyrics; } @Deprecated("In favor spotify lyrics api, this isn't needed anymore") static Future searchSong( String title, List artist, { String? apiKey, bool optimizeQuery = false, bool authHeader = false, }) async { if (apiKey == "" || apiKey == null) { apiKey = PrimitiveUtils.getRandomElement(/* lyricsSecrets */ []); } const searchUrl = 'https://api.genius.com/search?q='; String song = optimizeQuery ? getTitle(title, artists: artist) : "$title $artist"; String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; Map headers = {"Authorization": 'Bearer $apiKey'}; final response = await globalDio.getUri( Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), options: Options( headers: authHeader ? headers : null, responseType: ResponseType.json, ), ); Map data = response.data["response"]; if (data["hits"]?.length == 0) return null; List results = data["hits"]?.map((val) { return { "id": val["result"]["id"], "full_title": val["result"]["full_title"], "albumArt": val["result"]["song_art_image_url"], "url": val["result"]["url"], "author": val["result"]["primary_artist"]["name"], }; }).toList(); return results; } @Deprecated("In favor spotify lyrics api, this isn't needed anymore") static Future getLyrics( String title, List artists, { required String apiKey, bool optimizeQuery = false, bool authHeader = false, }) async { final results = await searchSong( title, artists, apiKey: apiKey, optimizeQuery: optimizeQuery, authHeader: authHeader, ); if (results == null) return null; title = getTitle( title, artists: artists, onlyCleanArtist: true, ).trim(); final ratedLyrics = results.map((result) { final gTitle = (result["full_title"] as String).toLowerCase(); int points = 0; final hasTitle = gTitle.contains(title); final hasAllArtists = artists.every((artist) => gTitle.contains(artist.toLowerCase())); final String lyricAuthor = result["author"].toLowerCase(); final fromOriginalAuthor = lyricAuthor.contains(artists.first.toLowerCase()); for (final criteria in [ hasTitle, hasAllArtists, fromOriginalAuthor, ]) { if (criteria) points++; } return {"result": result, "points": points}; }).sorted( (a, b) => ((a["points"] as int).compareTo(a["points"] as int)), ); final worthyOne = ratedLyrics.first["result"]; String? lyrics = await extractLyrics(Uri.parse(worthyOne["url"])); return lyrics; } @Deprecated("In favor spotify lyrics api, this isn't needed anymore") static const baseUri = "https://www.rentanadviser.com/subtitles"; @Deprecated("In favor spotify lyrics api, this isn't needed anymore") static Future getTimedLyrics(SourcedTrack track) async { final artistNames = track.artists?.map((artist) => artist.name!).toList() ?? []; final query = getTitle( track.name!, artists: artistNames, ); final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace( queryParameters: {"q": query}, ); final res = await globalDio.getUri( searchUri, options: Options(responseType: ResponseType.plain), ); final document = parser.parse(res.data); final results = document.querySelectorAll("#tablecontainer table tbody tr td a"); final rateSortedResults = results.map((result) { final title = result.text.trim().toLowerCase(); int points = 0; final hasAllArtists = track.artists ?.map((artist) => artist.name!) .every((artist) => title.contains(artist.toLowerCase())) ?? false; final hasTrackName = title.contains(track.name!.toLowerCase()); final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live"); final exactYtMatch = title == track.sourceInfo.title.toLowerCase(); if (exactYtMatch) points = 7; for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) { if (criteria) points++; } return {"result": result, "points": points}; }).sorted((a, b) => (b["points"] as int).compareTo(a["points"] as int)); // not result was found at all if (rateSortedResults.first["points"] == 0) { return Future.error("Subtitle lookup failed", StackTrace.current); } final topResult = rateSortedResults.first["result"] as Element; final subtitleUri = Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc"); final lrcDocument = parser.parse((await globalDio.getUri( subtitleUri, options: Options(responseType: ResponseType.plain), )) .data); final lrcList = lrcDocument .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") ?.innerHtml .replaceAll(RegExp(r'

.*

'), "") .split("
") .map((e) { e = e.trim(); final regexp = RegExp(r'\[.*\]'); final timeStr = regexp .firstMatch(e) ?.group(0) ?.replaceAll(RegExp(r'\[|\]'), "") .trim() .split(":"); final minuteSeconds = timeStr?.last.split("."); return LyricSlice( time: Duration( minutes: int.parse(timeStr?.first ?? "0"), seconds: int.parse(minuteSeconds?.first ?? "0"), milliseconds: int.parse(minuteSeconds?.last ?? "0"), ), text: e.split(regexp).last); }).toList() ?? []; final subtitle = SubtitleSimple( name: topResult.text.trim(), uri: subtitleUri, lyrics: lrcList, rating: rateSortedResults.first["points"] as int, provider: "Rent An Adviser", ); return subtitle; } static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { if (album == null || album.releaseDate == null) { return DateTime.parse("1975-01-01"); } switch (album.releaseDatePrecision ?? DatePrecision.year) { case DatePrecision.day: return DateTime.parse(album.releaseDate!); case DatePrecision.month: return DateTime.parse("${album.releaseDate}-01"); case DatePrecision.year: return DateTime.parse("${album.releaseDate}-01-01"); } } static List sortTracks(List tracks, SortBy sortBy) { if (sortBy == SortBy.none) return tracks; return List.from(tracks) ..sort((a, b) { switch (sortBy) { case SortBy.ascending: return a.name?.compareTo(b.name ?? "") ?? 0; case SortBy.descending: return b.name?.compareTo(a.name ?? "") ?? 0; case SortBy.newest: final aDate = parseSpotifyAlbumDate(a.album); final bDate = parseSpotifyAlbumDate(b.album); return bDate.compareTo(aDate); case SortBy.oldest: final aDate = parseSpotifyAlbumDate(a.album); final bDate = parseSpotifyAlbumDate(b.album); return aDate.compareTo(bDate); case SortBy.duration: return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0; case SortBy.artist: return a.artists?.first.name ?.compareTo(b.artists?.first.name ?? "") ?? 0; case SortBy.album: return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; default: return 0; } }); } static Future checkForUpdates( BuildContext context, WidgetRef ref, ) async { if (!Env.enableUpdateChecker) return; final database = ref.read(databaseProvider); final checkUpdate = await (database.selectOnly(database.preferencesTable) ..addColumns([database.preferencesTable.checkUpdate]) ..where(database.preferencesTable.id.equals(0))) .map((row) => row.read(database.preferencesTable.checkUpdate)) .getSingleOrNull(); if (checkUpdate == false) return; final packageInfo = await PackageInfo.fromPlatform(); if (Env.releaseChannel == ReleaseChannel.nightly) { final value = await globalDio.getUri( Uri.parse( "https://api.github.com/repos/KRTirtho/spotube/actions/workflows/spotube-release-binary.yml/runs?status=success&per_page=1", ), options: Options( responseType: ResponseType.json, ), ); final buildNum = value.data["workflow_runs"][0]["run_number"] as int; if (buildNum <= int.parse(packageInfo.buildNumber) || !context.mounted) { return; } await showDialog( context: context, barrierDismissible: true, barrierColor: Colors.black.withAlpha(66), builder: (context) { return RootAppUpdateDialog.nightly(nightlyBuildNum: buildNum); }, ); } else { final value = await globalDio.getUri( Uri.parse( "https://api.github.com/repos/KRTirtho/spotube/releases/latest", ), ); final tagName = (value.data["tag_name"] as String).replaceAll("v", ""); final currentVersion = packageInfo.version == "Unknown" ? null : Version.parse(packageInfo.version); final latestVersion = tagName == "nightly" ? null : Version.parse(tagName); if (currentVersion == null || latestVersion == null || (latestVersion.isPreRelease && !currentVersion.isPreRelease) || (!latestVersion.isPreRelease && currentVersion.isPreRelease)) { return; } if (latestVersion <= currentVersion || !context.mounted) return; showDialog( context: context, barrierDismissible: true, barrierColor: Colors.black.withAlpha(66), builder: (context) { return RootAppUpdateDialog(version: latestVersion); }, ); } } /// Spotify Images are always JPEGs static Future downloadImage( String imageUrl, ) async { try { final fileStream = DefaultCacheManager().getImageFile(imageUrl); final bytes = List.empty(growable: true); await for (final data in fileStream) { if (data is FileInfo) { bytes.addAll(data.file.readAsBytesSync()); break; } } return Uint8List.fromList(bytes); } catch (e, stackTrace) { AppLogger.reportError(e, stackTrace); return null; } } }