Skip to main content

Salin logo

Salin

Inspect, resolve, download, and clean YouTube subtitle tracks from Python.

License: MIT Python 3.12 | 3.13 CI Lint: ruff Types: mypy-style, checked with ty PyPI Sponsor


Salin is a typed Python library for working with YouTube subtitle tracks. It inspects which tracks and formats a video has, resolves requested languages against language-match and source policies, downloads the captions (to disk or straight into memory as snippets), and cleans rolling auto-caption noise into prose you can read, index, or feed to a model.

It uses yt-dlp for extraction. YouTube is the primary, tested target; other sites are best-effort and depend on what yt-dlp can expose (see Other sites).

Why this exists

youtube-transcript-api is a great default — small, in-memory, easy to install. It is also easy to break: it talks to YouTube's private player API, so when YouTube blocks your IP the library stops working entirely, and a burst of requests can get you a shared 429 with no clear signal.

Salin uses yt-dlp's extraction, which is more resilient and keeps inspecting and resolving subtitle availability even when the caption endpoint is throttling. It also treats rate limits as a first-class result rather than a silent retry: you get RateLimitedError / DownloadStatus.RATE_LIMITED with the parsed Retry-After, and a bulk run can stop cleanly instead of hammering an endpoint that is already refusing it. Honest about the one limit it shares with everyone: caption bytes come from the same timedtext endpoint, so Salin can be rate-limited too — it just tells you instead of hiding it.

How it compares

youtube-transcript-api Salin
Snippets in memory yes yes (fetch())
Subtitle files on disk no yes (7 formats)
Cleaning / dedup no yes
Language resolution policies limited manual/auto, regional variants
Bulk API no download_many with workers
Browser cookies no yes
Proxy / cookiefile proxy only cookies, cookiefile, proxy
Explicit rate-limit error IpBlocked RateLimitedError + status
Survives a watch-page IP block no yes (inspection still works)
Typed package yes yes (py.typed)
License MIT MIT
Python 3.9+ 3.12+

Install

pip install salin

Or with uv:

uv add salin

Requires Python 3.12+.

Quick start

from salin import Salin

