mirror of
https://github.com/iluvcapra/mfbatch.git
synced 2025-12-31 08:50:51 +00:00
Compare commits
50 Commits
v0.4.0
...
iluvcapra-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49055ef881 | ||
|
|
ecc93640c2 | ||
|
|
538da34a0c | ||
|
|
2d4ea5a8d8 | ||
|
|
532f67e3a8 | ||
|
|
4989832247 | ||
|
|
549f49da31 | ||
|
|
ba3b0dbf96 | ||
|
|
2c135d413e | ||
|
|
334fa56a2c | ||
|
|
66ac136270 | ||
|
|
aa64d5e183 | ||
|
|
6766e81b23 | ||
|
|
a2ce03a259 | ||
|
|
0ba40893df | ||
|
|
e2b93f5183 | ||
|
|
7015e80cf9 | ||
|
|
042f3116dd | ||
|
|
20518fa31c | ||
|
|
c4a2e380de | ||
|
|
4ec028b51b | ||
|
|
ac00f93a8d | ||
|
|
8bcfd2ee54 | ||
|
|
63ec226be1 | ||
|
|
69cdb6dec1 | ||
|
|
720eacc2a4 | ||
|
|
afa02e4c96 | ||
|
|
b986a36281 | ||
|
|
fb90b5db3c | ||
|
|
e69573b2a4 | ||
|
|
55cf591690 | ||
|
|
f0e05a9609 | ||
|
|
88add2da85 | ||
|
|
fd228493c6 | ||
|
|
11405ef06c | ||
|
|
229467c408 | ||
|
|
dc1c6cc742 | ||
|
|
d7a30275d1 | ||
|
|
61458660c9 | ||
|
|
d286c4e6c7 | ||
|
|
8d299e2335 | ||
|
|
e0431da6df | ||
|
|
d334181dc8 | ||
|
|
0d765f9d84 | ||
|
|
ce6542b64d | ||
|
|
5a47985154 | ||
|
|
649427dd33 | ||
|
|
21277aff15 | ||
|
|
1b044025ea | ||
|
|
2078c1559a |
8
.github/workflows/pylint.yml
vendored
8
.github/workflows/pylint.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Pylint
|
||||
name: Lint and Test
|
||||
|
||||
on: [push]
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -22,3 +22,7 @@ jobs:
|
||||
- name: Analysing the code with pylint
|
||||
run: |
|
||||
pylint $(git ls-files '*.py')
|
||||
- name: Testing with unittest
|
||||
run: |
|
||||
python -m unittest tests
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
*.pyc
|
||||
poetry.lock
|
||||
.venv/
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||

