Field Notes

Automating YouTube Downloads with yt-dlp and systemd

Saturday. Apr 18, 2026

Getting Past the YouTube Infinite Scroll

In the previous posts, I set up my little server to host media locally. This one is about automating YouTube downloads with yt-dlp, optimized so it doesn’t waste time re-checking a million old uploads, and runs on a systemd timer.

This is not meant to mirror all of YouTube. The point is narrower: I want a small personal queue from channels I already care about, downloaded on a schedule, watchable later through my local media setup. It removes the “open YouTube and get distracted” step.

The official yt-dlp README is the reference for options, supported sites, and update instructions. I’m only using a small subset here: a channel list, an archive file, metadata downloads, and a systemd timer.

What this solves

  • Downloads chosen YouTube channels without opening the YouTube homepage.
  • Uses an archive file so old videos are not downloaded repeatedly.
  • Runs on a systemd timer instead of manual commands.
  • Keeps automation bounded with storage and troubleshooting guardrails.

1. Prerequisites: ffmpeg + a working yt-dlp binary

Install FFmpeg (yt-dlp uses it to merge formats / embed metadata / post-process):

sudo apt update
sudo apt install -y ffmpeg

I then downloaded the yt-dlp binary from the yt-dlp github repo. Made sure the yt-dlp binary works and is executable:

ls -lah ~/.local/bin/yt-dlp
chmod a+rx ~/.local/bin/yt-dlp
~/.local/bin/yt-dlp --version

I keep the binary in ~/.local/bin because I’m running this as my normal server user. If that directory is not already in your PATH, use the full path in commands and systemd units.

Before automating anything, I test one harmless command:

~/.local/bin/yt-dlp --simulate https://www.youtube.com/@SomeChannel/videos

The --simulate flag is useful while building the config because it shows whether yt-dlp can read the URL without downloading files yet.

2. Create folders

I split “app state” from “media storage”:

  • App data: /srv/appdata/yt-dlp
  • Downloads: /srv/storage/youtube
sudo mkdir -p /srv/appdata/yt-dlp
sudo mkdir -p /srv/storage/youtube
sudo chown -R "$USER:$USER" /srv/appdata/yt-dlp /srv/storage/youtube

This mirrors the pattern from earlier posts: configuration and state live under /srv/appdata/, while large media files live under /srv/storage/. If I later move the YouTube library to a bigger drive, the service config does not have to move with it.

3. Create the channel list

This is just one channel (or playlist) URL per line.

vim /srv/appdata/yt-dlp/channels.txt

Paste something like:

https://www.youtube.com/@SomeChannel/videos
https://www.youtube.com/@AnotherChannel/videos

Save and quit: :wq

I start with one or two channels, not twenty. That makes the first run easier to inspect, and it avoids accidentally filling the disk because I pasted a huge backlog-heavy playlist.

4. Create the download archive file

The archive is what prevents re-downloading the same videos forever.

touch /srv/appdata/yt-dlp/downloaded.txt

yt-dlp’s --download-archive option records video IDs after a successful download. On the next run, those IDs are skipped. For a scheduled job, this is the difference between “download new videos” and “hammer the same channel archive forever.”

5. Create an optimized yt-dlp config

vim /srv/appdata/yt-dlp/yt-dlp.conf

I used these flags:

# File naming (channel/date/title/id)
-P /srv/storage/youtube
-o %(uploader)s/%(upload_date)s - %(title)s [%(id)s].%(ext)s

# Keep runs fast
--download-archive /srv/appdata/yt-dlp/downloaded.txt

# Quality (adjust if you want smaller/larger)
-f bv*+ba/b

# Metadata + organization
--write-description
--write-info-json
--write-thumbnail
--embed-metadata
--embed-thumbnail

# Subtitles (optional)
--write-subs
--write-auto-subs
--sub-langs en.*,en
--embed-subs

The -P line sets the download root, and the output template creates one folder per uploader. I include the video ID in the filename because titles can change and different videos can have similar names.

For the first real test, I run yt-dlp manually with the config and the channel list:

~/.local/bin/yt-dlp \
  --config-location /srv/appdata/yt-dlp/yt-dlp.conf \
  -a /srv/appdata/yt-dlp/channels.txt

Only after that works do I let systemd run it automatically.

6. systemd: run yt-dlp directly

Because my yt-dlp binary lives in ~/.local/bin, I run this as a user service/timer so paths resolve cleanly.

I actually install this as a system service with an explicit User= line. That gives systemd a stable unit location while still running the download process as my normal user instead of root.

Create the service:

sudo vim /etc/systemd/system/yt-dlp.service

Paste:

[Unit]
Description=yt-dlp channel auto-downloader
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=<YOUR_USER>
WorkingDirectory=/srv/appdata/yt-dlp
ExecStart=/home/<YOUR_USER>/.local/bin/yt-dlp --config-location /srv/appdata/yt-dlp/yt-dlp.conf -a /srv/appdata/yt-dlp/channels.txt

Create the timer:

sudo vim /etc/systemd/system/yt-dlp.timer

Paste:

[Unit]
Description=yt-dlp subscription downloader

[Timer]
OnBootSec=5min
OnUnitActiveSec=12h
Persistent=true

[Install]
WantedBy=timers.target

And enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now yt-dlp.timer

Then I check the timer and do one manual service run:

systemctl list-timers yt-dlp.timer
sudo systemctl start yt-dlp.service
journalctl -u yt-dlp.service -n 100 --no-pager

The systemd timer options are documented in systemd.timer. Persistent=true is the part I care about most here: if the laptop was off during a scheduled run, systemd can run the job after the next boot instead of silently skipping it.

7. Updating yt-dlp

Because I installed the standalone binary, I can simply update with

yt-dlp -U

If the binary lives at ~/.local/bin/yt-dlp, I use the same path when updating:

~/.local/bin/yt-dlp -U

yt-dlp breaks occasionally because websites change. If downloads suddenly fail, updating yt-dlp is my first troubleshooting step. My second step is running one channel manually with --verbose so the error is visible outside systemd:

~/.local/bin/yt-dlp --verbose \
  --config-location /srv/appdata/yt-dlp/yt-dlp.conf \
  -a /srv/appdata/yt-dlp/channels.txt

8. Guardrails I added for myself

Automation is useful until it quietly fills a disk. I keep a few guardrails around this setup:

  • Start with a short channel list.
  • Check disk space after the first run: df -h /srv/storage/youtube.
  • Keep the download archive file backed up with the app state.
  • Include video IDs in filenames.
  • Review journalctl -u yt-dlp.service when a run seems too fast or too slow.

I also avoid using this as a “save everything just in case” machine. The whole point of this project is to make media more intentional, not to create a different kind of infinite feed on my own hard drive.

At this point, YouTube downloads are basically “set and forget.” I drop new channels into /srv/appdata/yt-dlp/channels.txt, systemd runs the job on schedule, and the archive file makes sure I only ever grab what’s new.

This ended up being one of my favorite kinds of server automation: low effort, low maintenance, and it quietly builds a personal library in the background — no subscriptions, no doom-scrolling, just the stuff I actually want to watch.