vink 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. vink/__init__.py +10 -0
  2. vink/adapters/__init__.py +6 -0
  3. vink/adapters/cache/__init__.py +6 -0
  4. vink/adapters/cache/caching_source.py +74 -0
  5. vink/adapters/cache/filesystem.py +50 -0
  6. vink/adapters/cache/serialization.py +95 -0
  7. vink/adapters/render/__init__.py +5 -0
  8. vink/adapters/render/markdown.py +111 -0
  9. vink/adapters/sinks/__init__.py +5 -0
  10. vink/adapters/sinks/local.py +51 -0
  11. vink/adapters/youtube/__init__.py +6 -0
  12. vink/adapters/youtube/oembed.py +63 -0
  13. vink/adapters/youtube/transcript_source.py +126 -0
  14. vink/bootstrap.py +85 -0
  15. vink/config.py +71 -0
  16. vink/domain/__init__.py +56 -0
  17. vink/domain/errors.py +86 -0
  18. vink/domain/models.py +188 -0
  19. vink/domain/video_ref.py +81 -0
  20. vink/interfaces/__init__.py +6 -0
  21. vink/interfaces/cli/__init__.py +5 -0
  22. vink/interfaces/cli/app.py +187 -0
  23. vink/interfaces/logging_setup.py +16 -0
  24. vink/interfaces/mcp/__init__.py +5 -0
  25. vink/interfaces/mcp/schemas.py +27 -0
  26. vink/interfaces/mcp/server.py +180 -0
  27. vink/ports/__init__.py +14 -0
  28. vink/ports/cache.py +19 -0
  29. vink/ports/metadata_source.py +19 -0
  30. vink/ports/renderer.py +27 -0
  31. vink/ports/sink.py +22 -0
  32. vink/ports/transcript_source.py +22 -0
  33. vink/processing/__init__.py +23 -0
  34. vink/processing/chunking.py +49 -0
  35. vink/processing/cleaning.py +47 -0
  36. vink/processing/filenames.py +40 -0
  37. vink/processing/paragraphs.py +105 -0
  38. vink/processing/timecode.py +20 -0
  39. vink/processing/track_selection.py +114 -0
  40. vink/py.typed +0 -0
  41. vink/services/__init__.py +10 -0
  42. vink/services/export_service.py +69 -0
  43. vink/services/transcript_service.py +102 -0
  44. vink/styles/__init__.py +46 -0
  45. vink/styles/clean-transcript.md +28 -0
  46. vink/styles/detailed-notes.md +38 -0
  47. vink-0.1.0.dist-info/METADATA +236 -0
  48. vink-0.1.0.dist-info/RECORD +51 -0
  49. vink-0.1.0.dist-info/WHEEL +4 -0
  50. vink-0.1.0.dist-info/entry_points.txt +2 -0
  51. vink-0.1.0.dist-info/licenses/LICENSE +21 -0
