fix: downloaded tracks are not tagged with metadata

This commit is contained in:
Kingkor Roy Tirtho 2025-11-08 15:49:37 +06:00
parent 700a69fcd1
commit 3209c75144
17 changed files with 112 additions and 554 deletions

View File

@ -202,7 +202,6 @@ If you are curious, you can [read the reason of choosing this license](https://d
1. [Invidious](https://invidious.io/) - Invidious is an open source alternative front-end to YouTube. 1. [Invidious](https://invidious.io/) - Invidious is an open source alternative front-end to YouTube.
1. [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A feature-rich command-line audio/video downloader. 1. [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A feature-rich command-line audio/video downloader.
1. [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) - NewPipe's core library for extracting data from streaming sites. 1. [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) - NewPipe's core library for extracting data from streaming sites.
1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content
1. [LRCLib](https://lrclib.net/) - A public synced lyric API. 1. [LRCLib](https://lrclib.net/) - A public synced lyric API.
1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution
1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -66,6 +66,19 @@ class $AssetsImagesGen {
]; ];
} }
class $AssetsPluginsGen {
const $AssetsPluginsGen();
/// Directory path: assets/plugins/spotube-plugin-musicbrainz-listenbrainz
$AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen
get spotubePluginMusicbrainzListenbrainz =>
const $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen();
/// Directory path: assets/plugins/spotube-plugin-youtube-audio
$AssetsPluginsSpotubePluginYoutubeAudioGen get spotubePluginYoutubeAudio =>
const $AssetsPluginsSpotubePluginYoutubeAudioGen();
}
class $AssetsImagesLogosGen { class $AssetsImagesLogosGen {
const $AssetsImagesLogosGen(); const $AssetsImagesLogosGen();
@ -81,13 +94,30 @@ class $AssetsImagesLogosGen {
AssetGenImage get jiosaavn => AssetGenImage get jiosaavn =>
const AssetGenImage('assets/images/logos/jiosaavn.png'); const AssetGenImage('assets/images/logos/jiosaavn.png');
/// File path: assets/images/logos/songlink-transparent.png /// List of all assets
AssetGenImage get songlinkTransparent => List<AssetGenImage> get values => [dabMusic, invidious, jiosaavn];
const AssetGenImage('assets/images/logos/songlink-transparent.png'); }
class $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen {
const $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen();
/// File path: assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug
String get plugin =>
'assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug';
/// List of all assets /// List of all assets
List<AssetGenImage> get values => List<String> get values => [plugin];
[dabMusic, invidious, jiosaavn, songlinkTransparent]; }
class $AssetsPluginsSpotubePluginYoutubeAudioGen {
const $AssetsPluginsSpotubePluginYoutubeAudioGen();
/// File path: assets/plugins/spotube-plugin-youtube-audio/plugin.smplug
String get plugin =>
'assets/plugins/spotube-plugin-youtube-audio/plugin.smplug';
/// List of all assets
List<String> get values => [plugin];
} }
class Assets { class Assets {
@ -96,6 +126,7 @@ class Assets {
static const String license = 'LICENSE'; static const String license = 'LICENSE';
static const $AssetsBrandingGen branding = $AssetsBrandingGen(); static const $AssetsBrandingGen branding = $AssetsBrandingGen();
static const $AssetsImagesGen images = $AssetsImagesGen(); static const $AssetsImagesGen images = $AssetsImagesGen();
static const $AssetsPluginsGen plugins = $AssetsPluginsGen();
/// List of all assets /// List of all assets
static List<String> get values => [license]; static List<String> get values => [license];

View File

@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/components/ui/button_tile.dart';
@ -36,7 +35,6 @@ class TrackOptions extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
final trackOptionActions = ref.watch(trackOptionActionsProvider(track)); final trackOptionActions = ref.watch(trackOptionActionsProvider(track));
final ( final (
@ -260,24 +258,6 @@ class TrackOptions extends HookConsumerWidget {
leading: const Icon(SpotubeIcons.share), leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share), title: Text(context.l10n.share),
), ),
if (!isLocalTrack)
ButtonTile(
style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.songlink,
playlistId,
);
onTapItem?.call();
},
leading: Assets.images.logos.songlinkTransparent.image(
width: 22,
height: 22,
color: colorScheme.foreground.withValues(alpha: 0.5),
),
title: Text(context.l10n.song_link),
),
if (!isLocalTrack) if (!isLocalTrack)
ButtonTile( ButtonTile(
style: ButtonVariance.menu, style: ButtonVariance.menu,

View File

@ -4143,8 +4143,6 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $PluginsTableTable pluginsTable = $PluginsTableTable(this); late final $PluginsTableTable pluginsTable = $PluginsTableTable(this);
late final Index uniqueBlacklist = Index('unique_blacklist', late final Index uniqueBlacklist = Index('unique_blacklist',
'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)');
late final Index uniqTrackMatch = Index('uniq_track_match',
'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_info, source_type)');
@override @override
Iterable<TableInfo<Table, Object?>> get allTables => Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>(); allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@ -4160,8 +4158,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
historyTable, historyTable,
lyricsTable, lyricsTable,
pluginsTable, pluginsTable,
uniqueBlacklist, uniqueBlacklist
uniqTrackMatch
]; ];
} }

View File

@ -10,6 +10,8 @@ enum SpotubeMediaCompressionType {
@Freezed(unionKey: 'type') @Freezed(unionKey: 'type')
class SpotubeAudioSourceContainerPreset class SpotubeAudioSourceContainerPreset
with _$SpotubeAudioSourceContainerPreset { with _$SpotubeAudioSourceContainerPreset {
const SpotubeAudioSourceContainerPreset._();
@FreezedUnionValue("lossy") @FreezedUnionValue("lossy")
factory SpotubeAudioSourceContainerPreset.lossy({ factory SpotubeAudioSourceContainerPreset.lossy({
required SpotubeMediaCompressionType type, required SpotubeMediaCompressionType type,
@ -27,6 +29,14 @@ class SpotubeAudioSourceContainerPreset
factory SpotubeAudioSourceContainerPreset.fromJson( factory SpotubeAudioSourceContainerPreset.fromJson(
Map<String, dynamic> json) => Map<String, dynamic> json) =>
_$SpotubeAudioSourceContainerPresetFromJson(json); _$SpotubeAudioSourceContainerPresetFromJson(json);
String getFileExtension() {
return switch (name) {
"mp4" => "m4a",
"webm" => "weba",
_ => name,
};
}
} }
@freezed @freezed

View File

@ -197,12 +197,13 @@ class __$$SpotubeAudioSourceContainerPresetLossyImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$SpotubeAudioSourceContainerPresetLossyImpl class _$SpotubeAudioSourceContainerPresetLossyImpl
implements SpotubeAudioSourceContainerPresetLossy { extends SpotubeAudioSourceContainerPresetLossy {
_$SpotubeAudioSourceContainerPresetLossyImpl( _$SpotubeAudioSourceContainerPresetLossyImpl(
{required this.type, {required this.type,
required this.name, required this.name,
required final List<SpotubeAudioLossyContainerQuality> qualities}) required final List<SpotubeAudioLossyContainerQuality> qualities})
: _qualities = qualities; : _qualities = qualities,
super._();
factory _$SpotubeAudioSourceContainerPresetLossyImpl.fromJson( factory _$SpotubeAudioSourceContainerPresetLossyImpl.fromJson(
Map<String, dynamic> json) => Map<String, dynamic> json) =>
@ -338,12 +339,13 @@ class _$SpotubeAudioSourceContainerPresetLossyImpl
} }
abstract class SpotubeAudioSourceContainerPresetLossy abstract class SpotubeAudioSourceContainerPresetLossy
implements SpotubeAudioSourceContainerPreset { extends SpotubeAudioSourceContainerPreset {
factory SpotubeAudioSourceContainerPresetLossy( factory SpotubeAudioSourceContainerPresetLossy(
{required final SpotubeMediaCompressionType type, {required final SpotubeMediaCompressionType type,
required final String name, required final String name,
required final List<SpotubeAudioLossyContainerQuality> qualities}) = required final List<SpotubeAudioLossyContainerQuality> qualities}) =
_$SpotubeAudioSourceContainerPresetLossyImpl; _$SpotubeAudioSourceContainerPresetLossyImpl;
SpotubeAudioSourceContainerPresetLossy._() : super._();
factory SpotubeAudioSourceContainerPresetLossy.fromJson( factory SpotubeAudioSourceContainerPresetLossy.fromJson(
Map<String, dynamic> json) = Map<String, dynamic> json) =
@ -419,12 +421,13 @@ class __$$SpotubeAudioSourceContainerPresetLosslessImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$SpotubeAudioSourceContainerPresetLosslessImpl class _$SpotubeAudioSourceContainerPresetLosslessImpl
implements SpotubeAudioSourceContainerPresetLossless { extends SpotubeAudioSourceContainerPresetLossless {
_$SpotubeAudioSourceContainerPresetLosslessImpl( _$SpotubeAudioSourceContainerPresetLosslessImpl(
{required this.type, {required this.type,
required this.name, required this.name,
required final List<SpotubeAudioLosslessContainerQuality> qualities}) required final List<SpotubeAudioLosslessContainerQuality> qualities})
: _qualities = qualities; : _qualities = qualities,
super._();
factory _$SpotubeAudioSourceContainerPresetLosslessImpl.fromJson( factory _$SpotubeAudioSourceContainerPresetLosslessImpl.fromJson(
Map<String, dynamic> json) => Map<String, dynamic> json) =>
@ -561,12 +564,13 @@ class _$SpotubeAudioSourceContainerPresetLosslessImpl
} }
abstract class SpotubeAudioSourceContainerPresetLossless abstract class SpotubeAudioSourceContainerPresetLossless
implements SpotubeAudioSourceContainerPreset { extends SpotubeAudioSourceContainerPreset {
factory SpotubeAudioSourceContainerPresetLossless( factory SpotubeAudioSourceContainerPresetLossless(
{required final SpotubeMediaCompressionType type, {required final SpotubeMediaCompressionType type,
required final String name, required final String name,
required final List<SpotubeAudioLosslessContainerQuality> required final List<SpotubeAudioLosslessContainerQuality>
qualities}) = _$SpotubeAudioSourceContainerPresetLosslessImpl; qualities}) = _$SpotubeAudioSourceContainerPresetLosslessImpl;
SpotubeAudioSourceContainerPresetLossless._() : super._();
factory SpotubeAudioSourceContainerPresetLossless.fromJson( factory SpotubeAudioSourceContainerPresetLossless.fromJson(
Map<String, dynamic> json) = Map<String, dynamic> json) =

View File

@ -16,7 +16,7 @@ class UserDownloadsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final downloadManager = ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider);
final history = downloadManager.$backHistory; final history = downloadManager.$history;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -48,7 +48,7 @@ class UserDownloadsPage extends HookConsumerWidget {
child: ListView.builder( child: ListView.builder(
itemCount: history.length, itemCount: history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return DownloadItem(track: history.elementAt(index)); return DownloadItem(track: history.elementAt(index).query);
}, },
), ),
), ),

View File

@ -346,36 +346,41 @@ class LocalLibraryPage extends HookConsumerWidget {
controller: controller, controller: controller,
child: Skeletonizer( child: Skeletonizer(
enabled: trackSnapshot.isLoading, enabled: trackSnapshot.isLoading,
child: ListView.builder( child: CustomScrollView(
controller: controller, controller: controller,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: trackSnapshot.isLoading slivers: [
? 5 SliverList.builder(
: filteredTracks.length, itemCount: trackSnapshot.isLoading
itemBuilder: (context, index) { ? 5
if (trackSnapshot.isLoading) { : filteredTracks.length,
return TrackTile( itemBuilder: (context, index) {
playlist: playlist, if (trackSnapshot.isLoading) {
track: FakeData.track, return TrackTile(
index: index, playlist: playlist,
); track: FakeData.track,
} index: index,
);
}
final track = filteredTracks[index]; final track = filteredTracks[index];
return TrackTile( return TrackTile(
index: index, index: index,
playlist: playlist, playlist: playlist,
track: track, track: track,
userPlaylist: false, userPlaylist: false,
onTap: () async { onTap: () async {
await playLocalTracks( await playLocalTracks(
ref, ref,
sortedTracks, sortedTracks,
currentTrack: track, currentTrack: track,
);
},
); );
}, },
); ),
}, const SliverGap(200),
],
), ),
), ),
), ),
@ -398,7 +403,7 @@ class LocalLibraryPage extends HookConsumerWidget {
error: (error, stackTrace) => error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()), Text(error.toString() + stackTrace.toString()),
); );
}) }),
], ],
), ),
), ),

