feat: add songlink based track matching for youtube and open song link button

songlink.com will provide accurate match verified by community for most spotify tracks improving overall match accuracy for Youtube audio source
This commit is contained in:
Kingkor Roy Tirtho 2024-02-25 11:13:23 +06:00
parent 6f71e52ea8
commit 9095a8c8f8
14 changed files with 565 additions and 19 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
assets/logos/songlink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -9,6 +9,21 @@
import 'package:flutter/widgets.dart';
class $AssetsLogosGen {
const $AssetsLogosGen();
/// File path: assets/logos/songlink-transparent.png
AssetGenImage get songlinkTransparent =>
const AssetGenImage('assets/logos/songlink-transparent.png');
/// File path: assets/logos/songlink.png
AssetGenImage get songlink =>
const AssetGenImage('assets/logos/songlink.png');
/// List of all assets
List<AssetGenImage> get values => [songlinkTransparent, songlink];
}
class $AssetsTutorialGen {
const $AssetsTutorialGen();
@ -37,6 +52,7 @@ class Assets {
static const AssetGenImage jiosaavn = AssetGenImage('assets/jiosaavn.png');
static const AssetGenImage likedTracks =
AssetGenImage('assets/liked-tracks.jpg');
static const $AssetsLogosGen logos = $AssetsLogosGen();
static const AssetGenImage placeholder =
AssetGenImage('assets/placeholder.png');
static const AssetGenImage spotubeHeroBanner =

View File

@ -25,6 +25,7 @@ import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PlayerView extends HookConsumerWidget {
final PanelController panelController;
@ -137,6 +138,21 @@ class PlayerView extends HookConsumerWidget {
onPressed: panelController.close,
),
actions: [
IconButton(
icon: Assets.logos.songlink.image(
width: 20,
height: 20,
),
tooltip: context.l10n.song_link,
onPressed: currentTrack == null
? null
: () {
final url =
"https://song.link/s/${currentTrack.id}";
launchUrlString(url);
},
),
IconButton(
icon: const Icon(SpotubeIcons.info, size: 18),
tooltip: context.l10n.details,

View File

@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
@ -26,10 +27,12 @@ import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/search.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue {
album,
share,
songlink,
addToPlaylist,
addToQueue,
removeFromPlaylist,
@ -165,6 +168,7 @@ class TrackOptions extends HookConsumerWidget {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
@ -276,6 +280,10 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.share:
actionShare(context, track);
break;
case TrackOptionValue.songlink:
final url = "https://song.link/s/${track.id}";
await launchUrlString(url);
break;
case TrackOptionValue.details:
showDialog(
context: context,
@ -418,6 +426,15 @@ class TrackOptions extends HookConsumerWidget {
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share),
),
PopSheetEntry(
value: TrackOptionValue.songlink,
leading: Assets.logos.songlinkTransparent.image(
width: 22,
height: 22,
color: colorScheme.onSurface.withOpacity(0.5),
),
title: Text(context.l10n.song_link),
),
PopSheetEntry(
value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info),

View File

@ -294,5 +294,6 @@
"endless_playback": "Endless Playback",
"delete_playlist": "Delete Playlist",
"delete_playlist_confirmation": "Are you sure you want to delete this playlist?",
"local_tracks": "Local Tracks"
"local_tracks": "Local Tracks",
"song_link": "Song Link"
}

View File

@ -0,0 +1,19 @@
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

@ -0,0 +1,47 @@
library song_link;
import 'dart:convert';
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 Future<List<SongLink>> links(String spotifyId) async {
final dio = Dio();
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();
}
}

View File

@ -0,0 +1,320 @@
// 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#custom-getters-and-methods');
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;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$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;
@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);
@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(ignore: true)
@override
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
@JsonKey(ignore: true)
@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;
@override
@JsonKey(ignore: true)
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'song_link.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SongLinkImpl _$$SongLinkImplFromJson(Map<String, dynamic> 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,
};

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/source_match.dart';
import 'package:spotube/services/song_link/song_link.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
@ -216,6 +217,20 @@ class YoutubeSourcedTrack extends SourcedTrack {
required Track track,
required Ref ref,
}) async {
final links = await SongLinkService.links(track.id!);
final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube");
if (ytLink?.url != null) {
return [
await toSiblingType(
0,
YoutubeVideoInfo.fromVideo(
await youtubeClient.videos.get(ytLink!.url!),
),
)
];
}
final query = SourcedTrack.getSearchTerm(track);
final searchResults = await youtubeClient.search.search(

43
lib/songlink.dart Normal file
View File

@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:html/parser.dart';
import 'package:dio/dio.dart';
void main(List<String> args) async {
final dio = Dio();
final spotifyId = args[0];
print("Fetching song link for $spotifyId");
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) {
throw Exception("Could not find __NEXT_DATA__ script tag.");
}
final pageProps = jsonDecode(script) as Map<String, dynamic>;
final songLinks =
pageProps["props"]["pageProps"]["pageData"]["sections"].firstWhere(
(section) => section["sectionId"] == "section|auto|links|listen",
)["links"];
for (final link in songLinks) {
print("${link["platform"]} - ${link["url"]}");
}
}

View File

@ -153,6 +153,7 @@ flutter:
assets:
- assets/
- assets/tutorial/
- assets/logos/
- LICENSE
flutter_launcher_icons:

View File

@ -7,7 +7,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"bn": [
@ -18,7 +19,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"ca": [
@ -29,7 +31,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"de": [
@ -40,7 +43,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"es": [
@ -51,7 +55,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"fa": [
@ -62,7 +67,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"fr": [
@ -73,7 +79,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"hi": [
@ -84,7 +91,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"it": [
@ -95,7 +103,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"ja": [
@ -106,7 +115,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"ne": [
@ -117,7 +127,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"nl": [
@ -129,7 +140,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"pl": [
@ -140,7 +152,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"pt": [
@ -151,7 +164,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"ru": [
@ -162,7 +176,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"tr": [
@ -173,7 +188,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"uk": [
@ -184,7 +200,8 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
],
"zh": [
@ -195,6 +212,7 @@
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks"
"local_tracks",
"song_link"
]
}