(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...') ブログに載せるには長いなこれ。