diff options
| author | Conway <[email protected]> | 2026-07-08 19:28:49 -0400 |
|---|---|---|
| committer | Conway <[email protected]> | 2026-07-08 19:28:49 -0400 |
| commit | 6f922c0d1187d344e2345e8e2efaf574842370e4 (patch) | |
| tree | 1bc6470ff0199660630949d18bdb70349cabe4e5 | |
| parent | 0bc89dcf150e97dcafa5efc498a5cfd0e6509abd (diff) | |
| -rw-r--r-- | app.py | 119 |
1 files changed, 83 insertions, 36 deletions
@@ -12,6 +12,13 @@ on a slow probe; tracked lines that scroll past the top of the screen are untracked instead of corrupting the display; job bookkeeping is pruned rather than growing forever; playlist truncation at PLAYLIST_LIMIT is reported; quitting drains the queue but finishes the download in flight. + +v1.9: a playlist whose fan-out wouldn't fit on screen no longer prints a +queued line per entry up front — it prints a one-line summary and each +entry's line appears once, when its download starts; a job whose line +scrolled off-screen gets a fresh line above the prompt on its next status +change instead of updating invisibly, so the active download is always on +screen. """ from __future__ import annotations @@ -33,7 +40,7 @@ import yt_dlp from yt_dlp.postprocessor import EmbedThumbnailPP from yt_dlp.postprocessor.common import PostProcessor -VERSION = "1.8" +VERSION = "1.9" DOWNLOAD_DIR = Path.home() / "Downloads" PROMPT = "dlit> " @@ -90,6 +97,7 @@ _ids = itertools.count(1) # job ids; next() on a count is GIL-atomic, so both _shutting_down = False # set on quit: insert_lines stops redrawing the prompt _print_lock = threading.Lock() +_OFFSCREEN = -1 # _line_distance sentinel: line scrolled past the top, job alive _line_distance: dict[int, int] = {} # job_id -> rows above the current cursor position _job_title: dict[int, str] = {} # job_id -> resolved title (once known) _job_tag: dict[int, str] = {} # job_id -> "(PlaylistName - i/N) " prefix, "" for singles @@ -145,17 +153,18 @@ def _bump(n: int) -> None: A line pushed past the top of the screen can no longer be addressed — cursor-up (CSI A) clamps at row 1, so a later rewrite would land on - whatever unrelated line sits at the top. Such lines are untracked here, - which also keeps the dicts bounded by the screen height instead of growing - for the life of the session. + whatever unrelated line sits at the top. Such lines are marked _OFFSCREEN: + still alive (update_job_line re-homes them to a fresh line on their next + status change) but distinct from forgotten jobs, whose entries are removed + outright and must never reappear. Entries live until _forget_job, the same + lifetime as _job_title/_job_tag, so the dict stays bounded by in-flight + jobs rather than growing for the life of the session. """ height = shutil.get_terminal_size((80, 24)).lines - for k in list(_line_distance): - d = _line_distance[k] + n - if d >= height: - del _line_distance[k] - else: - _line_distance[k] = d + for k, d in _line_distance.items(): + if d == _OFFSCREEN: + continue + _line_distance[k] = _OFFSCREEN if d + n >= height else d + n def post_line(text: str, job_id: int | None = None) -> None: @@ -173,6 +182,23 @@ def post_line(text: str, job_id: int | None = None) -> None: sys.stdout.flush() +def _insert_locked(items: list[tuple[int | None, str]]) -> None: + """Body of insert_lines; caller must hold _print_lock.""" + n = len(items) + height = shutil.get_terminal_size((80, 24)).lines + _bump(n) + sys.stdout.write("\r\x1b[K") + for offset, (job_id, text) in enumerate(items): + if job_id is not None: + d = n - offset + # A batch taller than the screen scrolls its own head off the top. + _line_distance[job_id] = d if d < height else _OFFSCREEN + sys.stdout.write(_clamp(text) + "\n") + if not _shutting_down: + sys.stdout.write(PROMPT + readline.get_line_buffer()) + sys.stdout.flush() + + def insert_lines(items: list[tuple[int | None, str]]) -> None: """Any thread, while the prompt is live: push new lines above it; entries with a job id become tracked, None entries are one-off notices. @@ -185,29 +211,29 @@ def insert_lines(items: list[tuple[int | None, str]]) -> None: even if the user had moved it left (readline's own state is unaffected). """ with _print_lock: - n = len(items) - _bump(n) - sys.stdout.write("\r\x1b[K") - for offset, (job_id, text) in enumerate(items): - if job_id is not None: - _line_distance[job_id] = n - offset - sys.stdout.write(_clamp(text) + "\n") - if not _shutting_down: - sys.stdout.write(PROMPT + readline.get_line_buffer()) - sys.stdout.flush() + _insert_locked(items) def update_job_line(job_id: int, text: str) -> None: """Worker/resolver thread: rewrite this job's tracked line in place; cursor - returns to its prior spot. Untracked (pruned or finished) jobs are a no-op.""" + returns to its prior spot. + + A job whose original line scrolled past the top of the screen (a large + playlist fans out into more lines than the terminal is tall, so the head + entries — the ones the worker downloads first — start off-screen) can't be + rewritten by cursor-up. Instead of dropping the update, the job gets a + fresh tracked line just above the prompt, so the active download is always + visible and finished ones scroll up like a log. Forgotten jobs (their + entry removed by _forget_job) stay a no-op and are never resurrected.""" with _print_lock: d = _line_distance.get(job_id) if d is None: return - if d >= shutil.get_terminal_size((80, 24)).lines: - # Scrolled off since the last _bump (e.g. the terminal shrank); - # unreachable by cursor-up, so stop tracking it. - del _line_distance[job_id] + if d == _OFFSCREEN or d >= shutil.get_terminal_size((80, 24)).lines: + # Scrolled past the top (the >= check catches a terminal that + # shrank since the last _bump): re-home to a new line above the + # prompt. + _insert_locked([(job_id, text)]) return sys.stdout.write( "\x1b7" # save cursor (DECSC) @@ -457,9 +483,13 @@ def resolver(resolves: "queue.Queue", jobs: "queue.Queue") -> None: The placeholder line printed at paste time is reused in place for a single video (or a playlist's first entry); further playlist entries are inserted - above the live prompt as new tracked lines. Lines are registered before - their jobs are enqueued, so a fast worker never tries to update a line - whose distance isn't set yet. + above the live prompt as new tracked lines. A playlist whose fan-out would + not fit on screen (the head lines would scroll straight off the top, where + they can never be updated) instead reuses the placeholder for a one-line + summary and registers every entry as _OFFSCREEN, so each entry's line is + created exactly once — when its download starts. Lines are registered + before their jobs are enqueued, so a fast worker never tries to update a + line whose distance isn't set yet. """ while True: item = resolves.get() @@ -485,17 +515,29 @@ def resolver(resolves: "queue.Queue", jobs: "queue.Queue") -> None: if is_playlist: _job_tag[jid] = f"({name[:24]} - {i}/{total}) " - update_job_line(job_id, f"[{ICON_QUEUED}] {_job_tag.get(job_id, '')}{entries[0][1]}") - extra: list[tuple[int | None, str]] = [ - (jid, f"[{ICON_QUEUED}] {_job_tag.get(jid, '')}{title}") - for jid, (_eurl, title) in zip(ids[1:], entries[1:]) - ] + notices: list[tuple[int | None, str]] = [] if total >= PLAYLIST_LIMIT: # yt-dlp gives no overflow signal under playlistend, so hitting the # limit exactly is the best available proxy for truncation. - extra.append((None, f" '{name[:40]}' stopped at {PLAYLIST_LIMIT} entries")) - if extra: - insert_lines(extra) + notices.append((None, f" '{name[:40]}' stopped at {PLAYLIST_LIMIT} entries")) + if total >= shutil.get_terminal_size((80, 24)).lines - 1: + # Too many entries for one queued line each: summarise, and mark + # every job _OFFSCREEN so its single line appears when it starts. + update_job_line(job_id, f"[{ICON_QUEUED}] {name[:40]} — {total} entries queued") + with _print_lock: + for jid in ids: + _line_distance[jid] = _OFFSCREEN + if notices: + insert_lines(notices) + else: + update_job_line(job_id, f"[{ICON_QUEUED}] {_job_tag.get(job_id, '')}{entries[0][1]}") + extra: list[tuple[int | None, str]] = [ + (jid, f"[{ICON_QUEUED}] {_job_tag.get(jid, '')}{title}") + for jid, (_eurl, title) in zip(ids[1:], entries[1:]) + ] + extra.extend(notices) + if extra: + insert_lines(extra) for jid, (eurl, _title) in zip(ids, entries): jobs.put((jid, eurl, preset)) @@ -507,6 +549,11 @@ def worker(jobs: "queue.Queue") -> None: return job_id, url, preset = item tag = _job_tag.get(job_id, "") + # Announce pickup: flips an on-screen queued line to ↓ right away, and + # creates the (sole) line for a job that never had one on screen — + # otherwise nothing would show until the first progress callback, + # which trails the pre-download metadata probe by seconds. + update_job_line(job_id, f"[{ICON_DOWNLOADING}] {tag}{(_job_title.get(job_id) or url)[:60]}") try: with yt_dlp.YoutubeDL(make_opts(job_id, preset)) as ydl: if preset == "mp3": |
