Skip to content

Commit ebde312

Browse files
committed
feat(move): add configuration option to change paths
Illegal characters will also be removed now from paths.
1 parent e581808 commit ebde312

File tree

6 files changed

+223
-41
lines changed

6 files changed

+223
-41
lines changed

.pre-commit-config.yaml

+29-18
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,42 @@
11
default_stages: [commit]
22

33
repos:
4-
- repo: https://github.com/psf/black
5-
rev: 22.6.0
4+
- repo: local
65
hooks:
7-
- id: black
8-
language_version: python3
6+
- id: black
7+
name: black
8+
entry: black
9+
language: system
10+
types: [python]
911

10-
- repo: https://gitlab.com/pycqa/flake8
11-
rev: 3.9.2
12+
- repo: local
1213
hooks:
13-
- id: flake8
14-
additional_dependencies:
15-
[flake8-use-fstring, flake8-docstrings, wemake-python-styleguide]
14+
- id: flake8
15+
name: flake8
16+
entry: flake8
17+
language: system
18+
types: [python]
1619

17-
- repo: https://github.com/commitizen-tools/commitizen
18-
rev: v2.31.0
20+
- repo: local
1921
hooks:
20-
- id: commitizen
22+
- id: commitizen
23+
name: commitizen
24+
entry: commitizen
25+
language: system
2126
stages: [commit-msg]
2227

23-
- repo: https://github.com/timothycrosley/isort
24-
rev: 5.10.1
28+
- repo: local
2529
hooks:
26-
- id: isort
30+
- id: isort
31+
name: isort
32+
entry: isort
33+
language: system
34+
types: [python]
2735

28-
- repo: https://github.com/RobertCraigie/pyright-python
29-
rev: v1.1.267
36+
- repo: local
3037
hooks:
31-
- id: pyright
38+
- id: pyright
39+
name: pyright
40+
entry: pyright
41+
language: system
42+
types: [python]

docs/developers/api/core.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Config
1010
:members:
1111
:exclude-members: plugin, name
1212

13+
.. _Library API:
1314

1415
Library
1516
=======

docs/fields.rst

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. _Fields:
2+
13
######
24
Fields
35
######
@@ -30,6 +32,8 @@ Track Fields
3032
"track_num", "Track number", ""
3133
"year", "Album release year", ""
3234

35+
.. _Album Fields:
36+
3337
************
3438
Album Fields
3539
************
@@ -45,11 +49,14 @@ Album Fields
4549
"title", "Album title", ""
4650
"year", "Album release year", ""
4751

52+
.. _Extra Fields:
53+
4854
************
4955
Extra Fields
5056
************
5157
.. csv-table::
5258
:header: "Field", "Description", "Notes"
5359
:widths: 20, 50, 50
5460

61+
"filename", "The filename of the extra.", ""
5562
"path", "Filesystem path of the extra", ""

docs/plugins/move.rst

+30-13
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
1+
.. _Move Plugin:
2+
13
####
24
Move
35
####
46
Alters the location of files in your library.
57

