手持ちの音楽ファイルからアニソンのプレイリストを自動的に作れるようにした話

# はじめに
　私はCDから音楽(主にアニソン)を取り込みwalkmanで聴いているのですが，いつの間にか手持ちのファイルが8000曲を超えてしまいました．いざプレイリストを作ろうと考えても，**お気に入りのアニソンをプレイリストに放り込むのは非常に手間がかかり**，アーティスト一覧からタイアップ情報を調べて曲を追加して...などとしているうちに**休日が終わってしまいます**．(SpotifyやApple Musicなどを使用している人には関係ない話だと思いますが…)
　こういった作業を楽しめれば良いのですが，プレイリストを作りかけては飽きるということを繰り返しているうちに，**私はそもそもプレイリスト作成作業に向いていない**ことが分かりました．幸い，**私はプレイリストの作成はできませんがプログラムは書ける**ということに気づき，自動的にアニソンのプレイリストを作ってくれるプログラムを作ったのですが，うまくいかない点が多かったので情報共有も兼ねて記事にしました．


# 実装したもの
　前置きが長くなりましたが，今回作成したプログラムは**手持ちの音楽ファイル(m4a, mp3, flacファイル)**から**アニソンのプレイリスト(m3uファイル)**を作ってくれるプログラムです．
処理の内容は
1. csvファイルからアニソンの情報を取得
2. PCに保存されてる音楽ファイルから曲情報を取得
3. 1.と2.で登録したデータを比較し，一致したものをプレイリスト(m3uファイル)として出力
というシンプルなもので，以下のようなGUIも作成しました．(GUI部分については今回の記事では省略します．)
<img width="400" alt="apg_ui.png" src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504018/acaa32f2-d3af-6c8b-195c-685b959410a2.png>

