Initial commit

This commit is contained in:
Antoine Martin 2024-06-17 23:09:26 +02:00
commit 3818e91ef8
6 changed files with 2196 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1999
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "spotify-bpm-filter"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rspotify = { version = "0.13", features = ["cli"] }
tokio = { version = "1.38", features = ["full"] }
futures = "*"
futures-util = "*"

61
flake.lock Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1718208800,
"narHash": "sha256-US1tAChvPxT52RV8GksWZS415tTS7PV42KTc2PNDBmc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "cc54fb41d13736e92229c21627ea4f22199fee6b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

29
flake.nix Normal file
View file

@ -0,0 +1,29 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
cargo
clippy
rustPackages.clippy
rustc
rustfmt
rust-analyzer
openssl
pkg-config
];
RUST_SRC_PATH = pkgs.rustPlatform.rustLibSrc;
};
});
}

92
src/main.rs Normal file
View file

@ -0,0 +1,92 @@
use futures::stream::TryStreamExt;
use futures_util::pin_mut;
use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth};
const SPOTIFY_WEB_API_ADD_PLAYLIST_MAX_ID_NUM: usize = 100;
const SPOTIFY_WEB_API_TRACK_FEATURES_MAX_ID_NUM: usize = 100;
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 4 {
eprintln!("usage: {} PLAYLIST_TITLE MIN_TEMPO MAX_TEMPO", args[0]);
std::process::exit(1);
}
let playlist_title = &args[1];
let min_tempo = args[2].parse::<u64>().unwrap();
let max_tempo = args[3].parse::<u64>().unwrap();
let tempo_range = min_tempo..=max_tempo;
// export RSPOTIFY_CLIENT_ID="your client_id"
// export RSPOTIFY_CLIENT_SECRET="secret"
//
// See https://developer.spotify.com/documentation/web-api/tutorials/getting-started#create-an-app
let creds = Credentials::from_env().unwrap();
let oauth = OAuth {
redirect_uri: "http://localhost:8888/callback".to_string(),
scopes: scopes!("playlist-modify-private", "user-library-read"),
..Default::default()
};
let spotify = AuthCodeSpotify::new(creds, oauth);
// Obtaining the access token
let url = spotify.get_authorize_url(false).unwrap();
// This function requires the `cli` feature enabled.
spotify.prompt_for_token(&url).await.unwrap();
let user = spotify.current_user().await.unwrap();
eprintln!("Collecting saved tracks...");
let stream = spotify.current_user_saved_tracks(None);
pin_mut!(stream);
let mut track_ids = Vec::new();
while let Some(saved_track) = stream.try_next().await.unwrap() {
track_ids.push(saved_track.track.id.unwrap());
}
eprintln!("Fetching track features...");
let mut features = Vec::new();
for chunk in track_ids.chunks(SPOTIFY_WEB_API_TRACK_FEATURES_MAX_ID_NUM) {
// FIXME: why does rspotify return a Result<Option<Vec<Features>>>? I think this should be a
// Result<Vec<Option<Features>>>, the Spotify API returns `null` for a single track if the
// provided Id isn't valid
if let Ok(Some(mut features_chunk)) = spotify.tracks_features(chunk.iter().cloned()).await {
features.append(&mut features_chunk);
}
}
// if this fails then Spotify probably did not return some track's features, we can't rely on
// features and ids having the same index anymore!
assert_eq!(track_ids.len(), features.len());
let mut ids: Vec<PlayableId<'_>> = track_ids
.into_iter()
.zip(features.iter())
.filter(|(_, features)| {
let tempo = features.tempo.round() as u64;
let doubled_tempo = tempo * 2;
tempo_range.contains(&tempo) || tempo_range.contains(&doubled_tempo)
})
.map(|(id, _)| PlayableId::from(id))
.collect();
eprintln!("Creating playlist...");
let new_playlist = spotify
.user_playlist_create(user.id, playlist_title, Some(false), None, None)
.await
.unwrap();
while !ids.is_empty() {
// NOTE: Can't use ids.chunks() because PlayableId isn't Clone :(
//
// (chunks doesn't need Clone but playlist_add_items needs IntoIterator<Item = PlayableId>)
let ids_chunk = ids.drain(0..(SPOTIFY_WEB_API_ADD_PLAYLIST_MAX_ID_NUM.min(ids.len())));
spotify
.playlist_add_items(new_playlist.id.clone(), ids_chunk, None)
.await
.unwrap();
}
}