6-
The ``move`` plugin provides the following features:
7-
8-
* Any items added to your library will be copied to the location set by the global option, ``library_path`` in your configuration file.
9-
* Any items moved or copied will have their paths set to a default format. This default format cannot currently be configured, and is as follows:
10-
11-
* Albums: ``{library_path}/{albumartist}/{album_title} ({album_year})/``
12-
* Tracks: ``{album_path}/{track_number} - {track_title}.{file_ext}``
13-
14-
If the album contains more than one disc, tracks will be formatted as:
15-
16-
``{album_path}/Disc {disc#}/{track_number} - {track_title}.{file_ext}``
17-
* Extras: ``{album_path}/{original_file_name}``
8+
.. note::
9+
Any items added to your library will be automatically copied to their respective path configurations.
1810

1911
*************
2012
Configuration
@@ -26,10 +18,35 @@ The ``move`` plugin is enabled by default and provides the following configurati
2618

2719
If ``true``, non-ascii characters will be converted to their ascii equivalents, e.g. ``café.mp3`` will become ``cafe.mp3``.
2820

21+
Path Configuration Options
22+
--------------------------
23+
``album_path = "{album.artist}/{album.title} ({album.year})"``
24+
Album filesystem path format relative to the global configuration option, :ref:`library_path <library_path config option>`.
25+
26+
27+
``track_path = "{f'Disc {track.disc:02}' if track.disc_total > 1 else ''}/{track.track_num:02} - {track.title}{track.path.suffix}"``
28+
Track filesystem path format relative to ``album_path``.
29+
30+
.. note::
31+
- The ``if`` statement inside the path simply means that if there is more than one disc in the album, the tracks will be put into separate directories for their respective disc.
32+
- Include ``track.path.suffix`` at the end if you wish to retain the file extension of the track file.
33+
34+
``extra_path = "{extra.filename}"``
35+
Extra filesystem path format relative to ``album_path``.
36+
37+
Paths are formatted using python `f-strings <https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals>`_ which, as demonstrated by the default track path, allow all the advanced formatting and expression evaluation that come with them. You can access any of the :ref:`respective item's fields <Fields>` in these strings using ``{[album/track/extra].field}`` notation as shown.
38+
39+
.. important::
40+
Windows users should use a forward slash ``/`` when delineating sub-directories in path formats as the back slash ``\`` is used as an escape character.
41+
42+
.. tip::
43+
- For any path formatting changes, run ``moe move -n`` for a dry-run to avoid any unexpected results.
44+
- For a more detailed look at all the field options and types, you take a look at the :ref:`library api <Library API>`. ``album``, ``track``, and ``extra`` in the path formats are ``Album``, ``Track``, and ``Extra`` objects respectively and thus you can reference any of their available attributes.
45+
2946
***********
3047
Commandline
3148
***********
32-
The ``move`` command will move all items in the library according to your configuration file. This is useful if you make changes to the configuration file, and you'd like to move the items in your library to reflect the new changes.
49+
The ``move`` command will move all items in the library according to your configuration file. This can be used to update the items in your library to reflect changes in your configuration.
3350

3451
.. code-block:: bash
3552

moe/plugins/move/move_core.py

+113-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Core api for moving items."""
22

33
import logging
4+
import re
45
import shutil
56
from contextlib import suppress
67
from pathlib import Path
@@ -24,8 +25,24 @@
2425
@moe.hookimpl
2526
def add_config_validator(settings: dynaconf.base.LazySettings):
2627
"""Validate move plugin configuration settings."""
28+
default_album_path = "{album.artist}/{album.title} ({album.year})"
29+
default_extra_path = "{extra.filename}"
30+
default_track_path = (
31+
"{f'Disc {track.disc:02}' if track.disc_total > 1 else ''}/"
32+
"{track.track_num:02} - {track.title}{track.path.suffix}"
33+
)
34+
35+
settings.validators.register(
36+
dynaconf.Validator("MOVE.ASCIIFY_PATHS", default=False)
37+
)
38+
settings.validators.register(
39+
dynaconf.Validator("MOVE.ALBUM_PATH", default=default_album_path)
40+
)
2741
settings.validators.register(
28-
dynaconf.Validator("MOVE.ASCIIFY_PATHS", must_exist=True, default=False)
42+
dynaconf.Validator("MOVE.EXTRA_PATH", default=default_extra_path)
43+
)
44+
settings.validators.register(
45+
dynaconf.Validator("MOVE.TRACK_PATH", default=default_track_path)
2946
)
3047

3148

@@ -88,14 +105,17 @@ def _fmt_album_path(config: Config, album: Album) -> Path:
88105
Formatted album directory under the config ``library_path``.
89106
"""
90107
library_path = Path(config.settings.library_path).expanduser()
91-
album_dir_name = f"{album.artist}/{album.title} ({album.year})"
108+
album_path = _eval_path_template(config.settings.move.album_path, album)
92109

93-
return library_path / album_dir_name
110+
return library_path / album_path
94111

95112

96113
def _fmt_extra_path(config: Config, extra: Extra) -> Path:
97114
"""Returns a formatted extra path according to the user configuration."""
98-
return _fmt_album_path(config, extra.album_obj) / extra.path.name
115+
album_path = _fmt_album_path(config, extra.album_obj)
116+
extra_path = _eval_path_template(config.settings.move.extra_path, extra)
117+
118+
return album_path / extra_path
99119

100120

101121
def _fmt_track_path(config: Config, track: Track) -> Path:
@@ -111,14 +131,97 @@ def _fmt_track_path(config: Config, track: Track) -> Path:
111131
Returns:
112132
Formatted track path under its album path.
113133
"""
114-
disc_dir_name = ""
115-
if track.disc_total > 1:
116-
disc_dir_name = f"Disc {track.disc:02}"
117-
disc_dir = _fmt_album_path(config, track.album_obj) / disc_dir_name
134+
album_path = _fmt_album_path(config, track.album_obj)
135+
track_path = _eval_path_template(config.settings.move.track_path, track)
136+
137+
return album_path / track_path
138+
139+
140+
def _eval_path_template(template, lib_item) -> str:
141+
"""Evaluates and sanitizes a path template.
142+
143+
Args:
144+
template: Path template.
145+
See `_lazy_fstr_item()` for more info on accepted f-string templates.
146+
lib_item: Library item associated with the template.
147+
148+
Returns:
149+
Evaluated path.
150+
151+
Raises:
152+
NotImplementedError: You discovered a new library item!
153+
"""
154+
template_parts = template.split("/")
155+
sanitized_parts = []
156+
for template_part in template_parts:
157+
path_part = _lazy_fstr_item(template_part, lib_item)
158+
sanitized_part = _sanitize_path_part(path_part)
159+
if sanitized_part:
160+
sanitized_parts.append(sanitized_part)
161+
162+
return "/".join(sanitized_parts)
163+
164+
165+
def _lazy_fstr_item(template: str, lib_item: LibItem) -> str:
166+
"""Evalutes the given f-string template for a specific library item.
167+
168+
Args:
169+
template: f-string template to evaluate.
170+
All library items should have their own template and refer to variables as:
171+
Album: album (e.g. {album.title}, {album.artist})
172+
Track: track (e.g. {track.title}, {track.artist})
173+
Extra: extra (e.g. {extra.filename}
174+
lib_item: Library item referenced in the template.
175+
176+
177+
Example:
178+
The default path template for an album is::
179+
180+
{album.artist}/{album.title} ({album.year})
181+
182+
Returns:
183+
Evaluated f-string.
184+
185+
Raises:
186+
NotImplementedError: You discovered a new library item!
187+
"""
188+
# add the appropriate library item to the scope
189+
if isinstance(lib_item, Album):
190+
album = lib_item # noqa: F841
191+
elif isinstance(lib_item, Track):
192+
track = lib_item # noqa: F841
193+
elif isinstance(lib_item, Extra):
194+
extra = lib_item # noqa: F841
195+
else:
196+
raise NotImplementedError
197+
198+
return eval(f'f"""{template}"""')
199+
200+
201+
def _sanitize_path_part(path_part: str) -> str:
202+
"""Sanitizes a part of a path to be compatible with most filesystems.
203+
204+
Note:
205+
Only sub-paths of the library path will be affected.
206+
207+
Args:
208+
path_part: Path part to sanitize. Must be a single 'part' of a path, i.e. no /
209+
separators.
210+
211+
Returns:
212+
Path part with all the replacements applied.
213+
"""
214+
PATH_REPLACE_CHARS = {
215+
r"^\.": "_", # leading '.' (hidden files on Unix)
216+
r'[<>:"\?\*\|\\/]': "_", # <, >, : , ", ?, *, |, \, / (Windows reserved chars)
217+
r"\.$": "_", # trailing '.' (Windows restriction)
218+
r"\s+$": "", # trailing whitespace (Windows restriction)
219+
}
118220

119-
track_filename = f"{track.track_num:02} - {track.title}{track.path.suffix}"
221+
for regex, replacement in PATH_REPLACE_CHARS.items():
222+
path_part = re.sub(regex, replacement, path_part)
120223

121-
return disc_dir / track_filename
224+
return path_part
122225

123226

124227
########################################################################################

tests/plugins/move/test_move_core.py

+43
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,37 @@ def mock_copy():
2323
########################################################################################
2424
# Test format paths
2525
########################################################################################
26+
class TestReplaceChars:
27+
"""Test replacing of illegal or unwanted characters in paths."""
28+
29+
def test_replace_chars(self, mock_track_factory, tmp_config):
30+
"""Replace all the defined illegal characters from any paths."""
31+
config = tmp_config(
32+
settings="""
33+
default_plugins = ["move"]
34+
[move]
35+
track_path = "{track.title}"
36+
"""
37+
)
38+
tracks = []
39+
replacements = []
40+
tracks.append(mock_track_factory(title='/ reserved <, >, :, ", ?, *, |, /'))
41+
replacements.append("_ reserved _, _, _, _, _, _, _, _")
42+
tracks.append(mock_track_factory(title=".leading dot"))
43+
replacements.append("_leading dot")
44+
tracks.append(mock_track_factory(title="trailing dot."))
45+
replacements.append("trailing dot_")
46+
tracks.append(mock_track_factory(title="trailing whitespace "))
47+
replacements.append("trailing whitespace")
48+
49+
formatted_paths = []
50+
for track in tracks:
51+
formatted_paths.append(moe_move.fmt_item_path(config, track))
52+
53+
for path in formatted_paths:
54+
assert any(replacement == path.name for replacement in replacements)
55+
56+
2657
class TestFmtAlbumPath:
2758
"""Tests `fmt_item_path(album)`."""
2859

@@ -353,6 +384,18 @@ def test_asciify_paths(self, tmp_move_config):
353384
"""`asciify_paths` is not required and defaults to 'False'."""
354385
assert tmp_move_config.settings.move.asciify_paths == False # noqa: E712
355386

387+
def test_album_path(self, tmp_move_config):
388+
"""`album_path` is not required and has a default."""
389+
assert tmp_move_config.settings.move.album_path
390+
391+
def test_extra_path(self, tmp_move_config):
392+
"""`extra_path` is not required and has a default."""
393+
assert tmp_move_config.settings.move.extra_path
394+
395+
def test_track_path(self, tmp_move_config):
396+
"""`track_path` is not required and has a default."""
397+
assert tmp_move_config.settings.move.track_path
398+
356399

357400
class TestPostAdd:
358401
"""Test the `post_add` hook implementation.

0 commit comments

Comments
 (0)