rekordbox-edit 0.4.0.dev22__tar.gz → 0.4.0.dev24__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 (47) hide show
  1. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.pre-commit-config.yaml +2 -2
  2. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/AGENTS.md +1 -1
  3. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/CHANGELOG.md +18 -1
  4. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/PKG-INFO +2 -1
  5. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/pyproject.toml +2 -1
  6. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/commands/convert.py +6 -2
  7. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/commands/edit.py +15 -4
  8. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/commands/search.py +1 -3
  9. rekordbox_edit-0.4.0.dev24/rekordbox_edit/display.py +119 -0
  10. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/utils.py +0 -104
  11. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/commands/test_convert.py +21 -4
  12. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/commands/test_edit.py +101 -0
  13. rekordbox_edit-0.4.0.dev24/tests/test_display.py +149 -0
  14. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/test_utils.py +0 -157
  15. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/uv.lock +37 -1
  16. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.agent-style/RULES.md +0 -0
  17. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.agent-style/claude-code.md +0 -0
  18. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/actions/commitizen-bump/action.yml +0 -0
  19. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  20. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/actions/install/action.yml +0 -0
  21. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/actions/lint/action.yml +0 -0
  22. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/actions/test/action.yml +0 -0
  23. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/workflows/cd.yml +0 -0
  24. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/workflows/ci.yml +0 -0
  25. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/workflows/publish.yml +0 -0
  26. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.github/workflows/release.yml +0 -0
  27. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/.gitignore +0 -0
  28. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/CLAUDE.md +0 -0
  29. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/CONTRIBUTING.md +0 -0
  30. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/LICENSE +0 -0
  31. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/Makefile +0 -0
  32. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/README.md +0 -0
  33. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/codecov.yml +0 -0
  34. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/__init__.py +0 -0
  35. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/_click.py +0 -0
  36. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/cli.py +0 -0
  37. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/commands/__init__.py +0 -0
  38. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/logger.py +0 -0
  39. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/rekordbox_edit/query.py +0 -0
  40. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/renovate.json5 +0 -0
  41. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/ruff.toml +0 -0
  42. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/__init__.py +0 -0
  43. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/commands/__init__.py +0 -0
  44. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/commands/test_search.py +0 -0
  45. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/conftest.py +0 -0
  46. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/test_logger.py +0 -0
  47. {rekordbox_edit-0.4.0.dev22 → rekordbox_edit-0.4.0.dev24}/tests/test_query.py +0 -0
@@ -15,7 +15,7 @@ repos:
15
15
  - id: check-toml
16
16
  - id: check-case-conflict
17
17
  - repo: https://github.com/commitizen-tools/commitizen
18
- rev: v4.13.10
18
+ rev: v4.16.2
19
19
  hooks:
20
20
  - id: commitizen
21
21
  stages: [commit-msg]
@@ -28,7 +28,7 @@ repos:
28
28
  files: pyproject\.toml$
29
29
  pass_filenames: false
30
30
  - repo: https://github.com/astral-sh/ruff-pre-commit
31
- rev: v0.15.11
31
+ rev: v0.15.13
32
32
  hooks:
33
33
  - id: ruff-check
34
34
  args: [--fix]
@@ -89,7 +89,7 @@ Every module should get and use its own `logger` for all logging purposes and fo
89
89
  - Implement the smallest functional slice first, then layer features in follow-up commits. Each commit should be "green" and independently deployable.
90
90
  - Follow Conventional Commits (see `CONTRIBUTING.md` and the schema in `pyproject.toml`). No `Co-Authored-By` trailer.
91
91
  - Commit descriptions should be terse and use active tenses (e.g. "add feature")
92
- - Commit bodies should describe changes, but should not include agent conversation context, decisions, or plan notes.
92
+ - Commit bodies are completely optional, and should be used to describe changes when they aren't easily inferred by the main message, they should not include agent conversation context, decisions, or plan notes.
93
93
  - The agent may commit. But the user always handles pushes, PR creation, and rebases after merges.
94
94
  - Do not commit spec, brainstorming, or design documents (e.g. anything under `docs/superpowers/specs/`).
95
95
 
@@ -1,6 +1,23 @@
1
- ## v0.4.0.dev22 (2026-05-17)
1
+ ## v0.4.0.dev24 (2026-05-22)
2
2
 
3
3
 
