jwlib 1.0.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.
jwlib/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = '1.0.0'
2
+
jwlib/common.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ Utility functions shared across jwlib
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class NotFoundError(Exception):
16
+ """Raised when the server returns HTTP 404"""
17
+
18
+
19
+ class _DictWrapper:
20
+ """Wraps server response data"""
21
+
22
+ data: dict
23
+ """Object data as returned by the server.
24
+
25
+ If you need access to information that has no getter method, you can get it here.
26
+
27
+ .. note::
28
+ Editing this directory is an untested feature.
29
+ """
30
+
31
+ def __init__(self, data: dict):
32
+ if not isinstance(data, dict):
33
+ raise TypeError(f'{self.__class__} cannot be initialized with {type(data)}')
34
+ self.data = data
35
+
36
+ def _get_int(self, key, default: int = 0):
37
+ try:
38
+ return int(self.data[key])
39
+ except (KeyError, TypeError, ValueError):
40
+ if default is not None:
41
+ logger.debug('%s contains invalid data', self, exc_info=True)
42
+ return default
43
+ raise
44
+
45
+ def _get_float(self, key, default: float = 0.0):
46
+ try:
47
+ return float(self.data[key])
48
+ except (KeyError, TypeError, ValueError):
49
+ if default is not None:
50
+ logger.debug('%s contains invalid data', self, exc_info=True)
51
+ return default
52
+ raise
53
+
54
+ def _get_string(self, key, default: str | None = None) -> str:
55
+ """Return a non-zero string"""
56
+
57
+ value = self.data.get(key)
58
+ if not isinstance(value, str) or value == '':
59
+ if default is not None:
60
+ logger.debug(f'{self} contains invalid data', exc_info=True)
61
+ return default
62
+ raise
63
+
64
+ return value
65
+
66
+
67
+ def _get_json(url: str, query: dict | None = None, *, headers: dict | None = None):
68
+ """Send a query to the server and return loaded JSON"""
69
+
70
+ if query:
71
+ # Remove None, convert bool to int
72
+ query = {k: (int(v) if isinstance(v, bool) else v)
73
+ for k, v in query.items()
74
+ if v is not None}
75
+ url += '?' + urllib.parse.urlencode(query)
76
+
77
+ logger.debug(f'opening: {url}')
78
+
79
+ r = urllib.request.Request(url, headers=headers or {})
80
+ try:
81
+ return json.load(urllib.request.urlopen(r))
82
+ except urllib.error.HTTPError as e:
83
+ if e.code == 404:
84
+ raise NotFoundError from e
85
+ raise
@@ -0,0 +1,34 @@
1
+ """
2
+ Wrappers for the "mediator" API used in the video section at `jw.org <http://jw.org>`_.
3
+
4
+ The common way to start is to create a :class:`Session` in your language
5
+ of choice, use :meth:`~Session.get_category` to get the root and
6
+ work your way from there using :meth:`~Category.get_subcategories` and
7
+ :meth:`~Category.get_media`:
8
+
9
+ .. doctest::
10
+
11
+ >>> import jwlib.media as jw
12
+ >>> english_session = jw.Session()
13
+ >>> root = english_session.get_category()
14
+ >>> for category in root.get_subcategories():
15
+ >>> for media in category.get_media():
16
+ >>> print(media.title)
17
+ """
18
+
19
+ from ..common import NotFoundError
20
+ from .const import *
21
+ from .endpoints import request_translations
22
+ from .language import Language, request_languages
23
+ from .session import Category, File, Media, Session
24
+
25
+ __all__ = (
26
+ 'Session',
27
+ 'Category',
28
+ 'Media',
29
+ 'File',
30
+ 'Language',
31
+ 'request_languages',
32
+ 'request_translations',
33
+ 'NotFoundError'
34
+ )
jwlib/media/const.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ Constants used in the mediator API
3
+ """
4
+
5
+ # General
6
+ # =======
7
+
8
+ # Category.key of the root category.
9
+ # This is not part of the mediator API, it belongs to jwlib.
10
+ ROOT_CATEGORY = 'All'
11
+
12
+ # Time format used by the API, can be passed to time.strptime()
13
+ TIME_FORMAT = '%Y-%m-%dT%H:%M:%S'
14
+
15
+ # Client type
16
+ # ===========
17
+ # These can be passed to Session().
18
+ # They affect what data will be made available.
19
+
20
+ CLIENT_APPLETV = 'appletv'
21
+ CLIENT_FIRETV = 'firetv' # Default in jwlib.
22
+ CLIENT_JWORG = 'JWORG'
23
+ CLIENT_NONE = 'none' # Includes obscure categories and convention releases (slow).
24
+ CLIENT_ROKU = 'roku'
25
+ CLIENT_RWLS = 'rwls'
26
+ CLIENT_SATELLITE = 'satellite'
27
+ CLIENT_WWW = 'www' # Used by jw.org.
28
+
29
+ # Category type
30
+ # =============
31
+ CATEGORY_CONTAINER = 'container'
32
+ CATEGORY_ONDEMAND = 'ondemand'
33
+
34
+ # Media type
35
+ # ==========
36
+ MEDIA_AUDIO = 'audio'
37
+ MEDIA_VIDEO = 'video'
38
+
39
+ # Image selection
40
+ # ===============
41
+ # These can be passed to get_image().
42
+ # Ratios are sorted from large-ish to small-ish.
43
+
44
+ RATIOS_SQUARE = ('sqr', 'sqs', 'cvr')
45
+ RATIOS_16_9 = ('wsr', 'wss')
46
+ RATIOS_2_1 = ('lsr', 'lss')
47
+ RATIOS_3_1 = ('pnr',)
48
+
49
+ SIZES_FROM_SMALLEST = ('xs', 'sm', 'md', 'lg', 'xl')
50
+ SIZES_FROM_LARGEST = tuple(reversed(SIZES_FROM_SMALLEST))
51
+
52
+ # Tags
53
+ # ====
54
+ # These are found in Category.tags or Media.tags.
55
+
56
+ # Used by jw.org for subcategories with a Play button.
57
+ TAG_ALLOW_PLAY_ALL_AS_ICONS_IN_GRID = 'AllowPlayAllAsIconsInGrid'
58
+ TAG_ALLOW_PLAY_ALL_IN_CATEGORY_HEADER = 'AllowPlayAllInCategoryHeader'
59
+
60
+ # Used by jw.org for subcategories with a Shuffle button.
61
+ TAG_ALLOW_SHUFFLE_AS_ICONS_IN_GRID = 'AllowShuffleAsIconsInGrid'
62
+ TAG_ALLOW_SHUFFLE_IN_CATEGORY_HEADER = 'AllowShuffleInCategoryHeader'
63
+
64
+ # Convention release media item (excluded automatically by many client types).
65
+ TAG_CONVENTION_RELEASE = 'ConventionRelease'
66
+
67
+ # Note:
68
+ # You normally don't have to check for the EXCLUDE tags.
69
+ # For example, if your client type is CLIENT_APPLETV it will automatically
70
+ # exclude items tagged TAG_EXCLUDE_APPLETV.
71
+
72
+ TAG_EXCLUDE_ALL_VIDEOS = 'AllVideosExclude'
73
+ TAG_EXCLUDE_APPLETV = 'AppleTVExclude'
74
+ TAG_EXCLUDE_FIRETV = 'FireTVExclude'
75
+ TAG_EXCLUDE_FROM_BREADCRUMBS = 'ExcludeFromBreadcrumbs'
76
+ TAG_EXCLUDE_JWL = 'JWLExclude'
77
+ TAG_EXCLUDE_JWL_CATALOG = 'JWLCatalogExclude'
78
+ TAG_EXCLUDE_JWORG = 'JWORGExclude'
79
+ TAG_EXCLUDE_LATEST = 'LatestVideosExclude'
80
+ TAG_EXCLUDE_LIBRARY = 'LibraryVideosExclude'
81
+ TAG_EXCLUDE_ROKU = 'RokuExclude'
82
+ TAG_EXCLUDE_RWLS = 'RWLSExclude'
83
+ TAG_EXCLUDE_SATELLITE = 'SatelliteExclude'
84
+ TAG_EXCLUDE_SEARCH = 'SearchExclude'
85
+ TAG_EXCLUDE_WEB = 'WebExclude'
86
+ TAG_EXCLUDE_WWW = 'WWWExclude'
87
+ TAG_EXCLUDE_WWW_CAT_LIST = 'WWWCatListExclude'
88
+
89
+ # Used by StudioFeatured.
90
+ TAG_FEATURED = 'WebFeatured'
91
+
92
+ TAG_INCLUDE_IN_JWORG_ALL_VIDEOS_CAT_LIST = 'IncludeInJWORGAllVideosCatList'
93
+
94
+ TAG_INCLUDE_SUB_CATEGORIES_NAV_RWLS = 'RWLSIncludeSubCategoriesAsNav'
95
+ TAG_INCLUDE_SUB_CATEGORIES_NAV_WEB = 'WebIncludeSubCategoriesInNav'
96
+ TAG_INCLUDE_SUB_CATEGORIES_NAV_WWW = 'WWWIncludeSubCategoriesAsNav'
97
+
98
+ TAG_PNR_FEATURED_LAYOUT = 'PNRFeaturedLayout'
99
+
100
+ # Used by audio categories, since the album covers are square.
101
+ TAG_PREFER_SQUARE_IMAGES = 'PreferSquareImages'
102
+
103
+ TAG_ROKU_CATEGORY_CAROUSEL_LIST = 'RokuCategoryCarouselList'
104
+ TAG_ROKU_CATEGORY_GRID = 'RokuCategoryGrid'
105
+ TAG_ROKU_CATEGORY_GRID_SCREEN = 'RokuCategoryGridScreen'
106
+ TAG_ROKU_CATEGORY_SELECTION_POSTER_SCREEN = 'RokuCategorySelectionPosterScreen'
107
+ TAG_ROKU_GRID_STYLE_SQUARE = 'RokuGridStyleSquare'
108
+ TAG_ROKU_MEDIA_ITEM_LIST_SCREEN = 'RokuMediaItemListScreen'
109
+
110
+ # Used by jw.org for main categories with a Shuffle button.
111
+ TAG_STREAM_THIS_CHANNEL_ENABLED = 'StreamThisChannelEnabled'
112
+
113
+ TAG_SUPPRESS_TOP_CATEGORY_BANNER = 'SuppressTopCategoryBanner'
114
+
115
+ # Limit number of items in the list.
116
+ TAGS_ITEM_LIMIT = (
117
+ 'LimitToZero',
118
+ 'LimitToOne', # Used by FeaturedLibraryVideos.
119
+ 'LimitToTwo', # Used by StudioFeatured.
120
+ 'LimitToThree',
121
+ 'LimitToFour',
122
+ 'LimitToFive', # Used by FeaturedSetTopBoxes.
123
+ 'LimitToSix',
124
+ 'LimitToSeven',
125
+ 'LimitToEight',
126
+ 'LimitToNine',
127
+ 'LimitToTen',
128
+ )
129
+
130
+ # Month of release.
131
+ TAGS_MONTH = (
132
+ 'Month01',
133
+ 'Month02',
134
+ 'Month03',
135
+ 'Month04',
136
+ 'Month05',
137
+ 'Month06',
138
+ 'Month07',
139
+ 'Month08',
140
+ 'Month09',
141
+ 'Month10',
142
+ 'Month11',
143
+ 'Month12')
@@ -0,0 +1,120 @@
1
+ """
2
+ Functions for the different API endpoints
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from ..common import NotFoundError, _get_json
7
+ from .const import CATEGORY_CONTAINER, CLIENT_NONE, ROOT_CATEGORY
8
+
9
+ _API_BASE = 'https://b.jw-cdn.org/apis/mediator/v1'
10
+
11
+
12
+ def _request_category_data(language: str, key: str, *, client: str, include_media: bool, media_list_offset=0) -> dict:
13
+ """Request category data from the server"""
14
+
15
+ # If this is called for ROOT_CATEGORY it means we're trying to refresh() it
16
+ if key == ROOT_CATEGORY:
17
+ return _root_category_dict(subcategories=_request_top_categories(language, client))
18
+
19
+ query = {
20
+ 'clientType': client if client != CLIENT_NONE else None,
21
+ # detailed controls whether subcategories will be included in the response
22
+ # None means no, anything else means yes
23
+ 'detailed': 1,
24
+ # offset controls at which index the media list will start
25
+ # this is useful in case the total length is larger than 'limit'
26
+ 'offset': (media_list_offset or None) if include_media else None,
27
+ # limit controls the max length of the media list
28
+ # None means the server will decide
29
+ 'limit': None if include_media else 0,
30
+ # mediaLimit controls the max length of the media list inside subcategories
31
+ # None means the server will decide
32
+ 'mediaLimit': None if include_media else 0,
33
+ }
34
+ try:
35
+ response = _get_json(f'{_API_BASE}/categories/{language}/{key}', query)
36
+ category_data = response['category']
37
+ if not isinstance(category_data, dict):
38
+ raise TypeError(f'expected dict, got {type(category_data)}')
39
+ except (NotFoundError, KeyError, TypeError) as e:
40
+ raise NotFoundError(f'{language}/{key}') from e
41
+
42
+ # Save the total number of media items available, it may be used later for pagination
43
+ # (this is only available for type 'ondemand' categories)
44
+ try:
45
+ category_data['_paginationTotalCount'] = response['pagination']['totalCount']
46
+ except (KeyError, TypeError):
47
+ pass
48
+
49
+ # If we requested no media items for subcategories, we must make the subcategories aware of this
50
+ # so that calls to get_media() doesn't think there is actually no media.
51
+ if include_media is False:
52
+ for subcategory_data in category_data.get('subcategories', []):
53
+ try:
54
+ subcategory_data['_paginationLimit'] = 0
55
+ except TypeError:
56
+ pass
57
+
58
+ return category_data
59
+
60
+
61
+ def _request_top_categories(language: str, client: str) -> list[dict]:
62
+ """Request list of top-level categories from the server"""
63
+
64
+ # Never call this with detailed=1
65
+ # It results in 'subcategories': {} which is a TypeError (should be a list)
66
+ # This is a bug on the server side
67
+ query = {'clientType': client if client != CLIENT_NONE else None}
68
+ try:
69
+ response = _get_json(f'{_API_BASE}/categories/{language}', query)
70
+ top_level_list = response['categories']
71
+ if not isinstance(top_level_list, list):
72
+ raise TypeError(f'expected list, got {type(top_level_list)}')
73
+ except (NotFoundError, KeyError, TypeError) as e:
74
+ raise NotFoundError(f'{language}') from e
75
+ return top_level_list
76
+
77
+
78
+ def _root_category_dict(**kwargs) -> dict:
79
+ """Return a dict with root category data
80
+
81
+ The server has no 'root' category, it's just a list of top-level categories. But we make one to
82
+ make things more convenient and also give it a 'key' to not produce empty values for templates.
83
+
84
+ We use a function return a unique object each time, instead of just having this as a global constant,
85
+ since dicts are mutable that could (and have) messed things up during testing.
86
+ """
87
+ return dict(
88
+ description='Top-level category of all video and audio categories',
89
+ images={},
90
+ key=ROOT_CATEGORY,
91
+ name='All Categories',
92
+ tags=[],
93
+ type=CATEGORY_CONTAINER,
94
+ **kwargs
95
+ )
96
+
97
+
98
+ def _request_media_data(language: str, key: str, *, client: str) -> dict:
99
+ """Request media data from the server"""
100
+
101
+ try:
102
+ query = {'clientType': client if client != CLIENT_NONE else None}
103
+ response = _get_json(f'{_API_BASE}/media-items/{language}/{key}', query)
104
+ media_data = response['media'][0]
105
+ # It seems to return HTTP 200 with a response of [] if the item doesn't exist...
106
+ except (NotFoundError, TypeError, KeyError, IndexError) as e:
107
+ raise NotFoundError(f'{language}/{key}') from e
108
+ return media_data
109
+
110
+
111
+ def _request_languages(language: str) -> list[dict]:
112
+ """Request list of language data from the server"""
113
+
114
+ return _get_json(f'{_API_BASE}/languages/{language}/web')['languages']
115
+
116
+
117
+ def request_translations(language: str) -> dict:
118
+ """Return a dict of string IDs and translated string used at the website"""
119
+
120
+ return _get_json(f'{_API_BASE}/translations/{language}')['translations'][language]
@@ -0,0 +1,212 @@
1
+ """Script that generates an image format table.
2
+
3
+ The values from the table can be fed into :meth:`~jwlib.media.Media.get_image`.
4
+
5
+ .. rubric:: Category images
6
+
7
+ +---------+--------------+---------------+--------------+-----------------------------+
8
+ | ratio | dimensions | ratio alias | size alias | available for client type |
9
+ +=========+==============+===============+==============+=============================+
10
+ | 1:1 | 50x50 | sqs | xs | none, roku |
11
+ +---------+--------------+---------------+--------------+-----------------------------+
12
+ | 1:1 | 75x75 | sqs | sm | none, roku |
13
+ +---------+--------------+---------------+--------------+-----------------------------+
14
+ | 1:1 | 100x100 | sqr | xs | none, roku |
15
+ +---------+--------------+---------------+--------------+-----------------------------+
16
+ | 1:1 | 120x120 | sqr | sm | none, roku |
17
+ +---------+--------------+---------------+--------------+-----------------------------+
18
+ | 1:1 | 125x125 | sqs | md | none |
19
+ +---------+--------------+---------------+--------------+-----------------------------+
20
+ | 1:1 | 200x200 | sqs | lg | none |
21
+ +---------+--------------+---------------+--------------+-----------------------------+
22
+ | 1:1 | 224x224 | sqr | md | none, roku |
23
+ +---------+--------------+---------------+--------------+-----------------------------+
24
+ | 1:1 | 342x342 | sqr | lg | none, roku |
25
+ +---------+--------------+---------------+--------------+-----------------------------+
26
+ | 1:1 | 600x600 | sqr | xl | none |
27
+ +---------+--------------+---------------+--------------+-----------------------------+
28
+ | 269:152 | 269x152 | wsr | xs | none, roku |
29
+ +---------+--------------+---------------+--------------+-----------------------------+
30
+ | 16:9 | 160x90 | wss | xs | none, roku |
31
+ +---------+--------------+---------------+--------------+-----------------------------+
32
+ | 16:9 | 320x180 | wss | sm | firetv, none, roku, www |
33
+ +---------+--------------+---------------+--------------+-----------------------------+
34
+ | 16:9 | 640x360 | wss | lg | appletv, none |
35
+ +---------+--------------+---------------+--------------+-----------------------------+
36
+ | 16:9 | 640x360 | wsr | sm | none, roku |
37
+ +---------+--------------+---------------+--------------+-----------------------------+
38
+ | 16:9 | 720x405 | wsr | md | none |
39
+ +---------+--------------+---------------+--------------+-----------------------------+
40
+ | 16:9 | 1280x720 | wsr | lg | none, roku |
41
+ +---------+--------------+---------------+--------------+-----------------------------+
42
+ | 3:1 | 240x80 | pnr | xs | none, roku |
43
+ +---------+--------------+---------------+--------------+-----------------------------+
44
+ | 3:1 | 480x160 | pnr | sm | none, roku |
45
+ +---------+--------------+---------------+--------------+-----------------------------+
46
+ | 3:1 | 801x267 | pnr | md | none, roku |
47
+ +---------+--------------+---------------+--------------+-----------------------------+
48
+ | 3:1 | 1200x400 | pnr | lg | appletv, none, roku, www |
49
+ +---------+--------------+---------------+--------------+-----------------------------+
50
+
51
+ .. rubric:: Media images
52
+
53
+ +---------+--------------+---------------+--------------+----------------------------------+
54
+ | ratio | dimensions | ratio alias | size alias | available for client type |
55
+ +=========+==============+===============+==============+==================================+
56
+ | 1:1 | 50x50 | sqs | xs | roku |
57
+ +---------+--------------+---------------+--------------+----------------------------------+
58
+ | 1:1 | 100x100 | sqr | xs | roku |
59
+ +---------+--------------+---------------+--------------+----------------------------------+
60
+ | 1:1 | 120x120 | sqr | sm | roku |
61
+ +---------+--------------+---------------+--------------+----------------------------------+
62
+ | 1:1 | 160x160 | cvr | xs | roku |
63
+ +---------+--------------+---------------+--------------+----------------------------------+
64
+ | 1:1 | 224x224 | sqr | md | appletv, none, roku |
65
+ +---------+--------------+---------------+--------------+----------------------------------+
66
+ | 1:1 | 342x342 | sqr | lg | firetv, roku, www |
67
+ +---------+--------------+---------------+--------------+----------------------------------+
68
+ | 269:152 | 269x152 | wsr | xs | roku |
69
+ +---------+--------------+---------------+--------------+----------------------------------+
70
+ | 16:9 | 160x90 | wss | xs | roku |
71
+ +---------+--------------+---------------+--------------+----------------------------------+
72
+ | 16:9 | 320x180 | wss | sm | appletv, none, roku, www |
73
+ +---------+--------------+---------------+--------------+----------------------------------+
74
+ | 16:9 | 640x360 | wss | lg | appletv, firetv, none, www |
75
+ +---------+--------------+---------------+--------------+----------------------------------+
76
+ | 16:9 | 640x360 | wsr | sm | roku |
77
+ +---------+--------------+---------------+--------------+----------------------------------+
78
+ | 16:9 | 1280x720 | wsr | lg | roku |
79
+ +---------+--------------+---------------+--------------+----------------------------------+
80
+ | 2:1 | 760x380 | lss | lg | www |
81
+ +---------+--------------+---------------+--------------+----------------------------------+
82
+ | 2:1 | 1200x600 | lsr | xl | none, www |
83
+ +---------+--------------+---------------+--------------+----------------------------------+
84
+ | 3:1 | 240x80 | pnr | xs | roku |
85
+ +---------+--------------+---------------+--------------+----------------------------------+
86
+ | 3:1 | 480x160 | pnr | sm | roku |
87
+ +---------+--------------+---------------+--------------+----------------------------------+
88
+ | 3:1 | 801x267 | pnr | md | roku |
89
+ +---------+--------------+---------------+--------------+----------------------------------+
90
+ | 3:1 | 1200x400 | pnr | lg | appletv, firetv, none, roku, www |
91
+ +---------+--------------+---------------+--------------+----------------------------------+
92
+
93
+ You can generate an up-to-date version by running::
94
+
95
+ python -m jwlib.media.imagetable [CLIENT_TYPE] ...
96
+
97
+ .. note::
98
+ This requires `Pillow` and `tabulate` to be installed.
99
+ """
100
+ import sys
101
+ from fractions import Fraction
102
+ from typing import Dict, NamedTuple, Set
103
+ from urllib.request import urlopen
104
+
105
+ from .const import CLIENT_APPLETV, CLIENT_FIRETV, CLIENT_NONE, CLIENT_ROKU, CLIENT_WWW
106
+ from .session import Session
107
+
108
+ __all__ = (
109
+ 'generate_image_table',
110
+ )
111
+
112
+
113
+ def generate_image_table(*client_types: str) -> None:
114
+ # We import these here so Sphinx doesn't need to install them
115
+ # to generate the documentation
116
+ from PIL import Image
117
+ from tabulate import tabulate
118
+
119
+ if not client_types:
120
+ client_types = CLIENT_NONE, CLIENT_APPLETV, CLIENT_FIRETV, CLIENT_ROKU, CLIENT_WWW
121
+
122
+ class ImageType(NamedTuple):
123
+ ratio_alias: str
124
+ size_alias: str
125
+
126
+ class ClientsWhereImageTypeIsAvailable(Set[str]):
127
+ def __str__(self):
128
+ return ', '.join(sorted(self))
129
+
130
+ ClientAvailabilityMap = Dict[ImageType, ClientsWhereImageTypeIsAvailable]
131
+
132
+ class Dimensions(NamedTuple):
133
+ x: int
134
+ y: int
135
+
136
+ @classmethod
137
+ def from_url(cls, url):
138
+ print(url)
139
+ with urlopen(url) as response:
140
+ x, y = Image.open(response).size
141
+ return cls(x, y)
142
+
143
+ def __str__(self):
144
+ return f'{self.x}x{self.y}'
145
+
146
+ def as_fraction(self):
147
+ return Fraction(self.x, self.y)
148
+
149
+ def formatted_ratio(self):
150
+ fraction = self.as_fraction()
151
+ return f'{fraction.numerator}:{fraction.denominator}'
152
+
153
+ dimension_map: Dict[ImageType, Dimensions] = {}
154
+ category_image_availability: ClientAvailabilityMap = {}
155
+ media_image_availability: ClientAvailabilityMap = {}
156
+
157
+ def parse_images(images: dict, availability_map: ClientAvailabilityMap, client: str) -> None:
158
+ for ratio_alias in images:
159
+ for size_alias in images[ratio_alias]:
160
+ url = images[ratio_alias][size_alias]
161
+ if not url:
162
+ continue
163
+ image_type = ImageType(ratio_alias, size_alias)
164
+ if image_type not in dimension_map:
165
+ dimension_map[image_type] = Dimensions.from_url(url)
166
+ if image_type not in availability_map:
167
+ availability_map[image_type] = ClientsWhereImageTypeIsAvailable()
168
+ availability_map[image_type].add(client)
169
+
170
+ for client_name in client_types:
171
+ session = Session(client_type=client_name)
172
+
173
+ cat = session.get_category('VODStudio')
174
+ parse_images(cat.data['images'], category_image_availability, client_name)
175
+
176
+ media = next(next(cat.get_subcategories()).get_media())
177
+ parse_images(media.data['images'], media_image_availability, client_name)
178
+
179
+ headers = ['ratio', 'dimensions', 'ratio alias', 'size alias', 'available for client type']
180
+
181
+ class Row(NamedTuple):
182
+ formatted_ratio: str
183
+ dimensions: Dimensions
184
+ ratio_alias: str
185
+ size_alias: str
186
+ clients: ClientsWhereImageTypeIsAvailable
187
+
188
+ def create_rows(availability_map: ClientAvailabilityMap):
189
+ for image_type, available_clients in availability_map.items():
190
+ dimensions = dimension_map[image_type]
191
+ yield Row(
192
+ formatted_ratio=dimensions.formatted_ratio(),
193
+ dimensions=dimensions,
194
+ ratio_alias=image_type.ratio_alias,
195
+ size_alias=image_type.size_alias,
196
+ clients=available_clients
197
+ )
198
+
199
+ def row_sort_function(row: Row):
200
+ return row.dimensions.as_fraction(), row.dimensions
201
+
202
+ print('\n.. rubric:: Category images')
203
+ rows_category = sorted(create_rows(category_image_availability), key=row_sort_function)
204
+ print(tabulate(rows_category, headers=headers, tablefmt='grid'))
205
+
206
+ print('\n.. rubric:: Media images')
207
+ rows_media = sorted(create_rows(media_image_availability), key=row_sort_function)
208
+ print(tabulate(rows_media, headers=headers, tablefmt='grid'))
209
+
210
+
211
+ if __name__ == '__main__':
212
+ generate_image_table(*sys.argv[1:])
@@ -0,0 +1,65 @@
1
+ """
2
+ Language list and info
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from ..common import _DictWrapper
7
+ from .endpoints import _request_languages
8
+
9
+ __all__ = 'request_languages', 'Language'
10
+
11
+
12
+ def request_languages(language='E') -> list[Language]:
13
+ """Return list of available Languages"""
14
+
15
+ return [Language(L) for L in _request_languages(language)]
16
+
17
+
18
+ class Language(_DictWrapper):
19
+ """Information about a language"""
20
+
21
+ def __repr__(self):
22
+ return f'<{self.__class__.__name__} {self.code!r}>'
23
+
24
+ @property
25
+ def code(self) -> str:
26
+ """JW language code
27
+
28
+ The one that can be passed to :class:`Session` etc.
29
+ """
30
+ return self._get_string('code')
31
+
32
+ @property
33
+ def iso(self) -> str:
34
+ """ISO 639 language code"""
35
+ return self._get_string('locale')
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ """Display name"""
40
+ return self._get_string('name', '')
41
+
42
+ # This seems to always be False
43
+ # @property
44
+ # def pair(self) -> bool:
45
+ # return self.dict.get('isLangPair', False)
46
+
47
+ @property
48
+ def rtl(self) -> bool:
49
+ """True if written right to left"""
50
+ return self.data.get('isRTL', False)
51
+
52
+ @property
53
+ def script(self) -> str:
54
+ """Type of script, like 'ROMAN' or 'CYRILLIC'"""
55
+ return self._get_string('script', '')
56
+
57
+ @property
58
+ def signed(self) -> bool:
59
+ """True if it's a sign language"""
60
+ return self.data.get('isSignLanguage', False)
61
+
62
+ @property
63
+ def vernacular(self) -> str:
64
+ """Display name in the language itself"""
65
+ return self._get_string('vernacular', '')