Skip to content
Allan0x0Public

About

Automatically downloads English subtitles for videos in a watched folder, so VLC finds them. A macOS launchd agent.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

subget

tests

Automatically downloads English subtitles for videos in a watched folder, and leaves them sitting next to the video, so VLC loads them without being asked.

VLC is not modified. It auto-loads Movie.en.srt on its own, and VLC's built-in VLSub extension stays available for the "I'm watching right now" case.

English only. Every fetch asks subliminal for English and nothing else (subget.py:457, -l en), and the check for subtitles already inside the container looks for an English track (subget.py:229). That is a deliberate limit rather than an oversight: there is no language option, and adding one would mean deciding what the sidecar check and the Subs/ naming do about a second language. Nothing here answers that yet.

What this promises. A personal tool, published in case it is useful to someone else. Issues get read. None of them are guaranteed an answer, and pull requests are welcome but may sit for a while.

Install

git clone https://github.com/Allan0x0/subget.git
cd subget
./install.sh

One command, run from the checkout. It creates .venv in the repo and installs requirements.txt into it, symlinks ~/bin/subget to subget.py, renders com.subget.agent.plist.template with your real home path and the folder to watch into ~/Library/LaunchAgents/com.subget.agent.plist, and loads the agent. From then on downloads get subtitles with no terminal involved. Running it again is safe: an existing venv, symlink or loaded agent is not an error.

The venv needs Python 3.10 or newer, because subliminal declares Requires-Python: >=3.10. Stock macOS still ships 3.9.6 at /usr/bin/python3, so if python3 is too old the installer looks for python3.13, python3.12, python3.11 and python3.10 on your PATH and uses the first one that qualifies. If none does, it says so and tells you to brew install python@3.12. Only the venv interpreter has to be new: subget.py is standard library only and ~/bin/subget still runs under whatever /usr/bin/env python3 finds, 3.9 included.

It refuses in one line and changes nothing if python3 is missing or too old, or if ~/bin cannot be created. Add ~/bin to your PATH if it is not there already, and which subget resolves.

One manual step is left: Full Disk Access. The agent cannot read the watched folder until you grant it, and macOS will not prompt you. The next section says what the grant attaches to and why it is needed.

./install.sh --uninstall

Unloads the agent and removes the plist and the symlink. The venv, the log and the ledger stay where they are.

Which folder is watched. SUBGET_WATCH_DIR=~/Movies ./install.sh points the agent somewhere other than ~/Downloads. The choice sticks. With SUBGET_WATCH_DIR unset, the installer reads the folder back out of the plist it wrote last time, so a plain re-run, which is how you upgrade, keeps watching what it was already pointed at instead of quietly snapping back to ~/Downloads. Only a first install with nothing set falls back to ~/Downloads.

A folder that does not exist is refused in one line and nothing is created. The installer resolves the path rather than creating it, because a typo would otherwise become an empty folder plus a watcher that never fires, and an unexpanded ~/Movies would become a directory named ~. That refusal happens alongside the others, before the venv exists or launchd is touched. The path is stored absolute, so a relative one is settled at install time.

Where things live. The log is ~/.local/state/subget/log.txt, one line per outcome worth reading (see below: unchanged skips are not). The ledger is ~/.local/state/subget/state.json. Anything the job writes before the logging code runs, a crash included, lands in ~/.local/state/subget/launchd.log.

Set SUBGET_STATE_DIR to put the log and the ledger somewhere else; the directory is created if it is missing. launchd.log stays put, because the plist names its path and launchd opens the file before subget runs at all. Nothing else about the run changes: relocating your state is not a way to quieten subget, and never was worth losing the only output it has.

Silence. Set SUBGET_QUIET to any non-empty value and no notification fires, on success or on a denial. The log and the ledger are written exactly as before, so a quiet run still leaves the full record behind; this only takes away the banner. The test suite sets it on every subget it launches, which is what keeps a full run off your screen.

Why it needs Full Disk Access

macOS privacy protection (TCC) blocks background agents from reading ~/Downloads, and it blocks them silently. There is no prompt the way there is in Terminal, only Operation not permitted. So there is a manual step: grant Full Disk Access to <repo>/.venv/bin/python3 in System Settings > Privacy & Security > Full Disk Access.

