10月 032017
 

(2017/10/08 機能拡張版を書きました)

Pythonスクリプトによってプレイリストファイル(m3u/m3u8)を自動生成する話です。自分がすぐに使えるよう、Pythonと正規表現でサクッと作りましたが、そういった技術的要素が理解できないと利用は厳しいことは先に言っておきます。

動機

音楽ファイルの管理って結構悩ましい問題だと思っています。私は階層構造の整理とか自分でやりたいので、iTunesに全部おまかせみたいなことは嫌っていて、あらゆる音楽ソフトのライブラリは一切使用していません。

自分で整理した音楽ファイルを「バックアップと同期」(旧「Googleドライブ」)によってクラウドストレージ上に反映し、それをスマートフォン(Android)からFolderSyncで取得することで、スマートフォンにもそのままの階層構造を維持して音楽ファイルを利用できるようにしています。

きちんと整理していれば、PC上では好きな曲をその場で再生リストにぶち込むことに特に不便を感じることはないのですが、そのような頻繁なリスト操作はスマートフォン上では厳しく、プレイリストを作っておきたくなります。

で、どうせならプレイリストも同期してしまいたいです。

プレイリストの仕様

PCとスマートフォンの両方でそのまま使えるプレイリストがほしいので、下記条件を満たす必要があります。

  • MusicBee(PC用音楽プレイヤー)とPoweramp(Android用音楽プレイヤー)で利用可能
  • 環境が異なると絶対パスは変わるので、相対パスで曲を指定
  • 日本語もきちんと扱いたい(UTF-8対応)

最もシンプルで有名なプレイリストのフォーマットとしてm3uがあります(何の略なんでしょう?)。

基本的には、テキストファイルで改行区切りでパスを列挙するだけの簡単な仕様です。

プレイヤー側が対応していれば、パス指定を相対パスにしても問題ありません。私が利用しているMusicBeeとPowerampは相対パス対応でした。対応状況はよくわからないと思うので、試しに作って再生できるか動作確認するのが手っ取り早いでしょう。

m3uは#を開始文字としてコメントを記述することもできます。これを利用した拡張m3u仕様として、曲のタイトルや長さといったメタ情報を記述するフォーマットも存在します。今回はローカルのファイルを扱うので、メタ情報については不要と判断し扱わないことにします。

仕様としてはざっとそんなところですが、m3uには特に文字エンコーディングに関する決まりがありません。なので、UTF-8であることを明示するために、UTF-8で記述されたプレイリストを拡張子.m3u8で保存する文化があります。BOMはつけた方が良さそうです。改行コードはCR+LFで特に困ってません。

m3uの編集

m3uの編集に関して、PC上でよく利用しているソフトでは、

  • MusicBeeはプレイリストのm3u/m3u8出力が可能だが絶対パスになる
  • Mp3tagはm3u/m3u8の入出力に相対パスで対応するが、常にソートされるので順序が自由でない

といった感じの対応状況でした。なんか惜しい。

かと言って、テキストファイルを直接編集したり、絶対パスから相対パスに置換したりするのは、手間が増えていて継続しないなと思いました。音楽ファイルの名前を変えたりしたことを考えるとすぐ破綻するのは容易に想像できます。

というわけで、初期設定だけ済ませればあとは良い感じにパスを取得してプレイリストを更新してくれるスクリプトを考えます。

m3uの自動生成

m3uファイルのコメント機能を利用して、自動生成ルールを記述します。これを読み込んでプレイリストを更新するスクリプトを実装しました。なので、正確にはm3uファイルそのものの自動生成はせず、中身の自動生成をしている、ということになります。自動生成ルールだけ記述したプレイリストファイルを先に作っておく必要はあります。

自動生成ルールが読み取れなかったときは、そのままの内容を維持するので、自動更新したくないものとの共存も可能です。

スクリプトの保存した場所をカレントディレクトリとし、それ以下のプレイリストファイルが更新対象となります。

自動生成ルール

自動生成ルールは1行目に次のように指定します。

#rule:{ルール}

{ルール}部分はJSONで記述します。下記4つのパラメータに正規表現を与えて、対象・除外のルールとします。

  • includeDir: 対象ディレクトリ。省略するとすべて対象。
  • excludeDir: 除外ディレクトリ。対象ディレクトリから除外したいものを指定。
  • include: 対象ファイル。省略するとすべて対象。
  • exclude: 除外ファイル。対象ファイルから除外したいものを指定。

