spotube/lib/utils/service_utils.dart
Alessio fece073def This pull request primarily involves the removal of several configuration files and assets, as well as minor updates to documentation. The most significant changes are the deletion of various .vscode configuration files and the removal of unused assets from the project.
Configuration File Removals:

    .vscode/c_cpp_properties.json: Removed the entire configuration for C/C++ properties.
    .vscode/launch.json: Removed the Dart launch configurations for different environments and modes.
    .vscode/settings.json: Removed settings related to CMake, spell checking, file nesting, and Dart Flutter SDK path.
    .vscode/snippets.code-snippets: Removed code snippets for Dart, including PaginatedState and PaginatedNotifier templates.
    .vscode/tasks.json: Removed the tasks configuration file.

Documentation Updates:

    CONTRIBUTION.md: Removed heart emoji from the introductory text.
    README.md: Updated the logo image and made minor text adjustments, including removing emojis and updating section titles. [1] [2] [3] [4] [5]

Asset Removals:

    lib/collections/assets.gen.dart: Removed multiple unused asset references, including images related to Spotube logos and banners. [1] [2] [3]

Minor Code Cleanups:

    cli/commands/build/linux.dart, cli/commands/build/windows.dart, cli/commands/translated.dart, cli/commands/untranslated.dart: Adjusted import statements for consistency. [1] [2] [3] [4]
    integration_test/app_test.dart: Removed an unnecessary blank line.
    lib/collections/routes.dart: Commented out the TrackRoute configuration.
2025-04-13 18:40:37 +02:00

432 lines
14 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:html/dom.dart' hide Text;
import 'package:html/parser.dart' as parser;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Element;
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/models/lyrics.dart';
import 'package:spotube/modules/root/update_dialog.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.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: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(", ")}"
.toLowerCase()
.replaceAll(RegExp(r"\s*\[[^\]]*]"), ' ')
.replaceAll(RegExp(r"\sfeat\.|\sft\."), ' ')
.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.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'<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(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<T> sortTracks<T extends Track>(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 ?? "") ?? 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<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);
},
);
}
}
/// Spotify Images are always JPEGs
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)}";
}
}
}