Spotifyで聴いている曲を曲名,アーティスト名付きでツイートするWebアプリ

# はじめに
Spotifyという定額音楽配信サービスにて、楽曲をシェアする機能があります。が、これがいつだかのアップデートからリンクのみしか送られず、違和感を感じていました。

幸いSpotifyは多様なAPIを公開していますので、これを活用して、解消してしまおうと考えました。
（そもそもパソコンからはリンクのコピーしかできなくなっている、不便）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/75931ca5-4434-262a-1c99-335f7084d0fd.png)

　　
なお、この記事ではSpotify for Developersでの操作や、Djangoのセットアップの解説はしていません。

# 完成品
結果としてこのようなものが完成しました。実際に公開していますので、ぜひ使ってみてください。

https://soli0222.com/NowPlaying/

ジャケ写を大きく配置し、その下に曲名とアーティスト名、収録されているアルバム名を表示しています。また、背景はジャケ写にブラーをかけたものを使用しています。
![完成品](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/e9588f86-af61-4048-d314-014cd52a17e5.png)
ツイートボタンを押すと、QiitaのツイッターシェアのようにAPIから行うのではなく、公式クライアントに誘導しています。
![ツイート画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/18a7307e-4fab-ddbc-43fe-35710bf58ad8.png)

# 環境
* 言語: Python 3.11.1
* フレームワーク: Django 4.1.5
* CSSフレームワーク: django-bootstrap5 22.2
* デプロイ先: Google Compute Engine Ubuntu 22.04 LTS

Pythonで開発したかったことや、少し前にチュートリアルを触って理解が少し進んでいたこと、Ruby on Rails Tutorialを触っていたのでそれに近しいフレームワークであることからDjangoを選択しました。DBを一切使わないのでFlaskという手もありましたが、やりやすさには勝てませんでした。
また、GCEは無料枠の範疇で動かしています。お名前.comでドメイン取得にお金を払った程度です。

# 実装
簡単にですが、アプリの挙動をフローチャートにしました。このような流れで実装されています。
![フローチャート](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/8f312542-3cce-1949-0de8-939bac15ef2d.png)

## テンプレート
サイト全体において、``base.html``を基本としてその上に``xxx.html``を載せる形でレンダリングさせています。
``base.html``ではヘッダーとフッター、背景画像について主に記述してあります。
```html:base.html
{% load django_bootstrap5 %}
{% load static %}
<!DOCTYPE html>
<html lang="ja">
    <head>
        <title>{{title}}</title>
        <meta charset="utf-8">
		<meta name="viewport" content="width=device-width,initial-scale=1">
		<link rel="stylesheet" href="{% static 'css/footer.css' %}">
        {% bootstrap_css %}
        {% bootstrap_javascript %}

		<style>
			.bgImage{
				background: url({{bgImageURL}}) no-repeat center;
				background-size: cover;
				height: 100vh;
				position: relative;
				z-index: 0;
				overflow: hidden;
			}
		</style>
    </head>

    <body>
		<div class="bgImage">
		<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
			<div class="container-fluid">
        		<a class="navbar-brand" href="{% url 'general:index' %}">Soli's Works</a>
            	<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="navbar">
            	<span class="navbar-toggler-icon"></span>
            	</button>

            <div class="collapse navbar-collapse" id="navbar">
            	<ul class="navbar-nav me-auto mb-2 mb-md-0">
                	<li class="nav-item">
                  		<a class="nav-link active" href="{% url 'NowPlaying:index' %}">SpotifyNowPlaying</a>
                	</li>
                	<li class="nav-item">
                  		<a class="nav-link disabled">Blog</a>
                	</li>
                	<li class="nav-item">
            			<a class="nav-link disabled" href="{% url 'general:about' %}">About</a>
                	</li>
					<li class="nav-item">
            			<a class="nav-link disabled" href="{% url 'general:contact' %}">Contact</a>
                	</li>
              	</ul>
            </div>
      	</nav>

    	<div class="container-fluid">
        	{% block content %}
        	{% endblock content %}
      	</div>

		<footer class="footer bg-dark">
			<div class="container">
				<p class="text-muted">Soli's Works v0.0.9<br>
					©︎2023 Soli</p>
			</div>
		</footer>
		</div>
	</body>
</html>
```

