Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Use β€œafterward” for American English.

Change β€œafterwards” to β€œafterward” to match the project’s American-English wording convention.

🧰 Tools
πŸͺ› LanguageTool

[locale-violation] ~20-~20: In American English, β€˜afterward’ is the preferred variant. β€˜Afterwards’ is more commonly used in British English and other dialects.
Context: ... non-blocking mode stayed on the window afterwards: curses.wrapper does not reset it b...

(AFTERWARDS_US)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 20, Update the changelog wording from β€œafterwards” to
β€œafterward” in the affected sentence, preserving the rest of the text.

Source: Linters/SAST tools

`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
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -79,15 +79,15 @@ 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
`addOptions.monitor = "none"`, which makes Lidarr unmonitor the artist and
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
Expand Down
2 changes: 1 addition & 1 deletion chartarr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""chartarr β€” feed your album charts to Lidarr."""
"""chartarr - feed your album charts to Lidarr."""

__version__ = "0.1.1"
34 changes: 17 additions & 17 deletions chartarr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -233,19 +233,19 @@ 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:
reader = csv.DictReader(f)
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:
Expand Down Expand Up @@ -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)


Expand All @@ -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)
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions chartarr/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
14 changes: 7 additions & 7 deletions chartarr/lidarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -98,15 +98,15 @@ 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
except (requests.exceptions.InvalidSchema,
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
Expand 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:
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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"]
Expand Down
4 changes: 2 additions & 2 deletions chartarr/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion chartarr/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions chartarr/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_lidarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"])],
Expand Down
Loading