diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d14938..02f9e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ A fix release. The first one is the reason to upgrade. - **The review screen pinned a CPU core while it waited for you.** The progress screens poll for a keypress so they can notice `q` mid-run, - and that non-blocking mode stayed on the window afterwards — + and that non-blocking mode stayed on the window afterwards: `curses.wrapper` does not reset it between screens. The review list, which expects to wait for input, read "no key pressed" instead and redrew immediately: about 17,000 times a second, for as long as the @@ -29,8 +29,8 @@ A fix release. The first one is the reason to upgrade. decision only otherwise, so a row you skipped that later matched on a `--rematch` went to Lidarr anyway, and a re-pick lost to the candidate the matcher had led with. A decision now outranks the match result. A - matched row carrying no release group id — possible in a state file - from an older version — is left out instead of failing at Lidarr's + matched row carrying no release group id (possible in a state file + from an older version) is left out instead of failing at Lidarr's lookup. - **One failed search request stranded the albums behind it.** Searches go out in batches of a hundred and a failing batch stopped the rest, so @@ -41,7 +41,7 @@ A fix release. The first one is the reason to upgrade. - **A Lidarr URL with credentials in it appeared in error messages.** `http://user:pw@host` is how you get through a reverse proxy that asks for basic auth, and the whole URL was quoted back in connection errors, - timeouts, non-JSON replies and proxy error pages — the kind of text + timeouts, non-JSON replies and proxy error pages, the kind of text that ends up pasted into a bug report. The password is stripped from what's printed; the request still sends it. @@ -51,8 +51,8 @@ A fix release. The first one is the reason to upgrade. be permanent for that run: the state file has always understood a cleared decision, but nothing could write one. - **A push without `--search` now says how many albums are monitored but - idle.** Monitoring is not downloading — Lidarr picks monitored albums - up on its own schedule — and a run ending "added 40" while nothing + idle.** Monitoring is not downloading (Lidarr picks monitored albums + up on its own schedule), and a run ending "added 40" while nothing downloads reads like a finished job. ### Changed @@ -79,7 +79,7 @@ Match a CSV of albums against MusicBrainz, resolve the uncertain ones in a review screen, and add the results to Lidarr as monitored albums. Runs in three resumable stages and can be stopped and restarted at any point. -Notes for anyone who ran this from git before the release — three fixes +Notes for anyone who ran this from git before the release. Three fixes changed behaviour you may have been affected by: - **Albums pushed to Lidarr stayed unmonitored.** The artist was added with @@ -87,7 +87,7 @@ changed behaviour you may have been affected by: then, once its background scan finishes, every album of theirs including the one just pushed. Verified against Lidarr 3.1.0.4875: a fresh push reported "added" and fifteen seconds later nothing was monitored. If you - pushed a chart with an earlier build, check Lidarr — those albums are + pushed a chart with an earlier build, check Lidarr: those albums are probably sitting there unmonitored, and re-running chartarr will fix them. - **Editing the CSV between runs mixed up the matches.** Rows were keyed by position, so inserting a line at the top shifted every key and each album diff --git a/chartarr/__init__.py b/chartarr/__init__.py index 4cb0f1f..8d83539 100644 --- a/chartarr/__init__.py +++ b/chartarr/__init__.py @@ -1,3 +1,3 @@ -"""chartarr — feed your album charts to Lidarr.""" +"""chartarr - feed your album charts to Lidarr.""" __version__ = "0.1.1" diff --git a/chartarr/cli.py b/chartarr/cli.py index 30029ac..5ca1540 100644 --- a/chartarr/cli.py +++ b/chartarr/cli.py @@ -67,7 +67,7 @@ def status(line: str) -> None: if not sys.stdout.isatty(): return width = shutil.get_terminal_size().columns - 1 - # truncate and pad by display cells, not code points — a cjk label is + # truncate and pad by display cells, not code points: a cjk label is # wider than len() says, and overflowing the row garbles the redraw line = screen._fit(line, width) sys.stdout.write("\r" + line + " " * max(0, width - screen.cells(line))) @@ -119,13 +119,13 @@ def _ask(prompt: str, secret: bool = False) -> str: return (getpass.getpass(prompt) if secret else input(prompt)).strip() except (EOFError, KeyboardInterrupt): print(file=sys.stderr) # step off the interrupted prompt line - fail("setup cancelled — nothing was saved") + fail("setup cancelled; nothing was saved") return "" # unreachable; fail() exits def setup_wizard(existing: dict) -> dict: if not sys.stdin.isatty(): - fail("lidarr isn't set up and there's no terminal to ask on — set " + fail("lidarr isn't set up and there's no terminal to ask on; set " "LIDARR_URL and LIDARR_API_KEY, or run chartarr --setup in a terminal") default = existing.get("lidarr_url", "http://localhost:8686") url = _ask(f"lidarr url [{default}]: ") or default @@ -233,7 +233,7 @@ def load_csv(path: Path): # python 3.12 stopped rejecting those in the csv reader, so the file # parses into gibberish column names instead of failing; say what it is. if b"\x00" in head: - fail(f'{path} looks like utf-16 (excel\'s "unicode text" export) — ' + fail(f'{path} looks like utf-16 (excel\'s "unicode text" export); ' 're-save it as "csv utf-8" and rerun') try: with path.open(newline="", encoding="utf-8-sig") as f: @@ -241,11 +241,11 @@ def load_csv(path: Path): rows = list(reader) cols = reader.fieldnames or [] except UnicodeDecodeError: - fail(f"{path} isn't utf-8 — re-save it as utf-8 " + fail(f"{path} isn't utf-8; re-save it as utf-8 " '(in excel: "csv utf-8") and rerun') except csv.Error as e: - hint = (' — excel\'s "unicode text" export is utf-16, which does ' - 'this; re-save as "csv utf-8" and rerun' + hint = (' (excel\'s "unicode text" export is utf-16, which does ' + 'this; re-save as "csv utf-8" and rerun)' if "NUL" in str(e) else "") fail(f"can't parse {path}: {e}{hint}") except OSError as e: @@ -321,12 +321,12 @@ def events(): # don't promise saved progress when the first lookup was the one # that failed; there is nothing to resume and saying so is a lie done = sum(counts.values()) - sum(base.values()) - fail("musicbrainz stopped answering — " + fail("musicbrainz stopped answering; " + (f"the {_n(done, 'row')} matched so far {'is' if done == 1 else 'are'} " "saved, rerun to pick up the rest" if done else "nothing was matched, so nothing was saved; try again later")) if stopped: - print(dim("stopped — progress is saved, rerun to resume")) + print(dim("stopped; progress is saved, rerun to resume")) sys.exit(0) @@ -338,7 +338,7 @@ def stage_review(rows, artist_col, title_col, state: State) -> None: if not pending: return if not (sys.stdin.isatty() and sys.stdout.isatty()): - print(dim(f"{len(pending)} rows need review — rerun in a terminal, " + print(dim(f"{len(pending)} rows need review; rerun in a terminal, " f"or use --yes to push without them")) return review.run(pending, artist_col, title_col, state.add_decision) @@ -356,7 +356,7 @@ def import_set(rows, state: State) -> list[dict]: a decision outranks the match result. review only offers uncertain rows, but a row can be decided and then match cleanly on a --rematch, - and the state file is editable — either way the answer the user gave + and the state file is editable; either way the answer the user gave is the one they meant, so an explicit skip is honoured on a matched row and a re-pick replaces the automatic choice. """ @@ -472,14 +472,14 @@ def events(): elif touched and not args.dry_run: # without --search the albums sit there monitored and idle, which # looks like a finished job that downloaded nothing - print(dim(f"{_n(len(touched), 'album')} monitored but not searched — " + print(dim(f"{_n(len(touched), 'album')} monitored but not searched; " f"rerun with --search, or hit Search in lidarr")) for f_ in failures[:8]: print(dim(f" {f_}")) if len(failures) > 8: print(dim(f" … and {len(failures) - 8} more")) if stopped: - print(dim("stopped — the push is safe to rerun")) + print(dim("stopped; the push is safe to rerun")) sys.exit(0) @@ -542,10 +542,10 @@ def main(argv=None) -> None: try: _main(build_parser().parse_args(argv)) except KeyboardInterrupt: - # covers ctrl-c anywhere — csv loading, state replay, a wizard - # network call — not just the matching/push loops + # covers ctrl-c anywhere (csv loading, state replay, a wizard + # network call), not just the matching/push loops status_end() - print(dim("stopped — progress is saved, rerun to resume")) + print(dim("stopped; progress is saved, rerun to resume")) sys.exit(130) finally: # flush inside the try so a closed pipe surfaces here as a @@ -568,7 +568,7 @@ def _main(args) -> None: if p.exists(): fail("sample.csv already exists here") p.write_text(EXAMPLE_CSV, encoding="utf-8") - print("wrote sample.csv — try: chartarr sample.csv --dry-run") + print("wrote sample.csv; try: chartarr sample.csv --dry-run") return if args.demo: diff --git a/chartarr/demo.py b/chartarr/demo.py index dd32c2c..23a471f 100644 --- a/chartarr/demo.py +++ b/chartarr/demo.py @@ -124,7 +124,7 @@ def match_events(): f"review {cli.accent(counts.get('review', 0))} · " f"not found {cli.accent(counts.get('not_found', 0))}") if stopped: - print(cli.dim("demo over — nothing was saved or sent")) + print(cli.dim("demo over; nothing was saved or sent")) return # review, for real @@ -163,4 +163,4 @@ def push_events(): except KeyboardInterrupt: print() - print(cli.dim("demo over — nothing was saved or sent")) + print(cli.dim("demo over; nothing was saved or sent")) diff --git a/chartarr/lidarr.py b/chartarr/lidarr.py index 84c460f..754527a 100644 --- a/chartarr/lidarr.py +++ b/chartarr/lidarr.py @@ -30,7 +30,7 @@ def __init__(self, message: str, status: int = 0, body: str = ""): def _safe_url(url: str) -> str: """the url with any user:password@ removed, for showing in messages. - a lidarr url can carry basic-auth credentials — behind a reverse proxy + a lidarr url can carry basic-auth credentials: behind a reverse proxy that asks for them, http://user:pw@host is how you get through. those end up in every connection error otherwise, and errors get pasted into bug reports. @@ -98,7 +98,7 @@ def _call(self, path: str, method: str = "GET", **kw): r = self.s.request(method, url, timeout=60, **kw) except requests.ConnectionError as e: raise LidarrError( - f"Can't reach Lidarr at {self.shown} — is it running, and is the " + f"Can't reach Lidarr at {self.shown}; is it running, and is the " f"URL right? (the address you use in your browser)") from e except requests.Timeout as e: raise LidarrError(f"Lidarr at {self.shown} timed out.") from e @@ -106,7 +106,7 @@ def _call(self, path: str, method: str = "GET", **kw): requests.exceptions.MissingSchema, requests.exceptions.InvalidURL) as e: raise LidarrError( - f"{self.shown} is not a URL Lidarr can be reached at — it needs " + f"{self.shown} is not a URL Lidarr can be reached at; it needs " f"to start with http:// or https://") from e except requests.RequestException as e: # requests quotes the url it was given, credentials and all @@ -128,7 +128,7 @@ def _call(self, path: str, method: str = "GET", **kw): return r.json() except ValueError as e: raise LidarrError( - f"Lidarr returned something that isn't JSON — is {self.shown} " + f"Lidarr returned something that isn't JSON; is {self.shown} " f"really Lidarr, and not a login page or another service?") from e def status(self) -> dict: @@ -179,8 +179,8 @@ def add_album(self, rgid: str, quality_profile_id: int, adding does not search. lidarr's addOptions.searchForNewAlbum is read by SearchForRecentlyAdded, which ArtistScannedHandler only - reaches for an artist with no AddOptions — never the artist this - add just created — and the handler clears AddOptions on its way + reaches for an artist with no AddOptions (never the artist this + add just created), and the handler clears AddOptions on its way out, so the flag is stored and then dropped (Lidarr#5012). the caller searches explicitly with search_albums instead. """ @@ -194,7 +194,7 @@ def add_album(self, rgid: str, quality_profile_id: int, album = self.lookup(rgid) if album is None: raise LidarrError( - "Lidarr's lookup didn't find this MusicBrainz ID — it needs a " + "Lidarr's lookup didn't find this MusicBrainz ID; it needs a " "release group ID, not a release ID") wanted = [rgid] + [r for r in (also_monitor or []) if r != rgid] artist = album["artist"] diff --git a/chartarr/matcher.py b/chartarr/matcher.py index efdd0b4..6b039fb 100644 --- a/chartarr/matcher.py +++ b/chartarr/matcher.py @@ -163,7 +163,7 @@ def _first_artist(rg: dict) -> tuple[str | None, str]: # same-titled "Fleetwood Mac" rgs). each secondary type costs clearly more # than the Album bump (0.03) so a clean studio album always outranks a # same-titled flavoured one; ties otherwise favour the fewest secondary -# types. the penalty lives in the sort key ONLY — confidence/title_sim are +# types. the penalty lives in the sort key ONLY: confidence/title_sim are # untouched, so a legitimately live/soundtrack chart entry that wins its row # anyway ("The Last Waltz", "Purple Rain") still clears the auto-match gate. _SECONDARY_PENALTY = 0.05 @@ -289,7 +289,7 @@ def absorb(data: dict, from_alias: bool = False) -> None: # alias pass: when the winner is either unproven (fails the auto-match # gates) or a flavoured release group the row did not ask for, spend a # few lookups confirming aliases for candidates an alias could still - # save — right artist, unproven title. this is what lets "Blackstar" + # save: right artist, unproven title. this is what lets "Blackstar" # auto-match ★ instead of stalling on "Blackstar Radio Edits". hinted = _hinted_types(t_vars) diff --git a/chartarr/review.py b/chartarr/review.py index 01649da..0be4e01 100644 --- a/chartarr/review.py +++ b/chartarr/review.py @@ -21,7 +21,7 @@ def run(items, artist_col, title_col, on_decision): return if not available(): print(f"this terminal can't draw the review screen " - f"(TERM={os.environ.get('TERM', '')!r}) — rerun in a working " + f"(TERM={os.environ.get('TERM', '')!r}); rerun in a working " f"terminal, or use --yes to push just the confident matches") return _run(_loop, items, artist_col, title_col, on_decision) diff --git a/chartarr/screen.py b/chartarr/screen.py index 22a75ed..67c494c 100644 --- a/chartarr/screen.py +++ b/chartarr/screen.py @@ -22,7 +22,7 @@ def available() -> bool: back to the plain-line output instead of crashing. note: cpython caches setupterm's first success process-wide, so this - answers for the TERM the process started with — which is the one that + answers for the TERM the process started with, which is the one that matters. tests that flip TERM must probe in a subprocess. """ if curses is None: @@ -43,7 +43,7 @@ def _run(func, *args): # a utf-8 locale makes ncurses draw wide characters correctly, but an # LC_ALL this box doesn't know (classic ssh-forwarded locale) must not - # kill the screen — degrade toward the C locale instead + # kill the screen; degrade toward the C locale instead try: locale.setlocale(locale.LC_ALL, "") except locale.Error: @@ -56,7 +56,7 @@ def entry(scr, *inner): # every session starts blocking. cpython keeps nodelay on the window # itself and curses.wrapper does not reset it between sessions, so # the nodelay(True) _progress needs would carry into the review loop - # that follows it — whose getch() would then return -1 forever and + # that follows it, whose getch() would then return -1 forever and # redraw at ~17k frames a second on a pinned core. a screen that # wants non-blocking input asks for it, as _progress does. try: @@ -75,7 +75,7 @@ def _cell(ch: str) -> int: def cells(s: str) -> int: - """display cells s occupies — the terminal lays out by cell, not code + """display cells s occupies: the terminal lays out by cell, not code point, and cjk take two, so len() undercounts exactly on the dual-script charts this tool is for.""" return sum(_cell(ch) for ch in s) @@ -128,7 +128,7 @@ def _dark_background() -> bool: COLORFGBG is what terminals that care about this set (rxvt, konsole, some terminfo-aware setups); "15;0" means light text on dark. absent - it, assume dark — the common case, and both shades are readable there. + it, assume dark: the common case, and both shades are readable there. """ fgbg = os.environ.get("COLORFGBG", "") if ";" in fgbg: diff --git a/tests/test_cli.py b/tests/test_cli.py index c5f84ce..5b23bfd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -335,7 +335,7 @@ def half(rows, artist_col, title_col): def test_review_choice_beats_the_auto_match_and_skips_are_dropped(tmp_path): # the reviewed pick has to reach lidarr, not the candidate the matcher - # led with — that's the whole point of the review screen + # led with; that's the whole point of the review screen p = _csv(tmp_path, "title,artist\nZiggy Stardust,David Bowie\n" "Kid A,Radiohead\nDummy,Portishead\n") rows, _, _ = load_csv(p) diff --git a/tests/test_lidarr.py b/tests/test_lidarr.py index 13621b0..24f3d47 100644 --- a/tests/test_lidarr.py +++ b/tests/test_lidarr.py @@ -108,7 +108,7 @@ def test_also_monitor_names_every_sibling_once(): def test_adding_never_asks_lidarr_to_search(): # searchForNewAlbum is read by SearchForRecentlyAdded, which # ArtistScannedHandler only reaches when the artist has no AddOptions - # — never the artist this add just created — and the handler clears + # (never the artist this add just created), and the handler clears # AddOptions on the way out, so the flag is stored and dropped # (Lidarr#5012). searching is the caller's job, via AlbumSearch. stage_lookup() @@ -182,7 +182,7 @@ def test_duplicate_row_already_monitored_is_skipped(): @responses.activate def test_config_400_is_an_error_not_a_duplicate(): # the original client sniffed the body for "exist", so "Quality Profile - # does not exist" — a config mistake — took the duplicate path and died + # does not exist" (a config mistake) took the duplicate path and died # with a baffling "conflict but album not found afterwards". it must # surface as the validation error it is. stage_lookup() @@ -191,7 +191,7 @@ def test_config_400_is_an_error_not_a_duplicate(): "errorMessage": "Quality Profile does not exist"}]) with pytest.raises(LidarrError, match="Quality Profile"): add() - assert len(responses.calls) == 3 # find, lookup, post — no duplicate dance + assert len(responses.calls) == 3 # find, lookup, post; no duplicate dance @responses.activate diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 607e1b3..ef70c41 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -151,7 +151,7 @@ def test_hint_waives_only_the_named_type(): def test_alias_counts_as_title(): # mb titles bowie's Blackstar "★"; the name every chart uses is only an # alias, and releasegroup:"Blackstar" returns just "Blackstar Radio - # Edits" — the aliased album must outscore it + # Edits"; the aliased album must outscore it cands = score_rgs( [rg("Blackstar Radio Edits", "David Bowie", "radio-id", ptype="Single"), rg("★", "David Bowie", "star-id", aliases=["Blackstar", "★ (Blackstar)"])], diff --git a/tests/test_review.py b/tests/test_review.py index 27f4b24..02c12dd 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -64,7 +64,7 @@ def item(key, candidates, artist="David Bowie", title="Ziggy Stardust"): @pytest.fixture(autouse=True) def offline_curses(monkeypatch): - """the loop's terminal calls, neutered — no initscr, no real screen.""" + """the loop's terminal calls, neutered: no initscr, no real screen.""" if review.curses is None: pytest.skip("no curses at all") monkeypatch.setattr(review.curses, "curs_set", lambda n: None) @@ -236,7 +236,7 @@ def fake_wrapper(func, *args): def test_an_empty_read_costs_a_full_redraw(): """why the flag matters: -1 does nothing but go round again. - the loop has no idle branch — a non-blocking getch returns -1, falls + the loop has no idle branch: a non-blocking getch returns -1, falls through every key test, and redraws. that is the spin: on a real terminal it ran ~17k times a second until a key arrived. """ diff --git a/tests/test_screen.py b/tests/test_screen.py index 9e986eb..b16f590 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -35,7 +35,7 @@ def _run_probe(code, term): def _terminfo_has(term): # a SUCCESSFUL curses.setupterm is cached process-wide by cpython # (initialised_setupterm), after which every later call succeeds no - # matter what TERM says — so positive probes must run in a fresh + # matter what TERM says, so positive probes must run in a fresh # process, or they poison the TERM=unknown tests below. failures are # not cached, so the negative tests are safe in-process. return _run_probe( @@ -195,7 +195,7 @@ def test_nodelay_does_not_survive_into_the_next_session_on_a_real_terminal(): os.close(master) os.close(slave) assert proc.returncode == 0, ( - "the second session's getch did not block — nodelay leaked " + "the second session's getch did not block; nodelay leaked " f"({proc.stderr.decode()[-300:]})")