## index

このページにでは、ログインボタンを実装する程度ですので、``views.py``でも``index.html``でもチュートリアルにあるようなことしか書いていません。文字がいい感じになってしまい、Bootstrapによる恩恵を噛み締めています。
```python:views.py
def index(request):
    context = {
        'title': 'Top | SpotifyNowPlaying'
    }
    return render(request, 'NowPlaying/index.html', context)
```
```html:index.html
{% extends 'base.html' %}
{% block content %}

<div class="row">
	<div class="col-sm-12 col-lg-8 col-xl-4">
		<h1 class="display-4 font-weight-bold">Tweet the song playing on Spotify!</h1>
		<h5>Song title and artist name are required when sharing on Twitter.</h5>
		<a class="btn btn-success mt-2" href="login">Login with Spotify</a>
	</div>
</div>

{% endblock content %}

```

## login
このビューではSpotifyでのログインを行うにあたって、必要な情報をまとめて、認証を行うページへのURLを生成しています。
```python:views.py
def spotify_login(request):
    auth_url = 'https://accounts.spotify.com/authorize'
    scope = 'user-read-currently-playing'
    params = {
        'client_id': settings.SPOTIFY_CLIENT_ID,
        'response_type': 'code',
        'redirect_uri': settings.SPOTIFY_REDIRECT_URI,
        'scope': scope,
    }
    return redirect(f"{auth_url}?{urlencode(params)}")
```
　
必要な環境変数はプロジェクト直下に``.env``ファイルを置き、``settings.py``において``django-envrion``を用いて読み込んでいます。ついでにDjangoにおいて開発環境と本番環境で変えたい設定や、隠したいものを持ってきています。
```.env
DJANGO_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
DEBUG=False
ALLOWED_HOSTS=soli0222.com

SPOTIFY_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
SPOTIFY_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
SPOTIFY_REDIRECT_URI=https://soli0222.com/NowPlaying/callback/

```
```python:settings.py
from pathlib import Path
import environ
import os

BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(DEBUG=(bool, False),)
environ.Env.read_env(Path.joinpath(BASE_DIR, '.env'))

SECRET_KEY = env('DJANGO_SECRET_KEY')
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')

...

SPOTIFY_CLIENT_ID = env('SPOTIFY_CLIENT_ID')
SPOTIFY_CLIENT_SECRET = env('SPOTIFY_CLIENT_SECRET')
SPOTIFY_REDIRECT_URI = env('SPOTIFY_REDIRECT_URI')
```
　
URLを生成したらそのまま、Spotify側での認証にリダイレクトするので、ここでのレンダリングはありません。認証が終わったら、Spotifyのダッシュボードで設定したリダイレクトURLに飛びます。

## callback
認証が終わったら、楽曲情報の取得などに必要なアクセストークンを取得する必要があります。
先ほどの認証から得たコードや必要な情報をまとめて、トークンURLに対してリクエストを行います。そこからアクセストークンを取り出してメインページにリダイレクトを行っています。
```python:views.py
def callback(request):
    code = request.GET.get('code')
    token_url = 'https://accounts.spotify.com/api/token'
    params = {
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': settings.SPOTIFY_REDIRECT_URI,
        'client_id': settings.SPOTIFY_CLIENT_ID,
        'client_secret': settings.SPOTIFY_CLIENT_SECRET,
    }
    response = requests.post(token_url, params)
    data = json.loads(response.text)
    access_token = data['access_token']
    request.session["access_token"] = access_token

    return redirect(reverse('NowPlaying:home'))
```

ここではアクセストークン取得のための処理を行ったらメインページにリダイレクトしてしまうので、レンダリングはありません。

