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.
- vink/__init__.py +10 -0
- vink/adapters/__init__.py +6 -0
- vink/adapters/cache/__init__.py +6 -0
- vink/adapters/cache/caching_source.py +74 -0
- vink/adapters/cache/filesystem.py +50 -0
- vink/adapters/cache/serialization.py +95 -0
- vink/adapters/render/__init__.py +5 -0
- vink/adapters/render/markdown.py +111 -0
- vink/adapters/sinks/__init__.py +5 -0
- vink/adapters/sinks/local.py +51 -0
- vink/adapters/youtube/__init__.py +6 -0
- vink/adapters/youtube/oembed.py +63 -0
- vink/adapters/youtube/transcript_source.py +126 -0
- vink/bootstrap.py +85 -0
- vink/config.py +71 -0
- vink/domain/__init__.py +56 -0
- vink/domain/errors.py +86 -0
- vink/domain/models.py +188 -0
- vink/domain/video_ref.py +81 -0
- vink/interfaces/__init__.py +6 -0
- vink/interfaces/cli/__init__.py +5 -0
- vink/interfaces/cli/app.py +187 -0
- vink/interfaces/logging_setup.py +16 -0
- vink/interfaces/mcp/__init__.py +5 -0
- vink/interfaces/mcp/schemas.py +27 -0
- vink/interfaces/mcp/server.py +180 -0
- vink/ports/__init__.py +14 -0
- vink/ports/cache.py +19 -0
- vink/ports/metadata_source.py +19 -0
- vink/ports/renderer.py +27 -0
- vink/ports/sink.py +22 -0
- vink/ports/transcript_source.py +22 -0
- vink/processing/__init__.py +23 -0
- vink/processing/chunking.py +49 -0
- vink/processing/cleaning.py +47 -0
- vink/processing/filenames.py +40 -0
- vink/processing/paragraphs.py +105 -0
- vink/processing/timecode.py +20 -0
- vink/processing/track_selection.py +114 -0
- vink/py.typed +0 -0
- vink/services/__init__.py +10 -0
- vink/services/export_service.py +69 -0
- vink/services/transcript_service.py +102 -0
- vink/styles/__init__.py +46 -0
- vink/styles/clean-transcript.md +28 -0
- vink/styles/detailed-notes.md +38 -0
- vink-0.1.0.dist-info/METADATA +236 -0
- vink-0.1.0.dist-info/RECORD +51 -0
- vink-0.1.0.dist-info/WHEEL +4 -0
- vink-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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,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,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
|