Most downloads from a given install land in the same custom directory,
which today means picking it by hand every time. DEFAULT_FOLDER seeds the
folder field once the configuration arrives; the field stays editable, so
per-download folders still work, and a folder already typed this session
is not overwritten.
The value is trimmed of surrounding slashes, and dropped with a warning
when CUSTOM_DIRS is off, since the UI hides the field in that mode and
the download path check rejects a folder anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A subscription already carries every other download option and applies it to
each video it queues. Clip bounds were the one exception: 4f83174 added them
for one-off downloads and carved subscriptions out, rejecting the fields in the
subscribe route and stripping them in the UI before the request was built. So
the fields sat visible in the shared advanced-options panel while quietly doing
nothing for a subscription.
Carry them like the rest: two fields on SubscriptionInfo, threaded through the
check flow to add_entry. Stored records that predate the fields take the
defaults, since _from_stored filters by field name.
One thing does not carry over. parse_download_options reads a YouTube t=
timestamp from the URL and turns it into a clip start, which is right when you
paste a link to a moment in a video you want. A subscription URL is a channel
or a playlist, so a timestamp left on it says nothing about the videos that
feed will yield, and honouring it would silently truncate every future
download. The subscribe route therefore takes clip bounds only when the caller
sent the fields explicitly; the t= param is still stripped from the stored URL.
Worth knowing when using this: the range is a fixed offset applied to every
video, so it suits feeds with a consistent shape - a standing intro, a fixed
sponsor read - and will cut in the wrong place on a feed without one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The folder was already persisted on the subscription and already applied to
every download it queued, but it was missing from the small tuple of fields the
update route accepts, so it could be set when the subscription was created and
never afterwards. That is the same gap the subscription name had in #1044.
Add it to the accepted fields and validate it on the way in, following the
validate_* helpers already in this module. The check is deliberately narrow —
it rejects absolute paths and any '..' component, values that could never be
valid — because the authoritative resolution stays where it already lives, in
DownloadQueue at download time, along with the CUSTOM_DIRS / CREATE_CUSTOM_DIRS
rules and the directory creation. Doing it this way reports a bad edit while
the user is looking at the field instead of failing every check from then on,
without a second copy of the path logic drifting out of step with the first.
subscriptions.py cannot import ytdl.py in any case: ytdl imports _entry_id from
it.
An empty folder stays valid and means the base download directory. A change
applies to future downloads only; files already downloaded are not moved.
This covers the API side of the request. The subscriptions table does not show
the folder at all today, so exposing it in the UI is a separate change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint accepted {ids: [x]} and then rejected anything but exactly one
id, so the schema advertised a batch it never supported. Retry is genuinely
singular: unlike the /delete, /start and /cancel batches, which act on local
state and can't meaningfully fail for one id and not another, each retry
re-extracts the URL and the caller removes that item's done record only once
it is confirmed re-queued. A real batch form would need per-id results in the
response for the caller to know which records to remove; that only becomes
worth designing alongside moving done-record deletion server-side.
/retry has not shipped yet, so there is no compatibility cost.
The SSRF guard rejects any address that isn't globally routable, which breaks
proxy/VPN setups that resolve hosts into private or special-use ranges. The
reported case is Fake-IP clients (sing-box, Clash, Mihomo) that map YouTube to
the RFC 2544 benchmarking range 198.18.0.0/15 to tunnel the traffic; MeTube
rejected it with "Refusing to fetch internal address" even though yt-dlp itself
handles it fine.
Add a boolean ALLOW_PRIVATE_ADDRESSES (default false) that, when set, skips both
layers: validate_url returns after scheme validation without the internal-host
checks, and the connect-time socket guard is not installed. Threaded to the
download subprocess via Download so the guard sees it too. Scheme validation
(http/https only) still applies. Documented in the README as a trust-your-network
opt-out that disables SSRF protection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-download chapter_template was validated only against literal ".."
in the template string, but yt-dlp expands %(section_title)s (and every
other field) from attacker-controlled metadata at download time. On POSIX
hosts yt-dlp does not neutralise a ".." path component, so a chapter titled
".." turns a guard-passing template like
"%(section_title)s/%(section_title)s/x.%(ext)s" into "../../x.mp4" and writes
outside DOWNLOAD_DIR. The same class of escape applies to any multi-segment
output template (default/playlist/channel) whose fields resolve to "..".
The template string can never see the "..": it only exists after expansion.
So move the check to the one point every output path flows through —
YoutubeDL.prepare_filename — via a _ConfinedYoutubeDL subclass that refuses
any resolved path outside the download/temp roots (fail closed). This covers
the main file, split-chapter files, thumbnails and subtitles in one place.
With the chokepoint authoritative, the scattered ingress string checks
(chapter_template, custom_name_prefix) and the weaker _output_dir_escapes
literal-prefix check are removed. Tests move from the ingress layer to the
chokepoint, exercising the real metadata-resolution vector.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses a full-project review. Backend correctness and availability:
- ytdl: cancel() only SIGKILLs the child's process group when the child
actually became its own group leader, so a race (or failed setpgrp)
can no longer kill the whole server; kill the group on cancel and on
shutdown to avoid orphaned ffmpeg children
- ytdl: dedicated ThreadPoolExecutor for download supervision so active
downloads can't starve extract_info / live probes on the default pool
- ytdl/main/subscriptions: route fire-and-forget tasks through a
bg_tasks helper that keeps a strong ref and logs failures
- subscriptions: run flat-playlist extraction in an executor and check
feeds with bounded concurrency so one slow feed can't block the loop;
set last_checked on failure so broken feeds aren't retried every 60s
- main: validate ids on /start & /delete and numeric env vars at startup;
return 400 (not 500) on bad subscriptions/update input; serve /history
from memory; move get_custom_dirs off the event loop; restrict t=
stripping to YouTube hosts; drop double percent-decode in state guard
- dl_formats/ytdl: enforce requested caption format via
FFmpegSubtitlesConvertor and strip VTT header metadata only in the
pre-cue region so real dialogue is preserved
- ytdl: throttle progress events, dedup adds against pending, clear
filename/size on error and reject out-of-dir trashcan deletes, pin
fork start-method on Linux only
Frontend:
- retry deletes the done record only after a successful re-add
- surface HTTP errors for delete/start and reset the deleting flag
- ignore late 'updated' events for rows no longer in the queue
- track table rows by map key; FileSizePipe uses base-1024
Also: HTTPS-aware Docker healthcheck, dead-code removal, and shared
helpers for path-containment and yt-dlp option merging. Adds/updates
unit tests throughout (250 backend tests passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The on_prepare handler unconditionally reflected the Origin request
header into Access-Control-Allow-Origin, and Socket.IO was configured
with cors_allowed_origins='*'. This allowed any website to make
authenticated cross-origin requests to all API endpoints, enabling
cross-origin download initiation, cookie overwrite, and data deletion.
Replace the blanket origin reflection with an explicit allowlist via
the CORS_ALLOWED_ORIGINS environment variable. When unset, cross-origin
requests are denied by default. Users who need cross-origin access can
set CORS_ALLOWED_ORIGINS to a comma-separated list of trusted origins.
Allow users to prefer a specific video codec (H.264, H.265, AV1, VP9)
when adding downloads. The selector filters available formats via
yt-dlp format strings, falling back to best available if the preferred
codec is not found. The completed downloads table now shows Quality
and Codec columns.