backpack-backtrack 0.2.0__py3-none-any.whl
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.
- backpack_backtrack-0.2.0.dist-info/METADATA +460 -0
- backpack_backtrack-0.2.0.dist-info/RECORD +69 -0
- backpack_backtrack-0.2.0.dist-info/WHEEL +5 -0
- backpack_backtrack-0.2.0.dist-info/entry_points.txt +2 -0
- backpack_backtrack-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backtrack-0.2.0.dist-info/top_level.txt +1 -0
- backtrack/__init__.py +8 -0
- backtrack/__main__.py +3 -0
- backtrack/album_art.py +173 -0
- backtrack/bulk_pattern.py +350 -0
- backtrack/cli.py +511 -0
- backtrack/cli_commands.py +2242 -0
- backtrack/config.py +204 -0
- backtrack/deps.py +51 -0
- backtrack/feed.py +465 -0
- backtrack/history.py +73 -0
- backtrack/id3/__init__.py +0 -0
- backtrack/id3/browser.py +1167 -0
- backtrack/id3/bulk_art.py +374 -0
- backtrack/id3/bulk_assign.py +616 -0
- backtrack/id3/bulk_common.py +130 -0
- backtrack/id3/bulk_menu.py +823 -0
- backtrack/id3/bulk_names.py +468 -0
- backtrack/id3/bulk_ops.py +670 -0
- backtrack/id3/bulk_sort.py +402 -0
- backtrack/id3/cover_matcher.py +537 -0
- backtrack/id3/file_namer.py +299 -0
- backtrack/id3/filename_parser.py +517 -0
- backtrack/id3/tag_handler.py +1207 -0
- backtrack/id3/tag_registry.py +410 -0
- backtrack/id3/tag_writer.py +534 -0
- backtrack/lyrics/__init__.py +0 -0
- backtrack/lyrics/editor.py +821 -0
- backtrack/lyrics/editor_keys.py +678 -0
- backtrack/lyrics/editor_view.py +830 -0
- backtrack/lyrics/formats.py +1286 -0
- backtrack/lyrics/lyric_pane.py +289 -0
- backtrack/lyrics/md_overlay.py +390 -0
- backtrack/lyrics/sync_doc.py +317 -0
- backtrack/lyrics/text.py +521 -0
- backtrack/lyrics/time_fields.py +145 -0
- backtrack/lyrics/verify.py +372 -0
- backtrack/main.py +279 -0
- backtrack/menus/__init__.py +36 -0
- backtrack/menus/activity.py +79 -0
- backtrack/menus/browse.py +471 -0
- backtrack/menus/common.py +145 -0
- backtrack/menus/history.py +143 -0
- backtrack/menus/play.py +272 -0
- backtrack/menus/search.py +371 -0
- backtrack/menus/settings.py +520 -0
- backtrack/menus/sorting.py +199 -0
- backtrack/music_library.py +1215 -0
- backtrack/playback/__init__.py +0 -0
- backtrack/playback/ipc.py +387 -0
- backtrack/playback/libvlc.py +13 -0
- backtrack/playback/now_playing_box.py +156 -0
- backtrack/playback/player.py +744 -0
- backtrack/playback/player_art.py +330 -0
- backtrack/playback/player_geom.py +48 -0
- backtrack/playback/player_ui.py +1039 -0
- backtrack/playback/queue_pane.py +307 -0
- backtrack/playback/session.py +1096 -0
- backtrack/search.py +627 -0
- backtrack/trim/__init__.py +0 -0
- backtrack/trim/bulk.py +700 -0
- backtrack/trim/editor.py +847 -0
- backtrack/trim/engine.py +839 -0
- backtrack/tuning.py +134 -0
backtrack/album_art.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Album-art extraction and terminal rendering."""
|
|
2
|
+
import os
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
|
|
5
|
+
import mutagen.id3
|
|
6
|
+
from mutagen.id3 import ID3
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from backbone import ui
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decode_image(img_bytes: bytes) -> "Image.Image | None":
|
|
13
|
+
"""Image bytes as RGB pixels, any transparency laid over black (so a
|
|
14
|
+
transparent PNG isn't mangled), or None when they can't be decoded."""
|
|
15
|
+
try:
|
|
16
|
+
img = Image.open(BytesIO(img_bytes))
|
|
17
|
+
img.load()
|
|
18
|
+
except (OSError, ValueError, Image.DecompressionBombError):
|
|
19
|
+
return None
|
|
20
|
+
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
|
|
21
|
+
rgba = img.convert("RGBA")
|
|
22
|
+
img = Image.alpha_composite(Image.new("RGBA", rgba.size, (0, 0, 0, 255)), rgba)
|
|
23
|
+
return img.convert("RGB")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_native_half_block(img_bytes: bytes, width: int = 100) -> str:
|
|
27
|
+
"""Render image bytes as ANSI half-block characters for terminal display."""
|
|
28
|
+
# 1. Decode bytes to RGB pixels.
|
|
29
|
+
img = decode_image(img_bytes)
|
|
30
|
+
if img is None: return "Error decoding image."
|
|
31
|
+
|
|
32
|
+
# 2. Resize keeping the image's proportions on screen. Each cell shows one
|
|
33
|
+
# pixel column and two pixel rows, so a pixel row is half a cell tall:
|
|
34
|
+
# `ui.cell_aspect()` (cell height over width) says how many rows square pixels need.
|
|
35
|
+
width = max(1, width)
|
|
36
|
+
height = max(1, round(width * img.height / img.width * 2 / ui.cell_aspect()))
|
|
37
|
+
img = img.resize((width, height), Image.Resampling.BOX) # area average, as shrinking wants
|
|
38
|
+
px = img.load()
|
|
39
|
+
|
|
40
|
+
# 3. Convert to ANSI half-blocks (U+2580): top pixel as foreground, bottom as
|
|
41
|
+
# background. An odd height leaves the last row's bottom half the terminal's
|
|
42
|
+
# own background, rather than repeating a pixel row.
|
|
43
|
+
output = []
|
|
44
|
+
for y in range(0, height, 2):
|
|
45
|
+
for x in range(width):
|
|
46
|
+
r, g, b = px[x, y]
|
|
47
|
+
if y + 1 < height:
|
|
48
|
+
br, bg_, bb = px[x, y + 1]
|
|
49
|
+
bg = f"\033[48;2;{br};{bg_};{bb}m"
|
|
50
|
+
else:
|
|
51
|
+
bg = "\033[49m"
|
|
52
|
+
output.append(f"\033[38;2;{r};{g};{b}m{bg}\u2580")
|
|
53
|
+
output.append("\033[0m\n")
|
|
54
|
+
return "".join(output)
|
|
55
|
+
|
|
56
|
+
def render_album_art(image_source: str | bytes, width: int = 100, is_bytes: bool = False) -> str:
|
|
57
|
+
"""Render art from a path or raw bytes to terminal text."""
|
|
58
|
+
if is_bytes:
|
|
59
|
+
assert isinstance(image_source, bytes)
|
|
60
|
+
return render_native_half_block(image_source, width)
|
|
61
|
+
else:
|
|
62
|
+
if not isinstance(image_source, str):
|
|
63
|
+
return "Error: Expected a file path string."
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(image_source, 'rb') as f:
|
|
67
|
+
img_data = f.read()
|
|
68
|
+
return render_native_half_block(img_data, width)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
return f"Error reading file: {e}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_art_from_image_file(file_path: str, width: int) -> str:
|
|
74
|
+
"""Render a standalone image file (not embedded in a tag) to terminal text."""
|
|
75
|
+
return render_album_art(file_path, width=width, is_bytes=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _select_apic_frame(audio: ID3, preferred_desc: str | None = None,
|
|
79
|
+
preferred_type: int | None = None):
|
|
80
|
+
"""Pick an MP3's cover APIC frame: by description, then by picture type,
|
|
81
|
+
else the first embedded picture found."""
|
|
82
|
+
apic_keys = [k for k in audio.keys() if k.startswith('APIC')]
|
|
83
|
+
if not apic_keys:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if preferred_desc:
|
|
87
|
+
for key in apic_keys:
|
|
88
|
+
frame = audio[key]
|
|
89
|
+
if getattr(frame, 'desc', '').strip().lower() == preferred_desc.strip().lower():
|
|
90
|
+
return frame
|
|
91
|
+
|
|
92
|
+
if preferred_type is not None:
|
|
93
|
+
for key in apic_keys:
|
|
94
|
+
frame = audio[key]
|
|
95
|
+
if getattr(frame, 'type', None) == preferred_type:
|
|
96
|
+
return frame
|
|
97
|
+
|
|
98
|
+
return audio[apic_keys[0]]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_art_from_mp3(file_path: str, width: int,
|
|
102
|
+
preferred_desc: str | None = None,
|
|
103
|
+
preferred_type: int | None = None) -> str:
|
|
104
|
+
"""Extract and render an MP3's embedded cover art."""
|
|
105
|
+
try:
|
|
106
|
+
audio = ID3(file_path)
|
|
107
|
+
apic_frame = _select_apic_frame(audio, preferred_desc=preferred_desc, preferred_type=preferred_type)
|
|
108
|
+
if not apic_frame:
|
|
109
|
+
return "No album art found."
|
|
110
|
+
|
|
111
|
+
img_data = apic_frame.data
|
|
112
|
+
return render_album_art(img_data, width=width, is_bytes=True)
|
|
113
|
+
except (FileNotFoundError, OSError, mutagen.id3.ID3NoHeaderError) as e: # type: ignore[reportPrivateImportUsage]
|
|
114
|
+
return f"Error loading MP3 art: {e}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_art_bytes(file_path: str) -> bytes | None:
|
|
118
|
+
"""The raw cover image for a file (an MP3's embedded picture, an MP4's
|
|
119
|
+
`covr` atom, or an image file itself); None if there's none."""
|
|
120
|
+
try:
|
|
121
|
+
if file_path.lower().endswith(".mp3"):
|
|
122
|
+
frame = _select_apic_frame(ID3(file_path))
|
|
123
|
+
return bytes(frame.data) if frame is not None and frame.data else None
|
|
124
|
+
if os.path.splitext(file_path)[1].lower() in _MP4_EXTS:
|
|
125
|
+
from mutagen.mp4 import MP4
|
|
126
|
+
covr = (MP4(file_path).tags or {}).get("covr")
|
|
127
|
+
return bytes(covr[0]) if covr else None
|
|
128
|
+
if os.path.splitext(file_path)[1].lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
|
129
|
+
with open(file_path, "rb") as f:
|
|
130
|
+
return f.read()
|
|
131
|
+
except (OSError, mutagen.MutagenError):
|
|
132
|
+
pass
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_MP4_EXTS = (".m4a", ".mp4", ".m4p", ".m4b")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_art(file_path: str, width: int = 100) -> str:
|
|
140
|
+
"""Render a file's album art, dispatching to the MP3 tag reader or plain
|
|
141
|
+
image loader by extension."""
|
|
142
|
+
if not os.path.isfile(file_path):
|
|
143
|
+
return "Error: Invalid file path."
|
|
144
|
+
|
|
145
|
+
ext = file_path.rsplit(".", 1)[-1].lower()
|
|
146
|
+
if ext == "mp3":
|
|
147
|
+
return get_art_from_mp3(file_path, width)
|
|
148
|
+
if "." + ext in _MP4_EXTS:
|
|
149
|
+
raw = get_art_bytes(file_path)
|
|
150
|
+
return render_album_art(raw, width=width, is_bytes=True) if raw else "No album art found."
|
|
151
|
+
return get_art_from_image_file(file_path, width)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fit_art(render, max_w: int, avail_h: int) -> list[str]:
|
|
155
|
+
"""`render(width)`'s lines at the widest width up to `max_w` that is at most
|
|
156
|
+
`avail_h` rows tall. Only ever narrows: never crops or stretches. Empty when
|
|
157
|
+
not even one column fits. Every place that shows art sizes it through here."""
|
|
158
|
+
w = max(1, max_w)
|
|
159
|
+
lines = render(w).splitlines()
|
|
160
|
+
while len(lines) > avail_h:
|
|
161
|
+
if w == 1:
|
|
162
|
+
return []
|
|
163
|
+
# Straight to the proportional width, then a column at a time for rounding.
|
|
164
|
+
w = max(1, min(w - 1, w * avail_h // len(lines)))
|
|
165
|
+
lines = render(w).splitlines()
|
|
166
|
+
# The proportional guess can land short: take every column that still fits,
|
|
167
|
+
# so a wider window never gets narrower art.
|
|
168
|
+
while w < max_w:
|
|
169
|
+
wider = render(w + 1).splitlines()
|
|
170
|
+
if len(wider) > avail_h:
|
|
171
|
+
break
|
|
172
|
+
w, lines = w + 1, wider
|
|
173
|
+
return lines
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Ranged / periodic bulk tag assignment: pure logic (UI in id3/bulk_assign).
|
|
2
|
+
|
|
3
|
+
Given a set of tracks ordered by (disc, track, filename), assign a tag value to
|
|
4
|
+
each by position: explicit ranges, an every-N grouping, or a date schedule that
|
|
5
|
+
steps per track / per group / per disc. A counter placeholder in a text value is
|
|
6
|
+
the 1-based group index, in any of three number styles:
|
|
7
|
+
|
|
8
|
+
"Series {n}" → Series 1, 2, 3, … (arabic; "{n:02d}" pads)
|
|
9
|
+
"Act {r}" → Act I, II, III, … (roman; "{r:l}" for i, ii, iii)
|
|
10
|
+
"Series {en}" → Series One, Two, … (written out; "{en:l}", "{en:u}", "{en:t}")
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from backbone import datetime_parse as dtp
|
|
19
|
+
from backbone import numbering
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _num(v) -> int:
|
|
23
|
+
"""Leading integer of a tag value ('3', '3/12', 3) → 3; else 0."""
|
|
24
|
+
try:
|
|
25
|
+
return int(str(v).split('/')[0].strip())
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fnum(v) -> float:
|
|
31
|
+
"""Leading number of a tag value, keeping a fraction ('1.5/4' → 1.5); else 0.0.
|
|
32
|
+
|
|
33
|
+
Disc numbers are the one place a non-integer is meaningful: numbering a disc
|
|
34
|
+
``1.5`` is how you park a disc between two others before reflowing. Parsing
|
|
35
|
+
those with :func:`_num` yielded 0, which sorted the new disc *before* disc 1
|
|
36
|
+
and made the ordering (and so every position-based operation) wrong.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return float(str(v).split('/')[0].strip())
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def order_tracks(songs: list) -> list:
|
|
45
|
+
"""Order song dicts by disc, then track, then filename (natural-ish).
|
|
46
|
+
|
|
47
|
+
Disc and track sort on :func:`_fnum`, so an interleaved ``1.5`` disc lands
|
|
48
|
+
between 1 and 2 instead of ahead of everything.
|
|
49
|
+
"""
|
|
50
|
+
def key(s):
|
|
51
|
+
"""(disc, track, filename) ordering tuple for one song."""
|
|
52
|
+
base = os.path.basename(str(s.get('path', ''))).lower()
|
|
53
|
+
return (_fnum(s.get('disc')), _fnum(s.get('track')), base)
|
|
54
|
+
return sorted(songs, key=key)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _fmt_disc(x: float) -> str:
|
|
58
|
+
"""Render a disc number without a trailing '.0' (2.0 → '2', 1.5 → '1.5')."""
|
|
59
|
+
return str(int(x)) if float(x).is_integer() else str(x)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def disc_ranges(ordered: list) -> list:
|
|
63
|
+
"""Contiguous (from, to, disc_label) runs of the same disc in `ordered`.
|
|
64
|
+
|
|
65
|
+
Positions are 1-based inclusive, matching the range editors. `ordered` is
|
|
66
|
+
already disc-sorted, so each disc forms one run; this is what seeds the
|
|
67
|
+
per-range schedule editor with a row per disc.
|
|
68
|
+
"""
|
|
69
|
+
runs: list = []
|
|
70
|
+
for pos, s in enumerate(ordered, start=1):
|
|
71
|
+
d = _fnum(s.get('disc'))
|
|
72
|
+
if runs and runs[-1][2] == d:
|
|
73
|
+
runs[-1][1] = pos
|
|
74
|
+
else:
|
|
75
|
+
runs.append([pos, pos, d])
|
|
76
|
+
return [(lo, hi, _fmt_disc(d)) for lo, hi, d in runs]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Group-counter placeholders: {n} arabic, {r} roman, {en} written out, each
|
|
80
|
+
# with an optional ':spec': a case modifier for {r}/{en} ("l", "u", "t") or a
|
|
81
|
+
# format spec for {n} ("{n:02d}").
|
|
82
|
+
_COUNTER_RE = re.compile(r'\{(n|r|en)(?::([^}]*))?\}')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fmt_value(template: str, n: int) -> str:
|
|
86
|
+
"""Substitute the group counter in `template`.
|
|
87
|
+
|
|
88
|
+
``Series {n}`` → Series 3, ``Act {r}`` → Act III, ``Series {en}`` → Series
|
|
89
|
+
Three. ``{n:02d}``-style padding still works, and ``{r:l}`` / ``{en:u}``
|
|
90
|
+
change the case. Unknown braces are left alone.
|
|
91
|
+
"""
|
|
92
|
+
if '{' not in template:
|
|
93
|
+
return template
|
|
94
|
+
return _COUNTER_RE.sub(
|
|
95
|
+
lambda m: numbering.render(n, m.group(1), m.group(2) or ''), template)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def assign_ranges(ordered: list, ranges: list) -> dict:
|
|
99
|
+
"""ranges: list of (from, to, value) with 1-based inclusive positions.
|
|
100
|
+
Returns {path: value}; ``{n}`` in a value = that range's index (1-based).
|
|
101
|
+
Later ranges win on overlap; positions outside every range are unset."""
|
|
102
|
+
out: dict = {}
|
|
103
|
+
n = len(ordered)
|
|
104
|
+
for i, (lo, hi, value) in enumerate(ranges, start=1):
|
|
105
|
+
for pos in range(max(1, lo), min(n, hi) + 1):
|
|
106
|
+
out[ordered[pos - 1]['path']] = fmt_value(str(value), i)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def assign_periodic(ordered: list, group_size: int, template: str) -> dict:
|
|
111
|
+
"""Group every ``group_size`` tracks; value = template with ``{n}`` = group index."""
|
|
112
|
+
out: dict = {}
|
|
113
|
+
if group_size < 1:
|
|
114
|
+
return out
|
|
115
|
+
for pos, s in enumerate(ordered, start=1):
|
|
116
|
+
group = (pos - 1) // group_size + 1
|
|
117
|
+
out[s['path']] = fmt_value(template, group)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _date_group_indices(ordered: list, granularity: str, group_size: int = 1) -> list:
|
|
122
|
+
"""1-based group index per ordered track, for date stepping."""
|
|
123
|
+
if granularity == 'track':
|
|
124
|
+
return list(range(1, len(ordered) + 1))
|
|
125
|
+
if granularity == 'disc':
|
|
126
|
+
seen: dict = {}
|
|
127
|
+
out = []
|
|
128
|
+
for s in ordered:
|
|
129
|
+
d = _fnum(s.get('disc'))
|
|
130
|
+
if d not in seen:
|
|
131
|
+
seen[d] = len(seen) + 1
|
|
132
|
+
out.append(seen[d])
|
|
133
|
+
return out
|
|
134
|
+
gs = max(1, group_size) # 'group' / every-N
|
|
135
|
+
return [(pos - 1) // gs + 1 for pos in range(1, len(ordered) + 1)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def renumber_tracks(ordered: list, mode: str) -> dict:
|
|
139
|
+
"""Renumber track numbers across an ordered selection.
|
|
140
|
+
|
|
141
|
+
'continuous' (album-relative / movement systems): 1…N straight through, total
|
|
142
|
+
= N for every track. 'per_disc' (disc-relative): restart at 1 within each
|
|
143
|
+
disc, total = that disc's count. Returns {path: (track, total)}."""
|
|
144
|
+
out: dict = {}
|
|
145
|
+
if mode == 'continuous':
|
|
146
|
+
total = len(ordered)
|
|
147
|
+
for i, s in enumerate(ordered, start=1):
|
|
148
|
+
out[s['path']] = (i, total)
|
|
149
|
+
else: # per_disc
|
|
150
|
+
groups: dict = {}
|
|
151
|
+
for s in ordered:
|
|
152
|
+
groups.setdefault(_fnum(s.get('disc')) or 1.0, []).append(s)
|
|
153
|
+
for members in groups.values():
|
|
154
|
+
total = len(members)
|
|
155
|
+
for i, s in enumerate(members, start=1):
|
|
156
|
+
out[s['path']] = (i, total)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def norm_time(t) -> str | None:
|
|
161
|
+
"""Normalise a 'HH:MM' / 'HH:MM:SS' time to 'HH:MM:SS', or None if invalid.
|
|
162
|
+
|
|
163
|
+
Kept as the name the schedule code already calls; the rules live with every
|
|
164
|
+
other date/time reading in :mod:`backbone.datetime_parse`.
|
|
165
|
+
"""
|
|
166
|
+
return dtp.parse_time(t)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def date_groups(ordered: list, granularity: str = 'track', group_size: int = 1) -> list:
|
|
170
|
+
"""Distinct group indices (sorted) for the given date-stepping granularity,
|
|
171
|
+
used to prompt a time per group."""
|
|
172
|
+
return sorted(set(_date_group_indices(ordered, granularity, group_size)))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def assign_dates(ordered: list, start_iso: str, interval_days: int,
|
|
176
|
+
granularity: str = 'track', group_size: int = 1, times=None) -> dict:
|
|
177
|
+
"""Date schedule: each group's date = start + interval_days × (group-1).
|
|
178
|
+
granularity ∈ {'track', 'disc', 'group'}. With ``times`` a full ISO timestamp
|
|
179
|
+
is emitted: a single 'HH:MM[:SS]' string applies to all, or a {group: time}
|
|
180
|
+
dict gives a per-group (e.g. per-series) time. Returns {path: value}."""
|
|
181
|
+
try:
|
|
182
|
+
start = datetime.date.fromisoformat(str(start_iso)[:10])
|
|
183
|
+
except (ValueError, TypeError):
|
|
184
|
+
return {}
|
|
185
|
+
single_time = norm_time(times) if isinstance(times, str) else None
|
|
186
|
+
per_group = times if isinstance(times, dict) else {}
|
|
187
|
+
groups = _date_group_indices(ordered, granularity, group_size)
|
|
188
|
+
out: dict = {}
|
|
189
|
+
for s, g in zip(ordered, groups):
|
|
190
|
+
d = (start + datetime.timedelta(days=interval_days * (g - 1))).isoformat()
|
|
191
|
+
t = single_time or norm_time(per_group.get(g))
|
|
192
|
+
out[s['path']] = f"{d}T{t}" if t else d
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_start(raw) -> tuple:
|
|
197
|
+
"""Parse a schedule's start cell into ``(date, time_or_None, error)``.
|
|
198
|
+
|
|
199
|
+
Reads whatever :func:`backbone.datetime_parse.parse_datetime` accepts and
|
|
200
|
+
keeps any time given. A schedule steps in whole days, so a year- or
|
|
201
|
+
month-only start is rejected here rather than being completed to the 1st
|
|
202
|
+
behind your back; that is this caller's rule, not the parser's.
|
|
203
|
+
|
|
204
|
+
``error`` is '' on success, otherwise a short reason naming what was wrong.
|
|
205
|
+
"""
|
|
206
|
+
parsed = dtp.parse_datetime(raw)
|
|
207
|
+
if parsed.error:
|
|
208
|
+
return None, None, parsed.error
|
|
209
|
+
if parsed.precision in ('year', 'month'):
|
|
210
|
+
return None, None, (f"{str(raw).strip()!r} has no day: a schedule counts in "
|
|
211
|
+
"days, so it needs a full year-month-day")
|
|
212
|
+
return parsed.date, parsed.time, ''
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_schedule_rows(rows: list, n_tracks: int) -> tuple:
|
|
216
|
+
"""Turn the range editor's rows into schedule specs, reporting bad ones.
|
|
217
|
+
|
|
218
|
+
Returns ``(specs, errors)`` where each spec is
|
|
219
|
+
``(from, to, start_date, time_or_None, interval_days, step)`` and each error
|
|
220
|
+
is a human-readable ``'row N: …'`` string. Rows are checked up front so a
|
|
221
|
+
typo is named before anything is written, instead of the row vanishing from
|
|
222
|
+
the result with only a count to show for it.
|
|
223
|
+
"""
|
|
224
|
+
specs: list = []
|
|
225
|
+
errors: list = []
|
|
226
|
+
for i, row in enumerate(rows, start=1):
|
|
227
|
+
cells = list(row) if isinstance(row, (list, tuple)) else [row]
|
|
228
|
+
cells += [''] * (5 - len(cells))
|
|
229
|
+
raw_lo, raw_hi, raw_start, raw_every, raw_step = cells[:5]
|
|
230
|
+
if not any(str(c).strip() for c in cells[:4]):
|
|
231
|
+
continue # blank row, ignore
|
|
232
|
+
try:
|
|
233
|
+
lo, hi = int(str(raw_lo).strip()), int(str(raw_hi).strip())
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
errors.append(f"row {i}: FROM/TO must both be track positions")
|
|
236
|
+
continue
|
|
237
|
+
if lo > hi:
|
|
238
|
+
errors.append(f"row {i}: FROM ({lo}) is after TO ({hi})")
|
|
239
|
+
continue
|
|
240
|
+
if hi < 1 or lo > n_tracks:
|
|
241
|
+
errors.append(f"row {i}: positions {lo}-{hi} are outside 1-{n_tracks}")
|
|
242
|
+
continue
|
|
243
|
+
start, tod, err = parse_start(raw_start)
|
|
244
|
+
if err:
|
|
245
|
+
errors.append(f"row {i}: {err}")
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
every = int(str(raw_every).strip())
|
|
249
|
+
except (TypeError, ValueError):
|
|
250
|
+
errors.append(f"row {i}: EVERY must be a number of days")
|
|
251
|
+
continue
|
|
252
|
+
if every < 0:
|
|
253
|
+
errors.append(f"row {i}: EVERY cannot be negative")
|
|
254
|
+
continue
|
|
255
|
+
step = str(raw_step).strip().lower() or 'track'
|
|
256
|
+
if not (step.startswith('track') or step.startswith('disc')):
|
|
257
|
+
errors.append(f"row {i}: STEP must be 'track' or 'disc'")
|
|
258
|
+
continue
|
|
259
|
+
specs.append((lo, hi, start, tod, every, step))
|
|
260
|
+
return specs, errors
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def assign_range_schedules(ordered: list, specs: list) -> dict:
|
|
264
|
+
"""Per-range date schedules: every range carries its own start and interval.
|
|
265
|
+
|
|
266
|
+
``specs`` come from :func:`validate_schedule_rows`:
|
|
267
|
+
``(from, to, start_date, time_or_None, interval_days, step)`` with 1-based
|
|
268
|
+
inclusive positions. Within a range the date advances by ``interval_days``
|
|
269
|
+
once per track, or once per disc when ``step`` is ``'disc'``. That is what
|
|
270
|
+
lets disc 1 run weekly from one date while disc 3 runs fortnightly from
|
|
271
|
+
another.
|
|
272
|
+
|
|
273
|
+
A range carrying a time emits a full ``YYYY-MM-DDTHH:MM:SS``; without one the
|
|
274
|
+
value stays a bare date, which is a perfectly valid TDRC precision. Later
|
|
275
|
+
ranges win on overlap, matching :func:`assign_ranges`.
|
|
276
|
+
"""
|
|
277
|
+
out: dict = {}
|
|
278
|
+
n = len(ordered)
|
|
279
|
+
for spec in specs:
|
|
280
|
+
lo, hi, start, tod, interval, step = (list(spec) + ['track'])[:6]
|
|
281
|
+
# Accept a raw string start too, so a direct caller gets the same leniency.
|
|
282
|
+
if not isinstance(start, datetime.date):
|
|
283
|
+
start, parsed_tod, err = parse_start(start)
|
|
284
|
+
if err:
|
|
285
|
+
continue
|
|
286
|
+
tod = tod or parsed_tod
|
|
287
|
+
try:
|
|
288
|
+
iv = int(str(interval).strip())
|
|
289
|
+
lo_c, hi_c = max(1, int(lo)), min(n, int(hi))
|
|
290
|
+
except (ValueError, TypeError):
|
|
291
|
+
continue
|
|
292
|
+
if lo_c > hi_c:
|
|
293
|
+
continue
|
|
294
|
+
idx = -1
|
|
295
|
+
prev_disc = None
|
|
296
|
+
for pos in range(lo_c, hi_c + 1):
|
|
297
|
+
s = ordered[pos - 1]
|
|
298
|
+
if str(step).strip().lower().startswith('disc'):
|
|
299
|
+
d = _fnum(s.get('disc'))
|
|
300
|
+
if prev_disc is None or d != prev_disc:
|
|
301
|
+
idx += 1
|
|
302
|
+
prev_disc = d
|
|
303
|
+
else:
|
|
304
|
+
idx += 1
|
|
305
|
+
day = (start + datetime.timedelta(days=iv * idx)).isoformat()
|
|
306
|
+
out[s['path']] = f"{day}T{tod}" if tod else day
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def reflow_discs(ordered: list, renumber: bool = True, disc_totals: bool = True,
|
|
311
|
+
track_totals: bool = False) -> dict:
|
|
312
|
+
"""Re-flow disc numbering across an ordered selection.
|
|
313
|
+
|
|
314
|
+
With ``renumber`` the distinct disc numbers are mapped, in order, onto a
|
|
315
|
+
dense ``1…N``. That single rule covers all three edits: a disc parked at
|
|
316
|
+
``1.5`` becomes 2 and everything above it shifts up; a deleted disc closes
|
|
317
|
+
the gap and everything above shifts down; a disc appended at the end keeps
|
|
318
|
+
its number and only the totals move. ``disc_totals`` sets the "of N" half to
|
|
319
|
+
the disc count, and ``track_totals`` additionally rewrites each track's track
|
|
320
|
+
total to its own disc's track count (keeping its existing track number).
|
|
321
|
+
|
|
322
|
+
Tracks with no disc tag are treated as disc 1, as :func:`renumber_tracks`
|
|
323
|
+
already does, so an untagged file cannot invent a disc 0 and shift the rest.
|
|
324
|
+
|
|
325
|
+
Returns ``{path: {field: value}}`` shaped for ``tag_writer.write_fields``.
|
|
326
|
+
"""
|
|
327
|
+
def _disc_of(s) -> float:
|
|
328
|
+
"""A song's disc number, defaulting an untagged file to disc 1."""
|
|
329
|
+
return _fnum(s.get('disc')) or 1.0
|
|
330
|
+
|
|
331
|
+
counts: dict = {}
|
|
332
|
+
for s in ordered:
|
|
333
|
+
d = _disc_of(s)
|
|
334
|
+
counts[d] = counts.get(d, 0) + 1
|
|
335
|
+
discs = sorted(counts)
|
|
336
|
+
dense = {d: i + 1 for i, d in enumerate(discs)}
|
|
337
|
+
|
|
338
|
+
out: dict = {}
|
|
339
|
+
for s in ordered:
|
|
340
|
+
d = _disc_of(s)
|
|
341
|
+
fields: dict = {'disc': dense[d] if renumber else _fmt_disc(d)}
|
|
342
|
+
if disc_totals:
|
|
343
|
+
fields['total_discs'] = len(discs)
|
|
344
|
+
if track_totals and _num(s.get('track')):
|
|
345
|
+
# Keep the track's own number; only its total is being corrected.
|
|
346
|
+
# An unnumbered track is left alone: "or 1" made every one track 1.
|
|
347
|
+
fields['track'] = _num(s.get('track'))
|
|
348
|
+
fields['total_tracks'] = counts[d]
|
|
349
|
+
out[s['path']] = fields
|
|
350
|
+
return out
|