diff --git a/.gitignore b/.gitignore index d80f78d..9299da2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,11 @@ coverage/ .flaskenv* !.env.project !.env.vault -.history \ No newline at end of file +.history + +/shazam_api/lib/ +/shazam_api/include/ +/shazam_api/__pycache__ +/shazam_api/pyvenv.cfg +/shazam_api/pyvenv.cfg +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8c6d5d8..4ad2bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,10 @@ ARG dart_entryfile WORKDIR /app COPY pubspec.* /app/ +COPY shazam_client /app/ RUN dart pub get +COPY . /app COPY . /app RUN dart pub get diff --git a/bin/radio_horizon_development.dart b/bin/radio_horizon_development.dart index b9e552d..1b10464 100644 --- a/bin/radio_horizon_development.dart +++ b/bin/radio_horizon_development.dart @@ -47,7 +47,6 @@ Future<void> main() async { // Initialise our services MusicService.init(client); await DatabaseService.init(client); - SongRecognitionService.init(client, DatabaseService.instance); client.onReady.listen((_) async { BootUpService.init(client, DatabaseService.instance); diff --git a/bin/radio_horizon_production.dart b/bin/radio_horizon_production.dart index 6eb62b8..011ecd7 100644 --- a/bin/radio_horizon_production.dart +++ b/bin/radio_horizon_production.dart @@ -62,7 +62,6 @@ Future<void> main() async { // Initialise our services MusicService.init(client); await DatabaseService.init(client); - SongRecognitionService.init(client, DatabaseService.instance); BootUpService.init(client, DatabaseService.instance); // Connect diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 70184bf..772cf11 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,8 +9,10 @@ services: - .env/.env.production links: - lavalink + - shazam_api depends_on: - lavalink + - shazam_api lavalink: image: ghcr.io/lavalink-devs/lavalink:3 @@ -20,3 +22,13 @@ services: - 2333 volumes: - ./lavalink.yml:/opt/Lavalink/application.yml + + shazam_api: + build: + context: ./shazam_api + expose: + - 5000 + volumes: + - ./shazam_api:/app + environment: + FLASK_ENV: production diff --git a/docker-compose.yml b/docker-compose.yml index 96fa69a..be5fb22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,10 @@ services: - .env/.env.development links: - lavalink + - shazam_api depends_on: - lavalink + - shazam_api lavalink: image: ghcr.io/lavalink-devs/lavalink:3 @@ -23,3 +25,13 @@ services: - 2333 volumes: - ./lavalink.yml:/opt/Lavalink/application.yml + + shazam_api: + build: + context: ./shazam_api + expose: + - 5000 + volumes: + - ./shazam_api:/app + environment: + FLASK_ENV: development diff --git a/lib/src/commands/music.dart b/lib/src/commands/music.dart index 253a3fc..9ebf013 100644 --- a/lib/src/commands/music.dart +++ b/lib/src/commands/music.dart @@ -106,7 +106,7 @@ ChatCommand:music-play: { ); } - await SongRecognitionService.instance + await DatabaseService.instance .deleteRadioFromList(context.guild!.id); }), localizedDescriptions: localizedValues( diff --git a/lib/src/commands/radio.dart b/lib/src/commands/radio.dart index dce52de..cff463e 100644 --- a/lib/src/commands/radio.dart +++ b/lib/src/commands/radio.dart @@ -12,12 +12,12 @@ import 'package:logging/logging.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_commands/nyxx_commands.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; -import 'package:nyxx_pagination/nyxx_pagination.dart'; import 'package:radio_browser_api/radio_browser_api.dart'; import 'package:radio_horizon/radio_horizon.dart'; import 'package:radio_horizon/src/checks.dart'; import 'package:radio_horizon/src/models/song_recognition/current_station_info.dart'; import 'package:retry/retry.dart'; +import 'package:shazam_client/shazam_client.dart'; final _enRadioCommand = AppLocale.en.translations.commands.radio; final _enPlayCommand = _enRadioCommand.children.play; @@ -124,7 +124,8 @@ ChatCommand:radio-play: { channelId: context.channel.id, ).startPlaying(); - await SongRecognitionService.instance.setCurrentRadio( + final databaseService = DatabaseService.instance; + await databaseService.setCurrentRadio( context.guild!.id, context.member!.voiceState!.channel!.id, context.channel.id, @@ -158,39 +159,18 @@ ChatCommand:radio-play: { final translations = getCommandTranslations(context); final commandTranslations = translations.radio.children.recognize; CurrentStationInfo? stationInfo; - MusicLinksResponse? linksResponse; try { final recognitionService = SongRecognitionService.instance; + final databaseService = DatabaseService.instance; final guildId = context.guild!.id; - final stopwatch = Stopwatch()..start(); var recognitionSampleDuration = 10; - final guildRadio = await recognitionService.currentRadio(guildId); + final guildRadio = await databaseService.currentRadio(guildId); try { - final info = await retry( - () async => recognitionService.getCurrentStationInfo(guildRadio), - ); - if (!info.hasTitle) { - throw Exception('No title'); - } - final node = MusicService.instance.cluster - .getOrCreatePlayerNode(context.guild!.id); - final tracks = await node.autoSearch(info.title!); - stationInfo = info.copyWith( - image: - 'https://img.youtube.com/vi/${tracks.tracks.first.info?.identifier}/hqdefault.jpg', - ); - } catch (exception, stacktrace) { - _logger.severe( - 'Failed to get current station info', - exception, - stacktrace, - ); - - ShazamResult? result; + SongModel? result; await retry( () async { result = await recognitionService.identify( @@ -219,21 +199,18 @@ ChatCommand:radio-play: { stationInfo = CurrentStationInfo.fromShazamResult(result!, guildRadio); - } - - try { - linksResponse = await SongRecognitionService.instance - .getMusicLinks(stationInfo.title!); - } catch (exception, stacktrace) { - _logger.severe( - 'Failed to get music links for ${stationInfo.title}', - exception, - stacktrace, + } catch (e) { + await context.respond( + MessageBuilder.embed( + EmbedBuilder() + ..color = DiscordColor.red + ..title = commandTranslations.errors.noResults, + ), ); + return null; } final color = getRandomColor(); - stopwatch.stop(); final embed = EmbedBuilder() ..color = color @@ -245,7 +222,8 @@ ChatCommand:radio-play: { name: commandTranslations.radioStationField, content: stationInfo.name, ) - ..thumbnailUrl = stationInfo.image; + ..thumbnailUrl = stationInfo.image + ..url = stationInfo.url; final genre = stationInfo.genre; if (genre != null) { @@ -255,28 +233,7 @@ ChatCommand:radio-play: { ); } - embed.addField( - name: commandTranslations.computationalTimeField, - content: '${stopwatch.elapsedMilliseconds}ms', - ); - - final lyricsPages = stationInfo.lyricsPages(color: color); - if (lyricsPages == null || lyricsPages.isEmpty) { - final builder = ComponentMessageBuilder()..embeds = [embed]; - linksResponse?.componentRows.forEach(builder.addComponentRow); - return await context.respond(builder); - } - - final paginator = EmbedComponentPagination( - context.commands.interactions!, - [embed, ...lyricsPages], - user: context.user, - ); - - final messageBuilder = paginator.initMessageBuilder(); - linksResponse?.componentRows.forEach(messageBuilder.addComponentRow); - - await context.respond(messageBuilder); + await context.respond(MessageBuilder.embed(embed)); } catch (e, stacktrace) { _logger.severe( 'Failed to recognize radio', @@ -316,8 +273,8 @@ ChatCommand:radio-play: { late GuildRadio? guildRadio; try { - guildRadio = await SongRecognitionService.instance - .currentRadio(context.guild!.id); + guildRadio = + await DatabaseService.instance.currentRadio(context.guild!.id); } on RadioNotPlayingException { await context.respond( MessageBuilder.embed( diff --git a/lib/src/models/song_recognition/current_station_info.dart b/lib/src/models/song_recognition/current_station_info.dart index f2d21ec..322e1f8 100644 --- a/lib/src/models/song_recognition/current_station_info.dart +++ b/lib/src/models/song_recognition/current_station_info.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:nyxx/nyxx.dart'; import 'package:radio_horizon/radio_horizon.dart'; +import 'package:shazam_client/shazam_client.dart'; part 'current_station_info.g.dart'; @@ -14,34 +14,26 @@ class CurrentStationInfo { this.title, this.image, this.url, - }) : _lyrics = null; - - const CurrentStationInfo._lyrics({ - this.description, - this.genre, - this.name, - this.title, - this.image, - this.url, - List<String>? lyrics, - }) : _lyrics = lyrics, - contentType = null; + }); factory CurrentStationInfo.fromJson(Map<String, dynamic> json) => _$CurrentStationInfoFromJson(json); factory CurrentStationInfo.fromShazamResult( - ShazamResult result, - GuildRadio radio, - ) => - CurrentStationInfo._lyrics( - name: result.headline, - title: radio.station.name, + SongModel result, [ + GuildRadio? guildRadio, + ]) => + CurrentStationInfo( + title: '${result.title} - ${result.subtitle}', description: result.subtitle, - url: radio.station.urlResolved ?? radio.station.url, - image: result.share?.image ?? radio.station.favicon, + url: Uri.https( + 'youtube.com', + '/results', + {'search_query': result.title}, + ).toString(), + image: result.images?.coverart, genre: result.genres?.primary, - lyrics: result.lyrics, + name: guildRadio?.station.name, ); CurrentStationInfo copyWith({ @@ -53,8 +45,7 @@ class CurrentStationInfo { String? url, List<String>? lyrics, }) => - CurrentStationInfo._lyrics( - lyrics: lyrics ?? _lyrics, + CurrentStationInfo( description: description ?? this.description, genre: genre ?? this.genre, name: name ?? this.name, @@ -83,46 +74,7 @@ class CurrentStationInfo { final String? url; final String? image; - final List<String>? _lyrics; bool get hasName => name != null && name!.isNotEmpty; bool get hasTitle => title != null && title!.isNotEmpty; - - List<List<String>>? paragraphedLyrics(int paragraphsPerPage) { - if (_lyrics == null || _lyrics!.isEmpty) return null; - final lyrics = _lyrics ?? []; - final paragraphs = lyrics.join('\n').split('\n\n'); - - final m = (paragraphs.length / paragraphsPerPage).round(); - final lists = List.generate( - 3, - (i) => paragraphs.sublist( - m * i, - (i + 1) * m <= paragraphs.length ? (i + 1) * m : null, - ), - ); - - return lists; - } - - List<EmbedBuilder>? lyricsPages({ - required DiscordColor color, - }) { - final lyricsPages = <EmbedBuilder>[]; - final llyrics = paragraphedLyrics(3); - - if (llyrics == null) return null; - - // add 3 paragraphs per page - for (var i = 0; i < llyrics.length; i++) { - final embed = EmbedBuilder() - ..color = color - ..title = title - ..description = llyrics[i].join('\n\n'); - - lyricsPages.add(embed); - } - - return lyricsPages; - } } diff --git a/lib/src/services/db.dart b/lib/src/services/db.dart index 42d320f..77a0783 100644 --- a/lib/src/services/db.dart +++ b/lib/src/services/db.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:logging/logging.dart'; import 'package:mongo_dart/mongo_dart.dart'; import 'package:nyxx/nyxx.dart'; +import 'package:radio_browser_api/radio_browser_api.dart'; import 'package:radio_horizon/radio_horizon.dart'; class DatabaseService { @@ -63,6 +64,42 @@ class DatabaseService { } } + /// Gets the current radio playing by Guild. + /// + /// Throws [RadioNotPlayingException] if the Guild is not listening + /// to the radio + Future<GuildRadio> currentRadio(Snowflake guildId) async { + final currentlyPlaying = await getPlaying(guildId); + if (currentlyPlaying == null) { + throw const RadioNotPlayingException(); + } + + return currentlyPlaying; + } + + /// Adds or not the current radio that the guild is playing + Future<void> setCurrentRadio( + Snowflake guildId, + Snowflake voiceChannelId, + Snowflake textChannelId, + Station station, + ) async { + final newRadio = GuildRadio( + guildId, + voiceChannelId: voiceChannelId, + station: station, + textChannelId: textChannelId, + ); + + await setPlaying(newRadio); + } + + /// Deletes the radio from the [SongRecognitionService] radio. This is to let + /// the service know that the guild is no longer listening to the radio. + Future<void> deleteRadioFromList(Snowflake guildId) async { + await setNotPlaying(guildId); + } + Future<GuildRadio?> getPlaying(Snowflake guildId) async { try { await _checkConnection(); diff --git a/lib/src/services/music.dart b/lib/src/services/music.dart index 194d1b8..5cad787 100644 --- a/lib/src/services/music.dart +++ b/lib/src/services/music.dart @@ -155,7 +155,7 @@ class MusicService { guild.shard.changeVoiceState(guild.id, null); // delete the current radio station from the list, if it exists - await SongRecognitionService.instance.deleteRadioFromList(guild.id); + await DatabaseService.instance.deleteRadioFromList(guild.id); logger.info( 'Disconnected from voice channel in guild ${guild.id} ' diff --git a/lib/src/services/song_recognition.dart b/lib/src/services/song_recognition.dart index ecc2c87..1fbd0a5 100644 --- a/lib/src/services/song_recognition.dart +++ b/lib/src/services/song_recognition.dart @@ -9,79 +9,28 @@ import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; -import 'package:nyxx/nyxx.dart'; -import 'package:radio_browser_api/radio_browser_api.dart'; import 'package:radio_horizon/radio_horizon.dart'; import 'package:radio_horizon/src/models/song_recognition/current_station_info.dart'; +import 'package:shazam_client/shazam_client.dart'; import 'package:uuid/uuid.dart'; class SongRecognitionService { - SongRecognitionService._( - this.client, - this.databaseService, - ) : _httpClient = http.Client(); - - static void init(INyxxWebsocket client, DatabaseService databaseService) { - _instance = SongRecognitionService._( - client, - databaseService, - ); - } + SongRecognitionService._privateConstructor() + : _shazamClient = ShazamClient.dockerized(); - final DatabaseService databaseService; - final INyxxWebsocket client; + static SongRecognitionService get instance => _instance; - static SongRecognitionService get instance => - _instance ?? - (throw Exception('Song Recognition service must be initialised')); - static SongRecognitionService? _instance; + static final SongRecognitionService _instance = + SongRecognitionService._privateConstructor(); Uuid get uuid => const Uuid(); - http.Client get httpClient => - _httpClient ?? - (throw RadioCantCommunicateWithServer( - Exception('Http Client must be initialized'), - )); - - // HttpClient used to get the sample - final http.Client? _httpClient; - - /// Gets the current radio playing by Guild. - /// - /// Throws [RadioNotPlayingException] if the Guild is not listening - /// to the radio - Future<GuildRadio> currentRadio(Snowflake guildId) async { - final currentlyPlaying = await databaseService.getPlaying(guildId); - if (currentlyPlaying == null) { - throw const RadioNotPlayingException(); - } - - return currentlyPlaying; - } + ShazamClient get shazamClient => _shazamClient; - /// Adds or not the current radio that the guild is playing - Future<void> setCurrentRadio( - Snowflake guildId, - Snowflake voiceChannelId, - Snowflake textChannelId, - Station station, - ) async { - final newRadio = GuildRadio( - guildId, - voiceChannelId: voiceChannelId, - station: station, - textChannelId: textChannelId, - ); + // ShazamClient used to get the sample + final ShazamClient _shazamClient; - await databaseService.setPlaying(newRadio); - } - - /// Deletes the radio from the [SongRecognitionService] radio. This is to let - /// the service know that the guild is no longer listening to the radio. - Future<void> deleteRadioFromList(Snowflake guildId) async { - await databaseService.setNotPlaying(guildId); - } + final http.Client httpClient = http.Client(); Future<CurrentStationInfo> getCurrentStationInfo( GuildRadio radio, @@ -107,7 +56,7 @@ class SongRecognitionService { /// /// Receives a [url] to identify the radio and a [durationInSeconds] to /// grab that amount of time of the sample from the radio. - Future<ShazamResult> identify( + Future<SongModel> identify( String url, int? durationInSeconds, ) async { @@ -116,41 +65,14 @@ class SongRecognitionService { durationInSeconds: durationInSeconds ?? 10, ); - final sample = await songFile.readAsBytes(); - try { - final uri = Uri( - scheme: 'https', - host: 'shazam-song-recognizer.p.rapidapi.com', - path: 'recognize', - ); - - final request = http.MultipartRequest('POST', uri); - - request.headers.addAll({ - 'X-RapidAPI-Key': rapidapiShazamSongRecognizerKey, - 'X-RapidAPI-Host': 'shazam-song-recognizer.p.rapidapi.com', - }); - - request.files.add( - http.MultipartFile.fromBytes( - 'upload_file', - sample, - ), - ); - - final streamedResponse = await request.send(); - final response = await http.Response.fromStream(streamedResponse); - final result = ShazamSongRecognition.fromJson( - (jsonDecode(response.body) as Map).cast(), - ); - - final track = result.result; + final response = await shazamClient.recognizeSong(songFile); + final track = response; // Deletes the file after the recognition is done songFile.deleteSync(); - return track ?? (throw const RadioCantIdentifySongException()); + return track; } on RadioCantIdentifySongException { rethrow; } catch (e) { diff --git a/pubspec.yaml b/pubspec.yaml index 9482d1c..7ec07a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,21 +16,23 @@ dependencies: http: ">=0.13.0 <1.1.0" json_annotation: ^4.8.1 logging: ^1.2.0 - mongo_dart: ^0.9.3 + mongo_dart: ^0.10.3 nyxx: ^5.1.1 nyxx_commands: ^5.0.2 nyxx_interactions: ^4.6.0 nyxx_lavalink: ^3.2.0 nyxx_pagination: ^2.4.0 - radio_browser_api: ^1.0.0 + radio_browser_api: ^2.0.0+1 retry: ^3.1.2 sentry: ^7.10.1 sentry_logging: ^7.10.1 + shazam_client: + path: shazam_client shelf: ^1.4.1 shelf_router: ^1.1.4 slang: ^3.24.0 time_ago_provider: ^4.2.0 - uuid: ^3.0.7 + uuid: ^4.0.0 dev_dependencies: build_runner: ^2.4.6 diff --git a/sample.mp3 b/sample.mp3 index a1782e4..cd64610 100644 Binary files a/sample.mp3 and b/sample.mp3 differ diff --git a/shazam_api/.dockerignore b/shazam_api/.dockerignore new file mode 100644 index 0000000..785a7fe --- /dev/null +++ b/shazam_api/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +db.sqlite3 +.env +venv/ +.git +.dockerignore +.gitignore +Dockerfile +*.md diff --git a/shazam_api/Dockerfile b/shazam_api/Dockerfile new file mode 100644 index 0000000..408b0ba --- /dev/null +++ b/shazam_api/Dockerfile @@ -0,0 +1,26 @@ +# Use an official Python runtime as a parent image +FROM python:3.12-slim + +# Set the working directory to /app +WORKDIR /app + +# Install ffmpeg +RUN apt-get update && \ + apt-get install -y ffmpeg && \ + rm -rf /var/lib/apt/lists/* + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Make port 5000 available to the world outside this container +EXPOSE 5000 + +# Define environment variable +ENV FLASK_APP=app.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Run app.py when the container launches +CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"] diff --git a/shazam_api/app.py b/shazam_api/app.py new file mode 100644 index 0000000..63189e2 --- /dev/null +++ b/shazam_api/app.py @@ -0,0 +1,85 @@ +from flask import Flask, request, jsonify +from shazamio import Shazam +import asyncio +from concurrent.futures import ThreadPoolExecutor + +app = Flask(__name__) +executor = ThreadPoolExecutor() + +def run_async(func): + def wrapper(*args, **kwargs): + return asyncio.run(func(*args, **kwargs)) + return wrapper + +@app.route('/recognize', methods=['GET']) +def recognize_song(): + if 'file' not in request.files: + return jsonify({'error': 'No file provided', 'statusCode': 500, 'path': request.path}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': 'No file selected', 'statusCode': 400, 'path': request.path}), 400 + + if file and file.filename.lower().endswith('.mp3'): + # Read file data into memory + file_data = file.read() + + # Run the asynchronous recognition + result = executor.submit(run_async(recognize_song_async), file_data).result() + + if result and 'track' in result: + return jsonify(result['track']) + else: + return jsonify({'error': 'Song not recognized', 'statusCode': 404, 'path': request.path}), 404 + else: + return jsonify({'error': 'Invalid file format. Please upload an MP3 file.', 'statusCode': 400, 'path': request.path}), 400 + +@app.route('/search', methods=['GET']) +def search_song(): + if 'song' not in request.args: + return jsonify({'error': 'No song provided', 'statusCode': 400, 'path': request.path}), 400 + + song_name = request.args['song'] + if song_name == '': + return jsonify({'error': 'No song selected', 'statusCode': 400, 'path': request.path}), 400 + + # Run the asynchronous search + result = executor.submit(run_async(search_song_async), song_name).result() + + if result and 'tracks' in result: + return jsonify(result['tracks']) + else: + return jsonify({'error': 'Song not found', 'statusCode': 404, 'path': request.path}), 404 + +@app.route('/track', methods=['GET']) +def track_data(): + if 'id' not in request.args: + return jsonify({'error': 'No track ID provided', 'statusCode': 400, 'path': request.path}), 400 + + track_id = request.args['id'] + if track_id == '': + return jsonify({'error': 'No track ID selected', 'statusCode': 400, 'path': request.path}), 400 + + # Run the asynchronous track data retrieval + result = executor.submit(run_async(track_data_async), track_id).result() + print(result) + + if result and 'title' in result: + return jsonify(result) + else: + return jsonify({'error': 'Track not found', 'statusCode': 404, 'path': request.path}), 404 + +async def recognize_song_async(file_data): + shazam = Shazam() + return await shazam.recognize(file_data) + +async def search_song_async(song_name): + shazam = Shazam() + return await shazam.search_track(query=song_name, limit=5) + +async def track_data_async(track_id): + shazam = Shazam() + return await shazam.track_about(track_id=track_id) + +if __name__ == '__main__': + app.run(debug=False) diff --git a/shazam_api/bin/Activate.ps1 b/shazam_api/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/shazam_api/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/shazam_api/bin/activate b/shazam_api/bin/activate new file mode 100644 index 0000000..0b95e10 --- /dev/null +++ b/shazam_api/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath "/Users/Tomas/Downloads/venv") +else + # use the path as-is + export VIRTUAL_ENV="/Users/Tomas/Downloads/venv" +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(venv) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(venv) " + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/shazam_api/bin/activate.csh b/shazam_api/bin/activate.csh new file mode 100644 index 0000000..f3650b1 --- /dev/null +++ b/shazam_api/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi <davidedb@gmail.com>. +# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com> + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV "/Users/Tomas/Downloads/venv" + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/bin:$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = "(venv) $prompt" + setenv VIRTUAL_ENV_PROMPT "(venv) " +endif + +alias pydoc python -m pydoc + +rehash diff --git a/shazam_api/bin/activate.fish b/shazam_api/bin/activate.fish new file mode 100644 index 0000000..65e3ba6 --- /dev/null +++ b/shazam_api/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source <venv>/bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV "/Users/Tomas/Downloads/venv" + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/bin" $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) "(venv) " (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT "(venv) " +end diff --git a/shazam_api/bin/f2py b/shazam_api/bin/f2py new file mode 100755 index 0000000..496ab76 --- /dev/null +++ b/shazam_api/bin/f2py @@ -0,0 +1,8 @@ +#!/Users/nazarenocavazzon/Documents/tests/venv/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from numpy.f2py.f2py2e import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/shazam_api/bin/flask b/shazam_api/bin/flask new file mode 100755 index 0000000..61d5b15 --- /dev/null +++ b/shazam_api/bin/flask @@ -0,0 +1,8 @@ +#!/Users/nazarenocavazzon/Documents/tests/venv/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from flask.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/shazam_api/bin/pip b/shazam_api/bin/pip new file mode 100755 index 0000000..974a334 --- /dev/null +++ b/shazam_api/bin/pip @@ -0,0 +1,8 @@ +#!/Users/nazarenocavazzon/Documents/tests/venv/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/shazam_api/bin/pip3 b/shazam_api/bin/pip3 new file mode 100755 index 0000000..974a334 --- /dev/null +++ b/shazam_api/bin/pip3 @@ -0,0 +1,8 @@ +#!/Users/nazarenocavazzon/Documents/tests/venv/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/shazam_api/bin/pip3.12 b/shazam_api/bin/pip3.12 new file mode 100755 index 0000000..974a334 --- /dev/null +++ b/shazam_api/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/Users/nazarenocavazzon/Documents/tests/venv/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/shazam_api/bin/python b/shazam_api/bin/python new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/shazam_api/bin/python @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/shazam_api/bin/python3 b/shazam_api/bin/python3 new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/shazam_api/bin/python3 @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/shazam_api/bin/python3.12 b/shazam_api/bin/python3.12 new file mode 120000 index 0000000..a3f0508 --- /dev/null +++ b/shazam_api/bin/python3.12 @@ -0,0 +1 @@ +/opt/homebrew/opt/python@3.12/bin/python3.12 \ No newline at end of file diff --git a/shazam_api/requirements.txt b/shazam_api/requirements.txt new file mode 100644 index 0000000..e57a23f --- /dev/null +++ b/shazam_api/requirements.txt @@ -0,0 +1,26 @@ +aiofiles==23.2.1 +aiohttp==3.9.5 +aiohttp-retry==2.8.3 +aiosignal==1.3.1 +anyio==4.3.0 +attrs==23.2.0 +blinker==1.8.2 +click==8.1.7 +dataclass-factory==2.16 +Flask==3.0.3 +frozenlist==1.4.1 +idna==3.7 +itsdangerous==2.2.0 +Jinja2==3.1.4 +MarkupSafe==2.1.5 +multidict==6.0.5 +numpy==1.26.4 +pydantic==1.10.17 +pydub==0.25.1 +shazamio==0.6.0 +shazamio_core==1.0.7 +sniffio==1.3.1 +typing_extensions==4.12.2 +Werkzeug==3.0.3 +yarl==1.9.4 +gunicorn \ No newline at end of file diff --git a/shazam_client/.gitignore b/shazam_client/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/shazam_client/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/shazam_client/README.md b/shazam_client/README.md new file mode 100644 index 0000000..7ab0659 --- /dev/null +++ b/shazam_client/README.md @@ -0,0 +1,62 @@ +# Shazam Client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +A Very Good Project created by Very Good CLI. + +## Installation ๐ป + +**โ In order to start using Shazam Client you must have the [Dart SDK][dart_install_link] installed on your machine.** + +Install via `dart pub add`: + +```sh +dart pub add shazam_client +``` + +--- + +## Continuous Integration ๐ค + +Shazam Client comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐งช + +To run all unit tests: + +```sh +dart pub global activate coverage 1.2.0 +dart test --coverage=coverage +dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/shazam_client/analysis_options.yaml b/shazam_client/analysis_options.yaml new file mode 100644 index 0000000..30f632c --- /dev/null +++ b/shazam_client/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml + +linter: + rules: + public_member_api_docs: false + +analyzer: + exclude: + - "**.g.dart" diff --git a/shazam_client/coverage_badge.svg b/shazam_client/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/shazam_client/coverage_badge.svg @@ -0,0 +1,20 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="20"> + <linearGradient id="b" x2="0" y2="100%"> + <stop offset="0" stop-color="#bbb" stop-opacity=".1" /> + <stop offset="1" stop-opacity=".1" /> + </linearGradient> + <clipPath id="a"> + <rect width="102" height="20" rx="3" fill="#fff" /> + </clipPath> + <g clip-path="url(#a)"> + <path fill="#555" d="M0 0h59v20H0z" /> + <path fill="#44cc11" d="M59 0h43v20H59z" /> + <path fill="url(#b)" d="M0 0h102v20H0z" /> + </g> + <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"> + <text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text> + <text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text> + <text x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text> + <text x="795" y="140" transform="scale(.1)" textLength="330">100%</text> + </g> +</svg> diff --git a/shazam_client/lib/shazam_client.dart b/shazam_client/lib/shazam_client.dart new file mode 100644 index 0000000..b09f0a5 --- /dev/null +++ b/shazam_client/lib/shazam_client.dart @@ -0,0 +1,6 @@ +/// A Very Good Project created by Very Good CLI. +library; + +export 'src/exceptions.dart'; +export 'src/models/models.dart'; +export 'src/shazam_client.dart'; diff --git a/shazam_client/lib/src/exceptions.dart b/shazam_client/lib/src/exceptions.dart new file mode 100644 index 0000000..7e9c78a --- /dev/null +++ b/shazam_client/lib/src/exceptions.dart @@ -0,0 +1,58 @@ +/// Thrown if an exception occurs while making an http request. +class HttpException implements Exception {} + +/// {@template http_request_failure} +/// Thrown if an http request returns a non-200 status code. +/// {@endtemplate} +class HttpRequestFailure implements Exception { + /// {@macro http_request_failure} + const HttpRequestFailure(this.statusCode, this.error); + + /// The status code of the response. + final int statusCode; + + /// The error message of the response. + final String error; + + @override + String toString() => + 'HttpRequestFailure(statusCode: $statusCode, error: $error)'; +} + +/// Thrown when an error occurs while decoding the response body. +class JsonDecodeException implements Exception { + /// Thrown when an error occurs while decoding the response body. + const JsonDecodeException(); +} + +/// Thrown when an error occurs while decoding the response body. +class SpecifiedTypeNotMatchedException implements Exception { + /// Thrown when an error occurs while decoding the response body. + const SpecifiedTypeNotMatchedException(); +} + +/// Thrown when an unknown error occurs. +class UnknownException implements Exception { + /// Thrown when an unknown error occurs. + const UnknownException(); +} + +/// This exception is thrown if the server sends a request with an unexpected +/// status code or missing/invalid headers. +class ProtocolException implements Exception { + /// Create a new ProtocolException. + ProtocolException( + this.message, [ + this.code, + ]); + + /// Message from the exception + final String message; + + /// Code from the exception. + final int? code; + + /// Returns a string representation of this exception. + @override + String toString() => 'ProtocolException: ($code) $message'; +} diff --git a/shazam_client/lib/src/models/action.dart b/shazam_client/lib/src/models/action.dart new file mode 100644 index 0000000..e7d7817 --- /dev/null +++ b/shazam_client/lib/src/models/action.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +class Action { + Action({ + this.id, + this.name, + this.type, + this.uri, + }); + + factory Action.fromRawJson(String str) => + Action.fromJson(json.decode(str) as Map); + + factory Action.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Action( + id: json['id'] as String?, + name: json['name'] as String?, + type: json['type'] as String?, + uri: json['uri'] as String?, + ); + } + final String? id; + final String? name; + final String? type; + final String? uri; + + Action copyWith({ + String? id, + String? name, + String? type, + String? uri, + }) => + Action( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + uri: uri ?? this.uri, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'id': id, + 'name': name, + 'type': type, + 'uri': uri, + }; +} diff --git a/shazam_client/lib/src/models/artist_model.dart b/shazam_client/lib/src/models/artist_model.dart new file mode 100644 index 0000000..34a0dec --- /dev/null +++ b/shazam_client/lib/src/models/artist_model.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class Artist { + Artist({ + this.adamid, + this.id, + }); + + factory Artist.fromRawJson(String str) => + Artist.fromJson(json.decode(str) as Map); + + factory Artist.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Artist( + adamid: json['adamid'] as String?, + id: json['id'] as String?, + ); + } + final String? adamid; + final String? id; + + Artist copyWith({ + String? adamid, + String? id, + }) => + Artist( + adamid: adamid ?? this.adamid, + id: id ?? this.id, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'adamid': adamid, + 'id': id, + }; +} diff --git a/shazam_client/lib/src/models/beacondata.dart b/shazam_client/lib/src/models/beacondata.dart new file mode 100644 index 0000000..692644e --- /dev/null +++ b/shazam_client/lib/src/models/beacondata.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class Beacondata { + Beacondata({ + this.providername, + this.type, + }); + + factory Beacondata.fromRawJson(String str) => + Beacondata.fromJson(json.decode(str) as Map); + + factory Beacondata.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Beacondata( + providername: json['providername'] as String?, + type: json['type'] as String?, + ); + } + final String? providername; + final String? type; + + Beacondata copyWith({ + String? providername, + String? type, + }) => + Beacondata( + providername: providername ?? this.providername, + type: type ?? this.type, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'providername': providername, + 'type': type, + }; +} diff --git a/shazam_client/lib/src/models/exception_response_model.dart b/shazam_client/lib/src/models/exception_response_model.dart new file mode 100644 index 0000000..7a78c65 --- /dev/null +++ b/shazam_client/lib/src/models/exception_response_model.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:http/http.dart' as http; + +/// {@template exception_response} +/// A class that represents the error response from the backend. +/// {@endtemplate} +class ExceptionResponse with EquatableMixin implements Exception { + /// {@macro exception_response} + const ExceptionResponse({ + this.error, + this.statusCode, + this.path, + }); + + /// Creates an [ExceptionResponse] from a JSON object. + factory ExceptionResponse.fromJson(Map<String, dynamic> json) { + return ExceptionResponse( + error: json['error'] as String?, + statusCode: json['statusCode'] as int? ?? 500, + path: json['path'] as String?, + ); + } + + /// Checks if the response is a JSON error response. + static bool matches(http.Response response) { + if (response.statusCode != 200) { + try { + jsonDecode(response.body); + return true; + } catch (_) { + // If decoding fails, it might not be a JSON response. + return false; + } + } + return false; + } + + /// Merges the [ExceptionResponse] with the [http.Response]. + final String? error; + + /// The status code of the response. + final int? statusCode; + + /// The path of the request that caused the error. + final String? path; + + @override + String toString() => + 'ExceptionResponse(error: $error, statusCode: $statusCode, path: $path)'; + @override + List<Object?> get props => [error, statusCode, path]; +} diff --git a/shazam_client/lib/src/models/genres.dart b/shazam_client/lib/src/models/genres.dart new file mode 100644 index 0000000..1df8570 --- /dev/null +++ b/shazam_client/lib/src/models/genres.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class Genres { + Genres({ + this.primary, + this.secondary, + }); + + factory Genres.fromRawJson(String str) => + Genres.fromJson(json.decode(str) as Map); + + factory Genres.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Genres( + primary: json['primary'] as String?, + secondary: json['secondary'] as String?, + ); + } + final String? primary; + final String? secondary; + + Genres copyWith({ + String? primary, + String? secondary, + }) => + Genres( + primary: primary ?? this.primary, + secondary: secondary ?? this.secondary, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'primary': primary, + 'secondary': secondary, + }; +} diff --git a/shazam_client/lib/src/models/hub.dart b/shazam_client/lib/src/models/hub.dart new file mode 100644 index 0000000..7f96f9b --- /dev/null +++ b/shazam_client/lib/src/models/hub.dart @@ -0,0 +1,94 @@ + +import 'dart:convert'; + +import 'package:shazam_client/shazam_client.dart'; + +class Hub { + Hub({ + this.actions, + this.displayname, + this.explicit, + this.image, + this.options, + this.providers, + this.type, + }); + + factory Hub.fromRawJson(String str) => Hub.fromJson(json.decode(str) as Map); + + factory Hub.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Hub( + actions: json['actions'] == null + ? null + : List<Action>.from( + (json['actions'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Action.fromJson), + ), + displayname: json['displayname'] as String?, + explicit: json['explicit'] as bool?, + image: json['image'] as String?, + options: json['options'] == null + ? null + : List<Option>.from( + (json['options'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Option.fromJson), + ), + providers: json['providers'] == null + ? null + : List<Provider>.from( + (json['providers'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Provider.fromJson), + ), + type: json['type'] as String?, + ); + } + final List<Action>? actions; + final String? displayname; + final bool? explicit; + final String? image; + final List<Option>? options; + final List<Provider>? providers; + final String? type; + + Hub copyWith({ + List<Action>? actions, + String? displayname, + bool? explicit, + String? image, + List<Option>? options, + List<Provider>? providers, + String? type, + }) => + Hub( + actions: actions ?? this.actions, + displayname: displayname ?? this.displayname, + explicit: explicit ?? this.explicit, + image: image ?? this.image, + options: options ?? this.options, + providers: providers ?? this.providers, + type: type ?? this.type, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'actions': actions == null + ? null + : List<dynamic>.from(actions!.map((x) => x.toJson())), + 'displayname': displayname, + 'explicit': explicit, + 'image': image, + 'options': options == null + ? null + : List<dynamic>.from(options!.map((x) => x.toJson())), + 'providers': providers == null + ? null + : List<dynamic>.from(providers!.map((x) => x.toJson())), + 'type': type, + }; +} diff --git a/shazam_client/lib/src/models/metadatum.dart b/shazam_client/lib/src/models/metadatum.dart new file mode 100644 index 0000000..1ad7e85 --- /dev/null +++ b/shazam_client/lib/src/models/metadatum.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class Metadatum { + Metadatum({ + this.text, + this.title, + }); + + factory Metadatum.fromRawJson(String str) => + Metadatum.fromJson(json.decode(str) as Map); + + factory Metadatum.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Metadatum( + text: json['text'] as String?, + title: json['title'] as String?, + ); + } + final String? text; + final String? title; + + Metadatum copyWith({ + String? text, + String? title, + }) => + Metadatum( + text: text ?? this.text, + title: title ?? this.title, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'text': text, + 'title': title, + }; +} diff --git a/shazam_client/lib/src/models/metapage.dart b/shazam_client/lib/src/models/metapage.dart new file mode 100644 index 0000000..e778528 --- /dev/null +++ b/shazam_client/lib/src/models/metapage.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class Metapage { + Metapage({ + this.caption, + this.image, + }); + + factory Metapage.fromRawJson(String str) => + Metapage.fromJson(json.decode(str) as Map); + + factory Metapage.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Metapage( + caption: json['caption'] as String?, + image: json['image'] as String?, + ); + } + final String? caption; + final String? image; + + Metapage copyWith({ + String? caption, + String? image, + }) => + Metapage( + caption: caption ?? this.caption, + image: image ?? this.image, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'caption': caption, + 'image': image, + }; +} diff --git a/shazam_client/lib/src/models/models.dart b/shazam_client/lib/src/models/models.dart new file mode 100644 index 0000000..900c7dc --- /dev/null +++ b/shazam_client/lib/src/models/models.dart @@ -0,0 +1,23 @@ +export 'action.dart'; +export 'artist_model.dart'; +export 'beacondata.dart'; +export 'exception_response_model.dart'; +export 'genres.dart'; +export 'hub.dart'; +export 'metadatum.dart'; +export 'metapage.dart'; +export 'models.dart'; +export 'option.dart'; +export 'provider.dart'; +export 'provider_images.dart'; +export 'section.dart'; +export 'share.dart'; +export 'song_model.dart'; +export 'song_model_images.dart'; +export 'urlparams.dart'; + +/// The JSON serializable model for the API response. +typedef JSON = Map<String, dynamic>; + +/// When de API response is a List of [JSON] +typedef JSONLIST = List<JSON>; diff --git a/shazam_client/lib/src/models/option.dart b/shazam_client/lib/src/models/option.dart new file mode 100644 index 0000000..7b97fda --- /dev/null +++ b/shazam_client/lib/src/models/option.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:shazam_client/shazam_client.dart'; + +class Option { + Option({ + this.actions, + this.beacondata, + this.caption, + this.colouroverflowimage, + this.image, + this.listcaption, + this.overflowimage, + this.providername, + this.type, + }); + + factory Option.fromRawJson(String str) => + Option.fromJson(json.decode(str) as Map); + + factory Option.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Option( + actions: json['actions'] == null + ? null + : List<Action>.from( + (json['actions'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Action.fromJson), + ), + beacondata: json['beacondata'] == null + ? null + : Beacondata.fromJson(json['beacondata'] as Map), + caption: json['caption'] as String?, + colouroverflowimage: json['colouroverflowimage'] as bool?, + image: json['image'] as String?, + listcaption: json['listcaption'] as String?, + overflowimage: json['overflowimage'] as String?, + providername: json['providername'] as String?, + type: json['type'] as String?, + ); + } + final List<Action>? actions; + final Beacondata? beacondata; + final String? caption; + final bool? colouroverflowimage; + final String? image; + final String? listcaption; + final String? overflowimage; + final String? providername; + final String? type; + + Option copyWith({ + List<Action>? actions, + Beacondata? beacondata, + String? caption, + bool? colouroverflowimage, + String? image, + String? listcaption, + String? overflowimage, + String? providername, + String? type, + }) => + Option( + actions: actions ?? this.actions, + beacondata: beacondata ?? this.beacondata, + caption: caption ?? this.caption, + colouroverflowimage: colouroverflowimage ?? this.colouroverflowimage, + image: image ?? this.image, + listcaption: listcaption ?? this.listcaption, + overflowimage: overflowimage ?? this.overflowimage, + providername: providername ?? this.providername, + type: type ?? this.type, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'actions': actions == null + ? null + : List<dynamic>.from(actions!.map((x) => x.toJson())), + 'beacondata': beacondata?.toJson(), + 'caption': caption, + 'colouroverflowimage': colouroverflowimage, + 'image': image, + 'listcaption': listcaption, + 'overflowimage': overflowimage, + 'providername': providername, + 'type': type, + }; +} diff --git a/shazam_client/lib/src/models/provider.dart b/shazam_client/lib/src/models/provider.dart new file mode 100644 index 0000000..06063c9 --- /dev/null +++ b/shazam_client/lib/src/models/provider.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:shazam_client/shazam_client.dart'; + +class Provider { + Provider({ + this.actions, + this.caption, + this.images, + this.type, + }); + + factory Provider.fromRawJson(String str) => + Provider.fromJson(json.decode(str) as Map); + + factory Provider.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Provider( + actions: json['actions'] == null + ? null + : List<Action>.from( + (json['actions'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Action.fromJson), + ), + caption: json['caption'] as String?, + images: json['images'] == null + ? null + : ProviderImages.fromJson(json['images'] as Map), + type: json['type'] as String?, + ); + } + final List<Action>? actions; + final String? caption; + final ProviderImages? images; + final String? type; + + Provider copyWith({ + List<Action>? actions, + String? caption, + ProviderImages? images, + String? type, + }) => + Provider( + actions: actions ?? this.actions, + caption: caption ?? this.caption, + images: images ?? this.images, + type: type ?? this.type, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'actions': actions == null + ? null + : List<dynamic>.from(actions!.map((x) => x.toJson())), + 'caption': caption, + 'images': images?.toJson(), + 'type': type, + }; +} diff --git a/shazam_client/lib/src/models/provider_images.dart b/shazam_client/lib/src/models/provider_images.dart new file mode 100644 index 0000000..ba9c35d --- /dev/null +++ b/shazam_client/lib/src/models/provider_images.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class ProviderImages { + ProviderImages({ + this.imagesDefault, + this.overflow, + }); + + factory ProviderImages.fromRawJson(String str) => + ProviderImages.fromJson(json.decode(str) as Map); + + factory ProviderImages.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return ProviderImages( + imagesDefault: json['default'] as String?, + overflow: json['overflow'] as String?, + ); + } + final String? imagesDefault; + final String? overflow; + + ProviderImages copyWith({ + String? imagesDefault, + String? overflow, + }) => + ProviderImages( + imagesDefault: imagesDefault ?? this.imagesDefault, + overflow: overflow ?? this.overflow, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'default': imagesDefault, + 'overflow': overflow, + }; +} diff --git a/shazam_client/lib/src/models/section.dart b/shazam_client/lib/src/models/section.dart new file mode 100644 index 0000000..ed2be20 --- /dev/null +++ b/shazam_client/lib/src/models/section.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:shazam_client/shazam_client.dart'; + +class Section { + Section({ + this.metadata, + this.metapages, + this.tabname, + this.type, + this.url, + }); + + factory Section.fromRawJson(String str) => + Section.fromJson(json.decode(str) as Map); + + factory Section.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Section( + metadata: json['metadata'] == null + ? null + : List<Metadatum>.from( + (json['metadata'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Metadatum.fromJson), + ), + metapages: json['metapages'] == null + ? null + : List<Metapage>.from( + (json['metapages'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Metapage.fromJson), + ), + tabname: json['tabname'] as String?, + type: json['type'] as String?, + url: json['url'] as String?, + ); + } + final List<Metadatum>? metadata; + final List<Metapage>? metapages; + final String? tabname; + final String? type; + final String? url; + + Section copyWith({ + List<Metadatum>? metadata, + List<Metapage>? metapages, + String? tabname, + String? type, + String? url, + }) => + Section( + metadata: metadata ?? this.metadata, + metapages: metapages ?? this.metapages, + tabname: tabname ?? this.tabname, + type: type ?? this.type, + url: url ?? this.url, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'metadata': metadata == null + ? null + : List<dynamic>.from(metadata!.map((x) => x.toJson())), + 'metapages': metapages == null + ? null + : List<dynamic>.from(metapages!.map((x) => x.toJson())), + 'tabname': tabname, + 'type': type, + 'url': url, + }; +} diff --git a/shazam_client/lib/src/models/share.dart b/shazam_client/lib/src/models/share.dart new file mode 100644 index 0000000..34be4c7 --- /dev/null +++ b/shazam_client/lib/src/models/share.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +class Share { + Share({ + this.avatar, + this.href, + this.html, + this.image, + this.snapchat, + this.subject, + this.text, + this.twitter, + }); + + factory Share.fromRawJson(String str) => + Share.fromJson(json.decode(str) as Map); + + factory Share.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Share( + avatar: json['avatar'] as String?, + href: json['href'] as String?, + html: json['html'] as String?, + image: json['image'] as String?, + snapchat: json['snapchat'] as String?, + subject: json['subject'] as String?, + text: json['text'] as String?, + twitter: json['twitter'] as String?, + ); + } + + final String? avatar; + final String? href; + final String? html; + final String? image; + final String? snapchat; + final String? subject; + final String? text; + final String? twitter; + + Share copyWith({ + String? avatar, + String? href, + String? html, + String? image, + String? snapchat, + String? subject, + String? text, + String? twitter, + }) => + Share( + avatar: avatar ?? this.avatar, + href: href ?? this.href, + html: html ?? this.html, + image: image ?? this.image, + snapchat: snapchat ?? this.snapchat, + subject: subject ?? this.subject, + text: text ?? this.text, + twitter: twitter ?? this.twitter, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'avatar': avatar, + 'href': href, + 'html': html, + 'image': image, + 'snapchat': snapchat, + 'subject': subject, + 'text': text, + 'twitter': twitter, + }; +} diff --git a/shazam_client/lib/src/models/song_model.dart b/shazam_client/lib/src/models/song_model.dart new file mode 100644 index 0000000..2b57d9a --- /dev/null +++ b/shazam_client/lib/src/models/song_model.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:shazam_client/shazam_client.dart'; + +class SongModel { + SongModel({ + this.albumadamid, + this.artists, + this.genres, + this.hub, + this.images, + this.isrc, + this.key, + this.layout, + this.relatedtracksurl, + this.sections, + this.share, + this.subtitle, + this.title, + this.type, + this.url, + this.urlparams, + }); + + factory SongModel.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return SongModel( + albumadamid: json['albumadamid'] as String?, + artists: json['artists'] == null + ? null + : List<Artist>.from( + (json['artists'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Artist.fromJson), + ), + genres: json['genres'] == null + ? null + : Genres.fromJson(json['genres'] as Map), + hub: json['hub'] == null ? null : Hub.fromJson(json['hub'] as Map), + images: json['images'] == null + ? null + : SongModelImages.fromJson(json['images'] as Map), + isrc: json['isrc'] as String?, + key: json['key'] as String?, + layout: json['layout'] as String?, + relatedtracksurl: json['relatedtracksurl'] as String?, + sections: json['sections'] == null + ? null + : List<Section>.from( + (json['sections'] as List) + .cast<Map<dynamic, dynamic>>() + .map(Section.fromJson), + ), + share: + json['share'] == null ? null : Share.fromJson(json['share'] as Map), + subtitle: json['subtitle'] as String?, + title: json['title'] as String?, + type: json['type'] as String?, + url: json['url'] as String?, + urlparams: json['urlparams'] == null + ? null + : Urlparams.fromJson(json['urlparams'] as Map), + ); + } + + factory SongModel.fromRawJson(String str) => + SongModel.fromJson(json.decode(str) as Map); + + final String? albumadamid; + final List<Artist>? artists; + final Genres? genres; + final Hub? hub; + final SongModelImages? images; + final String? isrc; + final String? key; + final String? layout; + final String? relatedtracksurl; + final List<Section>? sections; + final Share? share; + final String? subtitle; + final String? title; + final String? type; + final String? url; + final Urlparams? urlparams; + + SongModel copyWith({ + String? albumadamid, + List<Artist>? artists, + Genres? genres, + Hub? hub, + SongModelImages? images, + String? isrc, + String? key, + String? layout, + String? relatedtracksurl, + List<Section>? sections, + Share? share, + String? subtitle, + String? title, + String? type, + String? url, + Urlparams? urlparams, + }) => + SongModel( + albumadamid: albumadamid ?? this.albumadamid, + artists: artists ?? this.artists, + genres: genres ?? this.genres, + hub: hub ?? this.hub, + images: images ?? this.images, + isrc: isrc ?? this.isrc, + key: key ?? this.key, + layout: layout ?? this.layout, + relatedtracksurl: relatedtracksurl ?? this.relatedtracksurl, + sections: sections ?? this.sections, + share: share ?? this.share, + subtitle: subtitle ?? this.subtitle, + title: title ?? this.title, + type: type ?? this.type, + url: url ?? this.url, + urlparams: urlparams ?? this.urlparams, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'albumadamid': albumadamid, + 'artists': artists == null + ? null + : List<dynamic>.from(artists!.map((x) => x.toJson())), + 'genres': genres?.toJson(), + 'hub': hub?.toJson(), + 'images': images?.toJson(), + 'isrc': isrc, + 'key': key, + 'layout': layout, + 'relatedtracksurl': relatedtracksurl, + 'sections': sections == null + ? null + : List<dynamic>.from(sections!.map((x) => x.toJson())), + 'share': share?.toJson(), + 'subtitle': subtitle, + 'title': title, + 'type': type, + 'url': url, + 'urlparams': urlparams?.toJson(), + }; +} diff --git a/shazam_client/lib/src/models/song_model_images.dart b/shazam_client/lib/src/models/song_model_images.dart new file mode 100644 index 0000000..5d25847 --- /dev/null +++ b/shazam_client/lib/src/models/song_model_images.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +class SongModelImages { + SongModelImages({ + this.background, + this.coverart, + this.coverarthq, + this.joecolor, + }); + + factory SongModelImages.fromRawJson(String str) => + SongModelImages.fromJson(json.decode(str) as Map); + + factory SongModelImages.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return SongModelImages( + background: json['background'] as String?, + coverart: json['coverart'] as String?, + coverarthq: json['coverarthq'] as String?, + joecolor: json['joecolor'] as String?, + ); + } + final String? background; + final String? coverart; + final String? coverarthq; + final String? joecolor; + + SongModelImages copyWith({ + String? background, + String? coverart, + String? coverarthq, + String? joecolor, + }) => + SongModelImages( + background: background ?? this.background, + coverart: coverart ?? this.coverart, + coverarthq: coverarthq ?? this.coverarthq, + joecolor: joecolor ?? this.joecolor, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + 'background': background, + 'coverart': coverart, + 'coverarthq': coverarthq, + 'joecolor': joecolor, + }; +} diff --git a/shazam_client/lib/src/models/urlparams.dart b/shazam_client/lib/src/models/urlparams.dart new file mode 100644 index 0000000..28571e5 --- /dev/null +++ b/shazam_client/lib/src/models/urlparams.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class Urlparams { + Urlparams({ + this.trackartist, + this.tracktitle, + }); + + factory Urlparams.fromRawJson(String str) => + Urlparams.fromJson(json.decode(str) as Map); + + factory Urlparams.fromJson(Map<dynamic, dynamic> json) { + json = json.cast<String, dynamic>(); + + return Urlparams( + trackartist: json['{trackartist}'] as String?, + tracktitle: json['{tracktitle}'] as String?, + ); + } + final String? trackartist; + final String? tracktitle; + + Urlparams copyWith({ + String? trackartist, + String? tracktitle, + }) => + Urlparams( + trackartist: trackartist ?? this.trackartist, + tracktitle: tracktitle ?? this.tracktitle, + ); + + String toRawJson() => json.encode(toJson()); + + Map<String, dynamic> toJson() => { + '{trackartist}': trackartist, + '{tracktitle}': tracktitle, + }; +} diff --git a/shazam_client/lib/src/shazam_client.dart b/shazam_client/lib/src/shazam_client.dart new file mode 100644 index 0000000..d295151 --- /dev/null +++ b/shazam_client/lib/src/shazam_client.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:shazam_client/shazam_client.dart'; +import 'package:shazam_client/src/shazam_client_base.dart'; + +/// {@template shazam_client} +/// A Very Good Project created by Very Good CLI. +/// {@endtemplate} +class ShazamClient extends ShazamApiClientBase { + /// {@macro shazam_client} + ShazamClient.localhost({ + super.timeout = const Duration(seconds: 10), + }) : _baseUrl = 'localhost', + _port = 5000; + + /// {@macro shazam_client} + ShazamClient.dockerized({ + super.timeout = const Duration(seconds: 10), + }) : _baseUrl = 'shazam_api', + _port = 5000; + + final String _baseUrl; + final int _port; + + @override + String get authority => _baseUrl; + + @override + int get port => _port; + + /// Recognizes a song from a given [song]. + Future<SongModel> recognizeSong(File song) async { + final uri = Uri( + scheme: 'http', + host: authority, + port: port, + path: '/recognize', + ); + + try { + final request = http.MultipartRequest('GET', uri) + ..files.add( + http.MultipartFile( + 'file', + song.readAsBytes().asStream(), + song.lengthSync(), + filename: song.path.split('/').last, + ), + ); + + final streamedResponse = await request.send(); + final response = await http.Response.fromStream(streamedResponse); + + if (response.isFailure) { + throw HttpException(); + } + + return SongModel.fromJson( + (jsonDecode(response.body) as Map).cast(), + ); + } on SocketException { + rethrow; + } on TimeoutException { + rethrow; + } catch (e) { + throw HttpException(); + } + } +} diff --git a/shazam_client/lib/src/shazam_client_base.dart b/shazam_client/lib/src/shazam_client_base.dart new file mode 100644 index 0000000..544c46b --- /dev/null +++ b/shazam_client/lib/src/shazam_client_base.dart @@ -0,0 +1,249 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; +import 'package:shazam_client/shazam_client.dart'; + +/// ShazamApiClientBase is the base class for all API Requests available. +@internal +abstract class ShazamApiClientBase { + ShazamApiClientBase({ + required this.timeout, + }); + + /// The timeout for all API requests. + /// Only exposed for testing purposes. Do not use directly. + @visibleForTesting + final Duration timeout; + + /// The host URL used for all API requests. + /// + /// Only exposed for testing purposes. Do not use directly. + @visibleForTesting + String get authority; + + /// The port to which the API requests are made. + /// Only exposed for testing purposes. Do not use directly. + /// Defaults to 5000. + @visibleForTesting + int get port => 5000; + + /// The http client used for all API requests. + /// + /// Only exposed for testing purposes. Do not use directly. + @visibleForTesting + final http.Client httpClient = http.Client(); + + Future<T> post<T>( + Uri uri, { + Object? body, + Map<String, String>? queryParams, + Map<String, String>? headers, + bool needsToken = true, + }) async { + assert( + body is Map || body is List || body == null, + 'Invalid body type. Only Map, List or null are allowed. ' + 'Got: ${body.runtimeType}', + ); + + http.Response response; + + try { + response = await httpClient.post( + uri, + body: (body != null) ? jsonEncode(body) : null, + headers: { + if (needsToken) ...headersAlways, + 'needs-token': '$needsToken', + ...?headers, + }, + ); + } on SocketException { + rethrow; + } on TimeoutException { + rethrow; + } catch (_) { + throw HttpException(); + } + + return _handleResponse<T>(response); + } + + Future<T> patch<T>( + Uri uri, { + Map<String, dynamic>? body, + Map<String, String>? queryParams, + Map<String, String>? headers, + bool needsToken = true, + }) async { + http.Response response; + + try { + response = await httpClient.patch( + uri, + body: (body != null) ? jsonEncode(body) : null, + headers: { + if (needsToken) ...headersAlways, + 'needs-token': '$needsToken', + ...?headers, + }, + ); + } on SocketException { + rethrow; + } on TimeoutException { + rethrow; + } catch (_) { + throw HttpException(); + } + + return _handleResponse<T>(response); + } + + Future<T> get<T>( + Uri uri, { + bool needsToken = true, + Map<String, String>? headers, + }) async { + http.Response response; + + try { + response = await httpClient.get( + uri, + headers: { + if (needsToken) ...headersAlways, + 'needs-token': '$needsToken', + ...?headers, + }, + ); + } on SocketException { + rethrow; + } on TimeoutException { + rethrow; + } catch (_) { + throw HttpException(); + } + + return _handleResponse<T>(response); + } + + Future<T> put<T>( + Uri uri, { + Map<String, dynamic>? body, + bool needsToken = true, + Map<String, String>? headers, + }) async { + http.Response response; + + try { + response = await httpClient.put( + uri, + body: (body != null) ? jsonEncode(body) : null, + headers: { + if (needsToken) ...headersAlways, + 'needs-token': '$needsToken', + ...?headers, + }, + ); + } on SocketException { + rethrow; + } on TimeoutException { + rethrow; + } catch (_) { + throw HttpException(); + } + + return _handleResponse<T>(response); + } + + Future<T> delete<T>( + Uri uri, { + Map<String, dynamic>? body, + bool needsToken = true, + Map<String, String>? headers, + }) async { + http.Response response; + + try { + response = await httpClient.delete( + uri, + body: (body != null) ? jsonEncode(body) : null, + headers: { + if (needsToken) ...headersAlways, + 'needs-token': '$needsToken', + ...?headers, + }, + ); + } on SocketException { + rethrow; + } on TimeoutException { + rethrow; + } catch (_) { + throw HttpException(); + } + + return _handleResponse<T>(response); + } + + T _handleResponse<T>(http.Response response) { + try { + if (response is T) return response as T; + + final dynamic decodedResponse = jsonDecode(response.body); + + if (response.isFailure && decodedResponse is Map<String, dynamic>) { + if (ExceptionResponse.matches(response)) { + throw ExceptionResponse.fromJson(decodedResponse); + } + + throw HttpRequestFailure( + response.statusCode, + response.reasonPhrase ?? '', + ); + } + + if (decodedResponse is T) return decodedResponse; + + try { + if (T == JSON) { + (decodedResponse as Map).cast<String, dynamic>() as T; + } else if (T == JSONLIST) { + final newResponse = (decodedResponse as List) + .map<JSON>( + (dynamic item) => (item as Map).cast<String, dynamic>(), + ) + .toList(); + return newResponse as T; + } + + return decodedResponse as T; + } catch (_) { + throw const SpecifiedTypeNotMatchedException(); + } + } on FormatException { + throw const JsonDecodeException(); + } + } + + /// Closes the http_interceptor client. + void close() { + httpClient.close(); + } + + @internal + Map<String, String> get headersAlways => <String, String>{ + 'accept': 'application/json', + 'Content-Type': 'application/json', + }; +} + +/// A class to make it easier to handle the response from the API. +extension Result on http.Response { + /// Returns true if the response is a success. + bool get isSuccess => statusCode >= 200 && statusCode < 300; + + /// Returns true if the response is a failure. + bool get isFailure => !isSuccess; +} diff --git a/shazam_client/pubspec.yaml b/shazam_client/pubspec.yaml new file mode 100644 index 0000000..9c95b5f --- /dev/null +++ b/shazam_client/pubspec.yaml @@ -0,0 +1,16 @@ +name: shazam_client +description: The Dart client for the Shazam API, used by Radio Horizon. +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.0 + +dev_dependencies: + mocktail: ^1.0.4 + test: ^1.25.7 + very_good_analysis: ^6.0.0 +dependencies: + equatable: ^2.0.5 + http: ^1.2.2 + meta: ^1.15.0 diff --git a/shazam_client/test/src/shazam_client_test.dart b/shazam_client/test/src/shazam_client_test.dart new file mode 100644 index 0000000..c770613 --- /dev/null +++ b/shazam_client/test/src/shazam_client_test.dart @@ -0,0 +1,11 @@ +// ignore_for_file: prefer_const_constructors +import 'package:shazam_client/shazam_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('ShazamClient', () { + test('can be instantiated', () { + expect(ShazamClient.localhost(), isNotNull); + }); + }); +} diff --git a/test/radio_recognizer_test.dart b/test/radio_recognizer_test.dart new file mode 100644 index 0000000..3bcd184 --- /dev/null +++ b/test/radio_recognizer_test.dart @@ -0,0 +1,54 @@ +import 'package:radio_horizon/radio_horizon.dart'; +import 'package:retry/retry.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_logging/sentry_logging.dart'; +import 'package:shazam_client/shazam_client.dart'; +import 'package:test/test.dart'; + +void main() { + var recognitionSampleDuration = 10; + + setUpAll(() async { + dotEnvFlavour = DotEnvFlavour.development; + dotEnvFlavour.initialize(); + + await Sentry.init( + (options) { + options + ..dsn = sentryDsn + ..environment = dotEnvFlavour.name + ..release = packageVersion + ..debug = dotEnvFlavour == DotEnvFlavour.development + ..attachStacktrace = true + ..sampleRate = 1.0 + ..sendDefaultPii = true + ..tracesSampleRate = 1.0 + ..addIntegration(LoggingIntegration()); + }, + ); + }); + + test( + 'test description', + () async { + SongModel? result; + await retry( + () async { + result = await SongRecognitionService.instance.identify( + 'https://ais-edge49-nyc04.cdnstream.com/2281_128.mp3', + recognitionSampleDuration, + ); + }, + maxDelay: const Duration(minutes: 2), + retryIf: (e) => true, + onRetry: (e) { + recognitionSampleDuration += + (recognitionSampleDuration * 0.25).toInt(); + }, + ).timeout(const Duration(minutes: 1)); + + expect(result, isNotNull); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/tool/identify_song.dart b/tool/identify_song.dart index ff0b06c..9139512 100644 --- a/tool/identify_song.dart +++ b/tool/identify_song.dart @@ -1,59 +1,22 @@ // ignore_for_file: avoid_print -import 'dart:convert'; -import 'dart:developer'; import 'dart:io'; -import 'dart:typed_data'; -import 'package:http/http.dart' as http; import 'package:radio_horizon/radio_horizon.dart'; - -Future<ShazamSongRecognition> identify(Uint8List data) async { - final sample = data.buffer.asUint8List(); - - final uri = Uri( - scheme: 'https', - host: 'shazam-song-recognizer.p.rapidapi.com', - path: 'recognize', - ); - - final request = http.MultipartRequest('POST', uri); - - request.headers.addAll({ - 'X-RapidAPI-Key': 'YOUR_KEY', - 'X-RapidAPI-Host': 'shazam-song-recognizer.p.rapidapi.com', - }); - - request.files.add( - http.MultipartFile.fromBytes( - 'upload_file', - sample, - ), - ); - - final streamedResponse = await request.send(); - final response = await http.Response.fromStream(streamedResponse); - return ShazamSongRecognition.fromJson( - (jsonDecode(response.body) as Map).cast(), - ); -} +import 'package:shazam_client/shazam_client.dart'; Future<void> main(List<String> args) async { dotEnvFlavour = DotEnvFlavour.development; dotEnvFlavour.initialize(); final stopwatch = Stopwatch()..start(); - log('Recognizing song...'); + print('Recognizing song...'); - final fileContents = File('sample.mp3').readAsBytesSync(); - final result = await identify(fileContents); + final fileContents = File('sample.mp3'); + final result = await ShazamClient.dockerized().recognizeSong(fileContents); - log('Done in ${stopwatch.elapsedMilliseconds}ms'); - final track = result.result; - log( - track == null - ? 'No song found' - : 'Song found: ${'${track.title} - ${track.subtitle}'}', - ); - log(track!.share!.image!); + print('Done in ${stopwatch.elapsedMilliseconds}ms'); + final track = result; + print('Song found: ${'${track.title} - ${track.subtitle}'}'); + print(track.share!.image); }