## home
ここがアプリのメインページとなる部分です。
### バックエンド
前に添付したフローチャートでは省いていますが、ここで多くの例外処理や取得したデータの加工を行なっており、homeだけでのフローチャートはこのようになっています。
![works_home.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/b41dcba9-9195-8e1e-00a3-3eaa06148da2.png)
これに沿って実装するとこのようになります。
```python:views.py
def home(request):
    try:
        access_token = request.session["access_token"]
        headers = {'Authorization': 'Bearer ' + access_token, 
                    'Accept-Language': 'ja'}
    except:
        return redirect(reverse('NowPlaying:login'))

    
    track_data = requests.get('https://api.spotify.com/v1/me/player/currently-playing', headers=headers)
    track_error = processStatusCode(track_data.status_code) + " - track_data"
    if track_data.status_code == 200:
        track_data = track_data.json()
        track_data, jacket_url = makeTrack(track_data)
    elif track_data.status_code == 401:
        return redirect(reverse('NowPlaying:login'))
    else:
        jacket_url = None

    if track_data == 1:
        track_error = 'Podcast is playing.'


    user_data = requests.get('https://api.spotify.com/v1/me', headers=headers)
    user_error = processStatusCode(user_data.status_code) + " - user_data"
    if user_data.status_code == 200:
        user_data = user_data.json()
        user_data = makeUser(user_data)

    context = {
        'title': 'Home | SpotifyNowPlaying',
        'track_data': track_data,
        'bgImageURL': jacket_url,
        'user_data': user_data,
        'track_error': track_error,
        'user_error': user_error,
    }

    return render(request, 'NowPlaying/home.html', context)
```
ここで、``processStatusCode``や``makeTrack``, ``makeUser``と自作のモジュール絡みの関数が組み込まれています。

``processStatusCode``はこのようになっています。得たステータスコードからどういったエラーが発生しているのか文言を加えて返すというものになっています。これをリスト``context``に含めてテンプレートに渡すことで、エラーメッセージの表示を実現しています。
例外処理の一部ですが、このうち``204``というエラーは楽曲を再生していない場合にあたり、かなり頻発します。テンプレート側でもありますが、このときの処理に地味に振り回されました。

```python:getStatusCode.py
def processStatusCode(code):
    if code == 204:
        return "204: No Content"
    elif code == 304:
        return "304: Not Modified. See Conditional requests."
    elif code == 400:
        return "400: Bad Request"
    elif code == 401:
        return "401: Unauthorized "
    elif code == 403:
        return "403: Forbidden"
    elif code == 404:
        return "404: NotFound"
    elif code == 429:
        return "429: Too Many Requests - Rate limiting has been applied."
    elif code == 500:
        return "500: Internal Server Error. "
    elif code == 502:
        return "502: Bad Gateway"
    elif code == 503:
        return "503: Service Unavailable"
    else:
        return "Succeeded"
```
　　
``makeTrack``, ``makeUser``はこのようになっています。
Spotifyから得られる楽曲情報はかなり多く、使う情報を適切に取り出す必要があります。これをビューに書いてしまうとかなり見た目が良く無いので、別にモジュールとして分けました。
ポッドキャストかどうかの分岐をし、各種必要な情報をリストにまとめて返しています。また、ログインしているSpotifyのユーザーネームやアイコンを表示させているのでこれの処理を行なっています。
```python:editJSONData.py
def makeTrack(current_playing):
    if current_playing is None:
        return 0
    elif current_playing["currently_playing_type"] == "episode":
        return 1


    track_name = current_playing['item']['name']
    artist_name = current_playing['item']['artists'][0]['name']
    track_url = current_playing['item']['external_urls']['spotify']

    album_name = current_playing['item']['album']['name']
    album_artist = current_playing['item']['album']['artists'][0]['name']
    album_url = current_playing['item']['album']['external_urls']['spotify']
    jacket_url = current_playing['item']['album']['images'][0]['url']

    count=1
    while(True):
        try:
            artist_name+=", "+current_playing['item']['artists'][count]['name']
            count+=1
        except IndexError:
            break

    count=1
    while(True):
        try:
            album_artist+=", "+current_playing['item']['album']['artists'][count]['name']
            count+=1
        except IndexError:
            break

    track_data = {
        'track_name': track_name,
        'artist_name': artist_name,
        'track_url': track_url,
        'album_name': album_name,
        'album_artist': album_artist,
        'album_url': album_url,

    }
    
    return track_data, jacket_url

def makeUser(user_data):
    user_data = {
        'name': user_data['display_name'],
        'icon': user_data['images'][0]['url'],
    }
    
    return user_data
```
### フロントエンド
続いて、テンプレートはこのようになっています。前述した楽曲を再生していない場合、ジャケットに当たる部分が空白になります。そのために、テンプレート内でもif文を記述し、ジャケ写がない場合は代替の画像を表示するようにしています。
また、エラーが発生しているときはそのままエラーメッセージを表示するように2つ目のif文を記述しています。

