batchmp 1.4__tar.gz → 1.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {batchmp-1.4 → batchmp-1.4.2}/PKG-INFO +32 -4
  2. {batchmp-1.4 → batchmp-1.4.2}/README.md +27 -3
  3. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/base/bmp_options.py +1 -1
  4. {batchmp-1.4 → batchmp-1.4.2}/batchmp/commons/utils.py +10 -0
  5. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/dirtools.py +25 -185
  6. batchmp-1.4.2/batchmp/fstools/virtual_organizer.py +301 -0
  7. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/PKG-INFO +32 -4
  8. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/SOURCES.txt +2 -0
  9. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/requires.txt +4 -0
  10. batchmp-1.4.2/pyproject.toml +3 -0
  11. {batchmp-1.4 → batchmp-1.4.2}/setup.py +8 -1
  12. {batchmp-1.4 → batchmp-1.4.2}/LICENSE +0 -0
  13. {batchmp-1.4 → batchmp-1.4.2}/batchmp/__init__.py +0 -0
  14. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/__init__.py +0 -0
  15. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/base/__init__.py +0 -0
  16. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/base/bmp_dispatch.py +0 -0
  17. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/base/vchk.py +0 -0
  18. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/bmfp/__init__.py +0 -0
  19. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/bmfp/bmfp_dispatch.py +0 -0
  20. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/bmfp/bmfp_options.py +0 -0
  21. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/renamer/__init__.py +0 -0
  22. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/renamer/renamer_dispatch.py +0 -0
  23. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/renamer/renamer_options.py +0 -0
  24. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/tagger/__init__.py +0 -0
  25. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/tagger/tagger_dispatch.py +0 -0
  26. {batchmp-1.4 → batchmp-1.4.2}/batchmp/cli/tagger/tagger_options.py +0 -0
  27. {batchmp-1.4 → batchmp-1.4.2}/batchmp/commons/__init__.py +0 -0
  28. {batchmp-1.4 → batchmp-1.4.2}/batchmp/commons/chainedhandler.py +0 -0
  29. {batchmp-1.4 → batchmp-1.4.2}/batchmp/commons/descriptors.py +0 -0
  30. {batchmp-1.4 → batchmp-1.4.2}/batchmp/commons/progressbar.py +0 -0
  31. {batchmp-1.4 → batchmp-1.4.2}/batchmp/commons/taskprocessor.py +0 -0
  32. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/__init__.py +0 -0
  33. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/__init__.py +0 -0
  34. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/cmdopt.py +0 -0
  35. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/convert.py +0 -0
  36. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/cuesplit.py +0 -0
  37. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/denoise.py +0 -0
  38. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/fragment.py +0 -0
  39. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/normalize_peak.py +0 -0
  40. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/segment.py +0 -0
  41. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffcommands/silencesplit.py +0 -0
  42. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffrunner.py +0 -0
  43. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/ffutils.py +0 -0
  44. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/processors/__init__.py +0 -0
  45. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/processors/basefp.py +0 -0
  46. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/processors/ffentry.py +0 -0
  47. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/utils/__init__.py +0 -0
  48. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/utils/cueparse.py +0 -0
  49. {batchmp-1.4 → batchmp-1.4.2}/batchmp/ffmptools/utils/cuesheet.py +0 -0
  50. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/__init__.py +0 -0
  51. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/builders/__init__.py +0 -0
  52. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/builders/fsb.py +0 -0
  53. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/builders/fsentry.py +0 -0
  54. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/builders/fsprms.py +0 -0
  55. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/fsutils.py +0 -0
  56. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/rename.py +0 -0
  57. {batchmp-1.4 → batchmp-1.4.2}/batchmp/fstools/walker.py +0 -0
  58. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/__init__.py +0 -0
  59. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/__init__.py +0 -0
  60. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/basehandler.py +0 -0
  61. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/ffmphandler.py +0 -0
  62. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/ffmphandlers/__init__.py +0 -0
  63. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/ffmphandlers/base.py +0 -0
  64. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/mtghandler.py +0 -0
  65. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/pmhandler.py +0 -0
  66. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/handlers/tagsholder.py +0 -0
  67. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/output/__init__.py +0 -0
  68. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/output/formatters.py +0 -0
  69. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/processors/__init__.py +0 -0
  70. {batchmp-1.4 → batchmp-1.4.2}/batchmp/tags/processors/basetp.py +0 -0
  71. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/dependency_links.txt +0 -0
  72. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/entry_points.txt +0 -0
  73. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/top_level.txt +0 -0
  74. {batchmp-1.4 → batchmp-1.4.2}/batchmp.egg-info/zip-safe +0 -0
  75. {batchmp-1.4 → batchmp-1.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchmp
3
- Version: 1.4
3
+ Version: 1.4.2
4
4
  Summary: Command-line tools for batch media processing
5
5
  Home-page: https://github.com/akpw/batch-mp-tools
6
6
  Author: Arseniy Kuznetsov
@@ -29,6 +29,9 @@ Requires-Dist: mutagen>=1.27
29
29
  Requires-Dist: pygtrie>=2.3.2
30
30
  Requires-Dist: filetype>=1.0.7
31
31
  Requires-Dist: mediafile>=0.13.0
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest; extra == "test"
34
+ Requires-Dist: pytest-mock; extra == "test"
32
35
  Dynamic: author
33
36
  Dynamic: author-email
34
37
  Dynamic: classifier
@@ -38,6 +41,7 @@ Dynamic: home-page
38
41
  Dynamic: keywords
39
42
  Dynamic: license
40
43
  Dynamic: license-file
44
+ Dynamic: provides-extra
41
45
  Dynamic: requires-dist
42
46
  Dynamic: summary
43
47
 
@@ -133,7 +137,10 @@ Or preview how files would look organized by date without moving them:
133
137
  |- 02/
134
138
  |- video.mp4
135
139
  ```
136
-
140
+ For more detailed examples and advanced organize / virtual views functionality, see:
141
+ - [Renamer Organize & Virtual Views](https://akpw.github.io/articles/2025/09/22/Print-and-Organize.html)
142
+ - [BatchMP Tools Tutorial, Part II: renaming files with renamer](https://akpw.github.io/articles/2015/04/11/batchmp-tutorial-part-ii.html)
143
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
137
144
 
138
145
 
139
146
  [**Tagger**](https://github.com/akpw/batch-mp-tools#tagger) manages media metadata, such as tags and artwork. Setting those in selected media file over multiple nested directories now becomes a breeze, with just a few simple commands working uniformly over almost any practically imaginable audio / video media formats. While easy to use, Tagger supports advanced metadata manipulation such as regexp-based replace, expandable template processing, etc. For example, to set the title tag to respective file names followed by the values of track and tracktotal tags:
@@ -160,6 +167,11 @@ Or preview how files would look organized by date without moving them:
160
167
  ```
161
168
  The commands above show some of the available global options: `-r` for recursion into nested folders and `-in` to select media files. In the example above just one file was selected (for the sake of output brevity), which also could be achived via using `-f` for the file source mode.
162
169
 
170
+ For more practical examples, see:
171
+ - [BatchMP Tools Tutorial Part III: setting tags and artwork with tagger](https://akpw.github.io/articles/2015/04/12/batchmp-tutorial-part-iii.html)
172
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
173
+
174
+
163
175
 
164
176
  [**BMFP**](https://github.com/akpw/batch-mp-tools/blob/master/README.md#bmfp-requires-ffmpeg) is all about efficient media content processing, such as conversion between various formats, normalizing sound volume, segmenting / fragmenting media files, denoising audio, detaching individual audio / video streams, etc. As processing media files can typically be resource consuming, BMFP is designed to take advantage of multi-core processors. By default, it automatically breaks up jobs into individual tasks that are then run as separate processes on available CPU cores.
165
177
  **BMFP is built on top of [FFmpeg](http://ffmpeg.org/download.html), which needs to be installed and available in the command line**. BMFP can be thought of as a batch FFmpeg runner, intended to make common uses of FFmpeg easy while not restricting its full power.
@@ -207,7 +219,10 @@ To check on the result, lets's just use the [tagger's](https://github.com/akpw/b
207
219
  ```
208
220
  From a brief glance, all looks OK. BMFP used FFmpeg to do the actual conversion, while taking care of all other things like preserving tags / artwork, etc.
209
221
 
210
- I will follow up with more examples and common use-cases in future blogs.
222
+ For more practical examples, see:
223
+ - [BatchMP Tools Tutorial, splitting a long media file with bmfp](https://akpw.github.io/articles/2015/04/10/batchmp-tutorial-part-i.html)
224
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
225
+
211
226
 
212
227
 
213
228
  ## Brief Description of CLI Commands (use -h to expand on details for individual commands)
@@ -395,10 +410,23 @@ Support via FFmpeg: 'AVI', 'FLV', 'MKV', 'MKA'
395
410
 
396
411
 
397
412
  ## Installing Development version
398
- - Clone the repo, then run: `$ python -m pip install .`
413
+ - Clone the repo, create a virtual environment, and activate it:
414
+ ```bash
415
+ $ python3 -m venv .venv
416
+ $ source .venv/bin/activate
417
+ ```
418
+ - Install the project in editable mode:
419
+ ```bash
420
+ $ pip install -e .
421
+ ```
399
422
 
400
423
  ## Running Tests
401
424
 
425
+ To run the test suite, first ensure you have installed the development dependencies:
426
+ ```bash
427
+ $ pip install -e ".[test]"
428
+ ```
429
+
402
430
  **Using pytest (recommended):**
403
431
  ```bash
404
432
  $ pytest -v --tb=short # Run all tests with verbose output
@@ -90,7 +90,10 @@ Or preview how files would look organized by date without moving them:
90
90
  |- 02/
91
91
  |- video.mp4
92
92
  ```
93
-
93
+ For more detailed examples and advanced organize / virtual views functionality, see:
94
+ - [Renamer Organize & Virtual Views](https://akpw.github.io/articles/2025/09/22/Print-and-Organize.html)
95
+ - [BatchMP Tools Tutorial, Part II: renaming files with renamer](https://akpw.github.io/articles/2015/04/11/batchmp-tutorial-part-ii.html)
96
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
94
97
 
95
98
 
96
99
  [**Tagger**](https://github.com/akpw/batch-mp-tools#tagger) manages media metadata, such as tags and artwork. Setting those in selected media file over multiple nested directories now becomes a breeze, with just a few simple commands working uniformly over almost any practically imaginable audio / video media formats. While easy to use, Tagger supports advanced metadata manipulation such as regexp-based replace, expandable template processing, etc. For example, to set the title tag to respective file names followed by the values of track and tracktotal tags:
@@ -117,6 +120,11 @@ Or preview how files would look organized by date without moving them:
117
120
  ```
118
121
  The commands above show some of the available global options: `-r` for recursion into nested folders and `-in` to select media files. In the example above just one file was selected (for the sake of output brevity), which also could be achived via using `-f` for the file source mode.
119
122
 
123
+ For more practical examples, see:
124
+ - [BatchMP Tools Tutorial Part III: setting tags and artwork with tagger](https://akpw.github.io/articles/2015/04/12/batchmp-tutorial-part-iii.html)
125
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
126
+
127
+
120
128
 
121
129
  [**BMFP**](https://github.com/akpw/batch-mp-tools/blob/master/README.md#bmfp-requires-ffmpeg) is all about efficient media content processing, such as conversion between various formats, normalizing sound volume, segmenting / fragmenting media files, denoising audio, detaching individual audio / video streams, etc. As processing media files can typically be resource consuming, BMFP is designed to take advantage of multi-core processors. By default, it automatically breaks up jobs into individual tasks that are then run as separate processes on available CPU cores.
122
130
  **BMFP is built on top of [FFmpeg](http://ffmpeg.org/download.html), which needs to be installed and available in the command line**. BMFP can be thought of as a batch FFmpeg runner, intended to make common uses of FFmpeg easy while not restricting its full power.
@@ -164,7 +172,10 @@ To check on the result, lets's just use the [tagger's](https://github.com/akpw/b
164
172
  ```
165
173
  From a brief glance, all looks OK. BMFP used FFmpeg to do the actual conversion, while taking care of all other things like preserving tags / artwork, etc.
166
174
 
167
- I will follow up with more examples and common use-cases in future blogs.
175
+ For more practical examples, see:
176
+ - [BatchMP Tools Tutorial, splitting a long media file with bmfp](https://akpw.github.io/articles/2015/04/10/batchmp-tutorial-part-i.html)
177
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
178
+
168
179
 
169
180
 
170
181
  ## Brief Description of CLI Commands (use -h to expand on details for individual commands)
@@ -352,10 +363,23 @@ Support via FFmpeg: 'AVI', 'FLV', 'MKV', 'MKA'
352
363
 
353
364
 
354
365
  ## Installing Development version
355
- - Clone the repo, then run: `$ python -m pip install .`
366
+ - Clone the repo, create a virtual environment, and activate it:
367
+ ```bash
368
+ $ python3 -m venv .venv
369
+ $ source .venv/bin/activate
370
+ ```
371
+ - Install the project in editable mode:
372
+ ```bash
373
+ $ pip install -e .
374
+ ```
356
375
 
357
376
  ## Running Tests
358
377
 
378
+ To run the test suite, first ensure you have installed the development dependencies:
379
+ ```bash
380
+ $ pip install -e ".[test]"
381
+ ```
382
+
359
383
  **Using pytest (recommended):**
360
384
  ```bash
361
385
  $ pytest -v --tb=short # Run all tests with verbose output
@@ -31,7 +31,7 @@
31
31
 
32
32
  import os, sys, string
33
33
  from argparse import ArgumentParser, HelpFormatter
34
- from distutils.util import strtobool
34
+ from batchmp.commons.utils import strtobool
35
35
  from urllib.parse import urlparse
36
36
  from batchmp.commons.utils import MiscHelpers
37
37
  from batchmp.fstools.fsutils import FSH
@@ -61,6 +61,16 @@ def run_cmd(cmd, shell = False):
61
61
  return output
62
62
 
63
63
 
64
+ def strtobool(val):
65
+ val = str(val).lower()
66
+ if val in ('y', 'yes', 't', 'true', 'on', '1'):
67
+ return 1
68
+ elif val in ('n', 'no', 'f', 'false', 'off', '0'):
69
+ return 0
70
+ else:
71
+ raise ValueError("invalid truth value %r" % (val,))
72
+
73
+
64
74
  class MiscHelpers:
65
75
  @staticmethod
66
76
  def int_num_digits(num):
@@ -15,13 +15,14 @@
15
15
  import os, sys
16
16
  from collections import namedtuple
17
17
  from collections.abc import Iterable
18
- from distutils.util import strtobool
18
+ from batchmp.commons.utils import strtobool
19
19
  import pygtrie
20
20
  from batchmp.fstools.walker import DWalker
21
21
  from batchmp.fstools.fsutils import FSH
22
22
  from batchmp.fstools.builders.fsentry import FSEntry, FSEntryType, FSEntryDefaults
23
23
  from batchmp.fstools.builders.fsprms import FSEntryParamsExt, FSEntryParamsOrganize
24
24
  from batchmp.commons.progressbar import progress_bar, CmdProgressBarRefreshRate
25
+ from batchmp.fstools.virtual_organizer import VirtualOrganizer
25
26
  # from profilehooks import profile
26
27
 
27
28
 
@@ -238,7 +239,6 @@ class DHandler:
238
239
  def rename_entries(fs_entry_params,
239
240
  num_entries = 0,
240
241
  formatter = None, check_unique = True):
241
-
242
242
  """ Renames directory entries via applying formatter function supplied by the caller
243
243
  """
244
244
  if not formatter or num_entries <= 0:
@@ -283,7 +283,6 @@ class DHandler:
283
283
 
284
284
  @staticmethod
285
285
  def remove_entries(fs_entry_params, formatter = None):
286
-
287
286
  """ Removes entries with formatter function supplied by the caller
288
287
  """
289
288
  if not formatter:
@@ -321,76 +320,26 @@ class DHandler:
321
320
  def organize(fs_entry_params):
322
321
  """ Organizes files into subdirectories based on specified attributes
323
322
  """
324
- # Build a trie of the target directory structure
325
- dir_trie = pygtrie.StringTrie(separator=os.path.sep)
326
- entries_to_process = list(DWalker.entries(fs_entry_params))
327
- fcnt = 0
328
- for entry in entries_to_process:
329
- if entry.type == FSEntryType.FILE and hasattr(entry, 'target_path'):
330
- fcnt += 1
331
- target_dir = os.path.dirname(entry.target_path)
332
- if not dir_trie.has_key(target_dir):
333
- dir_trie[target_dir] = []
334
- dir_trie[target_dir].append(entry)
335
323
 
336
- if fcnt == 0:
324
+ # Create and configure the virtual organizer
325
+ organizer = VirtualOrganizer(fs_entry_params)
326
+
327
+ # Build the virtual structure
328
+ if not organizer.build_virtual_structure():
337
329
  print("Nothing to process")
338
330
  return
339
-
340
- # Create a custom walker for the virtual tree preview
341
- def virtual_walker(root_dir):
342
- # Build hierarchical tree structure from trie
343
- tree = {}
344
- for target_path, files in dir_trie.items():
345
- rel_path = os.path.relpath(target_path, root_dir)
346
- if rel_path == '.': continue # Skip files that stay in root
347
-
348
- # Build nested tree structure
349
- parts = rel_path.split(os.path.sep)
350
- node = tree
351
- for part in parts:
352
- node = node.setdefault(part, {})
353
- node['__files__'] = [f.basename for f in files]
354
-
355
- # Recursive function to yield all levels of the tree
356
- def walk_tree(current_path, subtree):
357
- # Get directories and files at current level
358
- subdirs = sorted([k for k in subtree.keys() if k != '__files__'])
359
- files = sorted(subtree.get('__files__', []))
360
-
361
- # Yield current directory
362
- yield current_path, subdirs, files
363
-
364
- # Recursively yield subdirectories
365
- for subdir in subdirs:
366
- subdir_path = os.path.join(current_path, subdir)
367
- yield from walk_tree(subdir_path, subtree[subdir])
368
-
369
- # Start walking from root
370
- yield from walk_tree(root_dir, tree)
331
+
332
+ # Get components for organize preview
333
+ virtual_walker = organizer.organize_virtual_walker()
334
+ max_depth = organizer.max_directory_depth()
335
+ preview_params = organizer.organize_preview_params(max_depth)
336
+ entries_to_process = list(DWalker.entries(fs_entry_params))
337
+ fcnt = sum(1 for entry in entries_to_process if entry.type == FSEntryType.FILE and hasattr(entry, 'target_path'))
371
338
 
372
339
  # Visualize the changes
373
340
  if fs_entry_params.quiet:
374
341
  proceed = True
375
342
  else:
376
- # Calculate required depth based on organization structure
377
- max_depth = 0
378
- for target_path in dir_trie.keys():
379
- rel_path = os.path.relpath(target_path, fs_entry_params.src_dir)
380
- if rel_path != '.':
381
- depth = len(rel_path.split(os.path.sep))
382
- max_depth = max(max_depth, depth)
383
-
384
- # Create preview parameters directly
385
- preview_params = FSEntryParamsOrganize({
386
- 'all_files': True,
387
- 'all_dirs': True,
388
- 'end_level': max_depth
389
- })
390
- preview_params.src_dir = fs_entry_params.src_dir # Explicitly set src_dir
391
- # Override builder for preview
392
- from batchmp.fstools.builders.fsb import FSEntryBuilderOrganize
393
- preview_params.__dict__['fs_entry_builder'] = FSEntryBuilderOrganize()
394
343
  proceed, _, _ = DHandler.visualise_changes(preview_params, virtual_walker)
395
344
 
396
345
  if proceed and fcnt > 0:
@@ -417,133 +366,24 @@ class DHandler:
417
366
  def print_organized_view(fs_entry_params):
418
367
  """ Print hierarchical organized-like virtual view
419
368
  """
420
- # Build a trie of the virtual directory structure
421
- dir_trie = pygtrie.StringTrie(separator=os.path.sep)
422
- entries_to_process = list(DWalker.entries(fs_entry_params))
423
- fcnt = 0
424
-
425
- for entry in entries_to_process:
426
- if entry.type == FSEntryType.FILE and hasattr(entry, 'target_path'):
427
- fcnt += 1
428
- target_dir = os.path.dirname(entry.target_path)
429
- if not dir_trie.has_key(target_dir):
430
- dir_trie[target_dir] = []
431
- dir_trie[target_dir].append(entry)
369
+
370
+ # Create and configure the virtual organizer
371
+ organizer = VirtualOrganizer(fs_entry_params)
432
372
 
433
- if fcnt == 0:
373
+ # Build the virtual structure
374
+ if not organizer.build_virtual_structure():
434
375
  print("No files to organize view")
435
376
  return
436
377
 
437
- # Create a custom walker for the virtual tree preview
438
- def virtual_walker(root_dir):
439
- # Build hierarchical tree structure from trie
440
- tree = {}
441
- for target_path, files in dir_trie.items():
442
- rel_path = os.path.relpath(target_path, root_dir)
443
- if rel_path == '.':
444
- # Files that stay in root
445
- tree.setdefault('__files__', []).extend([f.basename for f in files])
446
- continue
447
-
448
- # Build nested tree structure
449
- parts = rel_path.split(os.path.sep)
450
- node = tree
451
- for part in parts:
452
- node = node.setdefault(part, {})
453
- node['__files__'] = [f.basename for f in files]
454
-
455
- # Recursive function to yield all levels of the tree
456
- def walk_tree(current_path, subtree):
457
- # Get directories and files at current level
458
- subdirs = sorted([k for k in subtree.keys() if k != '__files__'])
459
- files = sorted(subtree.get('__files__', []))
460
-
461
- # Yield current directory
462
- yield current_path, subdirs, files
463
-
464
- # Recursively yield subdirectories
465
- for subdir in subdirs:
466
- subdir_path = os.path.join(current_path, subdir)
467
- yield from walk_tree(subdir_path, subtree[subdir])
468
-
469
- # Start walking from root
470
- yield from walk_tree(root_dir, tree)
471
-
472
- # Calculate required depth based on organization structure
473
- max_depth = 0
474
- for target_path in dir_trie.keys():
475
- rel_path = os.path.relpath(target_path, fs_entry_params.src_dir)
476
- if rel_path != '.':
477
- depth = len(rel_path.split(os.path.sep))
478
- max_depth = max(max_depth, depth)
479
-
480
- # Pre-calculate directory sizes by aggregating file sizes
481
- dir_sizes = {}
482
- if fs_entry_params.show_size:
483
- for target_path, files in dir_trie.items():
484
- total_size = 0
485
- for file_entry in files:
486
- try:
487
- fsize = os.path.getsize(file_entry.realpath)
488
- total_size += fsize
489
- except (OSError, IOError):
490
- pass
491
- dir_sizes[target_path] = total_size
492
-
493
- # Create a custom formatter that shows sizes for both files and virtual dirs
494
- def size_aware_formatter(entry):
495
- if fs_entry_params.show_size:
496
- if entry.type == FSEntryType.FILE:
497
- # For files, show size from their real path (original file location)
498
- # Find the original file in our file mapping
499
- original_file = None
500
- for target_path, files in dir_trie.items():
501
- for file_entry in files:
502
- if file_entry.basename == entry.basename:
503
- original_file = file_entry
504
- break
505
- if original_file:
506
- break
507
-
508
- if original_file:
509
- try:
510
- fsize = os.path.getsize(original_file.realpath)
511
- size_str = FSH.fs_size(fsize)
512
- return f" {size_str} {entry.basename}"
513
- except (OSError, IOError):
514
- pass
515
-
516
- elif entry.type == FSEntryType.DIR:
517
- # For virtual directories, show aggregated size of contained files
518
- # Find the corresponding target path for this virtual directory
519
- virtual_path = entry.realpath
520
- if virtual_path in dir_sizes:
521
- total_size = dir_sizes[virtual_path]
522
- if total_size > 0:
523
- size_str = FSH.fs_size(total_size)
524
- return f" {size_str} {entry.basename}"
525
-
526
- # For directories without size info or when size not requested, just return basename
527
- return entry.basename
528
-
529
- # Create preview parameters
530
- from batchmp.fstools.builders.fsprms import FSEntryParamsOrganize
531
- from batchmp.fstools.builders.fsb import FSEntryBuilderOrganize
532
-
533
- preview_params = FSEntryParamsOrganize({
534
- 'all_files': True,
535
- 'all_dirs': True,
536
- 'end_level': max_depth,
537
- 'show_size': False # We handle sizes with custom formatter
538
- })
539
- preview_params.src_dir = fs_entry_params.src_dir
540
-
541
- # Override builder for preview
542
- preview_params.__dict__['fs_entry_builder'] = FSEntryBuilderOrganize()
378
+ # Get components for virtual view
379
+ virtual_walker = organizer.print_virtual_walker()
380
+ size_formatter = organizer.print_formatter_with_sizes()
381
+ max_depth = organizer.max_directory_depth()
382
+ preview_params = organizer.print_preview_params(max_depth)
543
383
 
544
384
  # Print the virtual view
545
385
  print(f"Virtual view by {fs_entry_params.by}:")
546
- DHandler.print_dir(preview_params, virtual_walker, formatter=size_aware_formatter)
386
+ DHandler.print_dir(preview_params, virtual_walker, formatter=size_formatter)
547
387
 
548
388
 
549
389
 
@@ -0,0 +1,301 @@
1
+ # coding=utf8
2
+ ## Copyright (c) 2014 Arseniy Kuznetsov
3
+ ##
4
+ ## This program is free software; you can redistribute it and/or
5
+ ## modify it under the terms of the GNU General Public License
6
+ ## as published by the Free Software Foundation; either version 2
7
+ ## of the License, or (at your option) any later version.
8
+ ##
9
+ ## This program is distributed in the hope that it will be useful,
10
+ ## but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ ## GNU General Public License for more details.
13
+
14
+ import os
15
+ import pygtrie
16
+ from collections import namedtuple
17
+ from batchmp.fstools.walker import DWalker
18
+ from batchmp.fstools.builders.fsentry import FSEntryType
19
+ from batchmp.fstools.fsutils import FSH
20
+ from batchmp.fstools.builders.fsprms import FSEntryParamsOrganize
21
+ from batchmp.fstools.builders.fsb import FSEntryBuilderOrganize
22
+
23
+
24
+ class VirtualOrganizer:
25
+ """ Helper class for creating virtual organized views without moving files.
26
+ """
27
+
28
+ def __init__(self, fs_entry_params):
29
+ self.fs_entry_params = fs_entry_params
30
+ self.dir_trie = pygtrie.StringTrie(separator=os.path.sep)
31
+ self.dir_sizes = {}
32
+
33
+ def build_virtual_structure(self):
34
+ """ Build the virtual directory structure from FSEntry data.
35
+ """
36
+ entries_to_process = list(DWalker.entries(self.fs_entry_params))
37
+ fcnt = 0
38
+
39
+ for entry in entries_to_process:
40
+ if entry.type == FSEntryType.FILE and hasattr(entry, 'target_path'):
41
+ fcnt += 1
42
+ target_dir = os.path.dirname(entry.target_path)
43
+ if not self.dir_trie.has_key(target_dir):
44
+ self.dir_trie[target_dir] = []
45
+ self.dir_trie[target_dir].append(entry)
46
+
47
+ if fcnt == 0:
48
+ return False
49
+
50
+ self._calculate_directory_sizes()
51
+ return True
52
+
53
+ def max_directory_depth(self):
54
+ """ Calculate required depth based on organization structure.
55
+ """
56
+ max_depth = 0
57
+ for target_path in self.dir_trie.keys():
58
+ rel_path = os.path.relpath(target_path, self.fs_entry_params.src_dir)
59
+ if rel_path != '.':
60
+ depth = len(rel_path.split(os.path.sep))
61
+ max_depth = max(max_depth, depth)
62
+ return max_depth
63
+
64
+ def organize_virtual_walker(self):
65
+ """ Create walker for organize method with simple alphabetical sorting.
66
+ """
67
+ root_dir = self.fs_entry_params.src_dir
68
+
69
+ def virtual_walker(root_dir):
70
+ # Build hierarchical tree structure from trie
71
+ tree = {}
72
+ for target_path, files in self.dir_trie.items():
73
+ rel_path = os.path.relpath(target_path, root_dir)
74
+ if rel_path == '.':
75
+ continue # Skip files that stay in root
76
+
77
+ # Build nested tree structure
78
+ parts = rel_path.split(os.path.sep)
79
+ node = tree
80
+ for part in parts:
81
+ node = node.setdefault(part, {})
82
+ node['__files__'] = [f.basename for f in files]
83
+
84
+ # Recursive function to yield all levels of the tree
85
+ def walk_tree(current_path, subtree):
86
+ # Get directories and files at current level (sorted alphabetically)
87
+ subdirs = sorted([k for k in subtree.keys() if k != '__files__'])
88
+ files = sorted(subtree.get('__files__', []))
89
+
90
+ # Yield current directory
91
+ yield current_path, subdirs, files
92
+
93
+ # Recursively yield subdirectories
94
+ for subdir in subdirs:
95
+ subdir_path = os.path.join(current_path, subdir)
96
+ yield from walk_tree(subdir_path, subtree[subdir])
97
+
98
+ # Start walking from root
99
+ yield from walk_tree(root_dir, tree)
100
+
101
+ return virtual_walker
102
+
103
+ def organize_preview_params(self, max_depth):
104
+ """ Create FSEntry parameters for organize preview with standard sorting.
105
+ """
106
+ preview_params = FSEntryParamsOrganize({
107
+ 'all_files': True,
108
+ 'all_dirs': True,
109
+ 'end_level': max_depth
110
+ })
111
+ preview_params.src_dir = self.fs_entry_params.src_dir
112
+ # Override builder for preview
113
+ preview_params.__dict__['fs_entry_builder'] = FSEntryBuilderOrganize()
114
+ return preview_params
115
+
116
+ def print_virtual_walker(self):
117
+ """ Create walker for print_organized_view with full sorting support.
118
+ """
119
+ root_dir = self.fs_entry_params.src_dir
120
+
121
+ def virtual_walker(root_dir):
122
+ # Build hierarchical tree structure from trie
123
+ tree = {}
124
+ for target_path, files in self.dir_trie.items():
125
+ rel_path = os.path.relpath(target_path, root_dir)
126
+ if rel_path == '.':
127
+ # Files that stay in root
128
+ tree.setdefault('__files__', []).extend([f.basename for f in files])
129
+ continue
130
+
131
+ # Build nested tree structure
132
+ parts = rel_path.split(os.path.sep)
133
+ node = tree
134
+ for part in parts:
135
+ node = node.setdefault(part, {})
136
+ node['__files__'] = [f.basename for f in files]
137
+
138
+ # Recursive function to yield all levels of the tree
139
+ def walk_tree(current_path, subtree):
140
+ # Get directories and files at current level
141
+ subdirs = [k for k in subtree.keys() if k != '__files__']
142
+ files = subtree.get('__files__', [])
143
+
144
+ # Apply custom sorting for size-based sorts (FSEntry can't handle virtual paths)
145
+ if self.fs_entry_params.by_size:
146
+ subdirs = self._sort_entries_by_size(subdirs, is_dir=True)
147
+ files = self._sort_entries_by_size(files, is_dir=False)
148
+
149
+ # Yield current directory (use copies to prevent DWalker from modifying our sorted lists)
150
+ yield current_path, subdirs.copy(), files.copy()
151
+
152
+ # Recursively yield subdirectories
153
+ for subdir in subdirs:
154
+ subdir_path = os.path.join(current_path, subdir)
155
+ yield from walk_tree(subdir_path, subtree[subdir])
156
+
157
+ # Start walking from root
158
+ yield from walk_tree(root_dir, tree)
159
+
160
+ return virtual_walker
161
+
162
+ def print_formatter_with_sizes(self):
163
+ """ Create formatter for print_organized_view that shows sizes for both files and virtual dirs.
164
+ """
165
+ def size_aware_formatter(entry):
166
+ if self.fs_entry_params.show_size:
167
+ if entry.type == FSEntryType.FILE:
168
+ # For files, show size from their real path (original file location)
169
+ # Find the original file in our file mapping
170
+ original_file = None
171
+ for target_path, files in self.dir_trie.items():
172
+ for file_entry in files:
173
+ if file_entry.basename == entry.basename:
174
+ original_file = file_entry
175
+ break
176
+ if original_file:
177
+ break
178
+
179
+ if original_file:
180
+ try:
181
+ fsize = os.path.getsize(original_file.realpath)
182
+ size_str = FSH.fs_size(fsize)
183
+ return f" {size_str} {entry.basename}"
184
+ except (OSError, IOError):
185
+ pass
186
+
187
+ elif entry.type == FSEntryType.DIR:
188
+ # For virtual directories, show aggregated size of contained files
189
+ # Find the corresponding target path for this virtual directory
190
+ virtual_path = entry.realpath
191
+ if virtual_path in self.dir_sizes:
192
+ total_size = self.dir_sizes[virtual_path]
193
+ if total_size > 0:
194
+ size_str = FSH.fs_size(total_size)
195
+ return f" {size_str} {entry.basename}"
196
+
197
+ # For directories without size info or when size not requested, just return basename
198
+ return entry.basename
199
+
200
+ return size_aware_formatter
201
+
202
+ def print_preview_params(self, max_depth):
203
+ """ Create FSEntry parameters for print_organized_view with advanced sorting.
204
+ """
205
+
206
+ # For size-based sorting, we need to completely bypass FSEntry sorting
207
+ # since it tries to access virtual file paths that don't exist
208
+ if self.fs_entry_params.by_size:
209
+ # Create a custom FSEntry class that bypasses sorting
210
+ class NoSortFSEntryParamsOrganize(FSEntryParamsOrganize):
211
+ class NoSortFilesDescriptor:
212
+ def __set__(self, instance, value):
213
+ # Just store the files without any sorting or filtering
214
+ # The virtual walker already sorted them correctly
215
+ instance._fnames = value
216
+ def __get__(self, instance, owner):
217
+ return getattr(instance, '_fnames', [])
218
+
219
+ class NoSortDirsDescriptor:
220
+ def __set__(self, instance, value):
221
+ # Just store the dirs without any sorting or filtering
222
+ # The virtual walker already sorted them correctly
223
+ DNames = namedtuple('DNames', ['passed', 'enclosing'])
224
+ instance._dnames = DNames(value, [])
225
+ def __get__(self, instance, owner):
226
+ DNames = namedtuple('DNames', ['passed', 'enclosing'])
227
+ return getattr(instance, '_dnames', DNames([], []))
228
+
229
+ # Override the descriptors to bypass sorting
230
+ fnames = NoSortFilesDescriptor()
231
+ dnames = NoSortDirsDescriptor()
232
+
233
+ preview_params = NoSortFSEntryParamsOrganize({
234
+ 'all_files': True,
235
+ 'all_dirs': True,
236
+ 'end_level': max_depth,
237
+ 'show_size': False # We handle sizes with custom formatter
238
+ })
239
+ preview_params.src_dir = self.fs_entry_params.src_dir
240
+ preview_params.sort = 'na' # Won't be used due to custom descriptors
241
+ else:
242
+ # For name-based sorting, let FSEntry handle it normally
243
+ preview_params = FSEntryParamsOrganize({
244
+ 'all_files': True,
245
+ 'all_dirs': True,
246
+ 'end_level': max_depth,
247
+ 'show_size': False # We handle sizes with custom formatter
248
+ })
249
+ preview_params.src_dir = self.fs_entry_params.src_dir
250
+ preview_params.sort = self.fs_entry_params.sort
251
+
252
+ # Override builder for preview
253
+ preview_params.__dict__['fs_entry_builder'] = FSEntryBuilderOrganize()
254
+ return preview_params
255
+
256
+
257
+ ## Helpers
258
+ def _calculate_directory_sizes(self):
259
+ """ Pre-calculate directory sizes by aggregating file sizes.
260
+ """
261
+ if self.fs_entry_params.show_size:
262
+ for target_path, files in self.dir_trie.items():
263
+ total_size = 0
264
+ for file_entry in files:
265
+ try:
266
+ fsize = os.path.getsize(file_entry.realpath)
267
+ total_size += fsize
268
+ except (OSError, IOError):
269
+ pass
270
+ self.dir_sizes[target_path] = total_size
271
+
272
+ def _sort_entries_by_size(self, items, is_dir):
273
+ """ Sort items by size when FSEntry sorting would fail due to virtual paths.
274
+ """
275
+ if not items:
276
+ return items
277
+
278
+ if is_dir:
279
+ # For directories, use aggregated sizes from dir_sizes
280
+ def size_key(dirname):
281
+ # Find the virtual directory path that corresponds to this dirname
282
+ for target_path in self.dir_sizes.keys():
283
+ if target_path.endswith(os.sep + dirname) or target_path.endswith(dirname):
284
+ return self.dir_sizes.get(target_path, 0)
285
+ return 0
286
+ sort_key = size_key
287
+ else:
288
+ # For files, look up original file size from dir_trie
289
+ def file_size_key(filename):
290
+ # Find the file entry in dir_trie to get its real path
291
+ for target_path, files in self.dir_trie.items():
292
+ for file_entry in files:
293
+ if file_entry.basename == filename:
294
+ try:
295
+ return os.path.getsize(file_entry.realpath)
296
+ except (OSError, IOError):
297
+ return 0
298
+ return 0
299
+ sort_key = file_size_key
300
+
301
+ return sorted(items, key=sort_key, reverse=self.fs_entry_params.descending)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchmp
3
- Version: 1.4
3
+ Version: 1.4.2
4
4
  Summary: Command-line tools for batch media processing
5
5
  Home-page: https://github.com/akpw/batch-mp-tools
6
6
  Author: Arseniy Kuznetsov
@@ -29,6 +29,9 @@ Requires-Dist: mutagen>=1.27
29
29
  Requires-Dist: pygtrie>=2.3.2
30
30
  Requires-Dist: filetype>=1.0.7
31
31
  Requires-Dist: mediafile>=0.13.0
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest; extra == "test"
34
+ Requires-Dist: pytest-mock; extra == "test"
32
35
  Dynamic: author
33
36
  Dynamic: author-email
34
37
  Dynamic: classifier
@@ -38,6 +41,7 @@ Dynamic: home-page
38
41
  Dynamic: keywords
39
42
  Dynamic: license
40
43
  Dynamic: license-file
44
+ Dynamic: provides-extra
41
45
  Dynamic: requires-dist
42
46
  Dynamic: summary
43
47
 
@@ -133,7 +137,10 @@ Or preview how files would look organized by date without moving them:
133
137
  |- 02/
134
138
  |- video.mp4
135
139
  ```
136
-
140
+ For more detailed examples and advanced organize / virtual views functionality, see:
141
+ - [Renamer Organize & Virtual Views](https://akpw.github.io/articles/2025/09/22/Print-and-Organize.html)
142
+ - [BatchMP Tools Tutorial, Part II: renaming files with renamer](https://akpw.github.io/articles/2015/04/11/batchmp-tutorial-part-ii.html)
143
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
137
144
 
138
145
 
139
146
  [**Tagger**](https://github.com/akpw/batch-mp-tools#tagger) manages media metadata, such as tags and artwork. Setting those in selected media file over multiple nested directories now becomes a breeze, with just a few simple commands working uniformly over almost any practically imaginable audio / video media formats. While easy to use, Tagger supports advanced metadata manipulation such as regexp-based replace, expandable template processing, etc. For example, to set the title tag to respective file names followed by the values of track and tracktotal tags:
@@ -160,6 +167,11 @@ Or preview how files would look organized by date without moving them:
160
167
  ```
161
168
  The commands above show some of the available global options: `-r` for recursion into nested folders and `-in` to select media files. In the example above just one file was selected (for the sake of output brevity), which also could be achived via using `-f` for the file source mode.
162
169
 
170
+ For more practical examples, see:
171
+ - [BatchMP Tools Tutorial Part III: setting tags and artwork with tagger](https://akpw.github.io/articles/2015/04/12/batchmp-tutorial-part-iii.html)
172
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
173
+
174
+
163
175
 
164
176
  [**BMFP**](https://github.com/akpw/batch-mp-tools/blob/master/README.md#bmfp-requires-ffmpeg) is all about efficient media content processing, such as conversion between various formats, normalizing sound volume, segmenting / fragmenting media files, denoising audio, detaching individual audio / video streams, etc. As processing media files can typically be resource consuming, BMFP is designed to take advantage of multi-core processors. By default, it automatically breaks up jobs into individual tasks that are then run as separate processes on available CPU cores.
165
177
  **BMFP is built on top of [FFmpeg](http://ffmpeg.org/download.html), which needs to be installed and available in the command line**. BMFP can be thought of as a batch FFmpeg runner, intended to make common uses of FFmpeg easy while not restricting its full power.
@@ -207,7 +219,10 @@ To check on the result, lets's just use the [tagger's](https://github.com/akpw/b
207
219
  ```
208
220
  From a brief glance, all looks OK. BMFP used FFmpeg to do the actual conversion, while taking care of all other things like preserving tags / artwork, etc.
209
221
 
210
- I will follow up with more examples and common use-cases in future blogs.
222
+ For more practical examples, see:
223
+ - [BatchMP Tools Tutorial, splitting a long media file with bmfp](https://akpw.github.io/articles/2015/04/10/batchmp-tutorial-part-i.html)
224
+ - Other related posts in [Practical BatchMP](https://akpw.github.io//tags.html#BatchMP+Tools)
225
+
211
226
 
212
227
 
213
228
  ## Brief Description of CLI Commands (use -h to expand on details for individual commands)
@@ -395,10 +410,23 @@ Support via FFmpeg: 'AVI', 'FLV', 'MKV', 'MKA'
395
410
 
396
411
 
397
412
  ## Installing Development version
398
- - Clone the repo, then run: `$ python -m pip install .`
413
+ - Clone the repo, create a virtual environment, and activate it:
414
+ ```bash
415
+ $ python3 -m venv .venv
416
+ $ source .venv/bin/activate
417
+ ```
418
+ - Install the project in editable mode:
419
+ ```bash
420
+ $ pip install -e .
421
+ ```
399
422
 
400
423
  ## Running Tests
401
424
 
425
+ To run the test suite, first ensure you have installed the development dependencies:
426
+ ```bash
427
+ $ pip install -e ".[test]"
428
+ ```
429
+
402
430
  **Using pytest (recommended):**
403
431
  ```bash
404
432
  $ pytest -v --tb=short # Run all tests with verbose output
@@ -1,5 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
+ pyproject.toml
3
4
  setup.cfg
4
5
  setup.py
5
6
  batchmp/__init__.py
@@ -52,6 +53,7 @@ batchmp/fstools/__init__.py
52
53
  batchmp/fstools/dirtools.py
53
54
  batchmp/fstools/fsutils.py
54
55
  batchmp/fstools/rename.py
56
+ batchmp/fstools/virtual_organizer.py
55
57
  batchmp/fstools/walker.py
56
58
  batchmp/fstools/builders/__init__.py
57
59
  batchmp/fstools/builders/fsb.py
@@ -2,3 +2,7 @@ mutagen>=1.27
2
2
  pygtrie>=2.3.2
3
3
  filetype>=1.0.7
4
4
  mediafile>=0.13.0
5
+
6
+ [test]
7
+ pytest
8
+ pytest-mock
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
@@ -20,7 +20,7 @@ with open(path.join(pkg_dir, 'README.md'), encoding='utf-8') as f:
20
20
 
21
21
  setup(
22
22
  name='batchmp',
23
- version='1.4',
23
+ version='1.4.2',
24
24
 
25
25
  url='https://github.com/akpw/batch-mp-tools',
26
26
 
@@ -38,6 +38,13 @@ setup(
38
38
 
39
39
  install_requires = ['mutagen>=1.27', 'pygtrie>=2.3.2', 'filetype>=1.0.7', 'mediafile>=0.13.0'], ##, 'profilehooks>=1.11.0'],
40
40
 
41
+ extras_require={
42
+ 'test': [
43
+ 'pytest',
44
+ 'pytest-mock',
45
+ ],
46
+ },
47
+
41
48
  entry_points={'console_scripts': [
42
49
  'batchmp = batchmp.cli.base.bmp_dispatch:main',
43
50
  'renamer = batchmp.cli.renamer.renamer_dispatch:main',
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes