From 325ad2a526a80a0b6978be1dc0bf3454a0ebc4b9 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 2 Mar 2025 20:14:01 +0600 Subject: [PATCH] feat: add soundcloud sourced track source --- lib/models/database/tables/preferences.dart | 3 +- lib/models/database/tables/source_match.dart | 3 +- lib/provider/server/routes/playback.dart | 27 +- .../sourced_track/models/video_info.dart | 19 ++ lib/services/sourced_track/sourced_track.dart | 12 + .../sourced_track/sources/invidious.dart | 23 ++ lib/services/sourced_track/sources/piped.dart | 24 ++ .../sourced_track/sources/soundcloud.dart | 317 ++++++++++++++++++ pubspec.lock | 10 +- pubspec.yaml | 2 + windows/runner/Runner.rc | 4 +- 11 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 lib/services/sourced_track/sources/soundcloud.dart diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart index 492ac1f9..6eacce00 100644 --- a/lib/models/database/tables/preferences.dart +++ b/lib/models/database/tables/preferences.dart @@ -15,7 +15,8 @@ enum AudioSource { youtube, piped, jiosaavn, - invidious; + invidious, + soundcloud; String get label => name[0].toUpperCase() + name.substring(1); } diff --git a/lib/models/database/tables/source_match.dart b/lib/models/database/tables/source_match.dart index 78d0eb05..b8a316b7 100644 --- a/lib/models/database/tables/source_match.dart +++ b/lib/models/database/tables/source_match.dart @@ -3,7 +3,8 @@ part of '../database.dart'; enum SourceType { youtube._("YouTube"), youtubeMusic._("YouTube Music"), - jiosaavn._("JioSaavn"); + jiosaavn._("JioSaavn"), + soundcloud._("SoundCloud"); final String label; diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index 9ee00896..a6e0e056 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -46,6 +46,27 @@ class ServerPlaybackRoutes { ServerPlaybackRoutes(this.ref) : dio = Dio(); + /// proxy hls playlist file + Future proxyHls(String url, Map headers) async { + try { + final response = await dio.get( + url, + options: Options(responseType: ResponseType.bytes), + ); + + return Response.ok( + response.data as Uint8List, + headers: { + "content-type": "audio/mpegurl", + "content-length": (response.data as Uint8List).length.toString(), + }, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + return Response.internalServerError(); + } + } + Future<({dio_lib.Response response, Uint8List? bytes})> streamTrack( SourcedTrack track, @@ -201,8 +222,12 @@ class ServerPlaybackRoutes { ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + if (sourcedTrack!.url.contains(".m3u8")) { + return await proxyHls(sourcedTrack.url, request.headers); + } + final (bytes: audioBytes, response: res) = - await streamTrack(sourcedTrack!, request.headers); + await streamTrack(sourcedTrack, request.headers); return Response( res.statusCode!, diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart index e3452c61..63dc3c2c 100644 --- a/lib/services/sourced_track/models/video_info.dart +++ b/lib/services/sourced_track/models/video_info.dart @@ -133,4 +133,23 @@ class YoutubeVideoInfo { DateTime.fromMillisecondsSinceEpoch(searchResponse.published * 1000), ); } + + factory YoutubeVideoInfo.fromVideoResponse( + InvidiousVideoResponse videoResponse, + SearchMode searchMode, + ) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: videoResponse.title, + duration: Duration(seconds: videoResponse.lengthSeconds), + thumbnailUrl: videoResponse.videoThumbnails.first.url, + id: videoResponse.videoId, + likes: videoResponse.likeCount, + dislikes: videoResponse.dislikeCount, + views: videoResponse.viewCount, + channelName: videoResponse.author, + channelId: videoResponse.authorId, + publishedAt: DateTime.fromMillisecondsSinceEpoch(videoResponse.published), + ); + } } diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 272295e4..1aae854c 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -9,6 +9,7 @@ import 'package:spotube/services/sourced_track/models/source_map.dart'; import 'package:spotube/services/sourced_track/sources/invidious.dart'; import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; +import 'package:spotube/services/sourced_track/sources/soundcloud.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -86,6 +87,13 @@ abstract class SourcedTrack extends Track { sourceInfo: sourceInfo, track: track, ), + AudioSource.soundcloud => SoundcloudSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ) }; } @@ -116,6 +124,8 @@ abstract class SourcedTrack extends Track { await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref), AudioSource.jiosaavn => await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.soundcloud => + await SoundcloudSourcedTrack.fetchFromTrack(track: track, ref: ref), }; } @@ -134,6 +144,8 @@ abstract class SourcedTrack extends Track { JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref), AudioSource.invidious => InvidiousSourcedTrack.fetchSiblings(track: track, ref: ref), + AudioSource.soundcloud => + SoundcloudSourcedTrack.fetchSiblings(track: track, ref: ref), }; } diff --git a/lib/services/sourced_track/sources/invidious.dart b/lib/services/sourced_track/sources/invidious.dart index 4a32ad41..0bf383cb 100644 --- a/lib/services/sourced_track/sources/invidious.dart +++ b/lib/services/sourced_track/sources/invidious.dart @@ -5,6 +5,8 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/logger/logger.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'; @@ -180,6 +182,27 @@ class InvidiousSourcedTrack extends SourcedTrack { final invidiousClient = ref.read(invidiousProvider); final preference = ref.read(userPreferencesProvider); + final links = await SongLinkService.links(track.id!); + final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); + + if (ytLink != null && track is! SourcedTrack) { + try { + final videoId = Uri.parse(ytLink.url!).queryParameters["v"]!; + + final manifest = await invidiousClient.videos.get(videoId, local: true); + + return [ + await toSiblingType( + 0, + YoutubeVideoInfo.fromVideoResponse(manifest, preference.searchMode), + invidiousClient, + ) + ]; + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + final query = SourcedTrack.getSearchTerm(track); final searchResults = await invidiousClient.search.list( diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 1728753a..0b3d19b9 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -6,6 +6,8 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/logger/logger.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'; @@ -181,6 +183,28 @@ class PipedSourcedTrack extends SourcedTrack { final pipedClient = ref.read(pipedProvider); final preference = ref.read(userPreferencesProvider); + final links = await SongLinkService.links(track.id!); + final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); + + if (ytLink != null && track is! SourcedTrack) { + try { + final videoId = Uri.parse(ytLink.url!).queryParameters["v"]!; + + final manifest = await pipedClient.streams(videoId); + + return [ + await toSiblingType( + 0, + YoutubeVideoInfo.fromStreamResponse( + manifest, preference.searchMode), + pipedClient, + ) + ]; + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + final query = SourcedTrack.getSearchTerm(track); final PipedSearchResult(items: searchResults) = await pipedClient.search( diff --git a/lib/services/sourced_track/sources/soundcloud.dart b/lib/services/sourced_track/sources/soundcloud.dart new file mode 100644 index 00000000..dcd1d30e --- /dev/null +++ b/lib/services/sourced_track/sources/soundcloud.dart @@ -0,0 +1,317 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/services/logger/logger.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'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:soundcloud_explode_dart/soundcloud_explode_dart.dart' + as soundcloud; + +final soundcloudProvider = Provider( + (ref) { + return soundcloud.SoundcloudClient(); + }, +); + +class SoundcloudSourceInfo extends SourceInfo { + SoundcloudSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class SoundcloudSourcedTrack extends SourcedTrack { + SoundcloudSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + // Indicates a stream url refresh + if (track is SoundcloudSourcedTrack) { + final manifest = await ref + .read(soundcloudProvider) + .tracks + .getStreams(int.parse(track.sourceInfo.id)); + + return SoundcloudSourcedTrack( + ref: ref, + siblings: track.siblings, + source: toSourceMap(manifest), + sourceInfo: track.sourceInfo, + track: track, + ); + } + + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .getSingleOrNull(); + final soundcloudClient = ref.read(soundcloudProvider); + + if (cachedSource == null || + cachedSource.sourceType != SourceType.soundcloud) { + final siblings = await fetchSiblings(ref: ref, track: track); + if (siblings.isEmpty) { + throw TrackNotFoundError(track); + } + + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.soundcloud), + ), + ); + + return SoundcloudSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } else { + final details = await soundcloudClient.tracks.get( + int.parse(cachedSource.sourceId), + ); + final streams = await soundcloudClient.tracks.getStreams( + int.parse(cachedSource.sourceId), + ); + + return SoundcloudSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(streams), + sourceInfo: SoundcloudSourceInfo( + id: details.id.toString(), + artist: details.user.username, + artistUrl: details.user.permalinkUrl.toString(), + pageUrl: details.permalinkUrl.toString(), + thumbnail: details.artworkUrl.toString(), + title: details.title, + duration: Duration(seconds: details.duration.toInt()), + album: null, + ), + track: track, + ); + } + } + + static SourceMap toSourceMap(List manifest) { + final m4a = manifest + .where((audio) => audio.container == soundcloud.Container.mp3) + .sorted((a, b) { + return a.quality == soundcloud.Quality.highQuality ? 1 : -1; + }); + + final weba = manifest + .where((audio) => audio.container == soundcloud.Container.ogg) + .sorted((a, b) { + return a.quality == soundcloud.Quality.highQuality ? 1 : -1; + }); + + return SourceMap( + m4a: SourceQualityMap( + high: m4a.first.url.toString(), + medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(), + low: m4a.last.url.toString(), + ), + weba: weba.isNotEmpty + ? SourceQualityMap( + high: weba.first.url.toString(), + medium: (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]) + .url + .toString(), + low: weba.last.url.toString(), + ) + : null, + ); + } + + static Future toSiblingType( + int index, + soundcloud.Track item, + soundcloud.SoundcloudClient soundcloudClient, + ) async { + SourceMap? sourceMap; + if (index == 0) { + final manifest = await soundcloudClient.tracks.getStreams(item.id); + sourceMap = toSourceMap(manifest); + } + + final SiblingType sibling = ( + info: SoundcloudSourceInfo( + id: item.id.toString(), + artist: item.user.username, + artistUrl: item.user.permalinkUrl.toString(), + pageUrl: item.permalinkUrl.toString(), + thumbnail: item.artworkUrl.toString(), + title: item.title, + duration: Duration(seconds: item.duration.toInt()), + album: null, + ), + source: sourceMap, + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final soundcloudClient = ref.read(soundcloudProvider); + + final links = await SongLinkService.links(track.id!); + final soundcloudLink = + links.firstWhereOrNull((link) => link.platform == "soundcloud"); + + if (soundcloudLink != null && track is! SourcedTrack) { + try { + final details = + await soundcloudClient.tracks.getByUrl(soundcloudLink.url!); + + return [ + await toSiblingType( + 0, + details, + soundcloudClient, + ) + ]; + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + + final query = SourcedTrack.getSearchTerm(track); + + final searchResults = await soundcloudClient.search + .getTracks(query, offset: 0, limit: 10) + .toList() + .then((value) => value.expand((e) => e).toList()); + + return await Future.wait( + searchResults.mapIndexed( + (i, r) => toSiblingType( + i, + soundcloud.Track( + id: r.id, + title: r.title, + duration: r.duration, + user: r.user, + artworkUrl: r.artworkUrl, + permalinkUrl: r.permalinkUrl, + caption: r.caption, + commentCount: r.commentCount, + createdAt: r.createdAt, + description: r.description, + downloadCount: r.downloadCount, + genre: r.genre, + commentable: r.commentable, + fullDuration: r.fullDuration, + labelName: r.labelName, + lastModified: r.lastModified, + license: r.license, + likesCount: r.likesCount, + monetizationModel: r.monetizationModel, + playbackCount: r.playbackCount, + policy: r.policy, + purchaseTitle: r.purchaseTitle, + purchaseUrl: r.purchaseUrl, + repostsCount: r.repostsCount, + tagList: r.tagList, + waveformUrl: r.waveformUrl, + ), + soundcloudClient, + ), + ), + ); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return SoundcloudSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final soundcloudClient = ref.read(soundcloudProvider); + + final manifest = await soundcloudClient.tracks.getStreams( + int.parse(newSourceInfo.id), + ); + + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.soundcloud), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); + + return SoundcloudSourcedTrack( + ref: ref, + siblings: newSiblings, + source: toSourceMap(manifest), + sourceInfo: newSourceInfo, + track: this, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5b92f19e..3513dce4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -376,7 +376,7 @@ packages: source: hosted version: "4.10.1" collection: - dependency: "direct overridden" + dependency: "direct main" description: name: collection sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf @@ -2165,6 +2165,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + soundcloud_explode_dart: + dependency: "direct main" + description: + name: soundcloud_explode_dart + sha256: "7585f95ed42359895734187f6dfdb5b88140c3ee48e7f23813c2b6301e37882f" + url: "https://pub.dev" + source: hosted + version: "1.0.3" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 89d09e22..b618b3e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -144,6 +144,8 @@ dependencies: git: url: https://github.com/KRTirtho/flutter_new_pipe_extractor.git http_parser: ^4.1.2 + soundcloud_explode_dart: ^1.0.3 + collection: any dev_dependencies: build_runner: ^2.4.13 diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index c77ce0c6..31002635 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER %{{SPOTUBE_VERSION_AS_NUMBER}}% +#define VERSION_AS_NUMBER 1,0,0,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "%{{SPOTUBE_VERSION}}%" +#define VERSION_AS_STRING "1.0.0.0" #endif VS_VERSION_INFO VERSIONINFO