40 Commits

Author SHA1 Message Date
Jamie Hardt
66ac136270 autopep 2025-05-20 15:23:14 -07:00
Jamie Hardt
aa64d5e183 Fixed a bug in the increment code
Not sure how that happened I thought this used to work!
2025-05-20 15:19:46 -07:00
Jamie Hardt
6766e81b23 Commenting this out for now 2024-10-17 21:32:34 -07:00
Jamie Hardt
a2ce03a259 Added default lines to the top of a new list 2024-10-17 12:19:08 -07:00
Jamie Hardt
0ba40893df Adding some blank picture methods...
...to be implemented later if ever a need.
2024-10-17 09:50:27 -07:00
Jamie Hardt
e2b93f5183 Fixed obvious bug in --help-commands 2024-10-16 14:39:41 -07:00
Jamie Hardt
7015e80cf9 Merge pull request #1 from iluvcapra/iluvcapra-patch-py313
Add support for Python 3.13
2024-10-16 14:33:43 -07:00
Jamie Hardt
042f3116dd Update pyproject.toml 2024-10-16 14:33:02 -07:00
Jamie Hardt
20518fa31c Update pyproject.toml 2024-10-16 14:32:31 -07:00
Jamie Hardt
c4a2e380de Update pylint.yml
Adding 3.13 to test matrix
2024-10-16 14:30:09 -07:00
Jamie Hardt
4ec028b51b Pylints 2024-10-16 14:19:15 -07:00
Jamie Hardt
ac00f93a8d Update pyproject.toml
Nudged version to 0.5.0
2024-10-16 14:10:49 -07:00
Jamie Hardt
8bcfd2ee54 Added .venv to gitignore 2024-10-16 14:09:57 -07:00
Jamie Hardt
63ec226be1 Implemented sorting on creation 2024-10-16 14:09:18 -07:00
Jamie Hardt
69cdb6dec1 Amended error line 2024-10-16 13:37:59 -07:00
Jamie Hardt
720eacc2a4 Added possible sort modes for --create 2024-10-15 22:09:55 -07:00
Jamie Hardt
afa02e4c96 Twiddle 2024-10-15 20:59:13 -07:00
Jamie Hardt
b986a36281 lint 2024-10-15 11:34:32 -07:00
Jamie Hardt
fb90b5db3c Online doc changes 2024-10-15 11:33:21 -07:00
Jamie Hardt
e69573b2a4 Made some online doc tweaks 2024-10-15 11:09:31 -07:00
Jamie Hardt
55cf591690 Added some commentary 2024-10-15 11:04:13 -07:00
Jamie Hardt
f0e05a9609 Paramaterized output stream
for reporting messages from BatchfileParser.
2024-07-07 22:44:52 -07:00
Jamie Hardt
88add2da85 Update pyproject.toml
Set explicit Trove descriptors for python version
2024-07-07 22:25:29 -07:00
Jamie Hardt
fd228493c6 Update README.md
Oops extra wheel badge
2024-07-07 22:22:55 -07:00
Jamie Hardt
11405ef06c Update pylint.yml
Added 3.12 to the test matrix
2024-07-07 22:22:00 -07:00
Jamie Hardt
229467c408 Update README.md
Added badges
2024-07-07 22:21:27 -07:00
Jamie Hardt
dc1c6cc742 Update pylint.yml
Tweaking
2024-07-07 22:16:46 -07:00
Jamie Hardt
d7a30275d1 Update pylint.yml
Added testing to the workflow
2024-07-07 22:15:07 -07:00
Jamie Hardt
61458660c9 pylint 2024-07-07 22:12:35 -07:00
Jamie Hardt
d286c4e6c7 Implemented some more tests 2024-07-07 22:11:40 -07:00
Jamie Hardt
8d299e2335 pylint 2024-07-07 15:21:24 -07:00
Jamie Hardt
e0431da6df Merge branch 'main' of https://github.com/iluvcapra/mfbatch 2024-07-07 15:15:14 -07:00
Jamie Hardt
d334181dc8 Test implementation 2024-07-07 15:15:01 -07:00
Jamie Hardt
0d765f9d84 Adding some tests 2024-07-07 14:40:52 -07:00
Jamie Hardt
ce6542b64d Updated version, gitignore 2024-07-07 13:50:23 -07:00
Jamie Hardt
5a47985154 Improved docs 2024-07-07 13:44:53 -07:00
Jamie Hardt
649427dd33 Made a fix that should work better 2024-07-07 13:44:10 -07:00
Jamie Hardt
21277aff15 Improved reset/key unset logic
Patterns and incrs are now unset as well
2024-07-07 13:42:24 -07:00
Jamie Hardt
1b044025ea Tweaked documentation 2024-07-07 13:35:55 -07:00
Jamie Hardt
2078c1559a Fixed bug in argparser help generation 2024-07-07 13:28:23 -07:00
7 changed files with 177 additions and 28 deletions

