Logo wizaman's blog (legacy)

続・m3uプレイリストの自動生成

October 7, 2017
9 min read
Table of Contents

(2018/03/11 ルールにroot追加)

前回、プレイリストファイル(m3u/m3u8)を自動生成するPythonスクリプトを書いたんですが、やっぱりもう少し機能が欲しかったので拡張しました。

そこそこ複雑になってきたので、使う人が他にいるのかは知らぬ。

使い方

使い方は特に変わりないです。

  • m3u/m3u8ファイルのコメント機能を利用して、自動生成命令を記述する。
  • スクリプトの保存した場所をカレントディレクトリとし、それ以下のプレイリストファイルが更新対象となる。

自動生成命令は複数指定できるようになり、1行目じゃなくても良くなりました。複数指定したときは、順に実行されて、各命令の結果が連結されます。

自動生成命令は下記2種類があります。外部参照が増えました。自動生成ルールでも使えるパラメータが増えてます。

  • 自動生成ルール
  • 外部参照

自動生成ルール

複数の正規表現によってプレイリストに含めるパスを決定します。

#rule:{ルール}

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

  • root: 検索を開始するディレクトリへの相対パス。省略するとプレイリストファイルと同じ階層となる。
  • includeFullDir: 対象ディレクトリのパス。省略するとすべて対象。
  • excludeFullDir: 除外ディレクトリのパス。対象から除外したいものを指定。
  • includeDir: 対象ディレクトリ名。省略するとすべて対象。
  • excludeDir: 除外ディレクトリ名。対象から除外したいものを指定。
  • include: 対象ファイル。省略するとすべて対象。
  • exclude: 除外ファイル。対象から除外したいものを指定。
  • before: 前方に列挙するファイルのパス。
  • after: 後方に列挙するファイルのパス。

上記説明で「パス」と書いているものは、プレイリストファイルから見た相対パス全体を指しています。

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

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

プレイリストの順序は基本的にパスに依存します。beforeを指定すると、マッチするリストが先に出力されます。afterを指定すると、マッチするリストが後に出力されます。beforeとafterが同時に指定されていればbeforeが優先されます。大雑把にまとめると、beforeの塊、その他の塊、afterの塊、といった順で出力されます。

外部参照

相対パスで他のプレイリストファイルを指定すると、その内容を取り込みます。

#import:パス

プレイリストファイルのパスに合わせて、各音楽ファイルへの相対パスは修正されます。とりあえず絶対パスが含まれることは想定してません。

参照先に自動生成命令の記述があれば、それらを実行して最新のリストを生成します。結果はキャッシュしているので、自動更新されるプレイリストで参照関係を作っておくと、結果の使い回しができて効率が良いです。

循環参照となったときは、その場で参照解決を打ち切りとしました。エラーとしても良いんですが、依存関係もキャッシュしないと処理順によってはチェックしきれないのが面倒だったので横着。

実装

Python3による実装です。

#!python3
# -*- coding: utf-8 -*-
 
'''
m3u/m3u8ファイルに記述した自動生成ルールをもとにプレイリストを更新するスクリプト
'''
 
mediaExtensions = [
    '.wav',
    '.mp3',
    '.m4a',
    '.ogg',
    '.flac',
]
 
playlistExtensions = [
    '.m3u',
    '.m3u8',
]
 
defaultPlaylistExtension = '.m3u8'
 
rulePrefix = '#rule:'
refPrefix = '#import:'
 
encoding = 'utf_8_sig'
 
import os, sys
import json
import re
import traceback
 
class Playlist(object):
    def __init__(self, path, playlist=[], instructions=[]):
        self.path = path
        self.root = os.path.dirname(path)
        self.instructions = instructions
        self.playlist = playlist
    def dump(self, root=None):
        contents = []
        contents.extend(self.instructions)
        contents.extend(self.getList(root))
        return '\n'.join(contents)
    def getList(self, root=None):
        if root is None or len(root) == 0:
            return list(self.playlist)
        playlist = []
        for path in self.playlist:
            path = os.path.join(self.root, path)
            path = os.path.relpath(path, root)
            path = modifyPath(path)
            playlist.append(path)
        return playlist
 
playlistCache = {}
 
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 getCommentValue(line, prefix):
    if not line.lower().startswith(prefix):
        return None
    value = line[len(prefix):].strip()
    if len(value) == 0:
        return None
    return value
 
def getReference(line):
    return getCommentValue(line, refPrefix)
 
def getRule(line):
    rule = getCommentValue(line, rulePrefix)
    if rule is None:
        return None
    return json.loads(rule)
 
