mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
459 lines
15 KiB
Dart
459 lines
15 KiB
Dart
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
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:spotube/models/metadata/metadata.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';
|
|
|
|
enum UserAgentDevice {
|
|
desktop,
|
|
mobile,
|
|
}
|
|
|
|
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<String> artists) {
|
|
return title
|
|
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
|
|
.trim();
|
|
}
|
|
|
|
static String getTitle(
|
|
String title, {
|
|
List<String> 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(", ")}"
|
|
.replaceAll(RegExp(r"\s*\[[^\]]*]"), ' ')
|
|
.replaceAll(RegExp(r"\sfeat\.|\sft\.", caseSensitive: false), ' ')
|
|
.replaceAll(RegExp(r"\s+"), ' ')
|
|
.trim();
|
|
}
|
|
|
|
static Future<String?> 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("<br>", "\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<List?> searchSong(
|
|
String title,
|
|
List<String> 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<String, String> 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 <String, dynamic>{
|
|
"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<String?> getLyrics(
|
|
String title,
|
|
List<String> 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<SubtitleSimple?> getTimedLyrics(SourcedTrack track) async {
|
|
final artistNames = track.query.artists;
|
|
final query = getTitle(
|
|
track.query.title,
|
|
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.query.artists
|
|
.every((artist) => title.contains(artist.toLowerCase()));
|
|
final hasTrackName = title.contains(track.query.title.toLowerCase());
|
|
final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live");
|
|
final exactYtMatch = title == track.info.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'<h3>.*</h3>'), "")
|
|
.split("<br>")
|
|
.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(SpotubeFullAlbumObject? album) {
|
|
if (album == null) {
|
|
return DateTime.parse("1975-01-01");
|
|
}
|
|
|
|
return DateTime.parse(album.releaseDate);
|
|
}
|
|
|
|
static List<T> sortTracks<T extends SpotubeTrackObject>(
|
|
List<T> tracks, SortBy sortBy) {
|
|
if (sortBy == SortBy.none) return tracks;
|
|
return List<T>.from(tracks)
|
|
..sort((a, b) {
|
|
switch (sortBy) {
|
|
case SortBy.ascending:
|
|
return a.name.compareTo(b.name);
|
|
case SortBy.descending:
|
|
return b.name.compareTo(a.name);
|
|
// TODO: We'll figure this one out later :')
|
|
// 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);
|
|
case SortBy.artist:
|
|
return a.artists.first.name.compareTo(b.artists.first.name);
|
|
case SortBy.album:
|
|
return a.album.name.compareTo(b.album.name);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
static Future<void> 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);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
static Future<Uint8List?> downloadImage(
|
|
String imageUrl,
|
|
) async {
|
|
try {
|
|
final fileStream = DefaultCacheManager().getImageFile(imageUrl);
|
|
|
|
final bytes = List<int>.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;
|
|
}
|
|
}
|
|
|
|
static int randomNumber(int min, int max) {
|
|
return min + Random().nextInt(max - min);
|
|
}
|
|
|
|
static String randomUserAgent(UserAgentDevice type) {
|
|
if (type == UserAgentDevice.desktop) {
|
|
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_${randomNumber(11, 15)}_${randomNumber(4, 9)}) AppleWebKit/${randomNumber(530, 537)}.${randomNumber(30, 37)} (KHTML, like Gecko) Chrome/${randomNumber(80, 105)}.0.${randomNumber(3000, 4500)}.${randomNumber(60, 125)} Safari/${randomNumber(530, 537)}.${randomNumber(30, 36)}";
|
|
} else {
|
|
return "Mozilla/5.0 (Linux; Android ${randomNumber(8, 13)}) AppleWebKit/${randomNumber(530, 537)}.${randomNumber(30, 36)} (KHTML, like Gecko) Chrome/${randomNumber(101, 116)}.0.${randomNumber(3000, 6000)}.${randomNumber(60, 125)} Mobile Safari/${randomNumber(530, 537)}.${randomNumber(30, 36)}";
|
|
}
|
|
}
|
|
|
|
static String sanitizeFilename(String input, {String replacement = ''}) {
|
|
final result = input
|
|
// illegalRe
|
|
.replaceAll(
|
|
RegExp(r'[\/\?<>\\:\*\|"]'),
|
|
replacement,
|
|
)
|
|
// controlRe
|
|
.replaceAll(
|
|
RegExp(
|
|
r'[\x00-\x1f\x80-\x9f]',
|
|
),
|
|
replacement,
|
|
)
|
|
// reservedRe
|
|
.replaceFirst(
|
|
RegExp(r'^\.+$'),
|
|
replacement,
|
|
)
|
|
// windowsReservedRe
|
|
.replaceFirst(
|
|
RegExp(
|
|
r'^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$',
|
|
caseSensitive: false,
|
|
),
|
|
replacement,
|
|
)
|
|
// windowsTrailingRe
|
|
.replaceFirst(RegExp(r'[\. ]+$'), replacement);
|
|
|
|
return result.length > 255 ? result.substring(0, 255) : result;
|
|
}
|
|
}
|