View File

@@ -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
View File

@@ -1,3 +1,5 @@
.DS_Store
dist/
*.pyc
poetry.lock
.venv/

View File

@@ -1,3 +1,8 @@
![GitHub last commit](https://img.shields.io/github/last-commit/iluvcapra/mfbatch)
![](https://img.shields.io/github/license/iluvcapra/mfbatch.svg) ![](https://img.shields.io/pypi/pyversions/mfbatch.svg) [![](https://img.shields.io/pypi/v/mfbatch.svg)](https://pypi.org/project/mfbatch/) ![](https://img.shields.io/pypi/wheel/mfbatch.svg)
[![Lint and Test](https://github.com/iluvcapra/mfbatch/actions/workflows/pylint.yml/badge.svg)](https://github.com/iluvcapra/mfbatch/actions/workflows/pylint.yml)
# mfbatch
`mfbatch` is a command-line tool for batch-editing FLAC audio file metadata.

View File

@@ -4,7 +4,7 @@ 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
@@ -14,7 +14,7 @@ import inspect
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,18 +29,51 @@ 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 create_batch_list(command_file: str, recursive=True, sort_mode='path'):
"""
Read all FLAC files in the cwd and create a batchfile that re-creates all
of their metadata.
:param recursive: Recursively enter directories
:param sort_mode: Order of paths in the batch list. Either 'path',
'mtime', 'ctime', 'name'
"""
with open(command_file, mode='w', encoding='utf-8') as f:
f.write("# mfbatch\n\n")
# f.write("""
# # :set DESCRIPTION ""
# # :set TITLE ""
# # :set VERSION ""
# # :set ALBUM ""
# # :set ARTIST ""
# # :set TRACKNUMBER ""
# # :set COPYRIGHT ""
# # :set LICENSE ""
# # :set CONTACT ""
# # :set ORGAIZATION ""
# # :set LOCATION ""
# # :set MICROPHONE ""
# """)
metadatums = {}
flac_files = glob('./**/*.flac', recursive=recursive)
flac_files = sorted(flac_files)
if sort_mode == 'path':
flac_files = sorted(flac_files)
elif sort_mode == 'mtime':
flac_files = sorted(flac_files, key=os.path.getmtime)
elif sort_mode == 'ctime':
flac_files = sorted(flac_files, key=os.path.getctime)
elif sort_mode == 'name':
flac_files = sorted(flac_files, key=os.path.basename)
for path in tqdm(flac_files, unit='File', desc='Scanning FLAC files'):
this_file_metadata = flac.read_metadata(path)
try:
this_file_metadata = metadata_funcs.read_metadata(path)
except CalledProcessError as e:
f.write(f"# !!! METAFLAC ERROR ({e.returncode}) while reading "
f"metadata from the file {path}\n\n")
continue
for this_key, this_value in this_file_metadata.items():
if this_key not in metadatums:
f.write(f":set {this_key} "
@@ -66,7 +99,8 @@ 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',
@@ -77,10 +111,13 @@ def main():
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 +137,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 +152,7 @@ def main():
if options.create:
mode_given = True
create_batch_list(options.batchfile)
create_batch_list(options.batchfile, sort_mode=options.sort)
if options.edit:
mode_given = True

View File

@@ -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

View File

@@ -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
View 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')