View File

@ -19,7 +19,6 @@ import 'package:spotube/utils/service_utils.dart';
class DownloadManagerProvider extends ChangeNotifier { class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref}) DownloadManagerProvider({required this.ref})
: $history = <SourcedTrack>{}, : $history = <SourcedTrack>{},
$backHistory = <SpotubeFullTrackObject>{},
dl = DownloadManager() { dl = DownloadManager() {
dl.statusStream.listen((event) async { dl.statusStream.listen((event) async {
try { try {
@ -28,14 +27,13 @@ class DownloadManagerProvider extends ChangeNotifier {
final sourcedTrack = $history.firstWhereOrNull( final sourcedTrack = $history.firstWhereOrNull(
(element) => (element) =>
element.getUrlOfQuality( element.getUrlOfQuality(
downloadContainer, downloadQualityIndex) == downloadContainer,
downloadQualityIndex,
) ==
request.url, request.url,
); );
if (sourcedTrack == null) return; if (sourcedTrack == null) return;
final track = $backHistory.firstWhereOrNull(
(element) => element.id == sourcedTrack.query.id,
);
if (track == null) return;
final savePath = getTrackFileUrl(sourcedTrack); final savePath = getTrackFileUrl(sourcedTrack);
// related to onFileExists // related to onFileExists
@ -47,12 +45,12 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.exists()) { await oldFile.exists()) {
await oldFile.rename(savePath); await oldFile.rename(savePath);
} }
if (status != DownloadStatus.completed || if (status != DownloadStatus.completed ||
//? WebA audiotagging is not supported yet //? WebA audiotagging is not supported yet
//? Although in future by converting weba to opus & then tagging it //? Although in future by converting weba to opus & then tagging it
//? is possible using vorbis comments //? is possible using vorbis comments
downloadContainer.name == "weba" || downloadContainer.getFileExtension() == "weba") {
downloadContainer.name == "webm") {
return; return;
} }
@ -63,13 +61,13 @@ class DownloadManagerProvider extends ChangeNotifier {
} }
final imageBytes = await ServiceUtils.downloadImage( final imageBytes = await ServiceUtils.downloadImage(
(track.album.images).asUrlString( (sourcedTrack.query.album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
index: 1, index: 1,
), ),
); );
final metadata = track.toMetadata( final metadata = sourcedTrack.query.toMetadata(
fileLength: await file.length(), fileLength: await file.length(),
imageBytes: imageBytes, imageBytes: imageBytes,
); );
@ -111,17 +109,16 @@ class DownloadManagerProvider extends ChangeNotifier {
final Set<SourcedTrack> $history; final Set<SourcedTrack> $history;
// these are the tracks which metadata hasn't been fetched yet // these are the tracks which metadata hasn't been fetched yet
final Set<SpotubeFullTrackObject> $backHistory;
final DownloadManager dl; final DownloadManager dl;
String getTrackFileUrl(SourcedTrack track) { String getTrackFileUrl(SourcedTrack track) {
final name = final name =
"${track.query.name} - ${track.query.artists.join(", ")}.${downloadContainer.name}"; "${track.query.name} - ${track.query.artists.map((e) => e.name).join(", ")}.${downloadContainer.getFileExtension()}";
return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name));
} }
bool isActive(SpotubeFullTrackObject track) { bool isActive(SpotubeFullTrackObject track) {
if ($backHistory.contains(track)) return true; if ($history.any((e) => e.query.id == track.id)) return true;
final sourcedTrack = $history.firstWhereOrNull( final sourcedTrack = $history.firstWhereOrNull(
(element) => element.query.id == track.id, (element) => element.query.id == track.id,
@ -146,9 +143,7 @@ class DownloadManagerProvider extends ChangeNotifier {
/// For singular downloads /// For singular downloads
Future<void> addToQueue(SpotubeFullTrackObject track) async { Future<void> addToQueue(SpotubeFullTrackObject track) async {
final sourcedTrack = await ref.read( final sourcedTrack = await ref.read(sourcedTrackProvider(track).future);
sourcedTrackProvider(track).future,
);
final savePath = getTrackFileUrl(sourcedTrack); final savePath = getTrackFileUrl(sourcedTrack);
@ -161,35 +156,17 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.rename("$savePath.old"); await oldFile.rename("$savePath.old");
} }
if (sourcedTrack.qualityPreset == downloadContainer) { final downloadTask = await dl.addDownload(
final downloadTask = await dl.addDownload( sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!,
sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!, savePath,
savePath, );
); if (downloadTask != null) {
if (downloadTask != null) { $history.add(sourcedTrack);
$history.add(sourcedTrack);
}
} else {
$backHistory.add(track);
final sourcedTrack =
await ref.read(sourcedTrackProvider(track).future).then((d) {
$backHistory.remove(track);
return d;
});
final downloadTask = await dl.addDownload(
sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!,
savePath,
);
if (downloadTask != null) {
$history.add(sourcedTrack);
}
} }
notifyListeners(); notifyListeners();
} }
Future<void> batchAddToQueue(List<SpotubeFullTrackObject> tracks) async { Future<void> batchAddToQueue(List<SpotubeFullTrackObject> tracks) async {
$backHistory.addAll(tracks);
notifyListeners(); notifyListeners();
for (final track in tracks) { for (final track in tracks) {
try { try {

View File

@ -48,7 +48,7 @@ class ServerPlaybackRoutes {
return join( return join(
await UserPreferencesNotifier.getMusicCacheDir(), await UserPreferencesNotifier.getMusicCacheDir(),
ServiceUtils.sanitizeFilename( ServiceUtils.sanitizeFilename(
'${track.query.name} - ${track.query.artists.map((d) => d.name).join(",")} (${track.info.id}).${track.qualityPreset!.name}', '${track.query.name} - ${track.query.artists.map((d) => d.name).join(",")} (${track.info.id}).${track.qualityPreset!.getFileExtension()}',
), ),
); );
} }
@ -263,8 +263,7 @@ class ServerPlaybackRoutes {
} }
if (contentRange.total == fileLength && if (contentRange.total == fileLength &&
track.qualityPreset!.name != "webm" || track.qualityPreset!.getFileExtension() != "weba") {
track.qualityPreset!.name != "weba") {
final playlistTrack = playlist.tracks.firstWhereOrNull( final playlistTrack = playlist.tracks.firstWhereOrNull(
(element) => element.id == track.query.id, (element) => element.id == track.query.id,
); );

View File

@ -21,12 +21,10 @@ import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue { enum TrackOptionValue {
album, album,
share, share,
songlink,
addToPlaylist, addToPlaylist,
addToQueue, addToQueue,
removeFromPlaylist, removeFromPlaylist,
@ -237,10 +235,6 @@ class TrackOptionsActions {
case TrackOptionValue.share: case TrackOptionValue.share:
actionShare(context); actionShare(context);
break; break;
case TrackOptionValue.songlink:
final url = "https://song.link/s/${track.id}";
await launchUrlString(url);
break;
case TrackOptionValue.details: case TrackOptionValue.details:
if (track is! SpotubeFullTrackObject) break; if (track is! SpotubeFullTrackObject) break;
showDialog( showDialog(
@ -252,8 +246,8 @@ class TrackOptionsActions {
); );
break; break;
case TrackOptionValue.download: case TrackOptionValue.download:
if (track is! SpotubeFullTrackObject) break; if (track is SpotubeLocalTrackObject) break;
await downloadManager.addToQueue(track as SpotubeFullTrackObject); downloadManager.addToQueue(track as SpotubeFullTrackObject);
break; break;
case TrackOptionValue.startRadio: case TrackOptionValue.startRadio:
actionStartRadio(context); actionStartRadio(context);

View File

@ -37,7 +37,7 @@ class SpotubeMedia extends mk.Media {
factory SpotubeMedia.media(Media media) { factory SpotubeMedia.media(Media media) {
assert(media.extras != null, "[Media] must have extra metadata set"); assert(media.extras != null, "[Media] must have extra metadata set");
return SpotubeMedia(SpotubeFullTrackObject.fromJson(media.extras!)); return SpotubeMedia(SpotubeTrackObject.fromJson(media.extras!));
} }
} }

View File

@ -1,19 +0,0 @@
part of './song_link.dart';
@freezed
class SongLink with _$SongLink {
const factory SongLink({
required String displayName,
required String linkId,
required String platform,
required bool show,
required String? uniqueId,
required String? country,
required String? url,
required String? nativeAppUriMobile,
required String? nativeAppUriDesktop,
}) = _SongLink;
factory SongLink.fromJson(Map<String, dynamic> json) =>
_$SongLinkFromJson(json);
}

View File

@ -1,54 +0,0 @@
library song_link;
import 'dart:convert';
import 'package:spotube/services/logger/logger.dart';
import 'package:dio/dio.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:html/parser.dart';
part 'model.dart';
part 'song_link.freezed.dart';
part 'song_link.g.dart';
abstract class SongLinkService {
static final dio = Dio();
static Future<List<SongLink>> links(String spotifyId) async {
try {
final res = await dio.get(
"https://song.link/s/$spotifyId",
options: Options(
headers: {
"Accept":
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
},
responseType: ResponseType.plain,
),
);
final document = parse(res.data);
final script = document.getElementById("__NEXT_DATA__")?.text;
if (script == null) {
return <SongLink>[];
}
final pageProps = jsonDecode(script) as Map<String, dynamic>;
final songLinks = pageProps["props"]?["pageProps"]?["pageData"]
?["sections"]
?.firstWhere(
(section) => section?["sectionId"] == "section|auto|links|listen",
)?["links"] as List?;
return songLinks?.map((link) => SongLink.fromJson(link)).toList() ??
<SongLink>[];
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
return <SongLink>[];
}
}
}

View File

@ -1,333 +0,0 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'song_link.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SongLink _$SongLinkFromJson(Map<String, dynamic> json) {
return _SongLink.fromJson(json);
}
/// @nodoc
mixin _$SongLink {
String get displayName => throw _privateConstructorUsedError;
String get linkId => throw _privateConstructorUsedError;
String get platform => throw _privateConstructorUsedError;
bool get show => throw _privateConstructorUsedError;
String? get uniqueId => throw _privateConstructorUsedError;
String? get country => throw _privateConstructorUsedError;
String? get url => throw _privateConstructorUsedError;
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
/// Serializes this SongLink to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SongLinkCopyWith<SongLink> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SongLinkCopyWith<$Res> {
factory $SongLinkCopyWith(SongLink value, $Res Function(SongLink) then) =
_$SongLinkCopyWithImpl<$Res, SongLink>;
@useResult
$Res call(
{String displayName,
String linkId,
String platform,
bool show,
String? uniqueId,
String? country,
String? url,
String? nativeAppUriMobile,
String? nativeAppUriDesktop});
}
/// @nodoc
class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
implements $SongLinkCopyWith<$Res> {
_$SongLinkCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? displayName = null,
Object? linkId = null,
Object? platform = null,
Object? show = null,
Object? uniqueId = freezed,
Object? country = freezed,
Object? url = freezed,
Object? nativeAppUriMobile = freezed,
Object? nativeAppUriDesktop = freezed,
}) {
return _then(_value.copyWith(
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
linkId: null == linkId
? _value.linkId
: linkId // ignore: cast_nullable_to_non_nullable
as String,
platform: null == platform
? _value.platform
: platform // ignore: cast_nullable_to_non_nullable
as String,
show: null == show
? _value.show
: show // ignore: cast_nullable_to_non_nullable
as bool,
uniqueId: freezed == uniqueId
? _value.uniqueId
: uniqueId // ignore: cast_nullable_to_non_nullable
as String?,
country: freezed == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String?,
url: freezed == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriMobile: freezed == nativeAppUriMobile
? _value.nativeAppUriMobile
: nativeAppUriMobile // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriDesktop: freezed == nativeAppUriDesktop
? _value.nativeAppUriDesktop
: nativeAppUriDesktop // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SongLinkImplCopyWith<$Res>
implements $SongLinkCopyWith<$Res> {
factory _$$SongLinkImplCopyWith(
_$SongLinkImpl value, $Res Function(_$SongLinkImpl) then) =
__$$SongLinkImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String displayName,
String linkId,
String platform,
bool show,
String? uniqueId,
String? country,
String? url,
String? nativeAppUriMobile,
String? nativeAppUriDesktop});
}
/// @nodoc
class __$$SongLinkImplCopyWithImpl<$Res>
extends _$SongLinkCopyWithImpl<$Res, _$SongLinkImpl>
implements _$$SongLinkImplCopyWith<$Res> {
__$$SongLinkImplCopyWithImpl(
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
: super(_value, _then);
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? displayName = null,
Object? linkId = null,
Object? platform = null,
Object? show = null,
Object? uniqueId = freezed,
Object? country = freezed,
Object? url = freezed,
Object? nativeAppUriMobile = freezed,
Object? nativeAppUriDesktop = freezed,
}) {
return _then(_$SongLinkImpl(
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
linkId: null == linkId
? _value.linkId
: linkId // ignore: cast_nullable_to_non_nullable
as String,
platform: null == platform
? _value.platform
: platform // ignore: cast_nullable_to_non_nullable
as String,
show: null == show
? _value.show
: show // ignore: cast_nullable_to_non_nullable
as bool,
uniqueId: freezed == uniqueId
? _value.uniqueId
: uniqueId // ignore: cast_nullable_to_non_nullable
as String?,
country: freezed == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String?,
url: freezed == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriMobile: freezed == nativeAppUriMobile
? _value.nativeAppUriMobile
: nativeAppUriMobile // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriDesktop: freezed == nativeAppUriDesktop
? _value.nativeAppUriDesktop
: nativeAppUriDesktop // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SongLinkImpl implements _SongLink {
const _$SongLinkImpl(
{required this.displayName,
required this.linkId,
required this.platform,
required this.show,
required this.uniqueId,
required this.country,
required this.url,
required this.nativeAppUriMobile,
required this.nativeAppUriDesktop});
factory _$SongLinkImpl.fromJson(Map<String, dynamic> json) =>
_$$SongLinkImplFromJson(json);
@override
final String displayName;
@override
final String linkId;
@override
final String platform;
@override
final bool show;
@override
final String? uniqueId;
@override
final String? country;
@override
final String? url;
@override
final String? nativeAppUriMobile;
@override
final String? nativeAppUriDesktop;
@override
String toString() {
return 'SongLink(displayName: $displayName, linkId: $linkId, platform: $platform, show: $show, uniqueId: $uniqueId, country: $country, url: $url, nativeAppUriMobile: $nativeAppUriMobile, nativeAppUriDesktop: $nativeAppUriDesktop)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SongLinkImpl &&
(identical(other.displayName, displayName) ||
other.displayName == displayName) &&
(identical(other.linkId, linkId) || other.linkId == linkId) &&
(identical(other.platform, platform) ||
other.platform == platform) &&
(identical(other.show, show) || other.show == show) &&
(identical(other.uniqueId, uniqueId) ||
other.uniqueId == uniqueId) &&
(identical(other.country, country) || other.country == country) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.nativeAppUriMobile, nativeAppUriMobile) ||
other.nativeAppUriMobile == nativeAppUriMobile) &&
(identical(other.nativeAppUriDesktop, nativeAppUriDesktop) ||
other.nativeAppUriDesktop == nativeAppUriDesktop));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
__$$SongLinkImplCopyWithImpl<_$SongLinkImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SongLinkImplToJson(
this,
);
}
}
abstract class _SongLink implements SongLink {
const factory _SongLink(
{required final String displayName,
required final String linkId,
required final String platform,
required final bool show,
required final String? uniqueId,
required final String? country,
required final String? url,
required final String? nativeAppUriMobile,
required final String? nativeAppUriDesktop}) = _$SongLinkImpl;
factory _SongLink.fromJson(Map<String, dynamic> json) =
_$SongLinkImpl.fromJson;
@override
String get displayName;
@override
String get linkId;
@override
String get platform;
@override
bool get show;
@override
String? get uniqueId;
@override
String? get country;
@override
String? get url;
@override
String? get nativeAppUriMobile;
@override
String? get nativeAppUriDesktop;
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -1,32 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'song_link.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl(
displayName: json['displayName'] as String,
linkId: json['linkId'] as String,
platform: json['platform'] as String,
show: json['show'] as bool,
uniqueId: json['uniqueId'] as String?,
country: json['country'] as String?,
url: json['url'] as String?,
nativeAppUriMobile: json['nativeAppUriMobile'] as String?,
nativeAppUriDesktop: json['nativeAppUriDesktop'] as String?,
);
Map<String, dynamic> _$$SongLinkImplToJson(_$SongLinkImpl instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'linkId': instance.linkId,
'platform': instance.platform,
'show': instance.show,
'uniqueId': instance.uniqueId,
'country': instance.country,
'url': instance.url,
'nativeAppUriMobile': instance.nativeAppUriMobile,
'nativeAppUriDesktop': instance.nativeAppUriDesktop,
};