🎧

RustでSpotifyのAPIから今聴いている曲の情報を取得する

2024/12/13に公開

これはLivesense Advent Calendar 2024 DAY 13の記事です。

はじめに

みなさん。音楽聴いていますか。私の場合、仕事中はだいたいSpotifyで音楽を聴いています。自分でアルバムを選んで聴くこともありますが、未知の音楽との出会いを求めて、公開されているプレイリストやSpotifyが作成したプレイリストを再生することもあります。

このような誰かが作ったプレイリストを作業中に聴き流していると「初めて聴く曲だけどいい曲だなぁ、なんて曲だろう」という風に思うことがあります。

こういう時に、後からその曲を探すのはちょっと大変です。前後に知っている曲があればすぐわかりますがそうとも限りません。Spotifyには自動再生トラックという似ている曲などを自動的に再生する機能もあり、一から聴き直すというのも難しい場合があります。

もちろんSpotifyのアプリを開けば直接確かめられますが、コードを書いている時にターミナルから離れたくないですよね。

そこで、Spotifyで今自分が再生してる曲の情報を取得するコマンドを作ることにしました。実装はRust + clap + reqwest です。

事前準備

SpotifyのAPIを利用するためには https://developer.spotify.com/dashboard からSpotifyアプリを作成する必要があります。
自分用のアプリなので redirect_uriは http://localhost:3000 にしました。なんでも良いと思いますが、このURL自体はあとで使います。

アプリを作ると client_idclient_secret が発行されるので、これらを用いてSpotifyのAPIを利用します。

コマンドの実装

コマンドには大きく分けて、認証とAPI利用の二つの処理を実装します。サブコマンドとして loginget を作成します。

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    ...

    match &cli.command {
        Commands::Login => {
            // 認証用の処理
            ...
        }
        Commands::Get => {
            // APIから曲の情報を取得し、表示する処理
            ...
        }
    }
    Ok(())
}

認証

APIを利用するためのアクセストークンを取得します。Spotifyの認証方法はいくつかありますが、今回は Authorization Code Flow を利用します。認証方法について詳しいことはSpotifyのドキュメントをご覧ください。

codeの取得

Authorization Code Flowは二段階に別れています。まず、ユーザーが明示的にブラウザでSpotifyにログインし、アプリで使用するスコープを許可しなければなりません。Authorization Code Flowのドキュメント

https://accounts.spotify.com/authorize?response_type=code&client_id={Client ID}&scope=user-read-currently-playing&redirect_uri={Redirect URL}

次のようなURLにアクセスすると、ログインを求められその後指定したURLにリダイレクトします。

http://localhost:3000/?code=*********

ブラウザでは何も表示されないはずですが、とくに問題ありません。code= に続く部分が認証に必要なコードです。これをアクセストークンを取得する際に使用します。

次のように実装しました。

let scope = "user-read-currently-playing";
open::that(format!("https://accounts.spotify.com/authorize?client_id={client_id}&response_type=code&redirect_uri={redirect_uri}&scope={scope}", client_id=conf.client_id, redirect_uri=conf.redirect_uri, scope=scope)).unwrap();

println!("enter your code:");
let mut buffer = String::new();
stdin().read_line(&mut buffer).unwrap();
let code = String::from(buffer.trim());
...

ブラウザを開くためにopenクレートを使っています。codeを手動でコンソールに入力する必要があり、若干煩わしいですが自分用なのでよしとしています。

アクセストークンの取得

アクセストークンの取得には先ほど取得したcodeとclient_idとclient_secretをBase64でエンコードした値を使います。

echo "{client_id}:{client_secret}" | base64

curlならこうなります。

curl -X POST https://accounts.spotify.com/api/token \
     -H "Authorization: Basic {エンコードした値}" \
     -d code={先ほどのcode} \
     -d redirect_uri={設定したredirect_uri} \
     -d grant_type=authorization_code

Rustでは次のように実装しました。

async fn get_access_token(
    client_id: &str,
    client_secret: &str,
    code: &str,
) -> Result<String, reqwest::Error> {
    let mut header = HeaderMap::new();
    let encoded = encode_id_secret(client_id, client_secret);
    header.insert(
        AUTHORIZATION,
        HeaderValue::from_str(&format!("Basic {}", &encoded)).unwrap(),
    );

    let client = reqwest::Client::new();
    let params = [
        ("code", code),
        ("grant_type", "authorization_code"),
        ("redirect_uri", "http://localhost:3000"),
    ];
    let res = client
        .post("https://accounts.spotify.com/api/token")
        .headers(header)
        .form(&params)
        .send()
        .await?
        .text()
        .await?;
    Ok(res)
}

