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.
Files changed (69) hide show
  1. backpack_backtrack-0.2.0.dist-info/METADATA +460 -0
  2. backpack_backtrack-0.2.0.dist-info/RECORD +69 -0
  3. backpack_backtrack-0.2.0.dist-info/WHEEL +5 -0
  4. backpack_backtrack-0.2.0.dist-info/entry_points.txt +2 -0
  5. backpack_backtrack-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. backpack_backtrack-0.2.0.dist-info/top_level.txt +1 -0
  7. backtrack/__init__.py +8 -0
  8. backtrack/__main__.py +3 -0
  9. backtrack/album_art.py +173 -0
  10. backtrack/bulk_pattern.py +350 -0
  11. backtrack/cli.py +511 -0
  12. backtrack/cli_commands.py +2242 -0
  13. backtrack/config.py +204 -0
  14. backtrack/deps.py +51 -0
  15. backtrack/feed.py +465 -0
  16. backtrack/history.py +73 -0
  17. backtrack/id3/__init__.py +0 -0
  18. backtrack/id3/browser.py +1167 -0
  19. backtrack/id3/bulk_art.py +374 -0
  20. backtrack/id3/bulk_assign.py +616 -0
  21. backtrack/id3/bulk_common.py +130 -0
  22. backtrack/id3/bulk_menu.py +823 -0
  23. backtrack/id3/bulk_names.py +468 -0
  24. backtrack/id3/bulk_ops.py +670 -0
  25. backtrack/id3/bulk_sort.py +402 -0
  26. backtrack/id3/cover_matcher.py +537 -0
  27. backtrack/id3/file_namer.py +299 -0
  28. backtrack/id3/filename_parser.py +517 -0
  29. backtrack/id3/tag_handler.py +1207 -0
  30. backtrack/id3/tag_registry.py +410 -0
  31. backtrack/id3/tag_writer.py +534 -0
  32. backtrack/lyrics/__init__.py +0 -0
  33. backtrack/lyrics/editor.py +821 -0
  34. backtrack/lyrics/editor_keys.py +678 -0
  35. backtrack/lyrics/editor_view.py +830 -0
  36. backtrack/lyrics/formats.py +1286 -0
  37. backtrack/lyrics/lyric_pane.py +289 -0
  38. backtrack/lyrics/md_overlay.py +390 -0
  39. backtrack/lyrics/sync_doc.py +317 -0
  40. backtrack/lyrics/text.py +521 -0
  41. backtrack/lyrics/time_fields.py +145 -0
  42. backtrack/lyrics/verify.py +372 -0
  43. backtrack/main.py +279 -0
  44. backtrack/menus/__init__.py +36 -0
  45. backtrack/menus/activity.py +79 -0
  46. backtrack/menus/browse.py +471 -0
  47. backtrack/menus/common.py +145 -0
  48. backtrack/menus/history.py +143 -0
  49. backtrack/menus/play.py +272 -0
  50. backtrack/menus/search.py +371 -0
  51. backtrack/menus/settings.py +520 -0
  52. backtrack/menus/sorting.py +199 -0
  53. backtrack/music_library.py +1215 -0
  54. backtrack/playback/__init__.py +0 -0
  55. backtrack/playback/ipc.py +387 -0
  56. backtrack/playback/libvlc.py +13 -0
  57. backtrack/playback/now_playing_box.py +156 -0
  58. backtrack/playback/player.py +744 -0
  59. backtrack/playback/player_art.py +330 -0
  60. backtrack/playback/player_geom.py +48 -0
  61. backtrack/playback/player_ui.py +1039 -0
  62. backtrack/playback/queue_pane.py +307 -0
  63. backtrack/playback/session.py +1096 -0
  64. backtrack/search.py +627 -0
  65. backtrack/trim/__init__.py +0 -0
  66. backtrack/trim/bulk.py +700 -0
  67. backtrack/trim/editor.py +847 -0
  68. backtrack/trim/engine.py +839 -0
  69. 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