Bursts of completions (e.g. small playlist entries) fire one item_moved
frame per item. Route item_moved through the same ItemBatcher mechanism
as item_updated: one frame per 500ms tick, payload always a list.
ItemBatcher gains an optional key callable since item_moved payloads are
{"to", "preset", "item"} dicts rather than ItemDTOs; moves coalesce by
item _id with last write wins, so an item that moves twice within a tick
delivers only its final destination. The notifications service consumes
ITEM_MOVED from the event bus directly and is unaffected.
BREAKING CHANGE: the item_moved WebSocket event payload (data) is now
always an array of {to, preset, item} objects instead of a single one.
The per-download 500ms throttle still scales linearly with concurrency:
N active downloads produce up to 2N item_updated frames per second, each
with its own serialization pass and per-client send.
Add an ItemBatcher at the WebSocket boundary that coalesces dirty items
by _id (last write wins) and emits one item_updated frame per 500ms
tick whose payload is always a list of items, even for a single update.
Leading-edge emits keep isolated updates (e.g. renames via the API)
instant, and a trailing flush at most 500ms later delivers the rest.
Pending items are flushed on shutdown before clients disconnect.
Emitter call sites, the EventBus, and the StatusTracker throttle are
unchanged; the batcher only changes the wire format. The frontend now
types item_updated as StoreItem[] and iterates the array.
BREAKING CHANGE: the item_updated WebSocket event payload (data) is now
always an array of items instead of a single item object.
Long-running instances accumulate history indefinitely. As the history
table grows, SQLite COUNT(*), paginated SELECT, and index scans degrade.
YTP_MAX_HISTORY (default: 0 = unlimited) caps the number of completed
download records kept. Enforcement is a hook in DataStore.put(): on
each insertion, the oldest excess entries are immediately evicted from
the in-memory OrderedDict and their deletes are enqueued to SQLite.
DataStore.load() also trims on startup in case the limit was lowered
between restarts. No background tasks, no DB reads needed for the check.
Every item_updated WebSocket event and every history/queue list response
was serializing the full ItemDTO including fields the UI never reads.
Add ItemDTO.serialize_for_list() which excludes: options,
template_chapter, temp_dir, total_bytes, total_bytes_estimate,
tmpfilename, archive_id. The global Encoder now uses serialize_for_list()
for all list/event contexts; the full serialize() remains available for
detail endpoints (GET /api/history/{id}).
Mark the excluded fields optional in the TypeScript StoreItem type.
Two startup cost reductions:
1. Folder enumeration is moved from /api/system/configuration to a new
GET /api/system/folders endpoint. The frontend fetches it only when
the user focuses a folder input field, and caches the result for the
session. On large directory trees this was a noticeable startup delay.
2. The queue array is removed from /api/system/configuration. The queue
is already loaded separately via /api/history/live on mount; including
it in the config response was a double-load that inflated the initial
payload for users with large queues.
yt-dlp fires progress hooks faster than the browser can usefully render
them. Previously every callback triggered a WebSocket emit → Vue
reactivity update → DOM re-render, causing unnecessary main-thread work
during active downloads.
Now status changes (e.g. downloading → finished) are always emitted
immediately, while progress-only updates are capped at one per 500ms
per download. A flush_pending() call at loop termination ensures the
final state is never dropped.