Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
codedbycurtis committed Nov 19, 2023
1 parent 3ce8ffa commit 4e3a291
Show file tree
Hide file tree
Showing 49 changed files with 7,043 additions and 12 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.dart_tool/
build/
pubspec.lock
doc/api/
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.0.0

- Initial release.
13 changes: 1 addition & 12 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,7 @@

END OF TERMS AND CONDITIONS

APPENDIX: How to apply the Apache License to your work.

To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2023 Curtis Caulfield

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
126 changes: 126 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# SoundcloudExplodeDart

SoundcloudExplodeDart utilises SoundCloud's internal V2 API to scrape metadata about users, tracks, playlists, and albums, without requiring an account, API key, or rate-limiting.

This API was **not** intended to be an exhaustive map of all SoundCloud endpoints, but I will be accepting feature requests, so feel free to suggest any functionality you would like to see by opening a new issue.

> This project takes inspiration from jerry08's [SoundCloudExplode](https://github.com/jerry08/SoundCloudExplode) library for C#.
## Usage

### Searching

Search for users, tracks, playlists, or albums, and apply specific search filters to each query:

```dart
import 'dart:async';
import 'soundcloud_explode_dart/soundcloud_explode_dart.dart';
final client = SoundcloudClient();
// Most functions return a stream of results in the
// form of Stream<Iterable<E>>.
// The number of results returned in each Iterable<E>, as well as
// the search result offset and search filter are optional parameters.
final stream = client.search(
'Haddaway - What Is Love',
searchFilter: SearchFilter.tracks,
offset: 0,
limit: 50
);
final streamIterator = StreamIterator(stream);
while (await streamIterator.moveNext()) {
for (final result in streamIterator.current) {
print(result.title);
}
}
```

### Querying users

Retrieve metadata about specific users:

```dart
import 'soundcloud_explode_dart/soundcloud_explode_dart.dart';
final client = SoundcloudClient();
// Users can be retrieved via URL...
final user1 = await client.users.getByUrl('https://www.soundcloud.com/a-user');
// ...or via their user ID.
final user2 = await client.users.get(123456789);
// Get the tracks/playlists/albums a specific user has uploaded...
final tracks = client.users.getTracks(user1.id);
final playlists = client.users.getPlaylists(user1.id);
final albums = client.users.getAlbums(user1.id);
```

### Querying tracks and streams

Metadata about specific tracks can also be retrieved:

```dart
import 'soundcloud_explode_dart/soundcloud_explode_dart.dart';
final client = SoundcloudClient();
// Tracks can also be retrieved via URL...
final track1 = await client.tracks.getByUrl('https://www.soundcloud.com/a-user/a-track');
// ...or via their track ID.
final track2 = await client.tracks.get(123456789);
```

In order to play a track, you need to resolve the streams available for it:

```dart
import 'soundcloud_explode_dart/soundcloud_explode_dart.dart';
import 'some_audio_player/some_audio_player.dart';
final client = SoundcloudClient();
final audioPlayer = SomeAudioPlayer();
final track = await client.tracks.getByUrl('https://www.soundcloud.com/a-user/a-track');
final streams = await client.tracks.getStreams(track.id);
final stream = streams.firstWhere((s) => s.container == Container.mp3);
await audioPlayer.play(stream.url);
```

> Note: some tracks only provide a 30 second snippet and cannot be played in their entirety; those that require a SoundCloud Go subscription are one such example.
>
> To determine whether or not a track is fully playable:
>
> ```dart
> final track = await client.tracks.get(123456789);
> if (track.duration == track.fullDuration) {
> // Track can be played until completion.
> ...
> }
> ```
### Querying playlists/albums
To retrieve metadata about specific playlists:
```dart
import 'soundcloud_explode_dart/soundcloud_explode_dart.dart';
final client = SoundcloudClient();
// Playlists/albums can be retrieved via URL...
final playlist11 = await client.playlists.getByUrl('https://www.soundcloud.com/a-user/sets/a-playlist-or-album');
// ...or via their playlist ID.
final playlist2 = await client.playlists.get(123456789);
// Indicates if the playlist is identified as an album or not.
final isAlbum = playlist1.isAlbum;
// Get the tracks contained with a playlist/album...
final tracks = client.playlists.getTracks(playlist1.id);
```
1 change: 1 addition & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include: package:lints/recommended.yaml
6 changes: 6 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
targets:
$default:
builders:
json_serializable:
options:
field_rename: snake
8 changes: 8 additions & 0 deletions lib/soundcloud_explode_dart.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// Scrape metadata about users, tracks, playlists, and albums from SoundCloud without requiring an account, API key, or rate-limiting.
library;

export 'src/soundcloud_client.dart';
export 'src/playlists/playlists.dart';
export 'src/search/search.dart';
export 'src/tracks/tracks.dart';
export 'src/users/users.dart';
60 changes: 60 additions & 0 deletions lib/src/bridge/soundcloud_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:http/http.dart';
import '../exceptions/client_unauthorized_exception.dart';