def getRuleParam(rule, key):
    if key not in rule:
        return None
    return rule[key]
 
def getRuleParams(rule, key):
    result = getRuleParam(rule, key)
    if result is None:
        return []
    if type(result) is not list:
        result = [result]
    return result
 
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 splitPlaylist(playlist, patterns):
    matched = []
    other = list(playlist)
    for pattern in patterns:
        temp = []
        for path in other:
            if match(path, pattern):
                matched.append(path)
            else:
                temp.append(path)
        other = temp
    return matched, other
 
def getPlaylistByRule(basePath, rule):
    rootDir = getRuleParam(rule, 'root')
    includeFullDirs = getRuleParams(rule, 'includeFullDir')
    excludeFullDirs = getRuleParams(rule, 'excludeFullDir')
    includeDirs = getRuleParams(rule, 'includeDir')
    excludeDirs = getRuleParams(rule, 'excludeDir')
    includeFiles = getRuleParams(rule, 'include')
    excludeFiles = getRuleParams(rule, 'exclude')
    beforePatterns = getRuleParams(rule, 'before')
    afterPatterns = getRuleParams(rule, 'after')
 
    playlist = []
 
    # 探索パス修正
    searchPath = basePath
    if rootDir is not None:
        searchPath = os.path.join(basePath, rootDir)
        searchPath = os.path.abspath(searchPath)
 
    # パス一覧取得
    for root, dirs, files in os.walk(searchPath):
        path = os.path.relpath(root, basePath)
        path = modifyPath(path)
        if len(includeFullDirs) > 0 and not match(path, includeFullDirs):
            continue
        if len(excludeFullDirs) > 0 and match(path, excludeFullDirs):
            continue
 
        dirNames = list(os.path.split(path))
 
        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)
 
    # ソート
    beforeList = []
    afterList = []
    otherList = playlist
    if len(beforePatterns) > 0:
        beforeList, otherList = splitPlaylist(otherList, beforePatterns)
    if len(afterPatterns) > 0:
        afterList, otherList = splitPlaylist(otherList, afterPatterns)
    playlist = []
    playlist.extend(beforeList)
    playlist.extend(otherList)
    playlist.extend(afterList)
 
    return playlist
 
def getPlaylist(path, root=None, referenced=set()):
    path = modifyPath(path)
 
    # 拡張子が省略されていたら補完
    name, extension = os.path.splitext(path)
    if not extension in playlistExtensions:
        path += defaultPlaylistExtension
 
    indent = '  ' * len(referenced)
 
    # キャッシュ取得
    if path in playlistCache:
        print(indent + 'Cache: {}'.format(path))
        return playlistCache[path]
 
    # 存在チェック
    if not os.path.isfile(path):
        print(indent + 'Missing; {}'.format(path))
        return None
 
    # 循環参照チェック
    if path in referenced:
        print(indent + 'Circular Ref; {}'.format(path))
        return None
    referenced.add(path)
 
    print(indent + 'Load: {}'.format(path))
 
    playlist = []
    instructions = []
 
    # プレイリスト処理
    root = os.path.dirname(path)
    with open(path, encoding=encoding) as file:
        content = file.read()
        origList = []
        for line in content.splitlines():
            line = line.strip()
            if not line.startswith('#'):
                origList.append(line)
 
            # プレイリスト取得
            rule = getRule(line)
            if rule is not None:
                instructions.append(line)
                subList = getPlaylistByRule(root, rule)
                playlist.extend(subList)
 
            # 外部プレイリスト取得
            ref = getReference(line)
            if ref is not None:
                instructions.append(line)
                ref = os.path.join(root, ref)
                ref = modifyPath(ref)
                subPlaylist = getPlaylist(ref, referenced)
                if subPlaylist is not None:
                    playlist.extend(subPlaylist.getList(root))
 
        if len(instructions) == 0:
            playlist = origList
 
    referenced.remove(path)
 
    playlist = Playlist(path, playlist, instructions)
    playlistCache[path] = playlist
    return playlist
 
def updatePlaylist(path):
    playlist = getPlaylist(path)
 
    if len(playlist.instructions) == 0:
        print('  No instruction.')
        return
 
    # プレイリスト出力
    with open(path, 'w', encoding=encoding) as file:
        file.write(playlist.dump())
 
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)
            paths.append(path)
    for path in paths:
        updatePlaylist(path)
 
if __name__ == '__main__':
    try:
        main()
    except:
        traceback.print_exc()
    input('Press Enter...')

ブログに載せるには長いなこれ。