client = Salin(languages="en")
result = client.download("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
# Subtitles are saved to ./salin_downloads unless you set output_dir on the
# client or on download()/download_many().

Prefer snippets in memory? Use fetch():

transcript = Salin(languages="en").fetch("dQw4w9WgXcQ")
for snippet in transcript:
    print(snippet.start, snippet.text)

Downloading subtitles

Pick a format:

client = Salin()
result = client.download(
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    subtitle_format="vtt",
)

Download several languages in one call:

client = Salin(languages=["en", "de"])
result = client.download("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
print(result)
SubtitleResult(
    url='https://www.youtube.com/watch?v=dQw4w9WgXcQ',
    video_id='dQw4w9WgXcQ',
    title='Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)',
    subtitles=(
        SubtitleFile(
            requested='en',
            resolved='en',
            source=<SubtitleSource.MANUAL: 'manual'>,
            status=<DownloadStatus.OK: 'ok'>,
            sub_path=WindowsPath('salin_downloads/dQw4w9WgXcQ.en.vtt'),
            format=<SubtitleFormat.VTT: 'vtt'>,
            error=None),
        SubtitleFile(
            requested='de',
            resolved='de-DE',
            source=<SubtitleSource.MANUAL: 'manual'>,
            status=<DownloadStatus.OK: 'ok'>,
            sub_path=WindowsPath('salin_downloads/dQw4w9WgXcQ.de-DE.vtt'),
            format=<SubtitleFormat.VTT: 'vtt'>,
            error=None)))

Other sites

Extraction is delegated to yt-dlp, so YouTube is not a hard limit — but support depends on the site exposing subtitle tracks in a format you ask for, and on yt-dlp having the right networking dependency (some extractors need impersonation, e.g. curl_cffi for Dailymotion).

Verified end to end: Dailymotion, which offered only srt in our sample:

client = Salin(languages="en", subtitle_format="srt")
result = client.download("https://www.dailymotion.com/video/x7tgad0")

Not verified here: Vimeo (OAuth 401), TED (extractor error) and archive.org (no captions exposed). Treat other sites as best-effort and call inspect() first to see which formats are actually available.

Fetch transcripts in memory

fetch() is the youtube-transcript-api-shaped path: it returns snippets directly, writes no file, and does not download the video.

from salin import Salin

transcript = Salin(languages="en").fetch("dQw4w9WgXcQ")
len(transcript)                      # number of snippets
transcript.video_id                  # 'dQw4w9WgXcQ'
transcript.language                  # 'English' (YouTube's name for the track)
transcript.language_code             # 'en'
transcript.is_generated              # False for a manual track, True for auto

for snippet in transcript:           # snippet.text / .start / .duration (seconds)
    print(snippet.start, snippet.text)

transcript.to_raw_data()             # [{'text': ..., 'start': ..., 'duration': ...}]
transcript.text                      # every snippet joined with a space

fetch() accepts a full URL or a bare video ID, and honors the same language_match, source_policy, and info= options as download(). It fetches the json3 track when one exists (falling back to srv3, srv1, srv2, ttml, or vtt), in memory.

Migrating from youtube-transcript-api

- from youtube_transcript_api import YouTubeTranscriptApi
- transcript = YouTubeTranscriptApi().fetch(video_id, languages=("en",))
+ from salin import Salin
+ transcript = Salin(languages="en").fetch(video_id)

The returned objects mirror yta's:

from salin import FetchedTranscript, TranscriptSnippet

Differences to know about:

  • fetch() takes the language list in the Salin(...) constructor (or a languages= override), not as a positional argument.
  • Snippets are normalized: inline markup, speaker chevrons (>>), and stage directions such as [Music] are stripped, and whitespace is collapsed. youtube-transcript-api only strips HTML tags, so text can differ slightly for captions that contain those markers.
  • preserve_formatting=True is not supported and raises SalinValueError.
  • Failures raise Salin errors: TranscriptUnavailableError when no requested language resolves, and SubtitleDownloadError when the caption request itself fails.
  • A 429 raises RateLimitedError (a SubtitleDownloadError subclass) with .retry_after set from the server's Retry-After header when present.

Inspecting available subtitles

inspect() reports the available manual and automatic tracks plus the formats each one offers.

client = Salin()
availability = client.inspect(url)
print(availability)
SubtitleAvailability(
    url='https://www.youtube.com/watch?v=dQw4w9WgXcQ',
    video_id='dQw4w9WgXcQ',
    title='Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)',
    manual=frozenset({'pt-BR', 'es-419', 'de-DE', 'en', 'ja'}),
    auto=frozenset({'en', 'en-orig'}),
    manual_formats={'en': frozenset({'srv3', 'srt', 'srv1', 'vtt', 'srv2', 'ttml', 'json3'}), ...},
    auto_formats={'en-orig': frozenset({'srv3', 'srt', 'srv1', 'vtt', 'srv2', 'ttml', 'json3'}), ...})

Resolving languages

resolve() returns the ResolvedSubtitle for each request according to the language_match, source_policy, and per-language alias list.

client = Salin()
result = client.resolve(
    url,
    language_match="exact",          # LanguageMatch.EXACT
    source_policy="manual_only",     # SubtitleSourcePolicy.MANUAL_ONLY
    languages={"en": ["en-US", "en-GB"], "de": ["de-DE", "de-AT"]},
)
print(result)
# (ResolvedSubtitle(requested='en', resolved='en', source=None),
#  ResolvedSubtitle(requested='de', resolved=None, source=None))

If resolved is None, the resolver could not satisfy the request under the given availability, language match, and source policy.

Language match and source policy

Both settings constrain each other. For example, if language_match="exact" but source_policy="auto_only", the client will not use a requested en track even when a manual one is available.

LanguageMatch.EXACT        # "en" only resolves to "en"
LanguageMatch.REGIONAL     # "en" can resolve to en-US, en-orig, etc.

SubtitleSourcePolicy.MANUAL_ONLY
SubtitleSourcePolicy.AUTO_ONLY
SubtitleSourcePolicy.MANUAL_THEN_AUTO
SubtitleSourcePolicy.AUTO_THEN_MANUAL

One metadata fetch per call

inspect(), resolve(), fetch(), and download() each fetch the video's metadata once. That fetch is ~4s and ~98% of a download's wall time (the bytes are ~0.3s), so calling three of them on one video costs ~12s — the same metadata three times.

download() already resolves and reports every requested language, so most flows need just one call:

result = client.download(url)
for sub in result.subtitles:
    print(sub.requested, sub.resolved, sub.status)

When you need the intermediate stages, extract once and pass info=:

info = extract_info(url)                                # one request per video
availability = client.inspect(url, info=info)
resolved = client.resolve(url, languages=["en", "de"], info=info)
transcript = client.fetch(url, languages="en", info=info)
result = client.download(url, languages=["en", "de"], info=info)

That pipeline is ~4.6s versus ~12s for three independent calls. info must come from extract_info(): hand-built or trimmed dicts are rejected, and playlists are not supported.

Cleaning transcripts

download() writes the subtitle file exactly as YouTube serves it — a poor thing to read, index, or feed to a model. clean_transcript() turns it into prose.

Auto-captions are written in short overlapping windows, so the same phrase appears on screen several times, with inline word-timing tags in between:

multiple hours to capture the same level
multiple hours to capture the same level
of<00:48:19.119><c> mistake</c><00:48:19.520><c> because</c> ...
of mistake because it requires like

They also carry display markup and stage directions ([Music], (laughter), >>, ♪, <br />). Cleaning strips that markup, merges the overlapping windows, and returns one paragraph:

multiple hours to capture the same level of mistake because it requires like

Write the cleaned text next to (or into) a directory:

from salin import Salin, clean_transcript

result = Salin(languages="en", subtitle_format="vtt").download(url)
paths = clean_transcript(result, output_dir="transcripts")

Or get it without touching the filesystem:

from salin import clean_cues

subtitle = result.ok_subs[0]
text = clean_cues(subtitle.sub_path, subtitle.format)

max_workers cleans in parallel (default 1); with output_dir omitted each transcript is written next to its source. Every SubtitleFormat is parsed: vtt, srt, json3, srv1, srv2, srv3, ttml.

VTT is approximate. It is the only rolling format, and YouTube sometimes repeats a word inside one cue where the overlap merge cannot see it. On a 72-minute auto-captioned podcast the cleaned vtt came out 0.3% longer (41 words) than the same video's other six formats. Manual tracks are usually not rolling; choose srt, srv1, srv3, json3, or ttml when you need exact text.

Bulk downloads

download_many() returns an Iterator[SubtitleResult], so it is the right tool for batches. It is implemented with a ThreadPoolExecutor, and max_workers can be set (default 1).

Note: download_many is both faster and more memory efficient than calling download in a loop. Results are yielded as they finish, so input order is not preserved unless max_workers=1.

client = Salin()

for result in client.download_many(urls, languages="en", max_workers=3):
    if not result.processable:
        continue
    for subtitle in result.ok_subs:
        print(subtitle.sub_path)

How many workers to actually use

Measured on 8 videos (one language each, median of 3 rounds):

max_workers wall time speedup
1 40.9s 1.0x
2 23.5s 1.7x
3 21.3s 1.9x
4 21.8s 1.9x
6 20.2s 2.0x

Treat 2–3 as the practical ceiling. Every download spends ~4s fetching metadata and only ~0.3s fetching the subtitle, so the bottleneck is yt-dlp's per-video extraction, and YouTube throttles those concurrent requests. Past 3 workers the gain is a few percent at best, and the tail is worse than the mean: one run at max_workers=4 took 38.4s — slower than sequential — while another took 18.8s. If you see bulk jobs stalling, drop to 2–3 workers rather than raising it.

Benchmarks

One residential IP, Windows, yt-dlp 2026.07.04, youtube-transcript-api 1.2.4, Salin 0.1.0, 2026-10-27/28, en only, sequential, no artificial delay. Two samples from the same Postgres table: 480 videos where captions_available = true, and 1,000 videos sampled uniformly from all 9,776 rows. Both were shuffled with a fixed seed. Times are local and the machine was in normal use, so sub-second differences are noise; block onset and success/failure are reliable.

How fast each library gets blocked

run attempted succeeded first block wall throughput p50 latency
youtube-transcript-api 60 55 #56, ~99s 105s 34.4/min 1.22s
Salin 480 479 none 31.9 min 15.0/min 3.25s

youtube-transcript-api made 55 consecutive requests at ~1.2s each, then hit IpBlocked and failed every request after that. An earlier run blocked at #89 after ~103s, so the stable number is the wall time, not the video count: youtube-transcript-api is good for roughly 100 seconds of sequential requests.

Salin then ran 479 of 480 in one continuous 31.9-minute session with zero blocks; the one failure was a genuinely unavailable video, so its success rate on usable videos is 100%. A further 1,000-video random sample ran 88 more minutes on the same IP, also with zero blocks (see below).

The limit both libraries share

Caption bytes come from the same youtube.com/api/timedtext endpoint whichever library asks. Running youtube-transcript-api first poisoned the next Salin run: yt-dlp still extracted id, title, and the full caption track list, but every caption fetch returned HTTP 429. So the useful claim is narrower than "yt-dlp doesn't get blocked":

  • Salin keeps inspecting and resolving while youtube-transcript-api is blocked.
  • Caption bytes are rate-limited separately, and a burst from either library spends the same budget.

At ~15 videos/min the limit did not trip for half an hour. We have not shown it survives youtube-transcript-api-level throughput (~34/min); it probably would not.

How long a block lasts

  • The youtube-transcript-api block was still active 35+ minutes after it started, and the shared timedtext limit stayed closed long enough to ruin the next run.
  • A second burst kept the endpoint closed for well over an hour: a probe every 5 minutes still got 429 at the 60-minute mark. The ~45-minute recovery seen once was the short case, not the norm.
  • In practice a burst like this has kept the IP limited for 1–2 days on this connection — repeated observation, not one controlled measurement.
  • Metadata is never the thing that gets blocked. yt-dlp extraction kept working throughout, and switching to a fresh IP cleared the limit immediately.

Content parity

metric youtube-transcript-api Salin
raw characters (median) 7032 7032
snippets (median) 116 116
latency (mean) 1.22s 3.86s

Measured on the 55 videos both libraries handled; the median ratio was exactly 1.000 and no video diverged by more than one character. Salin is ~3x slower per video because it pays a full yt-dlp extraction — the price of surviving the watch-page block.

Success rate on a random sample

The 480-video runs used a hand-marked subset, so they cannot say how often a video actually has captions. A separate run sampled 1,000 of the 9,776 videos uniformly (only 50 of them marked captioned) and processed every one:

outcome videos share
English transcript returned 898 89.8%
no captions at all 96 9.6%
English only as a machine translation 5 0.5%
video unavailable 1 0.1%

Zero blocks over 88 minutes. Every one of the 101 misses was checked: none had a native English track that Salin failed to fetch. 96 have no captions at all; the 5 are non-English videos whose only en track is a machine translation, which Salin skips by default rather than silently returning translated text.

That is also a warning about captions_available-style filters: 898 of the 1,000 videos had captions, but only 50 were marked. Treat an unset flag as unknown, not as "no captions".

Throughput

Throughput is not stable enough to quote one number:

  • The 480-video run above averaged 15.0/min, dipped mid-run (10.6/min for #101–200), then recovered to ~18/min for the last 280.
  • The 1,000-video random sample averaged 11.4/min and sped up over time (9.8 → 12.9/min).
  • An earlier 480-video run averaged 11.0/min and stayed near 10/min after the first hundred.

Expect roughly 10–18 videos/min single-threaded, with no hard block in either 480-video session. The faster run is also the first to reuse a YoutubeDL across videos (the pooled client), which likely explains much of the spread — though the two runs used different IPs, so it is not a controlled comparison.

When you will still get blocked

  • A burst of dozens of videos in a minute can close the shared timedtext limit for hours to days, with either library.
  • Page-level blocks (yta's failure) do not stop inspect() or resolve(); the caption endpoint still does.
  • Pace bulk work at ~10–15 videos/min, use 2–3 workers, and set max_consecutive_rate_limits so a run stops instead of hammering a closed endpoint.

Throttling and rate limits

Salin adds no artificial delay by default: sleep_interval_subtitles defaults to 0. Raise it when request volume, not latency, is the bottleneck:

client = Salin(languages=["en", "de", "ja", "pt-BR"], sleep_interval_subtitles=2)

Each extra language then costs (languages - 1) x interval seconds per video. sleep_interval_requests is forwarded to yt-dlp and only applies when yt-dlp performs the download itself.

When YouTube rate-limits you

YouTube enforces a shared limit on the timedtext caption endpoint that is independent of yt-dlp's page requests. When it trips, the request comes back HTTP 429, and Salin records it as a distinct status instead of a generic error:

result = client.download(url)

if result.is_rate_limited:
    print(result.retry_after)              # seconds from Retry-After, or None
    for sub in result.rate_limited_subs:
        print(sub.requested, sub.error)
  • A 429 is never retried and never handed to yt-dlp, because yt-dlp would retry the same limited endpoint with long, silent backoff.
  • Retry-After (delta-seconds or an HTTP-date) is parsed into SubtitleFile.retry_after, and SubtitleResult.retry_after reports the largest value seen across the requested subtitles.
  • Transient failures (5xx, connection errors, timeouts) are retried twice with short jittered backoff. An expired signed URL (403) is refreshed once by re-extracting the video before giving up.

Pass strict=True to raise instead of returning a partial result:

from salin import RateLimitedError

try:
    client.download(url, strict=True)
except RateLimitedError as exc:
    wait = exc.retry_after or 60

For batches, cap how many rate-limited videos in a row you tolerate. The generator ends when the cap trips, so you can stop instead of hammering an endpoint YouTube has already throttled:

results = client.download_many(urls, max_consecutive_rate_limits=3)
for result in results:
    ...
# Stopped early; everything yielded so far is inspectable.

The counter resets on any non-rate-limited result. With max_workers > 1, "consecutive" is approximate because results arrive in completion order.

Cookies and proxies

Some videos only expose subtitles to signed-in, region-locked, or differently routed clients. Pass the settings at the client level or per call:

client = Salin(
    languages="en",
    cookies="firefox:myprofile",        # read from a browser
    # cookiefile="~/cookies.txt",       # or a Netscape cookies.txt you control
    # proxy="socks5://127.0.0.1:1080",  # or route through a proxy
)

# per call, overriding the client
result = client.download(url, cookies="chrome", proxy="http://127.0.0.1:8080")

cookies uses yt-dlp's syntax: BROWSER[+KEYRING][:PROFILE][::CONTAINER], e.g. "chrome", "firefox", "firefox+gnomekeyring", "chromium::Container 1". cookiefile must be an existing Netscape-format cookies.txt. proxy accepts http, https, socks4, socks4a, socks5, and socks5h URLs. Invalid values raise SalinValueError before any request is made.

The same settings are used for metadata extraction, the direct caption fetch, the yt-dlp fallback, fetch(), and the re-extraction that happens when a signed track URL expires. inspect() and resolve() accept them too.

One limitation: fetch() reads caption bytes directly with urllib, so it supports only http:// proxies (the common CONNECT form). With an https://, socks4, or socks5 proxy, fetch() raises SalinValueError and points you at download(), which hands the fetch to yt-dlp and supports every scheme.

Security note: enabling cookies reads from that browser's cookie store (encrypted with your OS keyring) and sends your logged-in session to the remote site with every request. Only use it when you trust the source, and prefer a cookies.txt file you control over a browser profile on shared machines.

Performance notes

  • One extraction per video. Metadata extraction is ~4s and dominates wall time; the caption bytes are ~0.3s. Reuse info= across inspect/resolve/ fetch/download (see One metadata fetch per call).
  • Pooled yt-dlp clients. extract_info() keeps one yt-dlp client per thread and per request-config, so back-to-back calls reuse the connection and skip the ~0.3s client setup. A repeated extract drops from ~3.0s to ~1.2–1.6s in our measurements.
  • Bounded retries. yt-dlp's defaults are patient; Salin caps retries, extractor_retries, and socket_timeout so a stuck request fails fast instead of stalling a batch.

Development

uv sync --all-groups
uv run ruff check
uv run ruff format --check
uv run ty check
uv run pytest

CI runs the same commands on Python 3.12 and 3.13.

License

MIT - see LICENSE.

Metadata

Release files for salin 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for salin 0.1.0
File Size Uploaded Provenance Actions
salin-0.1.0.tar.gz 91.6 kB With provenance Details for salin-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for salin 0.1.0
File Interpreter ABI Platform Actions
salin-0.1.0-py3-none-any.whl Python 3 none any Details for salin-0.1.0-py3-none-any.whl

Total release size: 142.0 kB

Release files / salin-0.1.0.tar.gz

Download URL salin-0.1.0.tar.gz
Size 91.6 kB
Tags Source
SHA-256 checksum
How to use checksums
b97a59e9610d639258023c943e91d9270353bd5a394eb5301c42644af76f7030
BLAKE2b-256 checksum
How to use checksums
33e548a3b2f1d98636c64c91d77bb2ba8c6c50dc871c7941ad2907c824be62ea
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.

Transparency log

Release files / salin-0.1.0-py3-none-any.whl

Download URL salin-0.1.0-py3-none-any.whl
Size 50.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f2c13b8681e2d6cdb184ac4011439a96af4485f66d8849e33e0d44629894c7ed
BLAKE2b-256 checksum
How to use checksums
c4165223d3da5d247a09b6bdbaa552a4ac36e7cb53bef8f5ab0e812c1ce8b04d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.

Transparency log

Security for salin 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for salin-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page