　任意のパスを指定して実行ボタンを押せば，パソコンに保存されているアニソンを抽出してプレイリストファイルを出力してくれます．作品名を指定することで，特定のアニメを指定してプレイリストを作ることも可能です．ソースコードと実行ファイル(Windows用)は[GitHub](https://github.com/temple1026/AnisonPlaylistGenerator)に置いてあるので，併せて確認していただければ幸いです．

# プログラムの流れ
　プレイリスト生成プログラムの根幹となるコードは，githubで公開しているコードの中のapg.pyです．PyQt5で実装したGUI用のコードapg_gui.pyも実装しましたが，こちらはapg.pyで定義したクラスを呼び出しているだけなので，apg.pyだけあればプレイリストを作成することができます．
　apg.pyでは**A**nison **P**laylist **G**enerator略してAPGクラスというものを定義しており，上記実装したものの1.から3.に対応する関数を持っています．以下ではそれぞれの処理内容について説明します．

## csvファイルからアニソンの情報を取得
　apg.pyの中の関数MakeAnisonDatabaseについて説明します．ここではアニソンの情報をデータベースに保存します．データベースを使った理由は，プレイリストを作るたびにアニソンのデータや手持ちの音楽の取得をするのは無駄で，一度データベースを作ってしまえば2回目以降のプレイリスト作成にかかる時間を削減できるかなと考えた結果です．とりあえず動けばいいので，データベースの構造は深く考えずに実装してます(単純にデータベースの知識がないだけですが…)．使用したライブラリはsqlite3です．具体的なコードは以下の通りです．
　まず，AnisonGeneration様よりDLしたcsvファイルのパスを取得します．DLするzipファイルは3つあり，アニメであればanison.csv, ゲームであればgame.csv，特撮であればsf.csvというファイルに保存されています (本来の目的はアニメですが，せっかくなのでゲーム，特撮も使用します)．解凍した状態のフォルダをdataフォルダに配置すると以下のようになります．

```

./data/ --- anison/ -- anison.csv # アニメ
         |          └ readme.txt
         ├ game/ --- game.csv    # ゲーム
         |         └ readme.txt
         └ sf/--- sf.csv         # 特撮
               └ readme.txt
```

それぞれのフォルダそれぞれにはテキストファイルも含まれていますが，以下のコードを実行することで，解凍したフォルダをそのまま./dataに入れてもcsvファイルだけが取得できます．

```python

path_data = "./data" # Anison Generation様からDLしたcsvファイルを保存してあるフォルダ
file_paths = [i for i in glob.glob(path_data + "/**", recursive=True) if os.path.splitext(i)[-1] == ".csv"] # csvファイルのパスだけを再帰的に取得する
```
次にcsvファイルの中の情報をデータベースに登録していきます．目的のcsvファイルはanison.csv, game.csv, sf.csvの3つだけなので，それ以外のファイルを読まないようにします．以下のコードではanison, game, sfというテーブルを作成し，1000行ずつデータベースに登録します．(どこかのサイトを参考にしたのですが，あってもなくても変わらない気がします．)

```python

data_name = ["anison.csv", "game.csv", "sf.csv"] # AnisonGeneration様からDLしたcsvファイルの名前
for file_path in file_paths:
       if os.path.basename(file_path) in data_name: # csvファイルの名前がanison.csv, game.csv, sf.csvのいずれかなら
            category = os.path.splitext(os.path.basename(file_path))[0]

            # anison, game, sfという名前のテーブルを作成する
            with sqlite3.connect(self.path_database)as con, open(file_path, "r", encoding="utf-8") as f:
                cursor = con.cursor()
                # テーブルが存在しなかったら作成
                cursor.execute("CREATE TABLE IF NOT EXISTS '%s'('%s', '%s', '%s', '%s', '%s', '%s')" % (category, "artist", "title", "anime", "genre", "oped", "order"))

                command = "INSERT INTO " + category + " VALUES(?, ?, ?, ?, ?, ?)" # SQL文の定義
                
                lines = f.readlines() # csvファイルの読み込み
                buffer = []           # データをまとめて登録するための変数
                buffer_size = 1000    # 100件まとめて登録する

                for i, line in tqdm(enumerate(lines[1:])): # csvファイルを一行ずつ読む
                   *keys, = line.split(",")  # 各行のキーを取得
                    # 歌手名，曲名，放送順，OPEDの種類，アニメ名，ジャンルをキーとして取得
                    artist, title, order, oped, anime, genre = trim(keys[7]), trim(keys[6]), trim(keys[4]), trim(keys[3]), trim(keys[2]), trim(keys[1]) 
                        
                    buffer.append([artist, title, anime, genre, oped, order])

                    if i%buffer_size == 0 or i == len(lines) - 1:
                        cursor.executemany(command, buffer) # SQL実行
                        buffer = []
                

                # 重複して登録されているデータを削除
                cursor.executescript("""
                    CREATE TEMPORARY TABLE tmp AS SELECT DISTINCT * FROM """ + category + """;
                    DELETE FROM """ + category + """;
                    INSERT INTO """ + category + """ SELECT * FROM tmp;
                    DROP TABLE tmp;
                    """)

                con.commit()
```
trimという関数は，データベースやプレイリストの作成時に使うのが好ましくない文字を他の文字に置き換える関数で，改行やカンマなどの文字列を消去しています．また，全角と半角の違いでもプレイリスト作成時に問題がある場合があったので，mojimojiライブラリで半角文字に変換できる文字は全て半角文字に変更しています．

```python

import mojimoji as moji

def trim(name):
    name = name.replace("\n", "").replace('\'', '_').replace(" ", "").replace("\x00", "").replace("\"", "")
    name = moji.zen_to_han(name)
    return name.lower()
```

次はPCに保存されている音楽ファイルの情報を取得し，データベースに登録していきます．

## PCに保存されてる音楽ファイルから曲情報を取得
　プレイリストファイルであるm3uファイルの書式は以下の通りで，曲ごとに曲の長さ，曲名，音楽ファイルまでのパスが必要になります．

```
 #EXTM3U
 #EXTINF: 曲の長さ，曲名
 ファイルまでの絶対パス
 #EXTINF: 曲の長さ，曲名
 ファイルまでの絶対パス
　       :
```

関数MakeMusiclibraryでは，音楽ファイルのパスを取得して，アニソンデータと同様に音楽ファイルをデータベースに登録していきます．アニソンのデータを取得する処理が音楽ファイルに変わっただけで，基本的な処理は↑の処理とあまり変わりません．音楽ファイルの情報を取得するために以下の関数を定義しました．

```python
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4

def getMusicInfo(path):
    length, audio, title, artist = 0, "", "", ""
    
    if path.endswith(".flac"):
        audio = FLAC(path)
        artist = trim(audio.get('artist', [""])[0])
        title = trim(audio.get('title', [""])[0])
        length = audio.info.length

     elif path.endswith(".mp3"):
        audio = EasyID3(path)
        artist = trim(audio.get('artist', [""])[0])
        title = trim(audio.get('title', [""])[0])
        length = MP3(path).info.length
        
    elif path.endswith(".m4a"):
        audio = MP4(path)
        artist = trim(audio.get('\xa9ART', [""])[0])
        title = trim(audio.get('\xa9nam', [""])[0])
         length = audio.info.length
        
    return audio, artist, title, length

```
関数getMusicInfoでは，ファイルのパスから曲名，歌手名，曲の長さ，を取得しています．
音楽ライブラリをデータベースに登録するファイルは以下の通りです．歌手名とタイトルについてはtrim関数で不都合な文字を変換をしました．

```python

    def makeLibrary(path_music):
        music_files = glob.glob(path_music + "/**", recursive=True) # ライブラリ内のファイルをすべて取得
        
        # libraryという名前のテーブルを作成し，音楽ファイルを登録する
        with sqlite3.connect(self.path_database) as con:
            cursor = con.cursor()
            # libraryテーブルがなければ作成する
            cursor.execute("CREATE TABLE IF NOT EXISTS library(artist, title, length, path)")
            # SQL文
            command = "INSERT INTO library VALUES(?, ?, ?, ?)"

            buffer = []
            for i, music_file in tqdm(enumerate(music_files)):
                audio, artist, title, length = getMusicInfo(music_file) # パスから音楽ファイルの情報を取得する
                
                if audio != "":

                    buffer.append(tuple([trim(artist), trim(title), length, music_file]))

                    if i % 1000 == 0 or i == len(music_files) - 1:
                        cursor.executemany(command, buffer)
                        buffer = []
          
            cursor.executescript("""
                CREATE TEMPORARY TABLE tmp AS SELECT DISTINCT * FROM library;
                DELETE FROM library;
                INSERT INTO library SELECT * FROM tmp;
                DROP TABLE tmp;
                """)
            
            con.commit()

```
次にこれまでのデータを使ってプレイリストを出力する関数について説明します．

## プレイリスト(.m3uファイル)を出力する
　ここでの説明はapg.py内の関数generatePlaylistについてになります(説明用に色々と省略してますが…)．まずデータベースに登録したアニソン情報から，歌手名を取得します．SQL文と実行部分は以下の通りです．categoryはテーブル名で，anison, game, sfのいずれかが入ります．取得した歌手名一覧は適当な変数に代入しておきます．(データベースへのアクセスは↑のプログラムと同じなので省略してます) 

```python
cursor.execute("SELECT DISTINCT artist FROM '%s'" % category)
artist_db = cursor.fetchall()
```

同様に音楽ライブラリ内の歌手名を取得します．

```python

cursor.execute('SELECT artist FROM library')
artist_lib = sorted(set([artist[0] for artist in cursor.fetchall()]))
```

　次に音楽ライブラリの歌手名がアニソンデータベースの歌手名リストの中に存在するか，すなわち音楽ライブラリの歌手がアニソンを歌った可能性があるかどうかを調べます．アニソン特有の文化だと思うのですが，CDから音楽を取り込んだときに歌手名がキャラクター名(cv. 声優名)になっている場合があります．一方でアニソンデータベースの歌手リストは(恐らく)声優名で登録されているので，歌手名の完全一致で検索することができません．そこで，音楽ライブラリの歌手とアニソンデータベースの歌手の類似度を調べ，最も類似度が高く，かつ最大の類似度が閾値を超えている歌手名をアニソンを歌っている可能性がある歌手としました．
コード(apg.py)内では次の部分に該当します．

```python
# ライブラリ内のすべてのアーティストに対して
for i, artist in artist_lib:
    # アーティスト名の類似度を計算
    similarities = [difflib.SequenceMatcher(None, artist, a[0]).ratio() for a in artist_db]

    if th_artist < max(similarities): # 類似度の最大値が閾値以上の場合
        # 対象アーティストの楽曲情報をすべて取得
        info_list = self.getInfoDB('SELECT * FROM library WHERE artist LIKE \'' + artist + '\'', cursor)
```

　歌手を特定できた後は，音楽ライブラリ内のその歌手の楽曲情報をすべて取得し，音楽ライブラリの中の楽曲でアニソンのものを調べます．歌手名で完全一致が使えないということは，楽曲のタイトルも完全一致が使えないという問題もあります．例えば，楽曲名(album ver.) のような楽曲は完全一致で検索に引っかかりません．そこで，楽曲リストも同様に類似度を調べます．

```python

        # ↑のif文の続きです
        # 類似度が最も高かった歌手の楽曲をアニソンデータベースからすべて取得
        cursor.execute("SELECT DISTINCT * FROM '%s' WHERE artist LIKE \'%%%s%%\'" % (category, artist_db[similarities.index(max(similarities))][0]))
        title_list = cursor.fetchall() # アニソンデータベースに含まれる，特定のアーティストの楽曲リスト

        for info in info_list: # 音楽ライブラリに含まれるすべての楽曲に対してアニソンかどうかを調べる
            artist, title, length, path = info[0], info[1], info[2], info[3]                         

            title_ratio = [difflib.SequenceMatcher(None, title, t[1]).ratio() for t in title_list] # アニソンデータベース内の楽曲との類似度を計算
 
            if th_title < max(title_ratio): # 類似度が閾値以上ならアニソン
                t = title_list[title_ratio.index(max(title_ratio))]
                lines.append(['#EXTINF: ' + str(int(length)) + ', ' + title + "\n" + path, t[-1]]) # .m3uファイルの出力用の変数(リスト)に追加

```

全てのアーティストの類似度と楽曲の類似度を計算したら，linesという変数に入っているアニソンの情報をファイルへ書き出します．

```python
path_playlist = "./playlist/AnimeSongs.m3u"

with open(path_playlist, 'w', encoding='utf-16') as pl:
    pl.writelines('#EXTM3U \n')
    pl.writelines("\n".join([line[0] for line in lines]))

```

これで，アニソンのプレイリストが作成できました．説明のために，githubで公開しているコードとは異なっている部分があるので，興味がある方はgithubのapg.pyを確認していただければ幸いです．

# 問題点
　3つの機能を作成したことでプレイリストの作成は一応可能になったのですが，今回の実装で発生したいくつかの問題点について触れておこうと思います．

## アニソンじゃない曲もアニソンとして追加される
　現状，プレイリスト作成時にアニソンではない楽曲もプレイリストに入ってしまう問題があります．例えば，BLEACHというアニメを作品名として設定した場合，UVERworldのD-technolifeの他に，D-technorizeという非常に似ている楽曲がプレイリストに追加されてしまいます．他にも，ガンダムUCのプレイリストを作ろうとした場合には，AimerのRe:I amの他にRe:far, Re:prayという曲もガンダムUCのプレイリストに含まれてしまいます．こういった作品単体の場合は楽曲の類似度の閾値を上げることで対応可能ですが，楽曲の閾値はアニメによって異なるので，特定のアニメの限らないアニソンのプレイリストを作成するのは現状困難となっています．次の問題点とも関係しているかと思いますが，良い方法が思いついたら改善したいと思います．

## プレイリスト生成の処理が遅い
　現状データベースがただのデータ一時保管場所になってしまっており，最後はfor文で処理をすることになってます．もっとスマートにSQLを書けたらプレイリストの作成時間が減る気がするので，今後データベースが関わるコードは書き直したいと思います．

# まとめ
　アニソンのプレイリストを作るのがきつすぎて，自動でアニソンのプレイリストを作成してくれるソフトウェアを作成しました．まだまだ問題点はありますが，一応最低限の機能を備えたソフトウェアができたのではないかと思います．振り返ってみれば，**コーディングにかけた時間が地道にプレイリストを作る時間をはるかに超える**という結果になってしまいましたが，同じような悩みを抱えている人を救えたらいいなと思いました．
　最後になりますが，間違いや改善点等(特にデータベース関連の処理について)がありましたらコメントをいただければ幸いです．最後まで読んでいただきありがとうございました．

# 更新履歴
2020/03/04 公開