/// Interacts with the SoundCloud API.
///
/// This should only be used internally.
class SoundcloudController {
final Client _http;
String? _clientId;

/// Creates a new [SoundcloudController] that uses the provided [httpClient].
SoundcloudController(Client httpClient)
: _http = httpClient;

/// Gets the client ID for the current session.
///
/// This parameter is cached so that only one request is made for the duration of this session.
Future<String> getClientId() async {
if (_clientId != null) return _clientId!;

var response = await _http.get(Uri.https('soundcloud.com', ''));
final scripts = RegExp('<script.*?src="(.*?)"').allMatches(response.body);

if (scripts.isEmpty) throw ClientUnauthorizedException.clientId();

final scriptUrl = scripts.last.group(1);
if (scriptUrl == null) throw ClientUnauthorizedException.clientId();

response = await _http.get(Uri.parse(scriptUrl));
_clientId = response.body
.split(',client_id')[1]
.split('"')[1];

return _clientId!;
}

/// Returns the object represented by a SoundCloud [url].
///
/// E.g. https://soundcloud.com/a-user will resolve to a [User] object's JSON representation.
Future<String> resolveUrl(String url) async {
final resolvingUrl = Uri
.parse(url)
.replace(host: 'soundcloud.com')
.toString();

final clientId = await getClientId();

final uri = Uri.https(
'api-v2.soundcloud.com',
'/resolve',
{
'url': resolvingUrl,
'client_id': clientId
}
);

final response = await _http.get(uri);
return response.body;
}
}
5 changes: 5 additions & 0 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// The default number of results to skip when returning from a SoundCloud query.
const defaultOffset = 0;

/// The default number of results that are returned from a SoundCloud query.
const defaultLimit = 10;
12 changes: 12 additions & 0 deletions lib/src/exceptions/client_unauthorized_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'soundcloud_explode_exception.dart';

/// Indicates a failure to obtain a valid client ID from the SoundCloud server.
class ClientUnauthorizedException extends SoundcloudExplodeException {
ClientUnauthorizedException(super.message);

static ClientUnauthorizedException clientId() => ClientUnauthorizedException(
'''Could not resolve a valid client ID for this session.
This may be a bug in the library. If the issue persists, do not hesitate to report it on the
project's GitHub page.'''
);
}
10 changes: 10 additions & 0 deletions lib/src/exceptions/search_result_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import 'package:soundcloud_explode_dart/src/exceptions/soundcloud_explode_exception.dart';

/// Indicates a failure to resolve a [SearchResult] to a specific instance.
class SearchResultException extends SoundcloudExplodeException {
const SearchResultException(super.message);

static SearchResultException cannotResolve() => SearchResultException(
'Unable to resolve the provided value to a specific [SearchResult].'
);
}
10 changes: 10 additions & 0 deletions lib/src/exceptions/soundcloud_explode_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// Superclass of all exceptions thrown by this library.
class SoundcloudExplodeException implements Exception {
final String? message;

/// Creates a new [SoundcloudExplodeException] with the provided [message].
const SoundcloudExplodeException(this.message);

@override
String toString() => 'SoundcloudExplodeException: \n$message';
}
12 changes: 12 additions & 0 deletions lib/src/exceptions/track_resolution_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'soundcloud_explode_exception.dart';

/// Indicates a failure in resolving a track or its streams.
class TrackResolutionException extends SoundcloudExplodeException {
const TrackResolutionException(super.message);

static TrackResolutionException noStreams(int trackId) => TrackResolutionException(
'''Could not resolve any streams for the specified track ID: $trackId
This may be a bug in the library. If the issue persists, do not hesitate to report it on the
project's GitHub page.'''
);
}
33 changes: 33 additions & 0 deletions lib/src/playlists/playlist.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// ignore_for_file: invalid_annotation_target

import 'package:freezed_annotation/freezed_annotation.dart';
import '../users/mini_user.dart';
import 'soundcloud_playlist.dart';

part 'playlist.freezed.dart';
part 'playlist.g.dart';

/// Metadata about a SoundCloud playlist.
@freezed
class Playlist with _$Playlist implements SoundcloudPlaylist {
const factory Playlist({
required Uri? artworkUrl,
required DateTime createdAt,
required String? description,
required double duration,
required String? genre,
required int id,
required String? labelName,
required DateTime? lastModified,
@JsonKey(defaultValue: 0) required double likesCount,
required Uri permalinkUrl,
@JsonKey(defaultValue: 0) required double repostsCount,
required String? tagList,
required String title,
required bool isAlbum,
required MiniUser user,
@JsonKey(defaultValue: 0) required double trackCount
}) = _Playlist;

factory Playlist.fromJson(Map<String, Object?> json) => _$PlaylistFromJson(json);
}
Loading

0 comments on commit 4e3a291

Please sign in to comment.