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.
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.
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.
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.
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.
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.
MIT. See LICENSE.