私はアルバム名をディレクトリ名とし、ディスク番号とトラック番号と曲名をファイル名に利用しています。その上で、例えばシンフォギアのカラオケ版でないボーカル曲だけ欲しかったら、次のようにします。

#rule: { "includeDir": [ ".*シンフォギア" ], "excludeDir": [ ".*サウンドトラック.*" ], "exclude": [ ".*off vocal.*" ] }

プレイリストの順序はパスに依存します。ソートオプションも用意しようかと考えてますが、ひとまずこのシンプルな仕様でいいかなと思ってます。

実装

最後にPython3による実装を示します。

#!python3
# -*- coding: utf-8 -*-

'''
m3u/m3u8ファイルに記述した自動生成ルールをもとにプレイリストを更新するスクリプト
'''

mediaExtensions = [
    '.wav',
    '.mp3',
    '.m4a',
    '.ogg',
    '.flac',
]

playlistExtensions = [
    '.m3u',
    '.m3u8',
]

prefix = '#rule:'

import os, sys
import json
import re
import traceback

def modifyPath(path):
    path = path.replace('\\', '/')
    return path

def hasExtension(name, extensions):
    name = name.lower()
    base, extension = os.path.splitext(name)
    return extension in extensions

def isPlaylist(name):
    return hasExtension(name, playlistExtensions)

def isMedia(name):
    return hasExtension(name, mediaExtensions)

def getRule(header):
    if not header.lower().startswith(prefix):
        return None
    rule = header[len(prefix):].strip()
    if len(rule) == 0:
        return {}
    return json.loads(rule)

def getParam(rule, key, defaultParam=None):
    return rule[key] if key in rule else defaultParam

def match(texts, patterns):
    if type(texts) is not list:
        texts = [texts]
    if type(patterns) is not list:
        patterns = [patterns]
    for text in texts:
        for pattern in patterns:
            if re.match(pattern, text):
                return True
    return False

def getList(basePath, rule):
    includeDirs = getParam(rule, 'includeDir', [])
    excludeDirs = getParam(rule, 'excludeDir', [])
    includeFiles = getParam(rule, 'include', [])
    excludeFiles = getParam(rule, 'exclude', [])

    playlist = []

    for root, dirs, files in os.walk(basePath):
        dirNames = os.path.relpath(root, basePath)
        dirNames = list(os.path.split(dirNames))

        if len(includeDirs) > 0 and not match(dirNames, includeDirs):
            continue
        if len(excludeDirs) > 0 and match(dirNames, excludeDirs):
            continue

        for file in files:
            if not isMedia(file):
                continue
            if len(includeFiles) > 0 and not match(file, includeFiles):
                continue
            if len(excludeFiles) > 0 and match(file, excludeFiles):
                continue
            path = os.path.join(root, file)
            path = os.path.relpath(path, basePath)
            path = modifyPath(path)
            playlist.append(path)

    return playlist

def updatePlaylist(path):
    # ルール取得
    with open(path, encoding='utf_8_sig') as file:
        content = file.read()
        header = content.splitlines()[0]
        rule = getRule(header)
        if rule is None:
            print('No rule: {}'.format(path))
            return

    # プレイリスト取得
    print('Process: {}'.format(path))
    print('  Rule: {}'.format(rule))
    root = os.path.dirname(path)
    playlist = getList(root, rule)

    # プレイリスト出力
    contents = []
    contents.append(header)
    for record in playlist:
        contents.append(record)
    contents = '\n'.join(contents)
    with open(path, 'w', encoding='utf_8_sig') as file:
        file.write(contents)

def main():
    os.chdir(os.path.dirname(__file__))
    paths = []
    for root, dirs, files in os.walk('.'):
        for file in files:
            if not isPlaylist(file):
                continue
            path = os.path.join(root, file)
            path = modifyPath(path)
            paths.append(path)
    for path in paths:
        updatePlaylist(path)

if __name__ == '__main__':
    try:
        main()
    except:
        traceback.print_exc()
    input('Press Enter...')

プレイリストエディタを作ることも考えたのですが、UI実装面倒なのと、自分の目的に合っているのは自動更新だと思ったのでやめました。

コメントを残す(Markdown有効)

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

Top