responseのパースなどの処理はこのようにしています。

match get_access_token(&conf.client_id, &conf.client_secret, &code).await {
    Ok(res) => {
        let parsed: Value =
            serde_json::from_str(&res).expect("cannot parse response from api/token");
        let tokens = Tokens {
            code: buffer,
            access_token: parsed["access_token"]
                .to_string()
                .trim_matches('"')
                .to_string(),
            refresh_token: parsed["refresh_token"]
                .to_string()
                .trim_matches('"')
                .to_string(),
        };
        tokens.save();
    }
    Err(e) => return Err(e),

codeとアクセストークンをまとめてファイルに保存しています。

API

現在再生している曲の情報を取得するには /me/player/currently-playing を利用すればよいです。ドキュメント

curl "https://api.spotify.com/v1/me/player/currently-playing" \
     -H "Authorization: Bearer {アクセストークン}"

Rustでは次のように実装しています。

async fn get_now_playing(access_token: &str) -> Result<String, reqwest::Error> {
    let mut header = HeaderMap::new();
    header.insert(
        AUTHORIZATION,
        HeaderValue::from_str(&format!("Bearer {}", access_token)).unwrap(),
    );
    let client = reqwest::Client::new();
    let res = client
        .get("https://api.spotify.com/v1/me/player/currently-playing")
        .headers(header)
        .send()
        .await?;
    return match res.error_for_status_ref() {
        Ok(_) => {
            let res = res.text().await?;
            Ok(res)
        }
        Err(e) => Err(e),
    };
}

レスポンスには結構いろいろな情報が含まれています(関連のあるところだけ抜粋。Spotifyのドキュメントのページで実際に試せます。Spotifyのアカウントを持っている人はやってみてください)

{
  "item" : {
    "album" : {
      "album_type" : "album",
      "artists" : [ {
        "name" : "Steely Dan",
        "type" : "artist",
      } ],
      "name" : "Aja",
      "release_date" : "1977-09-23",
      "release_date_precision" : "day",
      "total_tracks" : 7,
      "type" : "album",
    },
    "artists" : [ {
      "name" : "Steely Dan",
      "type" : "artist",
    } ],
    "disc_number" : 1,
    "duration_ms" : 455497,
    "explicit" : false,
    "name" : "Deacon Blues",
    "popularity" : 54,
    "preview_url" : null,
    "track_number" : 3,
    "type" : "track",
  },
  "currently_playing_type" : "track",
  "actions" : {
    "disallows" : {
      "resuming" : true
    }
  },
  "is_playing" : true
}

ともかく item.nameitem.album.nameitem.artistsname(配列なので結合する)あたりを取得して表示すれば良さそうです。

というわけで、上のレスポンスをパースするメソッドは次のようになりました。

fn parse_response(response: &str) -> String {
    let parsed: Value =
        serde_json::from_str(response).expect("cannot parse response from currently-playing");
    let album = parsed["item"]["album"]["name"]
        .to_string()
        .trim_matches('"')
        .to_string();
    let title = parsed["item"]["name"]
        .to_string()
        .trim_matches('"')
        .to_string();
    let artists: Vec<String> = parsed["item"]["artists"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v["name"].to_string().trim_matches('"').to_string())
        .collect();
    format!("{} - {} ({})", title, album, artists.join(", "))
}

実行すると次のような出力になります。

Deacon Blues - Aja (Steely Dan)

複数のアーティストが紐ついている場合でも問題ありません(ただ、日本のアーティストでもアルファベット表記になってしまうようです)

今夜はブギー・バック - nice vocal / 2024 Remaster - LIFE (2024 Remaster) (Ozawa Kenji, SCHA DARA PARR)

使ってみた

まず、コマンドとして利用できるようにしておきます。

cargo build --release
sudo cp ./target/release/spotify-now-playing /usr/local/bin

後はこのコマンドをtmuxやvimから実行するだけです。

tmux

.tmux.conf に次のように書きます。

set -g status-interval 30
set-option -g status-right "#(spotify-now-playing get) #[default]"
set -g status-right-length 120

すると、ステータスバーに曲名などの情報が表示されるようになります。いい感じですね。

neovim

init.lua に次のように書きましょう。

vim.api.nvim_create_user_command("Spotify", function()
    print(vim.fn.system("spotify-now-playing get"))
end, {})

nvimを使用中に :Spotify と打ち込むだけで現在使っている曲のタイトルがわかります。

いいね😎

終わりに

早速この記事を書く間に使ってみているのですが、想像よりも便利です。いいものを作ったなと思います。

コードの全体はここに置いておきます。

Discussion