Which other folders macOS protects is not documented here, because it is not something this project has established. If you point SUBGET_WATCH_DIR somewhere else, treat the grant as required until a run proves otherwise: a denial is loud, so you will know.

Until you do, every run fails loudly instead of scanning nothing: the full message naming the interpreter goes to the log, and one notification tells you to grant the access. That banner fires once per denial episode, not once per 600-second tick; a marker file in the state directory remembers it, and the first successful listing deletes the marker so a later denial speaks up again.

Granting a Python interpreter unrestricted read access to your disk on the strength of a README is a lot to ask, and hesitating is the right instinct. What follows is the whole argument in one place.

What the grant attaches to. The venv interpreter at <repo>/.venv/bin/python3, not the shared system python. install.sh builds the venv with --copies rather than the default symlinks precisely so that the grant lands on a binary that is subget's alone. TCC resolves symlinks before deciding who is asking, so a symlinked venv would hand the same grant to the system python, and with it to every Python script on the machine. The plist runs that interpreter by absolute path for the same reason. A venv that already exists but is symlinked is rebuilt once, on the next install, to close exactly this hole.

What subget reads. File names, sizes and modification times under the watched folder, to a depth of two. Then, only for the files that survive every cheaper rule below, the container header via pymediainfo, to see whether an English subtitle track is already in there. That header is all subget itself takes from inside a file; it decodes no audio or video, and it never looks outside the watched folder. (subliminal reads a little more, to hash a candidate it is about to look up. That is covered below.)

What leaves the machine. Only the fetch step talks to the network, and the talking is subliminal's: subget.py opens no sockets of its own. What follows was read out of the subliminal 2.7.1 source in .venv, not assumed.

subget runs subliminal download without -p, so subliminal tries its whole default provider list: addic7ed, gestdown, napiprojekt, opensubtitles, opensubtitlescom, podnapisi, subtis, subtitulamos and tvsubtitles (subliminal/extensions.py). Two kinds of thing go out.

A fingerprint of the file, never the file. subliminal computes the OpenSubtitles hash locally: a 64-bit sum of the file's size and its first and last 64 KiB, written as sixteen hex digits (subliminal/refiners/hash.py). NapiProjekt gets a different one, an MD5 of the first 10 MB (subliminal/providers/napiprojekt.py). Both are computed on your machine, and only the resulting number is sent. No part of the video itself is uploaded.

The name, in one of two forms. subliminal parses the filename locally into a guessed title, year, season and episode, and most providers receive only those guesses. The opensubtitles provider is the exception: it sends the bare filename verbatim, as the tag field, along with the hash and the file's exact size in bytes (subliminal/providers/opensubtitles.py). The directory path is never sent, only the last component. So for a file named Some.Movie.2019.1080p.mkv, opensubtitles.org learns that string, its byte size and its hash, while the other providers learn "Some Movie, 2019".

Two further things, both subliminal's defaults rather than subget's choices. Its default refiner list includes OMDb and TheTVDB, each shipping a hardcoded shared API key, so a guessed title goes to omdbapi.com and api.thetvdb.com before any subtitle provider is asked (subliminal/refiners/omdb.py, subliminal/refiners/tvdb.py). And all of it is anonymous: subget configures no accounts, so the opensubtitles provider logs in with an empty username and password.

That is what the installed version does. Anything beyond it is subliminal's behaviour and can change when you upgrade, so the authority is subliminal's own documentation at https://subliminal.readthedocs.io.

The code is readable. subget.py is about 685 lines, standard library only, and can be read in one sitting. That is the real answer to "should I trust this": you do not have to.

Design

Trigger. A launchd agent, com.subget.agent, with both WatchPaths: ~/Downloads and StartInterval: 600. It fires on any change to the folder and, as a backstop, every ten minutes. launchd will not start a second copy of the agent while one is running, so ticks never overlap each other. A subget typed in a terminal during a tick is a second process, though, and a tick that runs five fetches at up to 180 seconds each leaves a window minutes wide, opening several times an hour. Both processes scan the same folder, both see no sidecar, both call subliminal on the same file, and the ledger's last write wins. That costs one wasted fetch and one duplicate banner, not corruption, which is why there is still no lock file.