|
||||
  [](https://pypi.org/project/mfbatch/) 
|
||||
|
||||
[](https://github.com/iluvcapra/mfbatch/actions/workflows/pylint.yml)
|
||||
|
||||
# mfbatch
|
||||
|
||||
`mfbatch` is a command-line tool for batch-editing FLAC audio file metadata.
|
||||
|
||||
@@ -4,17 +4,18 @@ mfbatch main - Command entrypoint for mfbatch
|
||||
|
||||
import os
|
||||
from glob import glob
|
||||
from subprocess import run
|
||||
from subprocess import CalledProcessError, run
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
import shlex
|
||||
from typing import Callable
|
||||
from typing import Callable, List, Tuple
|
||||
import inspect
|
||||
from io import StringIO
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from mfbatch.util import readline_with_escaped_newlines
|
||||
import mfbatch.metaflac as flac
|
||||
import mfbatch.metaflac as metadata_funcs
|
||||
from mfbatch.commands import BatchfileParser
|
||||
|
||||
|
||||
@@ -29,58 +30,112 @@ def execute_batch_list(batch_list_path: str, dry_run: bool, interactive: bool):
|
||||
parser.eval(line, line_no, interactive)
|
||||
|
||||
|
||||
def create_batch_list(command_file: str, recursive=True):
|
||||
def sort_flac_files(file_list, mode):
|
||||
"Sort flac files"
|
||||
if mode == 'path':
|
||||
return sorted(file_list)
|
||||
if mode == 'mtime':
|
||||
return sorted(file_list, key=os.path.getmtime)
|
||||
if mode == 'ctime':
|
||||
return sorted(file_list, key=os.path.getctime)
|
||||
if mode == 'name':
|
||||
return sorted(file_list, key=os.path.basename)
|
||||
|
||||
return file_list
|
||||
|
||||
|
||||
def write_batchfile_entries_for_file(path, metadatums) -> Tuple[dict, str]:
|
||||
"Create batchfile entries for `path`"
|
||||
buffer = StringIO()
|
||||
|
||||
try:
|
||||
this_file_metadata = metadata_funcs.read_metadata(path)
|
||||
|
||||
except CalledProcessError as e:
|
||||
buffer.write(f"# !!! METAFLAC ERROR ({e.returncode}) while reading "
|
||||
f"metadata from the file {path}\n\n")
|
||||
return metadatums, buffer.getvalue()
|
||||
|
||||
for this_key, this_value in this_file_metadata.items():
|
||||
if this_key not in metadatums:
|
||||
buffer.write(f":set {this_key} "
|
||||
f"{shlex.quote(this_value)}\n")
|
||||
metadatums[this_key] = this_value
|
||||
else:
|
||||
if this_value != metadatums[this_key]:
|
||||
buffer.write(f":set {this_key} "
|
||||
f"{shlex.quote(this_value)}"
|
||||
"\n")
|
||||
metadatums[this_key] = this_value
|
||||
|
||||
keys = list(metadatums.keys())
|
||||
for key in keys:
|
||||
if key not in this_file_metadata:
|
||||
buffer.write(f":unset {key}\n")
|
||||
del metadatums[key]
|
||||
|
||||
buffer.write(path + "\n\n")
|
||||
|
||||
return metadatums, buffer.getvalue()
|
||||
|
||||
|
||||
def create_batch_list(flac_files: List[str], command_file: str,
|
||||
sort_mode='path'):
|
||||
"""
|
||||
Read all FLAC files in the cwd and create a batchfile that re-creates all
|
||||
of their metadata.
|
||||
|
||||
:param flac_files: Paths of files to create batchfile from
|
||||
:param command_file: Name of new batchfile
|
||||
:param sort_mode: Order of paths in the batch list. Either 'path',
|
||||
'mtime', 'ctime', 'name'
|
||||
:param input_files: FLAC files to scan
|
||||
"""
|
||||
|
||||
flac_files = sort_flac_files(flac_files, sort_mode)
|
||||
|
||||
with open(command_file, mode='w', encoding='utf-8') as f:
|
||||
f.write("# mfbatch\n\n")
|
||||
metadatums = {}
|
||||
flac_files = glob('./**/*.flac', recursive=recursive)
|
||||
flac_files = sorted(flac_files)
|
||||
for path in tqdm(flac_files, unit='File', desc='Scanning FLAC files'):
|
||||
this_file_metadata = flac.read_metadata(path)
|
||||
for this_key, this_value in this_file_metadata.items():
|
||||
if this_key not in metadatums:
|
||||
f.write(f":set {this_key} "
|
||||
f"{shlex.quote(this_value)}\n")
|
||||
metadatums[this_key] = this_value
|
||||
else:
|
||||
if this_value != metadatums[this_key]:
|
||||
f.write(f":set {this_key} "
|
||||
f"{shlex.quote(this_value)}"
|
||||
"\n")
|
||||
metadatums[this_key] = this_value
|
||||
|
||||
keys = list(metadatums.keys())
|
||||
for key in keys:
|
||||
if key not in this_file_metadata:
|
||||
f.write(f":unset {key}\n")
|
||||
del metadatums[key]
|
||||
f.write("# mfbatch\n\n")
|
||||
|
||||
f.write(path + "\n\n")
|
||||
for path in tqdm(flac_files, unit='File',
|
||||
desc='Scanning with metaflac...'):
|
||||
|
||||
metadatums, buffer = write_batchfile_entries_for_file(path,
|
||||
metadatums)
|
||||
f.write(buffer)
|
||||
|
||||
f.write("# mfbatch: create batchlist operation complete\n")
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Entry point implementation
|
||||
"""
|
||||
op = ArgumentParser(usage="%prog (-c | -e | -W) [options]")
|
||||
op = ArgumentParser(
|
||||
prog='mfbatch', usage='%(prog)s (-c | -e | -W) [options]')
|
||||
|
||||
op.add_argument('-c', '--create', default=False,
|
||||
action='store_true',
|
||||
help='create a new list')
|
||||
op.add_argument('-F', '--from-file', metavar='FILE_LIST', action='store',
|
||||
default=None, help="get file paths from FILE_LIST when "
|
||||
"creating, instead of scanning directory"
|
||||
"a new list")
|
||||
op.add_argument('-e', '--edit', action='store_true',
|
||||
help="open batch file in the default editor",
|
||||
default=False)
|
||||
op.add_argument('-W', '--write', default=False,
|
||||
action='store_true',
|
||||
help="execute batch list, write to files")
|
||||
|
||||
op.add_argument('-p', '--path', metavar='DIR',
|
||||
help='chdir to DIR before running',
|
||||
default=None)
|
||||
op.add_argument('-s', '--sort', metavar='MODE', action='store',
|
||||
default='path', help="when creating, Set mode to sort "
|
||||
"files by. Default is 'path'. 'ctime, 'mtime' and 'name' "
|
||||
"are also options.")
|
||||
op.add_argument('-n', '--dry-run', action='store_true',
|
||||
help="dry-run -W.")
|
||||
op.add_argument('-f', '--batchfile', metavar='FILE',
|
||||
@@ -100,12 +155,12 @@ def main():
|
||||
if options.help_commands:
|
||||
print("Command Help\n------------")
|
||||
commands = [command for command in dir(BatchfileParser) if
|
||||
not command.startswith('_')]
|
||||
not command.startswith('_') and command != "eval"]
|
||||
print(f"{inspect.cleandoc(BatchfileParser.__doc__ or '')}\n\n")
|
||||
for command in commands:
|
||||
meth = getattr(BatchfileParser, command)
|
||||
if isinstance(meth, Callable):
|
||||
print(f"{inspect.cleandoc(meth.__doc__ or '')}\n")
|
||||
print(f"- {inspect.cleandoc(meth.__doc__ or '')}\n")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
@@ -115,7 +170,18 @@ def main():
|
||||
|
||||
if options.create:
|
||||
mode_given = True
|
||||
create_batch_list(options.batchfile)
|
||||
flac_files: List[str] = []
|
||||
|
||||
if options.from_file:
|
||||
with open(options.from_file, mode='r',
|
||||
encoding='utf-8') as from_file:
|
||||
flac_files = [line.strip() for line in from_file.readlines()]
|
||||
else:
|
||||
flac_files = glob('./**/*.flac', recursive=True)
|
||||
|
||||
# print(flac_files)
|
||||
create_batch_list(flac_files, options.batchfile,
|
||||
sort_mode=options.sort)
|
||||
|
||||
if options.edit:
|
||||
mode_given = True
|
||||
|
||||
@@ -9,9 +9,9 @@ import shutil
|
||||
import re
|
||||
import os.path
|
||||
|
||||
from typing import Dict, Tuple, Optional
|
||||
from typing import Callable, Dict, Tuple, Optional
|
||||
|
||||
import mfbatch.metaflac as flac
|
||||
from mfbatch.metaflac import write_metadata as flac
|
||||
|
||||
|
||||
class UnrecognizedCommandError(Exception):
|
||||
@@ -64,6 +64,18 @@ class CommandEnv:
|
||||
self.incr.pop(k, None)
|
||||
self.patterns.pop(k, None)
|
||||
|
||||
def reset_keys(self):
|
||||
"""
|
||||
Reset all keys in the environment
|
||||
"""
|
||||
all_keys = list(self.metadatums.keys())
|
||||
|
||||
for key in all_keys:
|
||||
self.unset_key(key)
|
||||
|
||||
self.patterns = {}
|
||||
self.incr = {}
|
||||
|
||||
def set_pattern(self, to: str, frm: str, pattern: str, repl: str):
|
||||
"""
|
||||
Establish a pattern replacement in the environment
|
||||
@@ -119,16 +131,17 @@ class CommandEnv:
|
||||
"""
|
||||
Increment all increment keys.
|
||||
"""
|
||||
for k, v in self.incr.items():
|
||||
v = int(v)
|
||||
self.metadatums[k] = self.incr[k] % (v + 1)
|
||||
for k, _ in self.incr.items():
|
||||
val = int(self.metadatums[k])
|
||||
self.metadatums[k] = self.incr[k] % (val + 1)
|
||||
|
||||
|
||||
class BatchfileParser:
|
||||
"""
|
||||
A batchfile is a text file of lines. Lines either begin with a '#' to denote a
|
||||
comment, a ':' to denote a Command, and if neither of these are present, the
|
||||
are interpreted as a file path to act upon. Empty lines are ignored.
|
||||
line is interpreted as a file path to act upon. Empty lines are ignored. Lines
|
||||
are split into arguments using `shlex.split`.
|
||||
|
||||
If a line ends with a backslash '\\', the backslash is deleted and the contents
|
||||
of the following line are appended to the end of the present line.
|
||||
@@ -140,6 +153,7 @@ they appear in the batchfile.
|
||||
|
||||
dry_run: bool
|
||||
env: CommandEnv
|
||||
write_metadata_f: Callable
|
||||
|
||||
COMMAND_LEADER = ':'
|
||||
COMMENT_LEADER = '#'
|
||||
@@ -147,6 +161,8 @@ they appear in the batchfile.
|
||||
def __init__(self):
|
||||
self.dry_run = True
|
||||
self.env = CommandEnv()
|
||||
self.write_metadata_f = flac
|
||||
self.outstream = sys.stdout
|
||||
|
||||
def eval(self, line: str, lineno: int, interactive: bool):
|
||||
"""
|
||||
@@ -178,11 +194,11 @@ they appear in the batchfile.
|
||||
|
||||
def _write_metadata_impl(self, line):
|
||||
if self.dry_run:
|
||||
print("DRY RUN would write metadata here.")
|
||||
print("DRY RUN would write metadata here.", file=self.outstream)
|
||||
else:
|
||||
sys.stdout.write("Writing metadata... ")
|
||||
flac.write_metadata(line, self.env.metadatums)
|
||||
sys.stdout.write("Complete!")
|
||||
self.outstream.write("Writing metadata... ")
|
||||
self.write_metadata_f(line, self.env.metadatums)
|
||||
self.outstream.write("Complete!")
|
||||
|
||||
self.env.increment_all()
|
||||
self.env.revert_onces()
|
||||
@@ -195,10 +211,10 @@ they appear in the batchfile.
|
||||
|
||||
for l in value_lines:
|
||||
if key:
|
||||
sys.stdout.write(f"{key:.<30} \033[4m{l}\033[0m\n")
|
||||
self.outstream.write(f"{key:.<30} \033[4m{l}\033[0m\n")
|
||||
key = None
|
||||
else:
|
||||
sys.stdout.write(f"{' ' * 30} \033[4m{l}\033[0m\n")
|
||||
self.outstream.write(f"{' ' * 30} \033[4m{l}\033[0m\n")
|
||||
|
||||
def _handle_file(self, line, interactive):
|
||||
while True:
|
||||
@@ -207,9 +223,9 @@ they appear in the batchfile.
|
||||
self.env.evaluate_patterns()
|
||||
|
||||
if self.dry_run:
|
||||
sys.stdout.write(f"\nDRY RUN File: \033[1m{line}\033[0m\n")
|
||||
self.outstream.write(f"\nDRY RUN File: \033[1m{line}\033[0m\n")
|
||||
else:
|
||||
sys.stdout.write(f"\nFile: \033[1m{line}\033[0m\n")
|
||||
self.outstream.write(f"\nFile: \033[1m{line}\033[0m\n")
|
||||
|
||||
for key, value in self.env.metadatums.items():
|
||||
|
||||
@@ -267,7 +283,7 @@ they appear in the batchfile.
|
||||
"""
|
||||
reset
|
||||
All keys in the environment will be reset, subsequent files will have
|
||||
no keys set.
|
||||
no keys set, including keys set by the `setinc` and `setp` commands.
|
||||
"""
|
||||
all_keys = list(self.env.metadatums.keys())
|
||||
for k in all_keys:
|
||||
@@ -313,3 +329,18 @@ they appear in the batchfile.
|
||||
"""
|
||||
val = args[0]
|
||||
self.env.set_once('DESCRIPTION', val)
|
||||
|
||||
# def picture(self, args):
|
||||
# """
|
||||
# picture PATH
|
||||
# Add PATH as a picture (flac picture type 0) to this and every
|
||||
# subsequent file.
|
||||
# """
|
||||
# pass
|
||||
#
|
||||
# def nopicture(self, args):
|
||||
# """
|
||||
# unpicture
|
||||
# Remove all p
|
||||
# """
|
||||
# pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "mfbatch"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
description = "MetaFlac batch editor"
|
||||
authors = ["Jamie Hardt <jamiehardt@me.com>"]
|
||||
readme = "README.md"
|
||||
@@ -13,6 +13,11 @@ classifiers = [
|
||||
'Environment :: Console',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Topic :: Multimedia :: Sound/Audio :: Editors',
|
||||
'Programming Language :: Python :: 3.13',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
|
||||
65
tests/__init__.py
Normal file
65
tests/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"mfbatch tests"
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from typing import cast
|
||||
|
||||
from mfbatch.commands import BatchfileParser
|
||||
|
||||
|
||||
class BatchfileParserTests(unittest.TestCase):
|
||||
"""
|
||||
Tests the BatchfileParser class
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.command_parser = BatchfileParser()
|
||||
self.command_parser.dry_run = False
|
||||
self.command_parser.write_metadata_f = MagicMock()
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def test_set_without_write(self):
|
||||
"Test setting a key without writing"
|
||||
self.command_parser.set(['TYPE', 'Everything'])
|
||||
self.assertFalse(cast(MagicMock,
|
||||
self.command_parser.write_metadata_f).called)
|
||||
self.assertEqual(self.command_parser.env.metadatums['TYPE'],
|
||||
'Everything')
|
||||
|
||||
def test_set_command(self):
|
||||
"Test set command"
|
||||
self.command_parser.set(['X', 'Y'])
|
||||
self.command_parser.eval("./testfile.flac", lineno=1,
|
||||
interactive=False)
|
||||
self.assertTrue(cast(MagicMock,
|
||||
self.command_parser.write_metadata_f).called)
|
||||
self.assertEqual(cast(MagicMock,
|
||||
self.command_parser.write_metadata_f).call_args.args,
|
||||
('./testfile.flac', {'X': 'Y'}))
|
||||
|
||||
def test_unset_command(self):
|
||||
"Test unset command"
|
||||
self.command_parser.set(['A', '1'])
|
||||
self.assertEqual(self.command_parser.env.metadatums['A'], '1')
|
||||
self.command_parser.unset(['A'])
|
||||
self.assertNotIn('A', self.command_parser.env.metadatums.keys())
|
||||
|
||||
def test_setp(self):
|
||||
"Test setp command"
|
||||
self.command_parser.set(['VAL', 'ABC123'])
|
||||
self.command_parser.setp(['DONE', 'VAL', r"([A-Z]+)123", r"X\1"])
|
||||
self.command_parser.eval("./testfile.flac", lineno=1,
|
||||
interactive=False)
|
||||
|
||||
self.assertTrue(cast(MagicMock,
|
||||
self.command_parser.write_metadata_f).called)
|
||||
self.assertEqual(cast(MagicMock,
|
||||
self.command_parser.write_metadata_f).call_args.args,
|
||||
("./testfile.flac", {'VAL': 'ABC123', 'DONE': 'XABC'}))
|
||||
|
||||
def test_eval(self):
|
||||
"Test eval"
|
||||
self.command_parser.eval(":set A 1", 1, False)
|
||||
self.assertEqual(self.command_parser.env.metadatums['A'], '1')
|
||||
Reference in New Issue
Block a user