```html:home.html
{% extends 'base.html' %}
{% load static %}
{% block content %}

<link rel="stylesheet" href="{% static 'css/NowPlaying/home.css' %}">
<div class="row">
        <div class="d-flex justify-content-end">
            <button class="btn btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
                <img src={{ user_data.icon }} id="icon" alt="{{ user_data.name }}">
                {{ user_data.name }}
            </button>
            <ul class="dropdown-menu dropdown-menu-dark">
                <li><a href="../logout" class="dropdown-item" href="#">Logout</a></li>
            </ul>
        </div>

        <div class="container text-center" style="margin-top:5px">
            {% if bgImageURL is None%}
                <img src="{% static "NowPlaying/l_e_others_501.png"%}" class="img-fluid" alt="jacket picture is None">
            {% else %}
                <img src="{{ bgImageURL }}" class="img-fluid" alt="jacket picture({{ track_data.album_name }})">
            {% endif %}

            {%if track_error == "Succeeded - track_data" and user_error == "Succeeded - user_data" %}
                <h1>{{ track_data.track_name }} / {{ track_data.artist_name }}</h1>
                <h2>{{ track_data.album_name }}</h2>

                <a href="https://twitter.com/share?url={{ track_data.track_url }}&text={{ track_data.track_name }} / {{ track_data.artist_name }}%0a%23NowPlaying%0a"
                    class="btn btn-primary" role="button" target="_blank">
                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-twitter" viewBox="0 0 16 16">
                        <path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"/>
                    </svg> Track</a>
                <a href="https://twitter.com/share?url={{ track_data.album_url }}&text={{ track_data.album_name }} / {{ track_data.album_artist }}%0a%23NowPlaying%0a"
                    class="btn btn-primary" role="button" target="_blank">
                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-twitter" viewBox="0 0 16 16">
                        <path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"/>
                    </svg> Album</a>
            {% else %}
                <h3>{{ track_error }}</h3>
                <h3>{{ user_error }}</h3>
                <a href="" class="btn btn-primary" role="button">Reload</a>
            {% endif %}
        </div>
</div>
{% endblock content %}
```
　
また、このページのみ背景画像に対してぼかしをかけるという処理を行なっています。これはCSSによって実現されており、このように実装しています。
```css:home.css
.bgImage:before{
    content: '';
    background: inherit;
    -webkit-filter: blur(70px);
    -moz-filter: blur(70px);
    -o-filter: blur(70px);
    -ms-filter: blur(70px);
    filter: blur(70px);
    position: absolute;
    top: -200px;
    left: -200px;
    right: -200px;
    bottom: -200px;
    z-index: -1;
    transform: translateZ(0);
}

#icon {
    width: 30px;
    border-radius: 50%;
}

h1{
    mix-blend-mode: difference;
    color:#fff;
}

h2{
    mix-blend-mode: difference;
    color:#fff;
}
```
　
おおまかですが、メインページはこのように実装されています。

## logout
ログアウトの処理はこのようにしています。各種リソースの削除やセッションのリフレッシュを行なっています。
```python:views.py
def spotify_logout(request):
    access_token = request.session["access_token"]
    headers = {
        "Authorization": f"Bearer {access_token}",
    }
    response = requests.delete("https://accounts.spotify.com/api/token", headers=headers)
    request.session.flush()
    return redirect(reverse('NowPlaying:index'))
```
 
これでアプリの一連の流れが終わります。


# まとめ
SpotifyのWeb APIを利用して、ログインから楽曲情報の取得を行い、それをDjangoを利用して表現することができました。
PythonでSpotify APIの利用をするとき、多くの記事はライブラリであるSpotipyを利用したものが多くあり、httpリクエストによるものがあまりないので、参考になれば幸いです。

## 今後の課題
* テキストが見辛い場合があるので、テキストの枠をいい感じにして視認性を向上させる
* 他SNSへのシェアの対応
* 自サイトのコンテンツの一部としているので、他のコンテンツも実装する

# 参考文献

https://qiita.com/moonwalkerpoday/items/9da47dda80d3ddac9a62