The job still wakes itself, and that is fine. subliminal has no option to write anywhere but beside the video, so for a video at the top level of ~/Downloads a successful fetch first creates <video>.en.srt there and then removes that entry when it moves the file into Subs/. Both are changes to the watched folder's own entries, so a run that fetches anything at the top level wakes itself twice (#6). The kqueue mechanics do not save us here, only afterwards: WatchPaths is a kqueue on the ~/Downloads directory and fires only for changes to that directory's entries, so once the subtitle is inside Subs/ the parent never notices it again. Measured with select.kqueue, a direct create, an os.rename in and a shutil.move in all fire.

The wake stays harmless for the reason issue #6 established, and test_the_run_a_fetch_wakes_fetches_nothing_and_reparses_nothing pins it: the woken run sees its own subtitle as a sidecar, skips every file it just fetched, and reaches no container parse. The cost of that wasted scan is what made "accept it" defensible in the first place: on a real 1034-file folder holding 47 videos, 10 ms to walk and stat, 5 ms to decide, under two seconds including parses.

Subs/ (#8) is still worth having on its own merits (it declutters the watched folder and matches VLC's sub-autodetect-path), just not as a cure for the self-wake.

Candidate selection. Scan ~/Downloads to a depth of two. Anything whose extension is not a video type is ignored entirely and gets no line. Of the rest, a video is a candidate only if every rule below lets it through. The rules are ordered by what they cost to evaluate, cheapest first, and the first rule that fires is the reason reported — so a file matching several is labelled by the earliest one in this list, not the most interesting one. That precedence is deliberate: the expensive checks at the bottom cannot report a reason without doing the work the ordering exists to avoid.

# Skip reason Rule Cost
1 temp file not a part-download: no .part, .crdownload, .download, .!qB suffix filename
2 too small size is at least 50 MB stat
3 too fresh last modified more than 120 seconds ago stat
4 sidecar no subtitle (.srt/.ass/.sub) sharing its base name, in Subs/ or beside the video scan index
5 gave up the ledger has not recorded 7 attempts dict lookup
6 tried today the ledger has no attempt dated today dict lookup
7 over daily cap fewer than 50 attempts recorded for today, counting the ones this run is about to make ledger scan
8 embedded subs no English subtitle track already in the container, read via pymediainfo (a dependency subliminal already brings) container header read
9 over batch cap fewer than five files already fetched this run counter

Rule 7 bounds the day, where rule 9 only bounds one run. The agent ticks 144 times a day, so five per run is 720 attempts a day: a fresh install on a large library can fire hundreds of anonymous requests at free providers inside its first day, get throttled, and record those throttled requests as misses that count toward the seven-try limit. The failure is silent and permanent: a folder of gave up files with nothing in the log saying why. Fifty a day still clears a 49-video folder in a day and a 400-video one inside a fortnight.

Its position above rule 8 is the point of it. Counting today's ledger entries is a scan over a dictionary already in memory, as cheap as tried today, while parsing a container header is by far the most expensive check here. Put the cap at the bottom instead and a 400-video library reads 350 headers every 600 seconds, forever, to produce the answer "over cap".

Rule 8 runs last of the file rules because reading a container header costs far more than the rules above it. A container that cannot be parsed, or a missing pymediainfo, counts as no embedded subtitles and the file is fetched. Keeping it below the ledger matters: a dead file would otherwise be re-parsed every 600 seconds forever.

Rule 9 stays at the bottom, and that is not an oversight. It depends on how many fetchable files came before it, which is unknown until every per-file rule has run.

One consequence worth naming. A file with an embedded English track that has also given up after seven tries reports gave up, not embedded subs. That is the truthful label — the embedded track is not why subget stopped trying — and gave up is the one a user can act on.

Candidates sort newest first, and one run processes at most five. Anything not processed waits for the next tick.

Fetching. <venv>/bin/subliminal download -l en -m 80 -- <file>, called by absolute path because launchd's PATH is bare. Score floor 80 out of 100 means a file hash or a full title/season/episode match passes, and a partial-title guess does not. A file with an unguessable name is either identified by its hash or gets nothing; it never gets a wrong subtitle that drifts out of sync.

Where the subtitle lands. <video dir>/Subs/<video base>.en.srt, so subtitles stop crowding the folder the videos are in. The folder name is not a preference: VLC's default sub-autodetect-path is ./Subtitles, ./subtitles, ./Subs, ./subs, and one of those four is the only reason a subfolder auto-loads at all. Renaming it to srt or subtitles-en would silently stop every subtitle loading, so test_the_subs_folder_is_named_one_vlc_autodetects asserts the name against that list. subget writes Subs and reads all four, so a folder made by hand still counts as a sidecar.

subliminal has no option to write anywhere but beside the video, so the file is moved into Subs/ once it lands. The folder is created only when there is a real subtitle to put in it, never on a miss.

Outcomes. One line appended to ~/.local/state/subget/log.txt: timestamp, outcome (OK/MISS/SKIP <reason>), file, provider, score. Every OK and every MISS is logged on every run, but a SKIP is logged only when that file's reason differs from the one already in the ledger: repeating the same verdict for the same 47 files every 600 seconds was 99 percent of the log. subget --dry-run is unaffected and still prints a line for every file. A macOS notification via osascript fires on OK, and once per episode when the watched folder cannot be read at all (see Full Disk Access above). Failures update the ledger at ~/.local/state/subget/state.json, keyed by path plus file size so a re-download counts as a new file, holding try count and last attempt date. After the seventh day, the file is dead unless you force it.

On demand. subget <file>... fetches for exactly the files named. It skips the selection rules altogether rather than relaxing them, so the ledger, the sidecar check, the 50 MB floor and the 120-second freshness rule all have no say, and an existing .en.srt is replaced. Both copies are cleared: the one in Subs/ and any older subtitle still sitting beside the video. Clearing the second is required rather than tidy, because a file already on subliminal's drop point is what tells fetch_subtitle nothing new landed, so leaving it would make every forced fetch report a miss; it would also win in VLC over the copy in Subs/, and the stale subtitle would survive the refetch meant to replace it. Both are moved aside first and deleted only once the new one lands, so a forced fetch that finds nothing leaves you no worse off. Progress goes to the terminal as well as the log, because a person is watching, and the exit code is non-zero if any named file failed. A success updates the ledger and fires the notification exactly as an automatic run does.

--min-score N lowers the score floor for that run, 0 to 100, default 80. The strict floor is right unattended and wrong when you are sitting there, can eyeball the result, and would rather have an imperfect subtitle than none: subget --min-score 50 Film.mkv accepts a match in the 50s that a normal run rejects. Anything outside 0-100 or non-numeric is one usage line on stderr and no fetch.

A rejected candidate reports no score. subliminal prints - [ 94.6] English subtitle from ... once per subtitle it saved, so when the best match is under the floor it saves nothing, prints nothing but 0 subtitle downloaded, and still exits 0. The log line is MISS <file> best none; there is no number to show, and inventing one would be worse than the gap. Lower --min-score and run again to see what was actually on offer.

subget with no arguments runs a normal, rule-respecting scan immediately. subget --dry-run prints the decision for every file in the folder and touches nothing. subget --help lists all three modes.

Layout

subget.py                      standard library only
requirements.txt               subliminal
install.sh                     venv, symlink ~/bin/subget, write + load plist
com.subget.agent.plist.template
test_subget.py                 asserts one skip reason per fake fixture
LICENSE                        MIT

The script resolves its own venv from its real path, so the manual command and the launchd runs execute identical code.

Run the tests from the checkout with:

python3 test_subget.py

They are plain unittest from the standard library, so they need neither pytest nor network access: every subliminal call and every video file is a fixture.

Known risks

Provider coverage rots. subliminal's providers break as sites change their markup or auth. When hit rate drops to zero, the fix is pip install -U subliminal, and failing that, registering an OpenSubtitles API key in subliminal's config.

The score floor of 80 applies to manual runs too. A film with only a loosely-matching subtitle available refuses to fetch even when asked directly, and says nothing about how close it came. The escape hatch is --min-score N on the manual path: subget --min-score 50 Film.mkv.

License

MIT. See LICENSE.

About

Automatically downloads English subtitles for videos in a watched folder, so VLC finds them. A macOS launchd agent.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages