jwlib 1.0.0b1__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 +2 -0
- jwlib/common.py +85 -0
- jwlib/media/__init__.py +34 -0
- jwlib/media/const.py +143 -0
- jwlib/media/endpoints.py +120 -0
- jwlib/media/imagetable.py +210 -0
- jwlib/media/language.py +65 -0
- jwlib/media/session.py +631 -0
- jwlib/pub.py +375 -0
- jwlib/py.typed +0 -0
- jwlib/search.py +141 -0
- jwlib/weblang.py +58 -0
- jwlib-1.0.0b1.dist-info/METADATA +129 -0
- jwlib-1.0.0b1.dist-info/RECORD +16 -0
- jwlib-1.0.0b1.dist-info/WHEEL +4 -0
- jwlib-1.0.0b1.dist-info/licenses/LICENSE +674 -0
jwlib/__init__.py
ADDED
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
|
jwlib/media/__init__.py
ADDED
|
@@ -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')
|
jwlib/media/endpoints.py
ADDED
|
@@ -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,210 @@
|
|
|
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
|
+
| ratio | dimensions | ratio alias | size alias | available for client type |
|
|
8
|
+
+=========+==============+===============+==============+=============================+
|
|
9
|
+
| 1:1 | 50x50 | sqs | xs | none, roku |
|
|
10
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
11
|
+
| 1:1 | 75x75 | sqs | sm | none, roku |
|
|
12
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
13
|
+
| 1:1 | 100x100 | sqr | xs | none, roku |
|
|
14
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
15
|
+
| 1:1 | 120x120 | sqr | sm | none, roku |
|
|
16
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
17
|
+
| 1:1 | 125x125 | sqs | md | none |
|
|
18
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
19
|
+
| 1:1 | 200x200 | sqs | lg | none |
|
|
20
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
21
|
+
| 1:1 | 224x224 | sqr | md | none, roku |
|
|
22
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
23
|
+
| 1:1 | 342x342 | sqr | lg | none, roku |
|
|
24
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
25
|
+
| 1:1 | 600x600 | sqr | xl | none |
|
|
26
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
27
|
+
| 269:152 | 269x152 | wsr | xs | none, roku |
|
|
28
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
29
|
+
| 16:9 | 160x90 | wss | xs | none, roku |
|
|
30
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
31
|
+
| 16:9 | 320x180 | wss | sm | firetv, none, roku, www |
|
|
32
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
33
|
+
| 16:9 | 640x360 | wss | lg | appletv, none |
|
|
34
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
35
|
+
| 16:9 | 640x360 | wsr | sm | none, roku |
|
|
36
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
37
|
+
| 16:9 | 720x405 | wsr | md | none |
|
|
38
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
39
|
+
| 16:9 | 1280x720 | wsr | lg | none, roku |
|
|
40
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
41
|
+
| 3:1 | 240x80 | pnr | xs | none, roku |
|
|
42
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
43
|
+
| 3:1 | 480x160 | pnr | sm | none, roku |
|
|
44
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
45
|
+
| 3:1 | 801x267 | pnr | md | none, roku |
|
|
46
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
47
|
+
| 3:1 | 1200x400 | pnr | lg | appletv, none, roku, www |
|
|
48
|
+
+---------+--------------+---------------+--------------+-----------------------------+
|
|
49
|
+
|
|
50
|
+
.. rubric:: Media images
|
|
51
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
52
|
+
| ratio | dimensions | ratio alias | size alias | available for client type |
|
|
53
|
+
+=========+==============+===============+==============+==================================+
|
|
54
|
+
| 1:1 | 50x50 | sqs | xs | roku |
|
|
55
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
56
|
+
| 1:1 | 100x100 | sqr | xs | roku |
|
|
57
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
58
|
+
| 1:1 | 120x120 | sqr | sm | roku |
|
|
59
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
60
|
+
| 1:1 | 160x160 | cvr | xs | roku |
|
|
61
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
62
|
+
| 1:1 | 224x224 | sqr | md | appletv, none, roku |
|
|
63
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
64
|
+
| 1:1 | 342x342 | sqr | lg | firetv, roku, www |
|
|
65
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
66
|
+
| 269:152 | 269x152 | wsr | xs | roku |
|
|
67
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
68
|
+
| 16:9 | 160x90 | wss | xs | roku |
|
|
69
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
70
|
+
| 16:9 | 320x180 | wss | sm | appletv, none, roku, www |
|
|
71
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
72
|
+
| 16:9 | 640x360 | wss | lg | appletv, firetv, none, www |
|
|
73
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
74
|
+
| 16:9 | 640x360 | wsr | sm | roku |
|
|
75
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
76
|
+
| 16:9 | 1280x720 | wsr | lg | roku |
|
|
77
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
78
|
+
| 2:1 | 760x380 | lss | lg | www |
|
|
79
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
80
|
+
| 2:1 | 1200x600 | lsr | xl | none, www |
|
|
81
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
82
|
+
| 3:1 | 240x80 | pnr | xs | roku |
|
|
83
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
84
|
+
| 3:1 | 480x160 | pnr | sm | roku |
|
|
85
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
86
|
+
| 3:1 | 801x267 | pnr | md | roku |
|
|
87
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
88
|
+
| 3:1 | 1200x400 | pnr | lg | appletv, firetv, none, roku, www |
|
|
89
|
+
+---------+--------------+---------------+--------------+----------------------------------+
|
|
90
|
+
|
|
91
|
+
You can generate an up-to-date version by running::
|
|
92
|
+
|
|
93
|
+
python -m jwlib.media.imagetable [CLIENT_TYPE] ...
|
|
94
|
+
|
|
95
|
+
.. note::
|
|
96
|
+
This requires `Pillow` and `tabulate` to be installed.
|
|
97
|
+
"""
|
|
98
|
+
import sys
|
|
99
|
+
from fractions import Fraction
|
|
100
|
+
from typing import Dict, NamedTuple, Set
|
|
101
|
+
from urllib.request import urlopen
|
|
102
|
+
|
|
103
|
+
from .const import CLIENT_APPLETV, CLIENT_FIRETV, CLIENT_NONE, CLIENT_ROKU, CLIENT_WWW
|
|
104
|
+
from .session import Session
|
|
105
|
+
|
|
106
|
+
__all__ = (
|
|
107
|
+
'generate_image_table',
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def generate_image_table(*client_types: str) -> None:
|
|
112
|
+
# We import these here so Sphinx doesn't need to install them
|
|
113
|
+
# to generate the documentation
|
|
114
|
+
from PIL import Image
|
|
115
|
+
from tabulate import tabulate
|
|
116
|
+
|
|
117
|
+
if not client_types:
|
|
118
|
+
client_types = CLIENT_NONE, CLIENT_APPLETV, CLIENT_FIRETV, CLIENT_ROKU, CLIENT_WWW
|
|
119
|
+
|
|
120
|
+
class ImageType(NamedTuple):
|
|
121
|
+
ratio_alias: str
|
|
122
|
+
size_alias: str
|
|
123
|
+
|
|
124
|
+
class ClientsWhereImageTypeIsAvailable(Set[str]):
|
|
125
|
+
def __str__(self):
|
|
126
|
+
return ', '.join(sorted(self))
|
|
127
|
+
|
|
128
|
+
ClientAvailabilityMap = Dict[ImageType, ClientsWhereImageTypeIsAvailable]
|
|
129
|
+
|
|
130
|
+
class Dimensions(NamedTuple):
|
|
131
|
+
x: int
|
|
132
|
+
y: int
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_url(cls, url):
|
|
136
|
+
print(url)
|
|
137
|
+
with urlopen(url) as response:
|
|
138
|
+
x, y = Image.open(response).size
|
|
139
|
+
return cls(x, y)
|
|
140
|
+
|
|
141
|
+
def __str__(self):
|
|
142
|
+
return f'{self.x}x{self.y}'
|
|
143
|
+
|
|
144
|
+
def as_fraction(self):
|
|
145
|
+
return Fraction(self.x, self.y)
|
|
146
|
+
|
|
147
|
+
def formatted_ratio(self):
|
|
148
|
+
fraction = self.as_fraction()
|
|
149
|
+
return f'{fraction.numerator}:{fraction.denominator}'
|
|
150
|
+
|
|
151
|
+
dimension_map: Dict[ImageType, Dimensions] = {}
|
|
152
|
+
category_image_availability: ClientAvailabilityMap = {}
|
|
153
|
+
media_image_availability: ClientAvailabilityMap = {}
|
|
154
|
+
|
|
155
|
+
def parse_images(images: dict, availability_map: ClientAvailabilityMap, client: str) -> None:
|
|
156
|
+
for ratio_alias in images:
|
|
157
|
+
for size_alias in images[ratio_alias]:
|
|
158
|
+
url = images[ratio_alias][size_alias]
|
|
159
|
+
if not url:
|
|
160
|
+
continue
|
|
161
|
+
image_type = ImageType(ratio_alias, size_alias)
|
|
162
|
+
if image_type not in dimension_map:
|
|
163
|
+
dimension_map[image_type] = Dimensions.from_url(url)
|
|
164
|
+
if image_type not in availability_map:
|
|
165
|
+
availability_map[image_type] = ClientsWhereImageTypeIsAvailable()
|
|
166
|
+
availability_map[image_type].add(client)
|
|
167
|
+
|
|
168
|
+
for client_name in client_types:
|
|
169
|
+
session = Session(client_type=client_name)
|
|
170
|
+
|
|
171
|
+
cat = session.get_category('VODStudio')
|
|
172
|
+
parse_images(cat.data['images'], category_image_availability, client_name)
|
|
173
|
+
|
|
174
|
+
media = next(next(cat.get_subcategories()).get_media())
|
|
175
|
+
parse_images(media.data['images'], media_image_availability, client_name)
|
|
176
|
+
|
|
177
|
+
headers = ['ratio', 'dimensions', 'ratio alias', 'size alias', 'available for client type']
|
|
178
|
+
|
|
179
|
+
class Row(NamedTuple):
|
|
180
|
+
formatted_ratio: str
|
|
181
|
+
dimensions: Dimensions
|
|
182
|
+
ratio_alias: str
|
|
183
|
+
size_alias: str
|
|
184
|
+
clients: ClientsWhereImageTypeIsAvailable
|
|
185
|
+
|
|
186
|
+
def create_rows(availability_map: ClientAvailabilityMap):
|
|
187
|
+
for image_type, available_clients in availability_map.items():
|
|
188
|
+
dimensions = dimension_map[image_type]
|
|
189
|
+
yield Row(
|
|
190
|
+
formatted_ratio=dimensions.formatted_ratio(),
|
|
191
|
+
dimensions=dimensions,
|
|
192
|
+
ratio_alias=image_type.ratio_alias,
|
|
193
|
+
size_alias=image_type.size_alias,
|
|
194
|
+
clients=available_clients
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def row_sort_function(row: Row):
|
|
198
|
+
return row.dimensions.as_fraction(), row.dimensions
|
|
199
|
+
|
|
200
|
+
print('\n.. rubric:: Category images')
|
|
201
|
+
rows_category = sorted(create_rows(category_image_availability), key=row_sort_function)
|
|
202
|
+
print(tabulate(rows_category, headers=headers, tablefmt='grid'))
|
|
203
|
+
|
|
204
|
+
print('\n.. rubric:: Media images')
|
|
205
|
+
rows_media = sorted(create_rows(media_image_availability), key=row_sort_function)
|
|
206
|
+
print(tabulate(rows_media, headers=headers, tablefmt='grid'))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == '__main__':
|
|
210
|
+
generate_image_table(*sys.argv[1:])
|
jwlib/media/language.py
ADDED
|
@@ -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', '')
|