4
+ - fix(display): split up the unified Location column into FolderPath and FileName
5
+ - docs: update AGENTS.md
6
+ - feat(display): add before/after change preview to print_track_info
7
+ - refactor(display): render print_track_info with rich.table.Table
8
+ - Replace the fixed-width f-string loop with a rich Table so column widths
9
+ adapt to content and embedded ANSI sequences no longer skew alignment.
10
+ PRINT_HEADERS becomes plain column labels (rich handles padding). Drain
11
+ the recorded output to the debug log after each render.
12
+ - refactor(display): extract print_track_info into display module
13
+ - Add rich dependency and move PrintableField, PRINT_WIDTHS, PRINT_HEADERS,
14
+ truncate_field, and print_track_info from utils to a new display module
15
+ with a module-level Console for upcoming rich-based rendering. Update
16
+ edit, convert, and search command imports. Move associated tests from
17
+ test_utils.py to test_display.py. Pure move; no behavior change.
18
+ - chore(deps): update pre-commit hooks (#29)
19
+ - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
20
+ - feat(edit): add --multi to allow batch edits past single-track guard
4
21
  - feat(edit): add --match for literal find/replace within field value
5
22
  - Introduces a _compute_new_value helper and --match option so that
6
23
  rbe edit can replace a substring of the current field value instead of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.4.0.dev22
3
+ Version: 0.4.0.dev24
4
4
  Summary: Tools for managing and modifying a RekordBox library en-masse
5
5
  Project-URL: Homepage, https://github.com/jviall/rekordbox-edit
6
6
  Project-URL: Repository, https://github.com/jviall/rekordbox-edit
@@ -14,6 +14,7 @@ Requires-Dist: click<=9.0.0,>=8.0.0
14
14
  Requires-Dist: ffmpeg-python>=0.2.0
15
15
  Requires-Dist: platformdirs<5.0.0,>=4.3.8
16
16
  Requires-Dist: pyrekordbox==0.4.4
17
+ Requires-Dist: rich<15.0.0,>=13.0.0
17
18
  Description-Content-Type: text/markdown
18
19
 
19
20
  # rekordbox-edit
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rekordbox-edit"
7
- version = "0.4.0.dev22"
7
+ version = "0.4.0.dev24"
8
8
  description = "Tools for managing and modifying a RekordBox library en-masse"
9
9
  authors = [{ name = "James Viall", email= "jamesviall@pm.me"}]
10
10
  license = "MIT"
@@ -16,6 +16,7 @@ dependencies = [
16
16
  "ffmpeg-python>=0.2.0",
17
17
  "click>=8.0.0,<=9.0.0",
18
18
  "platformdirs>=4.3.8,<5.0.0",
19
+ "rich>=13.0.0,<15.0.0",
19
20
  ]
20
21
 
21
22
  [dependency-groups]
@@ -22,6 +22,7 @@ from rekordbox_edit._click import (
22
22
  )
23
23
  from rekordbox_edit.logger import get_debug_file_path, set_level
24
24
  from rekordbox_edit.query import get_filtered_content
25
+ from rekordbox_edit.display import PrintableField, print_track_info
25
26
  from rekordbox_edit.utils import (
26
27
  OutputFormats,
27
28
  UserQuit,
@@ -30,7 +31,6 @@ from rekordbox_edit.utils import (
30
31
  get_extension_for_format,
31
32
  get_file_type_for_format,
32
33
  get_file_type_name,
33
- print_track_info,
34
34
  )
35
35
 
36
36
  logger = logging.getLogger(__name__)
@@ -456,7 +456,11 @@ def convert_command(
456
456
  logger.info(
457
457
  f"Found {len(files_to_process)} files to convert to {format_out.upper()}"
458
458
  )
459
- print_track_info(files_to_process)
459
+ print_track_info(
460
+ files_to_process,
461
+ changed_field=PrintableField.FileType,
462
+ new_values=[format_out.upper()] * len(files_to_process),
463
+ )
460
464
 
461
465
  if dry_run:
462
466
  if print_opt is PrintChoice.IDS:
@@ -16,7 +16,8 @@ from rekordbox_edit._click import (
16
16
  )
17
17
  from rekordbox_edit.logger import get_debug_file_path, set_level
18
18
  from rekordbox_edit.query import get_filtered_content
19
- from rekordbox_edit.utils import UserQuit, confirm, print_track_info
19
+ from rekordbox_edit.display import PrintableField, print_track_info
20
+ from rekordbox_edit.utils import UserQuit, confirm
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
@@ -73,6 +74,11 @@ def _compute_new_value(
73
74
  metavar="PATTERN",
74
75
  help="Find this literal string within the field value and replace only that portion",
75
76
  )
77
+ @click.option(
78
+ "--multi",
79
+ is_flag=True,
80
+ help="Allow editing more than one track (required when filters match multiple tracks)",
81
+ )
76
82
  @track_ids_argument
77
83
  @click.argument(
78
84
  "field",
@@ -82,6 +88,7 @@ def edit_command(
82
88
  field: str,
83
89
  replace_value: str,
84
90
  match_pattern: str | None,
91
+ multi: bool,
85
92
  dry_run: bool,
86
93
  yes: bool,
87
94
  interactive: bool,
@@ -157,13 +164,17 @@ def edit_command(
157
164
  logger.info("No changes to make.")
158
165
  return
159
166
 
160
- if len(edits) > 1:
167
+ if len(edits) > 1 and not multi:
161
168
  raise click.UsageError(
162
169
  f"Found {len(edits)} tracks that would be edited. "
163
- "Refine your filters, or use --dry-run to inspect."
170
+ "Refine your filters, use --dry-run to inspect, or pass --multi to edit all."
164
171
  )
165
172
 
166
- print_track_info([t for t, _ in edits])
173
+ print_track_info(
174
+ [t for t, _ in edits],
175
+ changed_field=PrintableField[field],
176
+ new_values=[str(v) for _, v in edits],
177
+ )
167
178
 
168
179
  if dry_run:
169
180
  if print_opt is PrintChoice.IDS:
@@ -16,9 +16,7 @@ from rekordbox_edit._click import (
16
16
  )
17
17
  from rekordbox_edit.logger import get_debug_file_path, set_level
18
18
  from rekordbox_edit.query import get_filtered_content
19
- from rekordbox_edit.utils import (
20
- print_track_info,
21
- )
19
+ from rekordbox_edit.display import print_track_info
22
20
 
23
21
  logger = logging.getLogger(__name__)
24
22
 
@@ -0,0 +1,119 @@
1
+ """Rich-based rendering for rekordbox-edit.
2
+
3
+ All rich Console output should go through the module-level ``console`` and be
4
+ drained to the debug log immediately after printing:
5
+
6
+ console.print(...)
7
+ logger.debug(console.export_text(clear=True))
8
+
9
+ Plain text output should continue to use ``logger.info()``.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ from enum import Enum
15
+ from typing import Dict, Sequence
16
+
17
+ from pyrekordbox.db6 import DjmdContent
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+
22
+ from rekordbox_edit.utils import get_file_type_name
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ console = Console(record=True)
27
+
28
+
29
+ class PrintableField(Enum):
30
+ """Columns of DjmdContent that you can print"""
31
+
32
+ ID = "ID"
33
+ FileNameL = "FileNameL"
34
+ FolderPath = "FolderPath"
35
+ FileType = "FileType"
36
+ SampleRate = "SampleRate"
37
+ BitDepth = "BitDepth"
38
+ BitRate = "BitRate"
39
+ ArtistName = "ArtistName"
40
+ AlbumName = "AlbumName"
41
+ Title = "Title"
42
+
43
+
44
+ # Column headers shown in the rendered table
45
+ PRINT_HEADERS: Dict[PrintableField, str] = {
46
+ PrintableField.ID: "ID",
47
+ PrintableField.FileNameL: "File",
48
+ PrintableField.Title: "Title",
49
+ PrintableField.ArtistName: "Artist",
50
+ PrintableField.AlbumName: "Album",
51
+ PrintableField.FileType: "Type",
52
+ PrintableField.SampleRate: "SampleRt",
53
+ PrintableField.BitRate: "BitRt",
54
+ PrintableField.BitDepth: "BitDp",
55
+ PrintableField.FolderPath: "Folder",
56
+ }
57
+
58
+
59
+ def _cell_value(content: DjmdContent, column: PrintableField) -> str:
60
+ """Render a single DjmdContent field as a string for a table cell."""
61
+ if column is PrintableField.ID:
62
+ return str(content.ID)
63
+ if column is PrintableField.FileType:
64
+ return get_file_type_name(content.FileType)
65
+ if column is PrintableField.FolderPath:
66
+ return os.path.dirname(content.FolderPath or "")
67
+ value = getattr(content, column.value)
68
+ return "" if value is None else str(value)
69
+
70
+
71
+ def print_track_info(
72
+ content_list: Sequence[DjmdContent],
73
+ print_columns: Sequence[PrintableField] | None = None,
74
+ changed_field: PrintableField | None = None,
75
+ new_values: Sequence[str] | None = None,
76
+ ):
77
+ """Print formatted track information.
78
+
79
+ When ``changed_field`` and ``new_values`` are both provided, the matching
80
+ column renders each row as a before/after preview: the old value is
81
+ struck through and the new value is appended.
82
+ """
83
+ if (changed_field is None) != (new_values is None):
84
+ raise ValueError("changed_field and new_values must be provided together")
85
+ if new_values is not None and len(new_values) != len(content_list):
86
+ raise ValueError(
87
+ f"new_values length ({len(new_values)}) must match content_list length ({len(content_list)})"
88
+ )
89
+
90
+ if not content_list:
91
+ return
92
+
93
+ print_columns = print_columns or [
94
+ PrintableField.ID,
95
+ PrintableField.Title,
96
+ PrintableField.FileType,
97
+ PrintableField.SampleRate,
98
+ PrintableField.BitDepth,
99
+ PrintableField.FolderPath,
100
+ PrintableField.FileNameL,
101
+ ]
102
+
103
+ table = Table(show_header=True, box=box.SIMPLE)
104
+ table.add_column("#", justify="right")
105
+ for column in print_columns:
106
+ table.add_column(PRINT_HEADERS[column], no_wrap=True, overflow="ellipsis")
107
+
108
+ for i, content in enumerate(content_list, 1):
109
+ cells = []
110
+ for col in print_columns:
111
+ old = _cell_value(content, col)
112
+ if col is changed_field and new_values is not None:
113
+ cells.append(f"[strike]{old}[/strike] [bold]{new_values[i - 1]}[/bold]")
114
+ else:
115
+ cells.append(old)
116
+ table.add_row(str(i), *cells)
117
+
118
+ console.print(table)
119
+ logger.debug(console.export_text(clear=True))
@@ -4,11 +4,9 @@ import logging
4
4
  import platform
5
5
  import shutil
6
6
  from enum import Enum
7
- from typing import Dict, Sequence
8
7
 
9
8
  import click
10
9
  import ffmpeg
11
- from pyrekordbox.db6 import DjmdContent
12
10
 
13
11
  logger = logging.getLogger(__name__)
14
12
 
@@ -77,108 +75,6 @@ class InputFormats(Enum):
77
75
  WAV = "wav"
78
76
 
79
77
 
80
- class PrintableField(Enum):
81
- """Columns of DjmdContent that you can print"""
82
-
83
- ID = "ID"
84
- FileNameL = "FileNameL"
85
- FolderPath = "FolderPath"
86
- FileType = "FileType"
87
- SampleRate = "SampleRate"
88
- BitDepth = "BitDepth"
89
- BitRate = "BitRate"
90
- ArtistName = "ArtistName"
91
- AlbumName = "AlbumName"
92
- Title = "Title"
93
-
94
-
95
- # Column widths (total ≈ 240 chars with spacing)
96
- PRINT_WIDTHS: Dict[PrintableField, int] = {
97
- PrintableField.ID: 10,
98
- PrintableField.FileNameL: 25,
99
- PrintableField.Title: 25,
100
- PrintableField.ArtistName: 20,
101
- PrintableField.AlbumName: 20,
102
- PrintableField.FileType: 4,
103
- PrintableField.SampleRate: 8,
104
- PrintableField.BitRate: 5,
105
- PrintableField.BitDepth: 5,
106
- PrintableField.FolderPath: 80,
107
- }
108
-
109
- # Print header
110
- PRINT_HEADERS: Dict[PrintableField, str] = {
111
- PrintableField.ID: f"{'ID':<{PRINT_WIDTHS[PrintableField.ID]}}",
112
- PrintableField.FileNameL: f"{'File':<{PRINT_WIDTHS[PrintableField.FileNameL]}}",
113
- PrintableField.Title: f"{'Title':<{PRINT_WIDTHS[PrintableField.Title]}}",
114
- PrintableField.ArtistName: f"{'Artist':<{PRINT_WIDTHS[PrintableField.ArtistName]}}",
115
- PrintableField.AlbumName: f"{'Album':<{PRINT_WIDTHS[PrintableField.AlbumName]}}",
116
- PrintableField.FileType: f"{'Type':<{PRINT_WIDTHS[PrintableField.FileType]}}",
117
- PrintableField.SampleRate: f"{'SampleRt':<{PRINT_WIDTHS[PrintableField.SampleRate]}}",
118
- PrintableField.BitRate: f"{'BitRt':<{PRINT_WIDTHS[PrintableField.BitRate]}}",
119
- PrintableField.BitDepth: f"{'BitDp':<{PRINT_WIDTHS[PrintableField.BitDepth]}}",
120
- PrintableField.FolderPath: f"{'FolderPath':<{PRINT_WIDTHS[PrintableField.FolderPath]}}",
121
- }
122
-
123
-
124
- def truncate_field(field: PrintableField, value: str | None):
125
- if value is None:
126
- return ""
127
- if len(value) <= PRINT_WIDTHS[field]:
128
- return value
129
- available = PRINT_WIDTHS[field] - 3 # Reserve 3 chars for "..."
130
- start_chars = available // 5 * 2
131
- end_chars = available - start_chars
132
- return f"{value[:start_chars]}...{value[-end_chars:]}"
133
-
134
-
135
- def print_track_info(
136
- content_list: Sequence[DjmdContent],
137
- print_columns: Sequence[PrintableField] | None = None,
138
- ):
139
- """Print formatted track information"""
140
- if not content_list:
141
- return
142
-
143
- print_columns = print_columns or [
144
- PrintableField.ID,
145
- PrintableField.Title,
146
- PrintableField.FileType,
147
- PrintableField.SampleRate,
148
- PrintableField.BitDepth,
149
- PrintableField.FolderPath,
150
- ]
151
-
152
- # Calculate width for position column: 2 spaces + digits needed for max position
153
- pos_width = 2 + len(str(len(content_list)))
154
- header = f"{'#':<{pos_width}}" + " ".join(
155
- map(lambda col: PRINT_HEADERS[col], print_columns)
156
- )
157
- logger.info(header)
158
- logger.info("-" * len(header))
159
-
160
- # Print each track
161
- for i, content in enumerate(content_list, 1):
162
- # Print row
163
- rows = {
164
- PrintableField.ID: f"{content.ID:<{PRINT_WIDTHS[PrintableField.ID]}}",
165
- PrintableField.FileNameL: f"{truncate_field(PrintableField.FileNameL, content.FileNameL):<{PRINT_WIDTHS[PrintableField.FileNameL]}}",
166
- PrintableField.Title: f"{truncate_field(PrintableField.Title, content.Title):<{PRINT_WIDTHS[PrintableField.Title]}}",
167
- PrintableField.AlbumName: f"{truncate_field(PrintableField.AlbumName, content.AlbumName):<{PRINT_WIDTHS[PrintableField.AlbumName]}}",
168
- PrintableField.ArtistName: f"{truncate_field(PrintableField.ArtistName, content.ArtistName):<{PRINT_WIDTHS[PrintableField.ArtistName]}}",
169
- PrintableField.FileType: f"{get_file_type_name(content.FileType):<{PRINT_WIDTHS[PrintableField.FileType]}}",
170
- PrintableField.SampleRate: f"{content.SampleRate:<{PRINT_WIDTHS[PrintableField.SampleRate]}}",
171
- PrintableField.BitRate: f"{content.BitRate:<{PRINT_WIDTHS[PrintableField.BitRate]}}",
172
- PrintableField.BitDepth: f"{content.BitDepth:<{PRINT_WIDTHS[PrintableField.BitDepth]}}",
173
- PrintableField.FolderPath: f"{truncate_field(PrintableField.FolderPath, content.FolderPath):<{PRINT_WIDTHS[PrintableField.FolderPath]}}",
174
- }
175
-
176
- row = f"{i:<{pos_width}}" + " ".join(map(lambda col: rows[col], print_columns))
177
-
178
- logger.info(row)
179
- logger.info("")
180
-
181
-
182
78
  def ffmpeg_in_path():
183
79
  """Check availability of ffmpeg program via which command"""
184
80
  return shutil.which("ffmpeg") is not None
@@ -16,6 +16,7 @@ from rekordbox_edit.commands.convert import (
16
16
  rollback_and_cleanup,
17
17
  update_database_record,
18
18
  )
19
+ from rekordbox_edit.display import PrintableField
19
20
  from rekordbox_edit.utils import OutputFormats, UserQuit
20
21
 
21
22
 
@@ -671,7 +672,11 @@ class TestConvertCommand:
671
672
  result = CliRunner().invoke(convert_command, ["--dry-run"])
672
673
 
673
674
  assert result.exit_code == 0
674
- mock_print_track_info.assert_called_once_with([mock_content])
675
+ mock_print_track_info.assert_called_once_with(
676
+ [mock_content],
677
+ changed_field=PrintableField.FileType,
678
+ new_values=["AIFF"],
679
+ )
675
680
  mock_db.session.commit.assert_not_called()
676
681
 
677
682
  @patch("rekordbox_edit.commands.convert.get_rekordbox_pid")
@@ -804,7 +809,11 @@ class TestConvertCommand:
804
809
  result = runner.invoke(convert_command, ["--dry-run"])
805
810
 
806
811
  assert result.exit_code == 0
807
- mock_print_track_info.assert_called_once_with([mock_flac_content])
812
+ mock_print_track_info.assert_called_once_with(
813
+ [mock_flac_content],
814
+ changed_field=PrintableField.FileType,
815
+ new_values=["AIFF"],
816
+ )
808
817
 
809
818
  @patch("rekordbox_edit.commands.convert.get_rekordbox_pid")
810
819
  @patch("rekordbox_edit.commands.convert.get_filtered_content")
@@ -1570,7 +1579,11 @@ class TestConvertCommandErrorPaths:
1570
1579
  result = CliRunner().invoke(convert_command, ["--yes", "--dry-run"])
1571
1580
 
1572
1581
  assert result.exit_code == 0
1573
- mock_print_track_info.assert_called_once_with([content2])
1582
+ mock_print_track_info.assert_called_once_with(
1583
+ [content2],
1584
+ changed_field=PrintableField.FileType,
1585
+ new_values=["AIFF"],
1586
+ )
1574
1587
 
1575
1588
  @patch("rekordbox_edit.commands.convert.print_track_info")
1576
1589
  @patch("rekordbox_edit.commands.convert.get_rekordbox_pid")
@@ -1613,7 +1626,11 @@ class TestConvertCommandErrorPaths:
1613
1626
 
1614
1627
  assert result.exit_code == 0
1615
1628
  mock_logger.warning.assert_called()
1616
- mock_print_track_info.assert_called_once_with([content2])
1629
+ mock_print_track_info.assert_called_once_with(
1630
+ [content2],
1631
+ changed_field=PrintableField.FileType,
1632
+ new_values=["AIFF"],
1633
+ )
1617
1634
 
1618
1635
  @patch("rekordbox_edit.commands.convert.sys")
1619
1636
  @patch("rekordbox_edit.commands.convert.confirm")
@@ -427,3 +427,104 @@ class TestEditCommandUnicode:
427
427
 
428
428
  assert result.exit_code == 0
429
429
  mock_db.session.commit.assert_not_called()
430
+
431
+
432
+ class TestEditCommandPhase4:
433
+ """Phase 4: --multi flag to allow batch edits past the single-track guard."""
434
+
435
+ @patch("rekordbox_edit.commands.edit.confirm")
436
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
437
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
438
+ def test_multi_allows_editing_multiple_tracks(
439
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
440
+ ):
441
+ """--multi bypasses the single-track guard and edits all matched tracks."""
442
+ tracks = [
443
+ make_djmd_content_item(Title="Track A (feat. X)"),
444
+ make_djmd_content_item(Title="Track B (feat. Y)"),
445
+ ]
446
+ mock_db, mock_result = _make_db_and_result(tracks)
447
+ mock_db_class.return_value = mock_db
448
+ mock_gfc.return_value = mock_result
449
+ mock_confirm.return_value = True
450
+
451
+ from click.testing import CliRunner
452
+ result = CliRunner().invoke(
453
+ edit_command,
454
+ ["Title", "--match", "feat.", "--replace", "ft.", "--multi"],
455
+ )
456
+
457
+ assert result.exit_code == 0
458
+ assert tracks[0].Title == "Track A (ft. X)"
459
+ assert tracks[1].Title == "Track B (ft. Y)"
460
+ mock_db.session.commit.assert_called_once()
461
+
462
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
463
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
464
+ def test_without_multi_still_aborts_on_multiple(
465
+ self, mock_db_class, mock_gfc, make_djmd_content_item
466
+ ):
467
+ """Without --multi, the single-track guard still aborts on multiple matches."""
468
+ tracks = [
469
+ make_djmd_content_item(Title="Track A"),
470
+ make_djmd_content_item(Title="Track B"),
471
+ ]
472
+ mock_db, mock_result = _make_db_and_result(tracks)
473
+ mock_db_class.return_value = mock_db
474
+ mock_gfc.return_value = mock_result
475
+
476
+ from click.testing import CliRunner
477
+ result = CliRunner().invoke(
478
+ edit_command, ["Title", "--replace", "New", "--yes"]
479
+ )
480
+
481
+ assert result.exit_code != 0
482
+ mock_db.session.commit.assert_not_called()
483
+
484
+ @patch("rekordbox_edit.commands.edit.confirm")
485
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
486
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
487
+ def test_multi_with_yes_skips_confirm(
488
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
489
+ ):
490
+ """--multi --yes edits multiple tracks without prompting."""
491
+ tracks = [
492
+ make_djmd_content_item(Title="Track A"),
493
+ make_djmd_content_item(Title="Track B"),
494
+ ]
495
+ mock_db, mock_result = _make_db_and_result(tracks)
496
+ mock_db_class.return_value = mock_db
497
+ mock_gfc.return_value = mock_result
498
+
499
+ from click.testing import CliRunner
500
+ result = CliRunner().invoke(
501
+ edit_command,
502
+ ["Title", "--replace", "New", "--multi", "--yes"],
503
+ )
504
+
505
+ assert result.exit_code == 0
506
+ mock_confirm.assert_not_called()
507
+ mock_db.session.commit.assert_called_once()
508
+
509
+ @patch("rekordbox_edit.commands.edit.confirm")
510
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
511
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
512
+ def test_multi_single_track_still_works(
513
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
514
+ ):
515
+ """--multi with only one matching track works fine (guard is not inverted)."""
516
+ track = make_djmd_content_item(Title="Only Track")
517
+ mock_db, mock_result = _make_db_and_result([track])
518
+ mock_db_class.return_value = mock_db
519
+ mock_gfc.return_value = mock_result
520
+ mock_confirm.return_value = True
521
+
522
+ from click.testing import CliRunner
523
+ result = CliRunner().invoke(
524
+ edit_command,
525
+ ["Title", "--replace", "New Title", "--multi"],
526
+ )
527
+
528
+ assert result.exit_code == 0
529
+ assert track.Title == "New Title"
530
+ mock_db.session.commit.assert_called_once()
@@ -0,0 +1,149 @@
1
+ """Unit tests for the display module."""
2
+
3
+ import pytest
4
+ from rich.console import Console
5
+
6
+ from rekordbox_edit import display
7
+ from rekordbox_edit.display import (
8
+ PrintableField,
9
+ print_track_info,
10
+ )
11
+ from rekordbox_edit.utils import get_file_type_name
12
+
13
+
14
+ @pytest.fixture
15
+ def wide_console(monkeypatch):
16
+ """Swap the module console for one wide enough to render without truncation.
17
+
18
+ Rich's default non-TTY width is 80 cols, which truncates long values
19
+ (e.g. FolderPath) with an ellipsis and makes substring assertions flaky.
20
+ """
21
+ monkeypatch.setattr(display, "console", Console(record=True, width=400))
22
+
23
+
24
+ class TestPrintTrackInfo:
25
+ """Test print_track_info function."""
26
+
27
+ TEST_PRINT_COLUMNS = [
28
+ PrintableField.ID,
29
+ PrintableField.FileNameL,
30
+ PrintableField.Title,
31
+ PrintableField.ArtistName,
32
+ PrintableField.AlbumName,
33
+ PrintableField.FileType,
34
+ PrintableField.SampleRate,
35
+ PrintableField.BitDepth,
36
+ PrintableField.BitRate,
37
+ PrintableField.FolderPath,
38
+ ]
39
+
40
+ def test_empty_content_list(self, capsys):
41
+ """Test printing with empty content list."""
42
+ print_track_info([])
43
+
44
+ captured = capsys.readouterr()
45
+ assert captured.out == ""
46
+
47
+ def test_default_columns(
48
+ self,
49
+ capsys,
50
+ wide_console,
51
+ make_djmd_content_item,
52
+ ):
53
+ """Default columns include ID, Title, FileType, SampleRate, BitDepth, FileNameL, FolderPath."""
54
+ mock_content = make_djmd_content_item(
55
+ FileNameL="my-song.flac",
56
+ FolderPath="/music/library/my-song.flac",
57
+ )
58
+
59
+ print_track_info([mock_content])
60
+
61
+ captured = capsys.readouterr()
62
+ assert mock_content.Title in captured.out
63
+ assert get_file_type_name(mock_content.FileType) in captured.out
64
+ assert str(mock_content.SampleRate) in captured.out
65
+ assert str(mock_content.BitDepth) in captured.out
66
+ assert "my-song.flac" in captured.out
67
+ # FolderPath renders the directory portion, not the full path
68
+ assert "/music/library" in captured.out
69
+
70
+ def test_track_with_zero_values(self, capsys, wide_console, make_djmd_content_item):
71
+ """Test printing track with zero values."""
72
+ # Setup mock content with zero values
73
+ mock_content = make_djmd_content_item(
74
+ ID=123,
75
+ SampleRate=0,
76
+ BitRate=0,
77
+ BitDepth=0,
78
+ )
79
+
80
+ print_track_info([mock_content], self.TEST_PRINT_COLUMNS)
81
+
82
+ captured = capsys.readouterr()
83
+ lines = captured.out.split("\n")
84
+ data_line = [line for line in lines if "test" in line][0]
85
+ assert data_line.count("0") == 3
86
+
87
+ def test_change_preview_renders_old_struck_through_with_new(
88
+ self, capsys, wide_console, make_djmd_content_item
89
+ ):
90
+ """When changed_field + new_values are provided, both old and new appear in the cell."""
91
+ mock_content = make_djmd_content_item(Title="Old Name")
92
+
93
+ print_track_info(
94
+ [mock_content],
95
+ print_columns=[PrintableField.Title],
96
+ changed_field=PrintableField.Title,
97
+ new_values=["New Name"],
98
+ )
99
+
100
+ captured = capsys.readouterr()
101
+ assert "Old Name" in captured.out
102
+ assert "New Name" in captured.out
103
+
104
+ def test_change_preview_requires_both_args(self, make_djmd_content_item):
105
+ """Providing only one of changed_field/new_values raises ValueError."""
106
+ mock_content = make_djmd_content_item()
107
+
108
+ with pytest.raises(ValueError, match="must be provided together"):
109
+ print_track_info(
110
+ [mock_content], changed_field=PrintableField.Title
111
+ )
112
+
113
+ with pytest.raises(ValueError, match="must be provided together"):
114
+ print_track_info([mock_content], new_values=["x"])
115
+
116
+ def test_change_preview_length_mismatch_raises(self, make_djmd_content_item):
117
+ """new_values length must match content_list length."""
118
+ mock_content = make_djmd_content_item()
119
+
120
+ with pytest.raises(ValueError, match="length"):
121
+ print_track_info(
122
+ [mock_content],
123
+ changed_field=PrintableField.Title,
124
+ new_values=["a", "b"],
125
+ )
126
+
127
+ def test_multiple_tracks(self, capsys, wide_console, make_djmd_content_item):
128
+ """Test printing multiple tracks."""
129
+ mock_content1 = make_djmd_content_item(
130
+ ID=123,
131
+ FileNameL="track1.flac",
132
+ FileType=5,
133
+ FolderPath="/path/track1.flac",
134
+ )
135
+
136
+ mock_content2 = make_djmd_content_item(
137
+ ID=456,
138
+ FileNameL="track2.mp3",
139
+ FileType=1,
140
+ FolderPath="/path/track2.mp3",
141
+ )
142
+
143
+ print_track_info([mock_content1, mock_content2])
144
+
145
+ captured = capsys.readouterr()
146
+ assert "track1.flac" in captured.out
147
+ assert "track2.mp3" in captured.out
148
+ assert "FLAC" in captured.out
149
+ assert "MP3" in captured.out
@@ -1,20 +1,15 @@
1
1
  """Unit tests for utils module functionality."""
2
2
 
3
- from typing import Callable
4
3
  from unittest.mock import patch
5
4
 
6
5
  import pytest
7
- from pyrekordbox.db6 import DjmdContent
8
6
 
9
7
  from rekordbox_edit.utils import (
10
- PRINT_WIDTHS,
11
- PrintableField,
12
8
  UserQuit,
13
9
  get_audio_info,
14
10
  get_extension_for_format,
15
11
  get_file_type_for_format,
16
12
  get_file_type_name,
17
- print_track_info,
18
13
  )
19
14
 
20
15
 
@@ -96,158 +91,6 @@ class TestGetGetExtensionForFormat:
96
91
  get_extension_for_format(None) # ty: ignore[invalid-argument-type]
97
92
 
98
93
 
99
- class TestTruncateField:
100
- """Test truncate_field function."""
101
-
102
- def test_truncate_field_none_value(self):
103
- """Test truncate_field returns empty string for None value."""
104
- from rekordbox_edit.utils import PrintableField, truncate_field
105
-
106
- result = truncate_field(PrintableField.Title, None)
107
- assert result == ""
108
-
109
- def test_truncate_field_empty_string(self):
110
- """Test truncate_field with empty string."""
111
- from rekordbox_edit.utils import PrintableField, truncate_field
112
-
113
- result = truncate_field(PrintableField.Title, "")
114
- assert result == ""
115
-
116
- def test_truncate_field_short_value(self):
117
- """Test truncate_field returns value as-is when it fits."""
118
- from rekordbox_edit.utils import PrintableField, truncate_field
119
-
120
- short_title = "Short Title"
121
- result = truncate_field(PrintableField.Title, short_title)
122
- assert result == short_title
123
-
124
- def test_truncate_field_exact_width(self):
125
- """Test truncate_field with value exactly at width limit."""
126
- from rekordbox_edit.utils import (
127
- PRINT_WIDTHS,
128
- PrintableField,
129
- truncate_field,
130
- )
131
-
132
- # Create a value exactly the width of Title field (25 chars)
133
- exact_width_title = "X" * PRINT_WIDTHS[PrintableField.Title]
134
- result = truncate_field(PrintableField.Title, exact_width_title)
135
- assert result == exact_width_title
136
-
137
- def test_truncate_field_long_value(self):
138
- """Test truncate_field truncates long values with ellipsis."""
139
- from rekordbox_edit.utils import PrintableField, truncate_field
140
-
141
- long_title = "This is a very long title that exceeds the width limit"
142
- result = truncate_field(PrintableField.Title, long_title)
143
-
144
- assert "..." in result
145
- assert len(result) == PRINT_WIDTHS[PrintableField.Title]
146
-
147
- def test_truncate_field_minimal_truncation(self):
148
- """Test truncate_field with value just over the limit."""
149
- from rekordbox_edit.utils import (
150
- PRINT_WIDTHS,
151
- PrintableField,
152
- truncate_field,
153
- )
154
-
155
- # Create a value just 1 char over the limit
156
- over_limit_title = "X" * (PRINT_WIDTHS[PrintableField.Title] + 1)
157
- result = truncate_field(PrintableField.Title, over_limit_title)
158
-
159
- assert "..." in result
160
- assert len(result) == PRINT_WIDTHS[PrintableField.Title]
161
-
162
-
163
- class TestPrintTrackInfo:
164
- """Test print_track_info function."""
165
-
166
- TEST_PRINT_COLUMNS = [
167
- PrintableField.ID,
168
- PrintableField.FileNameL,
169
- PrintableField.Title,
170
- PrintableField.ArtistName,
171
- PrintableField.AlbumName,
172
- PrintableField.FileType,
173
- PrintableField.SampleRate,
174
- PrintableField.BitDepth,
175
- PrintableField.BitRate,
176
- PrintableField.FolderPath,
177
- ]
178
-
179
- def test_empty_content_list(self, capsys):
180
- """Test printing with empty content list."""
181
- print_track_info([])
182
-
183
- captured = capsys.readouterr()
184
- assert captured.out == ""
185
-
186
- def test_default_columns(
187
- self, capsys, make_djmd_content_item: Callable[[], DjmdContent]
188
- ):
189
- """Test printing a single track with the default print_columns.
190
-
191
- Default columns are: ID, Title, FileType, SampleRate, BitDepth, FolderPath
192
- (ArtistName and AlbumName are NOT included by default)
193
- """
194
- # Setup mock content
195
- mock_content = make_djmd_content_item()
196
-
197
- print_track_info([mock_content])
198
-
199
- captured = capsys.readouterr()
200
- # Check default columns are present
201
- assert mock_content.Title in captured.out
202
- assert get_file_type_name(mock_content.FileType) in captured.out
203
- assert str(mock_content.SampleRate) in captured.out
204
- assert str(mock_content.BitDepth) in captured.out
205
- # FolderPath should be in output (may or may not be truncated depending on length)
206
- # The default test path is 66 chars, column width is 80, so no truncation
207
- assert "test_track.wav" in captured.out
208
-
209
- def test_track_with_zero_values(self, capsys, make_djmd_content_item):
210
- """Test printing track with zero values."""
211
- # Setup mock content with zero values
212
- mock_content = make_djmd_content_item(
213
- ID=123,
214
- SampleRate=0,
215
- BitRate=0,
216
- BitDepth=0,
217
- )
218
-
219
- print_track_info([mock_content], self.TEST_PRINT_COLUMNS)
220
-
221
- captured = capsys.readouterr()
222
- lines = captured.out.split("\n")
223
- data_line = [line for line in lines if "test" in line][0]
224
- assert data_line.count("0") == 3
225
-
226
- def test_multiple_tracks(self, capsys, make_djmd_content_item):
227
- """Test printing multiple tracks."""
228
- mock_content1 = make_djmd_content_item(
229
- ID=123,
230
- FileNameL="track1.flac",
231
- FileType=5,
232
- FolderPath="/path/track1.flac",
233
- )
234
-
235
- mock_content2 = make_djmd_content_item(
236
- ID=456,
237
- FileNameL="track2.mp3",
238
- FileType=1,
239
- FolderPath="/path/track2.mp3",
240
- )
241
-
242
- print_track_info([mock_content1, mock_content2])
243
-
244
- captured = capsys.readouterr()
245
- assert "track1.flac" in captured.out
246
- assert "track2.mp3" in captured.out
247
- assert "FLAC" in captured.out
248
- assert "MP3" in captured.out
249
-
250
-
251
94
  class TestGetAudioInfo:
252
95
  """Test get_audio_info function."""
253
96
 
@@ -472,6 +472,18 @@ wheels = [
472
472
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
473
473
  ]
474
474
 
475
+ [[package]]
476
+ name = "markdown-it-py"
477
+ version = "4.2.0"
478
+ source = { registry = "https://pypi.org/simple" }
479
+ dependencies = [
480
+ { name = "mdurl" },
481
+ ]
482
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
483
+ wheels = [
484
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
485
+ ]
486
+
475
487
  [[package]]
476
488
  name = "markupsafe"
477
489
  version = "3.0.3"
@@ -557,6 +569,15 @@ wheels = [
557
569
  { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
558
570
  ]
559
571
 
572
+ [[package]]
573
+ name = "mdurl"
574
+ version = "0.1.2"
575
+ source = { registry = "https://pypi.org/simple" }
576
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
577
+ wheels = [
578
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
579
+ ]
580
+
560
581
  [[package]]
561
582
  name = "nodeenv"
562
583
  version = "1.10.0"
@@ -985,13 +1006,14 @@ wheels = [
985
1006
 
986
1007
  [[package]]
987
1008
  name = "rekordbox-edit"
988
- version = "0.4.0.dev22"
1009
+ version = "0.4.0.dev24"
989
1010
  source = { editable = "." }
990
1011
  dependencies = [
991
1012
  { name = "click" },
992
1013
  { name = "ffmpeg-python" },
993
1014
  { name = "platformdirs" },
994
1015
  { name = "pyrekordbox" },
1016
+ { name = "rich" },
995
1017
  ]
996
1018
 
997
1019
  [package.dev-dependencies]
@@ -1013,6 +1035,7 @@ requires-dist = [
1013
1035
  { name = "ffmpeg-python", specifier = ">=0.2.0" },
1014
1036
  { name = "platformdirs", specifier = ">=4.3.8,<5.0.0" },
1015
1037
  { name = "pyrekordbox", specifier = "==0.4.4" },
1038
+ { name = "rich", specifier = ">=13.0.0,<15.0.0" },
1016
1039
  ]
1017
1040
 
1018
1041
  [package.metadata.requires-dev]
@@ -1028,6 +1051,19 @@ dev = [
1028
1051
  { name = "ty", specifier = ">=0.0.1,<1" },
1029
1052
  ]
1030
1053
 
1054
+ [[package]]
1055
+ name = "rich"
1056
+ version = "14.3.4"
1057
+ source = { registry = "https://pypi.org/simple" }
1058
+ dependencies = [
1059
+ { name = "markdown-it-py" },
1060
+ { name = "pygments" },
1061
+ ]
1062
+ sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
1063
+ wheels = [
1064
+ { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
1065
+ ]
1066
+
1031
1067
  [[package]]
1032
1068
  name = "ruff"
1033
1069
  version = "0.15.9"