ytextract 0.0.1__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.
ytextract/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ # flake8: noqa: F401
2
+ # noreorder
3
+ """
4
+ ytextract: a very serious Python library for downloading YouTube Videos.
5
+ """
6
+ from importlib.metadata import version, PackageNotFoundError
7
+
8
+ __title__ = "ytextract"
9
+ __author__ = "Josh-XT"
10
+ __license__ = "The Unlicense (Unlicense)"
11
+ __js__ = None
12
+ __js_url__ = None
13
+
14
+ try:
15
+ __version__ = version("ytextract")
16
+ except PackageNotFoundError:
17
+ __version__ = "0.0.1" # fallback for development
18
+
19
+ from ytextract.streams import Stream
20
+ from ytextract.captions import Caption
21
+ from ytextract.query import CaptionQuery, StreamQuery
22
+ from ytextract.__main__ import YouTube
23
+ from ytextract.innertube import InnerTube
24
+ from ytextract.download_helper import (
25
+ download_video,
26
+ download_videos_from_channels,
27
+ download_videos_from_list,
28
+ download_captions,
29
+ get_videos_from_channel,
30
+ )
31
+
32
+ download = download_video
ytextract/__main__.py ADDED
@@ -0,0 +1,491 @@
1
+ """
2
+ This module implements the core developer interface for ytextract.
3
+
4
+ The problem domain of the :class:`YouTube <YouTube> class focuses almost
5
+ exclusively on the developer interface. ytextract offloads the heavy lifting to
6
+ smaller peripheral modules and functions.
7
+
8
+ """
9
+
10
+ import logging
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+ import ytextract
14
+ import ytextract.exceptions as exceptions
15
+ from ytextract import extract, request
16
+ from ytextract import Stream, StreamQuery
17
+ from ytextract.helpers import install_proxy
18
+ from ytextract.innertube import InnerTube, _default_clients
19
+ from ytextract.metadata import YouTubeMetadata
20
+ from ytextract.monostate import Monostate
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class YouTube:
26
+ """Core developer interface for ytextract."""
27
+
28
+ def __init__(
29
+ self,
30
+ url: str,
31
+ on_progress_callback: Optional[Callable[[Any, bytes, int], None]] = None,
32
+ on_complete_callback: Optional[Callable[[Any, Optional[str]], None]] = None,
33
+ proxies: Dict[str, str] = None,
34
+ use_oauth: bool = False,
35
+ allow_oauth_cache: bool = True,
36
+ ):
37
+ """Construct a :class:`YouTube <YouTube>`.
38
+
39
+ :param str url:
40
+ A valid YouTube watch URL.
41
+ :param func on_progress_callback:
42
+ (Optional) User defined callback function for stream download
43
+ progress events.
44
+ :param func on_complete_callback:
45
+ (Optional) User defined callback function for stream download
46
+ complete events.
47
+ :param dict proxies:
48
+ (Optional) A dict mapping protocol to proxy address which will be used by ytextract.
49
+ :param bool use_oauth:
50
+ (Optional) Prompt the user to authenticate to YouTube.
51
+ If allow_oauth_cache is set to True, the user should only be prompted once.
52
+ :param bool allow_oauth_cache:
53
+ (Optional) Cache OAuth tokens locally on the machine. Defaults to True.
54
+ These tokens are only generated if use_oauth is set to True as well.
55
+ """
56
+ self._js: Optional[str] = None # js fetched by js_url
57
+ self._js_url: Optional[str] = None # the url to the js, parsed from watch html
58
+
59
+ self._vid_info: Optional[Dict] = None # content fetched from innertube/player
60
+
61
+ self._watch_html: Optional[str] = None # the html of /watch?v=<video_id>
62
+ self._embed_html: Optional[str] = None
63
+ self._player_config_args: Optional[Dict] = (
64
+ None # inline js in the html containing
65
+ )
66
+ self._age_restricted: Optional[bool] = None
67
+
68
+ self._fmt_streams: Optional[List[Stream]] = None
69
+
70
+ self._initial_data = None
71
+ self._metadata: Optional[YouTubeMetadata] = None
72
+
73
+ # video_id part of /watch?v=<video_id>
74
+ self.video_id = extract.video_id(url)
75
+
76
+ self.watch_url = f"https://youtube.com/watch?v={self.video_id}"
77
+ self.embed_url = f"https://www.youtube.com/embed/{self.video_id}"
78
+
79
+ # Shared between all instances of `Stream` (Borg pattern).
80
+ self.stream_monostate = Monostate(
81
+ on_progress=on_progress_callback, on_complete=on_complete_callback
82
+ )
83
+
84
+ if proxies:
85
+ install_proxy(proxies)
86
+
87
+ self._author = None
88
+ self._title = None
89
+ self._publish_date = None
90
+
91
+ self.use_oauth = use_oauth
92
+ self.allow_oauth_cache = allow_oauth_cache
93
+
94
+ def __repr__(self):
95
+ return f"<ytextract.__main__.YouTube object: videoId={self.video_id}>"
96
+
97
+ def __eq__(self, o: object) -> bool:
98
+ # Compare types and urls, if they're same return true, else return false.
99
+ return type(o) == type(self) and o.watch_url == self.watch_url
100
+
101
+ @property
102
+ def watch_html(self):
103
+ if self._watch_html:
104
+ return self._watch_html
105
+ self._watch_html = request.get(url=self.watch_url)
106
+ return self._watch_html
107
+
108
+ @property
109
+ def embed_html(self):
110
+ if self._embed_html:
111
+ return self._embed_html
112
+ self._embed_html = request.get(url=self.embed_url)
113
+ return self._embed_html
114
+
115
+ @property
116
+ def age_restricted(self):
117
+ if self._age_restricted:
118
+ return self._age_restricted
119
+ self._age_restricted = extract.is_age_restricted(self.watch_html)
120
+ return self._age_restricted
121
+
122
+ @property
123
+ def js_url(self):
124
+ if self._js_url:
125
+ return self._js_url
126
+
127
+ if self.age_restricted:
128
+ self._js_url = extract.js_url(self.embed_html)
129
+ else:
130
+ self._js_url = extract.js_url(self.watch_html)
131
+
132
+ return self._js_url
133
+
134
+ @property
135
+ def js(self):
136
+ if self._js:
137
+ return self._js
138
+
139
+ # If the js_url doesn't match the cached url, fetch the new js and update
140
+ # the cache; otherwise, load the cache.
141
+ if ytextract.__js_url__ != self.js_url:
142
+ self._js = request.get(self.js_url)
143
+ ytextract.__js__ = self._js
144
+ ytextract.__js_url__ = self.js_url
145
+ else:
146
+ self._js = ytextract.__js__
147
+
148
+ return self._js
149
+
150
+ @property
151
+ def initial_data(self):
152
+ if self._initial_data:
153
+ return self._initial_data
154
+ self._initial_data = extract.initial_data(self.watch_html)
155
+ return self._initial_data
156
+
157
+ @property
158
+ def streaming_data(self):
159
+ """Return streamingData from video info."""
160
+ if "streamingData" in self.vid_info:
161
+ return self.vid_info["streamingData"]
162
+ else:
163
+ self.bypass_age_gate()
164
+ return self.vid_info["streamingData"]
165
+
166
+ @property
167
+ def fmt_streams(self):
168
+ """Returns a list of streams if they have been initialized.
169
+
170
+ If the streams have not been initialized, finds all relevant
171
+ streams and initializes them.
172
+ """
173
+ self.check_availability()
174
+ if self._fmt_streams:
175
+ return self._fmt_streams
176
+
177
+ self._fmt_streams = []
178
+
179
+ stream_manifest = extract.apply_descrambler(self.streaming_data)
180
+
181
+ # If the cached js doesn't work, try fetching a new js file
182
+ # https://github.com/Josh-XT/ytextract/issues/1054
183
+ try:
184
+ extract.apply_signature(stream_manifest, self.vid_info, self.js)
185
+ except exceptions.ExtractError:
186
+ # To force an update to the js file, we clear the cache and retry
187
+ self._js = None
188
+ self._js_url = None
189
+ ytextract.__js__ = None
190
+ ytextract.__js_url__ = None
191
+ extract.apply_signature(stream_manifest, self.vid_info, self.js)
192
+
193
+ # build instances of :class:`Stream <Stream>`
194
+ # Initialize stream objects
195
+ for stream in stream_manifest:
196
+ video = Stream(
197
+ stream=stream,
198
+ monostate=self.stream_monostate,
199
+ )
200
+ self._fmt_streams.append(video)
201
+
202
+ self.stream_monostate.title = self.title
203
+ self.stream_monostate.duration = self.length
204
+
205
+ return self._fmt_streams
206
+
207
+ def check_availability(self):
208
+ """Check whether the video is available.
209
+
210
+ Raises different exceptions based on why the video is unavailable,
211
+ otherwise does nothing.
212
+ """
213
+ status, messages = extract.playability_status(self.watch_html)
214
+
215
+ for reason in messages:
216
+ if status == "UNPLAYABLE":
217
+ if reason == (
218
+ "Join this channel to get access to members-only content "
219
+ "like this video, and other exclusive perks."
220
+ ):
221
+ raise exceptions.MembersOnly(video_id=self.video_id)
222
+ elif reason == "This live stream recording is not available.":
223
+ raise exceptions.RecordingUnavailable(video_id=self.video_id)
224
+ else:
225
+ raise exceptions.VideoUnavailable(video_id=self.video_id)
226
+ elif status == "LOGIN_REQUIRED":
227
+ if reason == (
228
+ "This is a private video. "
229
+ "Please sign in to verify that you may see it."
230
+ ):
231
+ raise exceptions.VideoPrivate(video_id=self.video_id)
232
+ elif status == "ERROR":
233
+ if reason == "Video unavailable":
234
+ raise exceptions.VideoUnavailable(video_id=self.video_id)
235
+ elif status == "LIVE_STREAM":
236
+ raise exceptions.LiveStreamError(video_id=self.video_id)
237
+
238
+ @property
239
+ def vid_info(self):
240
+ """Parse the raw vid info and return the parsed result.
241
+
242
+ :rtype: Dict[Any, Any]
243
+ """
244
+ if self._vid_info:
245
+ return self._vid_info
246
+
247
+ innertube = InnerTube(
248
+ use_oauth=self.use_oauth, allow_cache=self.allow_oauth_cache
249
+ )
250
+
251
+ innertube_response = innertube.player(self.video_id)
252
+ self._vid_info = innertube_response
253
+ return self._vid_info
254
+
255
+ def bypass_age_gate(self):
256
+ """Attempt to update the vid_info by bypassing the age gate."""
257
+ try:
258
+ innertube = InnerTube(
259
+ client="ANDROID_EMBED",
260
+ use_oauth=self.use_oauth,
261
+ allow_cache=self.allow_oauth_cache,
262
+ )
263
+ except:
264
+ for client in _default_clients:
265
+ try:
266
+ innertube = InnerTube(
267
+ client=client,
268
+ use_oauth=self.use_oauth,
269
+ allow_cache=self.allow_oauth_cache,
270
+ )
271
+ break
272
+ except:
273
+ continue
274
+ innertube_response = innertube.player(self.video_id)
275
+ playability_status = innertube_response["playabilityStatus"].get("status", None)
276
+
277
+ # If we still can't access the video, raise an exception
278
+ # (tier 3 age restriction)
279
+ if playability_status == "UNPLAYABLE":
280
+ raise exceptions.AgeRestrictedError(self.video_id)
281
+
282
+ self._vid_info = innertube_response
283
+
284
+ @property
285
+ def caption_tracks(self) -> List[ytextract.Caption]:
286
+ """Get a list of :class:`Caption <Caption>`.
287
+
288
+ :rtype: List[Caption]
289
+ """
290
+ raw_tracks = (
291
+ self.vid_info.get("captions", {})
292
+ .get("playerCaptionsTracklistRenderer", {})
293
+ .get("captionTracks", [])
294
+ )
295
+ return [ytextract.Caption(track) for track in raw_tracks]
296
+
297
+ @property
298
+ def captions(self) -> ytextract.CaptionQuery:
299
+ """Interface to query caption tracks.
300
+
301
+ :rtype: :class:`CaptionQuery <CaptionQuery>`.
302
+ """
303
+ return ytextract.CaptionQuery(self.caption_tracks)
304
+
305
+ @property
306
+ def streams(self) -> StreamQuery:
307
+ """Interface to query both adaptive (DASH) and progressive streams.
308
+
309
+ :rtype: :class:`StreamQuery <StreamQuery>`.
310
+ """
311
+ self.check_availability()
312
+ return StreamQuery(self.fmt_streams)
313
+
314
+ @property
315
+ def thumbnail_url(self) -> str:
316
+ """Get the thumbnail url image.
317
+
318
+ :rtype: str
319
+ """
320
+ thumbnail_details = (
321
+ self.vid_info.get("videoDetails", {}).get("thumbnail", {}).get("thumbnails")
322
+ )
323
+ if thumbnail_details:
324
+ thumbnail_details = thumbnail_details[-1] # last item has max size
325
+ return thumbnail_details["url"]
326
+
327
+ return f"https://img.youtube.com/vi/{self.video_id}/maxresdefault.jpg"
328
+
329
+ @property
330
+ def publish_date(self):
331
+ """Get the publish date.
332
+
333
+ :rtype: datetime
334
+ """
335
+ if self._publish_date:
336
+ return self._publish_date
337
+ self._publish_date = extract.publish_date(self.watch_html)
338
+ return self._publish_date
339
+
340
+ @publish_date.setter
341
+ def publish_date(self, value):
342
+ """Sets the publish date."""
343
+ self._publish_date = value
344
+
345
+ @property
346
+ def title(self) -> str:
347
+ """Get the video title.
348
+
349
+ :rtype: str
350
+ """
351
+ if self._title:
352
+ return self._title
353
+
354
+ try:
355
+ self._title = self.vid_info["videoDetails"]["title"]
356
+ except KeyError:
357
+ # Check_availability will raise the correct exception in most cases
358
+ # if it doesn't, ask for a report.
359
+ self.check_availability()
360
+ raise exceptions.YtextractError(
361
+ (
362
+ f"Exception while accessing title of {self.watch_url}. "
363
+ "Please file a bug report at https://github.com/Josh-XT/ytextract"
364
+ )
365
+ )
366
+
367
+ return self._title
368
+
369
+ @title.setter
370
+ def title(self, value):
371
+ """Sets the title value."""
372
+ self._title = value
373
+
374
+ @property
375
+ def description(self) -> str:
376
+ """Get the video description.
377
+
378
+ :rtype: str
379
+ """
380
+ return self.vid_info.get("videoDetails", {}).get("shortDescription")
381
+
382
+ @property
383
+ def rating(self) -> float:
384
+ """Get the video average rating.
385
+
386
+ :rtype: float
387
+
388
+ """
389
+ return self.vid_info.get("videoDetails", {}).get("averageRating")
390
+
391
+ @property
392
+ def length(self) -> int:
393
+ """Get the video length in seconds.
394
+
395
+ :rtype: int
396
+ """
397
+ return int(self.vid_info.get("videoDetails", {}).get("lengthSeconds"))
398
+
399
+ @property
400
+ def views(self) -> int:
401
+ """Get the number of the times the video has been viewed.
402
+
403
+ :rtype: int
404
+ """
405
+ return int(self.vid_info.get("videoDetails", {}).get("viewCount"))
406
+
407
+ @property
408
+ def author(self) -> str:
409
+ """Get the video author.
410
+ :rtype: str
411
+ """
412
+ if self._author:
413
+ return self._author
414
+ self._author = self.vid_info.get("videoDetails", {}).get("author", "unknown")
415
+ return self._author
416
+
417
+ @author.setter
418
+ def author(self, value):
419
+ """Set the video author."""
420
+ self._author = value
421
+
422
+ @property
423
+ def keywords(self) -> List[str]:
424
+ """Get the video keywords.
425
+
426
+ :rtype: List[str]
427
+ """
428
+ return self.vid_info.get("videoDetails", {}).get("keywords", [])
429
+
430
+ @property
431
+ def channel_id(self) -> str:
432
+ """Get the video poster's channel id.
433
+
434
+ :rtype: str
435
+ """
436
+ return self.vid_info.get("videoDetails", {}).get("channelId", None)
437
+
438
+ @property
439
+ def channel_url(self) -> str:
440
+ """Construct the channel url for the video's poster from the channel id.
441
+
442
+ :rtype: str
443
+ """
444
+ return f"https://www.youtube.com/channel/{self.channel_id}"
445
+
446
+ @property
447
+ def metadata(self) -> Optional[YouTubeMetadata]:
448
+ """Get the metadata for the video.
449
+
450
+ :rtype: YouTubeMetadata
451
+ """
452
+ if self._metadata:
453
+ return self._metadata
454
+ else:
455
+ self._metadata = extract.metadata(self.initial_data)
456
+ return self._metadata
457
+
458
+ def register_on_progress_callback(self, func: Callable[[Any, bytes, int], None]):
459
+ """Register a download progress callback function post initialization.
460
+
461
+ :param callable func:
462
+ A callback function that takes ``stream``, ``chunk``,
463
+ and ``bytes_remaining`` as parameters.
464
+
465
+ :rtype: None
466
+
467
+ """
468
+ self.stream_monostate.on_progress = func
469
+
470
+ def register_on_complete_callback(self, func: Callable[[Any, Optional[str]], None]):
471
+ """Register a download complete callback function post initialization.
472
+
473
+ :param callable func:
474
+ A callback function that takes ``stream`` and ``file_path``.
475
+
476
+ :rtype: None
477
+
478
+ """
479
+ self.stream_monostate.on_complete = func
480
+
481
+ @staticmethod
482
+ def from_id(video_id: str) -> "YouTube":
483
+ """Construct a :class:`YouTube <YouTube>` object from a video id.
484
+
485
+ :param str video_id:
486
+ The video id of the YouTube video.
487
+
488
+ :rtype: :class:`YouTube <YouTube>`
489
+
490
+ """
491
+ return YouTube(f"https://www.youtube.com/watch?v={video_id}")
ytextract/captions.py ADDED
@@ -0,0 +1,184 @@
1
+ import math
2
+ import os
3
+ import time
4
+ import json
5
+ import xml.etree.ElementTree as ElementTree
6
+ from html import unescape
7
+ from typing import Dict, Optional
8
+
9
+ from ytextract import request
10
+ from ytextract.helpers import safe_filename, target_directory
11
+
12
+
13
+ class Caption:
14
+ """Container for caption tracks."""
15
+
16
+ def __init__(self, caption_track: Dict):
17
+ """Construct a :class:`Caption <Caption>`.
18
+
19
+ :param dict caption_track:
20
+ Caption track data extracted from ``watch_html``.
21
+ """
22
+ self.url = caption_track.get("baseUrl")
23
+
24
+ # Certain videos have runs instead of simpleText
25
+ # this handles that edge case
26
+ name_dict = caption_track["name"]
27
+ if "simpleText" in name_dict:
28
+ self.name = name_dict["simpleText"]
29
+ else:
30
+ for el in name_dict["runs"]:
31
+ if "text" in el:
32
+ self.name = el["text"]
33
+
34
+ # Use "vssId" instead of "languageCode", fix issue #779
35
+ self.code = caption_track["vssId"]
36
+ # Remove preceding '.' for backwards compatibility, e.g.:
37
+ # English -> vssId: .en, languageCode: en
38
+ # English (auto-generated) -> vssId: a.en, languageCode: en
39
+ self.code = self.code.strip(".")
40
+
41
+ @property
42
+ def xml_captions(self) -> str:
43
+ """Download the xml caption tracks."""
44
+ return request.get(self.url)
45
+
46
+ @property
47
+ def json_captions(self) -> dict:
48
+ """Download and parse the json caption tracks."""
49
+ json_captions_url = self.url.replace("fmt=srv3", "fmt=json3")
50
+ text = request.get(json_captions_url)
51
+ parsed = json.loads(text)
52
+ assert parsed["wireMagic"] == "pb3", "Unexpected captions format"
53
+ return parsed
54
+
55
+ def generate_srt_captions(self) -> str:
56
+ """Generate "SubRip Subtitle" captions.
57
+
58
+ Takes the xml captions from :meth:`~ytextract.Caption.xml_captions` and
59
+ recompiles them into the "SubRip Subtitle" format.
60
+ """
61
+ return self.xml_caption_to_srt(self.xml_captions)
62
+
63
+ @staticmethod
64
+ def float_to_srt_time_format(d: float) -> str:
65
+ """Convert decimal durations into proper srt format.
66
+
67
+ :rtype: str
68
+ :returns:
69
+ SubRip Subtitle (str) formatted time duration.
70
+
71
+ float_to_srt_time_format(3.89) -> '00:00:03,890'
72
+ """
73
+ fraction, whole = math.modf(d)
74
+ time_fmt = time.strftime("%H:%M:%S,", time.gmtime(whole))
75
+ ms = f"{fraction:.3f}".replace("0.", "")
76
+ return time_fmt + ms
77
+
78
+ def xml_caption_to_srt(self, xml_captions: str) -> str:
79
+ """Convert xml caption tracks to "SubRip Subtitle (srt)".
80
+ :param str xml_captions:
81
+ XML formatted caption tracks.
82
+ """
83
+ segments = []
84
+ try:
85
+ root = ElementTree.fromstring(xml_captions)
86
+ except ElementTree.ParseError as e:
87
+ print(f"Warning: Failed to parse the XML captions. Error: {e}")
88
+ return "" # Return an empty string if parsing fails
89
+
90
+ try:
91
+ for i, child in enumerate(
92
+ list(root[0])
93
+ ): # Assuming the first child is the correct element
94
+ text = child.text or ""
95
+ caption = unescape(
96
+ text.replace("\n", " ").replace(" ", " "),
97
+ )
98
+ try:
99
+ duration = float(child.attrib["d"]) / 1000.0
100
+ except KeyError:
101
+ duration = 0.0
102
+ try:
103
+ start = float(child.attrib["t"]) / 1000.0
104
+ except KeyError:
105
+ start = 0.0
106
+ end = start + duration
107
+ sequence_number = i + 1 # convert from 0-indexed to 1.
108
+ line = "{seq}\n{start} --> {end}\n{text}\n".format(
109
+ seq=sequence_number,
110
+ start=self.float_to_srt_time_format(start),
111
+ end=self.float_to_srt_time_format(end),
112
+ text=caption,
113
+ )
114
+ segments.append(line)
115
+
116
+ except IndexError as e:
117
+ print(
118
+ f"Warning: The XML structure does not contain the expected elements. Error: {e}"
119
+ )
120
+ except Exception as e:
121
+ print(f"An unexpected error occurred: {e}")
122
+
123
+ return "\n".join(segments).strip()
124
+
125
+ def download(
126
+ self,
127
+ title: str,
128
+ srt: bool = True,
129
+ output_path: Optional[str] = None,
130
+ filename_prefix: Optional[str] = None,
131
+ ) -> str:
132
+ """Write the media stream to disk.
133
+
134
+ :param title:
135
+ Output filename (stem only) for writing media file.
136
+ If one is not specified, the default filename is used.
137
+ :type title: str
138
+ :param srt:
139
+ Set to True to download srt, false to download xml. Defaults to True.
140
+ :type srt bool
141
+ :param output_path:
142
+ (optional) Output path for writing media file. If one is not
143
+ specified, defaults to the current working directory.
144
+ :type output_path: str or None
145
+ :param filename_prefix:
146
+ (optional) A string that will be prepended to the filename.
147
+ For example a number in a playlist or the name of a series.
148
+ If one is not specified, nothing will be prepended
149
+ This is separate from filename so you can use the default
150
+ filename but still add a prefix.
151
+ :type filename_prefix: str or None
152
+
153
+ :rtype: str
154
+ """
155
+ if title.endswith(".srt") or title.endswith(".xml"):
156
+ filename = ".".join(title.split(".")[:-1])
157
+ else:
158
+ filename = title
159
+
160
+ if filename_prefix:
161
+ filename = f"{safe_filename(filename_prefix)}{filename}"
162
+
163
+ filename = safe_filename(filename)
164
+
165
+ filename += f" ({self.code})"
166
+
167
+ if srt:
168
+ filename += ".srt"
169
+ else:
170
+ filename += ".xml"
171
+
172
+ file_path = os.path.join(target_directory(output_path), filename)
173
+
174
+ with open(file_path, "w", encoding="utf-8") as file_handle:
175
+ if srt:
176
+ file_handle.write(self.generate_srt_captions())
177
+ else:
178
+ file_handle.write(self.xml_captions)
179
+
180
+ return file_path
181
+
182
+ def __repr__(self):
183
+ """Printable object representation."""
184
+ return '<Caption lang="{s.name}" code="{s.code}">'.format(s=self)