feat: add soundcloud sourced track source

This commit is contained in:
Kingkor Roy Tirtho 2025-03-02 20:14:01 +06:00
parent edc9636940
commit 325ad2a526
11 changed files with 438 additions and 6 deletions

View File

@ -15,7 +15,8 @@ enum AudioSource {
youtube, youtube,
piped, piped,
jiosaavn, jiosaavn,
invidious; invidious,
soundcloud;
String get label => name[0].toUpperCase() + name.substring(1); String get label => name[0].toUpperCase() + name.substring(1);
} }

View File

@ -3,7 +3,8 @@ part of '../database.dart';
enum SourceType { enum SourceType {
youtube._("YouTube"), youtube._("YouTube"),
youtubeMusic._("YouTube Music"), youtubeMusic._("YouTube Music"),
jiosaavn._("JioSaavn"); jiosaavn._("JioSaavn"),
soundcloud._("SoundCloud");
final String label; final String label;

View File

@ -46,6 +46,27 @@ class ServerPlaybackRoutes {
ServerPlaybackRoutes(this.ref) : dio = Dio(); ServerPlaybackRoutes(this.ref) : dio = Dio();
/// proxy hls playlist file
Future<Response> proxyHls(String url, Map<String, dynamic> 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<Uint8List> response, Uint8List? bytes})> Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
streamTrack( streamTrack(
SourcedTrack track, SourcedTrack track,
@ -201,8 +222,12 @@ class ServerPlaybackRoutes {
ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack);
if (sourcedTrack!.url.contains(".m3u8")) {
return await proxyHls(sourcedTrack.url, request.headers);
}
final (bytes: audioBytes, response: res) = final (bytes: audioBytes, response: res) =
await streamTrack(sourcedTrack!, request.headers); await streamTrack(sourcedTrack, request.headers);
return Response( return Response(
res.statusCode!, res.statusCode!,

View File

@ -133,4 +133,23 @@ class YoutubeVideoInfo {
DateTime.fromMillisecondsSinceEpoch(searchResponse.published * 1000), 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),
);
}
} }

View File

@ -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/invidious.dart';
import 'package:spotube/services/sourced_track/sources/jiosaavn.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/piped.dart';
import 'package:spotube/services/sourced_track/sources/soundcloud.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -86,6 +87,13 @@ abstract class SourcedTrack extends Track {
sourceInfo: sourceInfo, sourceInfo: sourceInfo,
track: track, 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), await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref),
AudioSource.jiosaavn => AudioSource.jiosaavn =>
await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), 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), JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref),
AudioSource.invidious => AudioSource.invidious =>
InvidiousSourcedTrack.fetchSiblings(track: track, ref: ref), InvidiousSourcedTrack.fetchSiblings(track: track, ref: ref),
AudioSource.soundcloud =>
SoundcloudSourcedTrack.fetchSiblings(track: track, ref: ref),
}; };
} }

View File

@ -5,6 +5,8 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.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/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.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_info.dart';
@ -180,6 +182,27 @@ class InvidiousSourcedTrack extends SourcedTrack {
final invidiousClient = ref.read(invidiousProvider); final invidiousClient = ref.read(invidiousProvider);
final preference = ref.read(userPreferencesProvider); 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 query = SourcedTrack.getSearchTerm(track);
final searchResults = await invidiousClient.search.list( final searchResults = await invidiousClient.search.list(

View File

@ -6,6 +6,8 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.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/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/exceptions.dart';
@ -181,6 +183,28 @@ class PipedSourcedTrack extends SourcedTrack {
final pipedClient = ref.read(pipedProvider); final pipedClient = ref.read(pipedProvider);
final preference = ref.read(userPreferencesProvider); 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 query = SourcedTrack.getSearchTerm(track);
final PipedSearchResult(items: searchResults) = await pipedClient.search( final PipedSearchResult(items: searchResults) = await pipedClient.search(

View File

@ -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<soundcloud.SoundcloudClient>(
(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<SourcedTrack> 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<soundcloud.StreamInfo> 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<SiblingType> 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<List<SiblingType>> 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<SourcedTrack> 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<SourcedTrack?> 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,
);
}
}

View File

@ -376,7 +376,7 @@ packages:
source: hosted source: hosted
version: "4.10.1" version: "4.10.1"
collection: collection:
dependency: "direct overridden" dependency: "direct main"
description: description:
name: collection name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
@ -2165,6 +2165,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: source_gen:
dependency: transitive dependency: transitive
description: description:

View File

@ -144,6 +144,8 @@ dependencies:
git: git:
url: https://github.com/KRTirtho/flutter_new_pipe_extractor.git url: https://github.com/KRTirtho/flutter_new_pipe_extractor.git
http_parser: ^4.1.2 http_parser: ^4.1.2
soundcloud_explode_dart: ^1.0.3
collection: any
dev_dependencies: dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13

View File

@ -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) #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 #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else #else
#define VERSION_AS_NUMBER %{{SPOTUBE_VERSION_AS_NUMBER}}% #define VERSION_AS_NUMBER 1,0,0,0
#endif #endif
#if defined(FLUTTER_VERSION) #if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING FLUTTER_VERSION #define VERSION_AS_STRING FLUTTER_VERSION
#else #else
#define VERSION_AS_STRING "%{{SPOTUBE_VERSION}}%" #define VERSION_AS_STRING "1.0.0.0"
#endif #endif
VS_VERSION_INFO VERSIONINFO VS_VERSION_INFO VERSIONINFO