vink/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """vink — turn YouTube transcripts into clean, timestamped Markdown and detailed notes."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("vink")
7
+ except PackageNotFoundError: # pragma: no cover - only when running from an uninstalled tree
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Adapters: concrete implementations of ports.
2
+
3
+ All I/O and third-party SDK usage lives here. Each adapter translates between
4
+ an external system and the domain, and converts library exceptions into
5
+ :mod:`vink.domain.errors`. Only :mod:`vink.bootstrap` should import adapters.
6
+ """
@@ -0,0 +1,6 @@
1
+ """Cache adapters: a filesystem key-value store and a caching transcript source."""
2
+
3
+ from vink.adapters.cache.caching_source import CachingTranscriptSource
4
+ from vink.adapters.cache.filesystem import FileCache
5
+
6
+ __all__ = ["CachingTranscriptSource", "FileCache"]
@@ -0,0 +1,74 @@
1
+ """A transcript source decorator that caches results in any :class:`~vink.ports.Cache`."""
2
+
3
+ import logging
4
+
5
+ from vink.adapters.cache import serialization
6
+ from vink.domain.errors import VinkError
7
+ from vink.domain.models import RawTranscript, TrackInfo, TrackSelection
8
+ from vink.domain.video_ref import VideoRef
9
+ from vink.ports.cache import Cache
10
+ from vink.ports.transcript_source import TranscriptSource
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class CachingTranscriptSource:
16
+ """Wraps a :class:`~vink.ports.TranscriptSource` and caches its results.
17
+
18
+ Services stay unaware of caching: they receive this object as their
19
+ transcript source. Corrupt entries are treated as misses and overwritten.
20
+
21
+ Args:
22
+ inner: The source to delegate to on a cache miss.
23
+ cache: Where to store serialized results.
24
+ """
25
+
26
+ def __init__(self, inner: TranscriptSource, cache: Cache) -> None:
27
+ self._inner = inner
28
+ self._cache = cache
29
+
30
+ def list_tracks(self, video: VideoRef) -> list[TrackInfo]:
31
+ """Return tracks from the cache, or fetch and cache them."""
32
+ key = f"v{serialization.SCHEMA_VERSION}:tracks:{video.video_id}"
33
+ cached = self._cache.get(key)
34
+ if cached is not None:
35
+ try:
36
+ return serialization.load_tracks(cached)
37
+ except ValueError as exc:
38
+ logger.warning("Discarding corrupt cache entry %s: %s", key, exc)
39
+
40
+ tracks = self._inner.list_tracks(video)
41
+ self._cache.set(key, serialization.dump_tracks(tracks))
42
+ return tracks
43
+
44
+ def fetch(self, video: VideoRef, selection: TrackSelection) -> RawTranscript:
45
+ """Return the transcript from the cache, or fetch and cache it."""
46
+ key = _transcript_key(video, selection)
47
+ cached = self._cache.get(key)
48
+ if cached is not None:
49
+ try:
50
+ transcript = serialization.load_transcript(cached)
51
+ except (ValueError, VinkError) as exc:
52
+ logger.warning("Discarding corrupt cache entry %s: %s", key, exc)
53
+ else:
54
+ logger.debug("Cache hit for %s", key)
55
+ return transcript
56
+
57
+ transcript = self._inner.fetch(video, selection)
58
+ self._cache.set(key, serialization.dump_transcript(transcript))
59
+ return transcript
60
+
61
+
62
+ def _transcript_key(video: VideoRef, selection: TrackSelection) -> str:
63
+ """Build a cache key that uniquely identifies one fetched transcript."""
64
+ track = selection.track
65
+ return ":".join(
66
+ [
67
+ f"v{serialization.SCHEMA_VERSION}",
68
+ "transcript",
69
+ video.video_id,
70
+ track.language_code,
71
+ track.kind.value,
72
+ selection.translate_to or "-",
73
+ ]
74
+ )
@@ -0,0 +1,50 @@
1
+ """A :class:`~vink.ports.Cache` that stores entries as files in a directory."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class FileCache:
13
+ """Stores each entry as a UTF-8 file named after the SHA-256 of its key.
14
+
15
+ Failures are logged and treated as cache misses, never raised: the cache
16
+ is an optimization and must not break the main workflow.
17
+
18
+ Args:
19
+ directory: Directory to store entries in. Created on first write.
20
+ """
21
+
22
+ def __init__(self, directory: Path) -> None:
23
+ self._directory = directory
24
+
25
+ def get(self, key: str) -> str | None:
26
+ """Return the cached value for ``key``, or ``None`` on a miss or read error."""
27
+ path = self._path_for(key)
28
+ try:
29
+ return path.read_text(encoding="utf-8")
30
+ except FileNotFoundError:
31
+ return None
32
+ except OSError as exc:
33
+ logger.warning("Ignoring unreadable cache entry %s: %s", path, exc)
34
+ return None
35
+
36
+ def set(self, key: str, value: str) -> None:
37
+ """Store ``value`` under ``key`` atomically; log and continue on failure."""
38
+ path = self._path_for(key)
39
+ try:
40
+ self._directory.mkdir(parents=True, exist_ok=True)
41
+ fd, temp_name = tempfile.mkstemp(dir=self._directory, suffix=".tmp")
42
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
43
+ handle.write(value)
44
+ Path(temp_name).replace(path)
45
+ except OSError as exc:
46
+ logger.warning("Could not write cache entry %s: %s", path, exc)
47
+
48
+ def _path_for(self, key: str) -> Path:
49
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
50
+ return self._directory / f"{digest}.json"
@@ -0,0 +1,95 @@
1
+ """JSON (de)serialization of domain models for caching.
2
+
3
+ The format is versioned through :data:`SCHEMA_VERSION`, which is part of every
4
+ cache key. Bump it whenever the shape below changes; old entries then simply
5
+ stop matching and are refetched.
6
+ """
7
+
8
+ import json
9
+ from collections.abc import Sequence
10
+ from typing import Any, Final
11
+
12
+ from vink.domain.models import RawTranscript, Segment, TrackInfo, TrackKind
13
+ from vink.domain.video_ref import VideoRef
14
+
15
+ SCHEMA_VERSION: Final = 1
16
+
17
+
18
+ def dump_tracks(tracks: Sequence[TrackInfo]) -> str:
19
+ """Serialize a list of tracks to JSON."""
20
+ return json.dumps(
21
+ [
22
+ {
23
+ "language_code": track.language_code,
24
+ "language_name": track.language_name,
25
+ "is_generated": track.is_generated,
26
+ "translation_codes": sorted(track.translation_codes),
27
+ }
28
+ for track in tracks
29
+ ],
30
+ ensure_ascii=False,
31
+ )
32
+
33
+
34
+ def load_tracks(data: str) -> list[TrackInfo]:
35
+ """Deserialize tracks produced by :func:`dump_tracks`.
36
+
37
+ Raises:
38
+ ValueError: If ``data`` is not valid serialized tracks.
39
+ """
40
+ try:
41
+ return [
42
+ TrackInfo(
43
+ language_code=str(item["language_code"]),
44
+ language_name=str(item["language_name"]),
45
+ is_generated=bool(item["is_generated"]),
46
+ translation_codes=frozenset(str(c) for c in item["translation_codes"]),
47
+ )
48
+ for item in _load_json(data, list)
49
+ ]
50
+ except (KeyError, TypeError) as exc:
51
+ raise ValueError(f"Malformed cached tracks: {exc}") from exc
52
+
53
+
54
+ def dump_transcript(transcript: RawTranscript) -> str:
55
+ """Serialize a raw transcript to JSON."""
56
+ return json.dumps(
57
+ {
58
+ "video_id": transcript.video.video_id,
59
+ "language_code": transcript.language_code,
60
+ "language_name": transcript.language_name,
61
+ "kind": transcript.kind.value,
62
+ "segments": [[s.start, s.duration, s.text] for s in transcript.segments],
63
+ },
64
+ ensure_ascii=False,
65
+ )
66
+
67
+
68
+ def load_transcript(data: str) -> RawTranscript:
69
+ """Deserialize a transcript produced by :func:`dump_transcript`.
70
+
71
+ Raises:
72
+ ValueError: If ``data`` is not a valid serialized transcript.
73
+ """
74
+ try:
75
+ payload = _load_json(data, dict)
76
+ return RawTranscript(
77
+ video=VideoRef(str(payload["video_id"])),
78
+ language_code=str(payload["language_code"]),
79
+ language_name=str(payload["language_name"]),
80
+ kind=TrackKind(payload["kind"]),
81
+ segments=tuple(
82
+ Segment(start=float(start), duration=float(duration), text=str(text))
83
+ for start, duration, text in payload["segments"]
84
+ ),
85
+ )
86
+ except (KeyError, TypeError) as exc:
87
+ raise ValueError(f"Malformed cached transcript: {exc}") from exc
88
+
89
+
90
+ def _load_json(data: str, expected: type) -> Any:
91
+ """Parse JSON and check the top-level type."""
92
+ value = json.loads(data)
93
+ if not isinstance(value, expected):
94
+ raise ValueError(f"Expected JSON {expected.__name__}, got {type(value).__name__}")
95
+ return value
@@ -0,0 +1,5 @@
1
+ """Renderer adapters. Markdown today; PDF, HTML and DOCX can be added alongside."""
2
+
3
+ from vink.adapters.render.markdown import MarkdownRenderer
4
+
5
+ __all__ = ["MarkdownRenderer"]
@@ -0,0 +1,111 @@
1
+ """Markdown rendering of transcripts.
2
+
3
+ Output starts with YAML front matter so the files drop straight into
4
+ Markdown-based notes apps such as Obsidian, followed by a readable header and
5
+ one paragraph per block, each prefixed with a clickable timestamp.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from typing import Final
11
+
12
+ from vink.domain.models import Chunk, Paragraph, TrackKind, Transcript
13
+ from vink.processing.timecode import format_timecode
14
+
15
+ _MARKDOWN_SPECIAL: Final = re.compile(r"([\\`*_\[\]<>])")
16
+ """Characters that change meaning inline. Lines never start with user text, so
17
+ block-level markers such as ``#`` or ``-`` need no escaping."""
18
+
19
+ _KIND_LABELS: Final = {
20
+ TrackKind.MANUAL: "creator captions",
21
+ TrackKind.GENERATED: "auto-generated captions",
22
+ TrackKind.TRANSLATED: "machine-translated captions",
23
+ }
24
+
25
+
26
+ class MarkdownRenderer:
27
+ """:class:`~vink.ports.Renderer` producing CommonMark with YAML front matter."""
28
+
29
+ @property
30
+ def format_name(self) -> str:
31
+ """Identifier used to select this renderer."""
32
+ return "markdown"
33
+
34
+ @property
35
+ def file_extension(self) -> str:
36
+ """File extension for Markdown output."""
37
+ return "md"
38
+
39
+ def render_transcript(self, transcript: Transcript) -> str:
40
+ """Render the complete transcript with front matter and a header."""
41
+ sections = [
42
+ _front_matter(transcript),
43
+ f"# {_escape(transcript.metadata.title)}",
44
+ _summary_line(transcript),
45
+ "## Transcript",
46
+ *(_paragraph(transcript, p) for p in transcript.paragraphs),
47
+ ]
48
+ return "\n\n".join(sections) + "\n"
49
+
50
+ def render_chunk(self, transcript: Transcript, chunk: Chunk) -> str:
51
+ """Render one chunk with a heading that states its position and time range."""
52
+ heading = (
53
+ f"## Part {chunk.index} of {chunk.total} "
54
+ f"({format_timecode(chunk.start)}\u2013{format_timecode(chunk.end)})" # en dash
55
+ )
56
+ sections = [
57
+ f"# {_escape(transcript.metadata.title)}",
58
+ heading,
59
+ *(_paragraph(transcript, p) for p in chunk.paragraphs),
60
+ ]
61
+ return "\n\n".join(sections) + "\n"
62
+
63
+
64
+ def _front_matter(transcript: Transcript) -> str:
65
+ """Build YAML front matter. Strings are JSON-encoded, which is valid YAML."""
66
+ fields: dict[str, str | None] = {
67
+ "title": transcript.metadata.title,
68
+ "channel": transcript.metadata.channel,
69
+ "url": transcript.video.url,
70
+ "video_id": transcript.video.video_id,
71
+ "language": transcript.language_code,
72
+ "captions": transcript.kind.value,
73
+ "duration": format_timecode(transcript.duration),
74
+ }
75
+ lines = [
76
+ f"{key}: {json.dumps(value, ensure_ascii=False)}"
77
+ for key, value in fields.items()
78
+ if value is not None
79
+ ]
80
+ return "---\n" + "\n".join(lines) + "\n---"
81
+
82
+
83
+ def _summary_line(transcript: Transcript) -> str:
84
+ """Build the one-line header under the title."""
85
+ metadata = transcript.metadata
86
+ parts: list[str] = []
87
+ if metadata.channel:
88
+ channel = _escape(metadata.channel)
89
+ parts.append(
90
+ f"**Channel:** [{channel}]({metadata.channel_url})"
91
+ if metadata.channel_url
92
+ else f"**Channel:** {channel}"
93
+ )
94
+ parts.append(f"**Duration:** {format_timecode(transcript.duration)}")
95
+ parts.append(
96
+ f"**Language:** {_escape(transcript.language_name)} ({_KIND_LABELS[transcript.kind]})"
97
+ )
98
+ parts.append(f"**Source:** <{transcript.video.url}>")
99
+ return " · ".join(parts)
100
+
101
+
102
+ def _paragraph(transcript: Transcript, paragraph: Paragraph) -> str:
103
+ """Render one paragraph with a bold, clickable timestamp."""
104
+ stamp = format_timecode(paragraph.start)
105
+ link = transcript.video.url_at(paragraph.start)
106
+ return f"**[{stamp}]({link})** {_escape(paragraph.text)}"
107
+
108
+
109
+ def _escape(text: str) -> str:
110
+ """Backslash-escape characters that would otherwise be read as Markdown syntax."""
111
+ return _MARKDOWN_SPECIAL.sub(r"\\\1", text)
@@ -0,0 +1,5 @@
1
+ """Sink adapters. Local folder today; Notion, Obsidian and others can be added alongside."""
2
+
3
+ from vink.adapters.sinks.local import LocalDirectorySink
4
+
5
+ __all__ = ["LocalDirectorySink"]
@@ -0,0 +1,51 @@
1
+ """A :class:`~vink.ports.Sink` that writes files into a local directory."""
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from vink.domain.errors import SinkError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class LocalDirectorySink:
14
+ """Writes each output as a UTF-8 file inside one directory.
15
+
16
+ Names must be plain file names: anything containing a path separator or
17
+ referring to a parent directory is rejected, so output can never escape
18
+ the configured directory. Existing files with the same name are replaced.
19
+
20
+ Args:
21
+ directory: Destination directory. Created on first write.
22
+ """
23
+
24
+ def __init__(self, directory: Path) -> None:
25
+ self._directory = directory
26
+
27
+ def write(self, name: str, content: str) -> str:
28
+ """Write ``content`` to ``<directory>/<name>`` atomically.
29
+
30
+ Returns:
31
+ The absolute path of the written file.
32
+
33
+ Raises:
34
+ SinkError: If ``name`` is not a plain file name or the write fails.
35
+ """
36
+ if not name or Path(name).name != name or name in {".", ".."}:
37
+ raise SinkError(f"Refusing to write to unsafe file name {name!r}.")
38
+
39
+ target = self._directory / name
40
+ try:
41
+ self._directory.mkdir(parents=True, exist_ok=True)
42
+ fd, temp_name = tempfile.mkstemp(dir=self._directory, suffix=".tmp")
43
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
44
+ handle.write(content)
45
+ Path(temp_name).replace(target)
46
+ except OSError as exc:
47
+ raise SinkError(f"Could not write {target}: {exc}") from exc
48
+
49
+ location = str(target.resolve())
50
+ logger.info("Wrote %s", location)
51
+ return location
@@ -0,0 +1,6 @@
1
+ """YouTube adapters: caption tracks via ``youtube-transcript-api``, metadata via oEmbed."""
2
+
3
+ from vink.adapters.youtube.oembed import OEmbedMetadataSource
4
+ from vink.adapters.youtube.transcript_source import YouTubeTranscriptSource
5
+
6
+ __all__ = ["OEmbedMetadataSource", "YouTubeTranscriptSource"]
@@ -0,0 +1,63 @@
1
+ """Video metadata from YouTube's public oEmbed endpoint.
2
+
3
+ oEmbed returns the title and channel without an API key and is far lighter
4
+ than downloading the watch page. It does not provide duration or chapters;
5
+ those will come from a richer adapter (e.g. ``yt-dlp``) when needed.
6
+ """
7
+
8
+ import logging
9
+ from typing import Final
10
+
11
+ import httpx
12
+
13
+ from vink.domain.errors import MetadataFetchError
14
+ from vink.domain.models import VideoMetadata
15
+ from vink.domain.video_ref import VideoRef
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ OEMBED_URL: Final = "https://www.youtube.com/oembed"
20
+
21
+
22
+ class OEmbedMetadataSource:
23
+ """:class:`~vink.ports.MetadataSource` backed by YouTube oEmbed.
24
+
25
+ Args:
26
+ client: HTTP client to use. The caller owns it and must close it.
27
+ It should be configured with timeouts and a ``User-Agent``.
28
+ """
29
+
30
+ def __init__(self, client: httpx.Client) -> None:
31
+ self._client = client
32
+
33
+ def get_metadata(self, video: VideoRef) -> VideoMetadata:
34
+ """Return the title and channel of ``video``.
35
+
36
+ Raises:
37
+ MetadataFetchError: If the request fails or the response is malformed.
38
+ """
39
+ try:
40
+ response = self._client.get(OEMBED_URL, params={"url": video.url, "format": "json"})
41
+ response.raise_for_status()
42
+ payload = response.json()
43
+ except httpx.HTTPStatusError as exc:
44
+ raise MetadataFetchError(
45
+ f"Metadata lookup for {video.video_id} failed with HTTP {exc.response.status_code}."
46
+ ) from exc
47
+ except (httpx.HTTPError, ValueError) as exc:
48
+ raise MetadataFetchError(f"Metadata lookup for {video.video_id} failed: {exc}") from exc
49
+
50
+ title = payload.get("title") if isinstance(payload, dict) else None
51
+ if not isinstance(title, str) or not title:
52
+ raise MetadataFetchError(f"Metadata for {video.video_id} has no title.")
53
+
54
+ return VideoMetadata(
55
+ title=title,
56
+ channel=_optional_str(payload.get("author_name")),
57
+ channel_url=_optional_str(payload.get("author_url")),
58
+ )
59
+
60
+
61
+ def _optional_str(value: object) -> str | None:
62
+ """Return ``value`` if it is a non-empty string, else ``None``."""
63
+ return value if isinstance(value, str) and value else None
@@ -0,0 +1,126 @@
1
+ """Caption tracks from YouTube via the open-source ``youtube-transcript-api`` library.
2
+
3
+ The library reads the same internal endpoints YouTube's own player uses, so no
4
+ API key is required. See ``docs/adr/0002-transcripts-without-data-api.md``.
5
+ """
6
+
7
+ import logging
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+
11
+ from youtube_transcript_api import (
12
+ AgeRestricted,
13
+ CouldNotRetrieveTranscript,
14
+ InvalidVideoId,
15
+ IpBlocked,
16
+ NoTranscriptFound,
17
+ NotTranslatable,
18
+ RequestBlocked,
19
+ TranscriptsDisabled,
20
+ TranslationLanguageNotAvailable,
21
+ VideoUnavailable,
22
+ VideoUnplayable,
23
+ YouTubeTranscriptApi,
24
+ )
25
+ from youtube_transcript_api import Transcript as LibraryTranscript
26
+
27
+ from vink.domain.errors import (
28
+ LanguageUnavailableError,
29
+ TranscriptFetchError,
30
+ TranscriptsDisabledError,
31
+ VideoUnavailableError,
32
+ )
33
+ from vink.domain.models import RawTranscript, Segment, TrackInfo, TrackSelection
34
+ from vink.domain.video_ref import VideoRef
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class YouTubeTranscriptSource:
40
+ """:class:`~vink.ports.TranscriptSource` backed by ``youtube-transcript-api``.
41
+
42
+ Args:
43
+ api: Library client to use. Inject a configured instance to set a proxy
44
+ or a custom HTTP session; defaults to a plain client.
45
+ """
46
+
47
+ def __init__(self, api: YouTubeTranscriptApi | None = None) -> None:
48
+ self._api = api or YouTubeTranscriptApi()
49
+
50
+ def list_tracks(self, video: VideoRef) -> list[TrackInfo]:
51
+ """Return every caption track available for ``video``."""
52
+ with _translate_errors(video.video_id):
53
+ transcript_list = self._api.list(video.video_id)
54
+ tracks = [_to_track_info(item) for item in transcript_list]
55
+ logger.debug("Found %d caption tracks for %s", len(tracks), video.video_id)
56
+ return tracks
57
+
58
+ def fetch(self, video: VideoRef, selection: TrackSelection) -> RawTranscript:
59
+ """Fetch the transcript described by ``selection``."""
60
+ track = selection.track
61
+ with _translate_errors(video.video_id, requested=selection.language_code):
62
+ transcript_list = self._api.list(video.video_id)
63
+ if track.is_generated:
64
+ source = transcript_list.find_generated_transcript([track.language_code])
65
+ else:
66
+ source = transcript_list.find_manually_created_transcript([track.language_code])
67
+ if selection.translate_to:
68
+ source = source.translate(selection.translate_to)
69
+ fetched = source.fetch()
70
+
71
+ segments = tuple(
72
+ Segment(start=snippet.start, duration=snippet.duration, text=snippet.text)
73
+ for snippet in fetched.snippets
74
+ )
75
+ logger.info(
76
+ "Fetched %d segments (%s, %s) for %s",
77
+ len(segments),
78
+ selection.language_code,
79
+ selection.kind,
80
+ video.video_id,
81
+ )
82
+ return RawTranscript(
83
+ video=video,
84
+ language_code=selection.language_code,
85
+ language_name=source.language,
86
+ kind=selection.kind,
87
+ segments=segments,
88
+ )
89
+
90
+
91
+ def _to_track_info(item: LibraryTranscript) -> TrackInfo:
92
+ """Convert a library transcript descriptor into a domain :class:`TrackInfo`."""
93
+ return TrackInfo(
94
+ language_code=item.language_code,
95
+ language_name=item.language,
96
+ is_generated=item.is_generated,
97
+ translation_codes=frozenset(lang.language_code for lang in item.translation_languages),
98
+ )
99
+
100
+
101
+ @contextmanager
102
+ def _translate_errors(video_id: str, requested: str | None = None) -> Iterator[None]:
103
+ """Convert ``youtube-transcript-api`` and network exceptions into domain errors."""
104
+ try:
105
+ yield
106
+ except TranscriptsDisabled as exc:
107
+ raise TranscriptsDisabledError(video_id) from exc
108
+ except (VideoUnavailable, InvalidVideoId) as exc:
109
+ raise VideoUnavailableError(video_id, "the video does not exist or was removed") from exc
110
+ except AgeRestricted as exc:
111
+ raise VideoUnavailableError(video_id, "the video is age-restricted") from exc
112
+ except VideoUnplayable as exc:
113
+ raise VideoUnavailableError(video_id, "the video is not playable") from exc
114
+ except (NoTranscriptFound, NotTranslatable, TranslationLanguageNotAvailable) as exc:
115
+ raise LanguageUnavailableError(
116
+ video_id, requested=[requested] if requested else [], available=[]
117
+ ) from exc
118
+ except (RequestBlocked, IpBlocked) as exc:
119
+ raise TranscriptFetchError(
120
+ "YouTube is blocking requests from this network (common on cloud servers). "
121
+ "Run vink from a home or office connection, or configure a proxy."
122
+ ) from exc
123
+ except CouldNotRetrieveTranscript as exc:
124
+ raise TranscriptFetchError(f"Could not retrieve transcript for {video_id}.") from exc
125
+ except OSError as exc: # requests.RequestException derives from OSError
126
+ raise TranscriptFetchError(f"Network error while contacting YouTube: {exc}") from exc