┌   ┐
54
└   ┘

summaryrefslogtreecommitdiff
path: root/app.py
diff options
context:
space:
mode:
authorConway <[email protected]>2026-07-08 19:28:49 -0400
committerConway <[email protected]>2026-07-08 19:28:49 -0400
commit6f922c0d1187d344e2345e8e2efaf574842370e4 (patch)
tree1bc6470ff0199660630949d18bdb70349cabe4e5 /app.py
parent0bc89dcf150e97dcafa5efc498a5cfd0e6509abd (diff)
v1.9 - sequential listing for very long playlistsHEADmain
Diffstat (limited to 'app.py')
-rw-r--r--app.py119
1 files changed, 83 insertions, 36 deletions
diff --git a/app.py b/app.py
index 3dc1214..39e7f0c 100644
--- a/app.py
+++ b/app.py
@@ -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":