diff --git a/assets/logos/songlink-transparent.png b/assets/logos/songlink-transparent.png new file mode 100644 index 00000000..6b7064c9 Binary files /dev/null and b/assets/logos/songlink-transparent.png differ diff --git a/assets/logos/songlink.png b/assets/logos/songlink.png new file mode 100644 index 00000000..43d823a5 Binary files /dev/null and b/assets/logos/songlink.png differ diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 2587800e..8a2950fb 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -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 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 = diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 298458b5..a43fcbca 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -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, diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 419f61a4..a094259d 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -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), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a3c93546..6b684d48 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } \ No newline at end of file diff --git a/lib/services/song_link/model.dart b/lib/services/song_link/model.dart new file mode 100644 index 00000000..ae9d3833 --- /dev/null +++ b/lib/services/song_link/model.dart @@ -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 json) => + _$SongLinkFromJson(json); +} diff --git a/lib/services/song_link/song_link.dart b/lib/services/song_link/song_link.dart new file mode 100644 index 00000000..c3d73f23 --- /dev/null +++ b/lib/services/song_link/song_link.dart @@ -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> 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 []; + } + + final pageProps = jsonDecode(script) as Map; + 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(); + } +} diff --git a/lib/services/song_link/song_link.freezed.dart b/lib/services/song_link/song_link.freezed.dart new file mode 100644 index 00000000..a8230eeb --- /dev/null +++ b/lib/services/song_link/song_link.freezed.dart @@ -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 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 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 toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SongLinkCopyWith 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 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 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 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; +} diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart new file mode 100644 index 00000000..911849e3 --- /dev/null +++ b/lib/services/song_link/song_link.g.dart @@ -0,0 +1,33 @@ +// 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 _$$SongLinkImplToJson(_$SongLinkImpl instance) => + { + '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, + }; diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 5f75c4db..3fc78f0b 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -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( diff --git a/lib/songlink.dart b/lib/songlink.dart new file mode 100644 index 00000000..65aae7e3 --- /dev/null +++ b/lib/songlink.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:html/parser.dart'; +import 'package:dio/dio.dart'; + +void main(List 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; + 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"]}"); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 82377d08..9bacf6dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -153,6 +153,7 @@ flutter: assets: - assets/ - assets/tutorial/ + - assets/logos/ - LICENSE flutter_launcher_icons: diff --git a/untranslated_messages.json b/untranslated_messages.json index 41251435..cc14528c 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -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" ] }