Skip to content

Commit e9eef09

Browse files
authored
[plugin.video.drnu] 6.6.0 (#4666)
1 parent a305404 commit e9eef09

7 files changed

Lines changed: 159 additions & 98 deletions

File tree

plugin.video.drnu/addon.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<addon id="plugin.video.drnu" version="6.5.0" name="DR TV" provider-name="TermeHansen">
2+
<addon id="plugin.video.drnu" version="6.6.0" name="DR TV" provider-name="TermeHansen">
33
<requires>
44
<import addon="xbmc.python" version="3.0.1"/>
55
<import addon="script.module.dateutil" version="2.8.2" />
@@ -31,6 +31,11 @@
3131
<screenshot>resources/media/Screenshot3.jpg</screenshot>
3232
<screenshot>resources/media/Screenshot4.jpg</screenshot>
3333
</assets>
34+
<news>[B]Version 6.6.0 - 2025-05-09[/B]
35+
- Fix change in drtv login flow
36+
- Use oidc auth flow
37+
- Add new setting for subtitles on livetv
38+
</news>
3439
<news>[B]Version 6.5.0 - 2024-01-22[/B]
3540
- Fix "IOException error" and HTTP timeouts.
3641
- Better output during re-caching.

plugin.video.drnu/changelog.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
[B]Version 6.6.0 - 2025-05-09[/B]
2+
- Fix change in drtv login flow
3+
- Use oidc auth flow
4+
- Add new setting for subtitles on livetv
5+
16
[B]Version 6.5.0 - 2024-01-22[/B]
27
- Fix "IOException error" and HTTP timeouts.
38
- Better output during re-caching.

plugin.video.drnu/resources/language/resource.language.da_dk/strings.po

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ msgctxt "#30207"
119119
msgid "Enable Hard-of-hearing subtitles"
120120
msgstr "Vis undertekster for hørehæmmedde"
121121

122+
msgctxt "#30208"
123+
msgid "Enable Live tv subtitles"
124+
msgstr "Vis undertekster på Live tv"
125+
122126
msgctxt "#30213"
123127
msgid "Clear the favorite programs list"
124128
msgstr "Nulstil listen med foretrukne programmer"

plugin.video.drnu/resources/language/resource.language.en_gb/strings.po

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ msgctxt "#30207"
120120
msgid "Enable Hard-of-hearing subtitles"
121121
msgstr ""
122122

123+
msgctxt "#30208"
124+
msgid "Enable Live tv subtitles"
125+
msgstr ""
126+
123127
msgctxt "#30208"
124128
msgid "Remove programs from Ramasjang/Ultra in alphabetical lists"
125129
msgstr ""

plugin.video.drnu/resources/lib/addon.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ def getIptvLiveChannels(self):
239239

240240
iptv_channel = {
241241
'name': api_channel['title'],
242-
'stream': self.api.get_channel_url(api_channel, bool_setting('enable.subtitles')),
242+
'stream': self.api.get_channel_url(api_channel, bool_setting('enable.livetv_subtitles')),
243243
'logo': api_channel['item']['images']['logo'],
244244
'id': 'drnu.' + api_channel['item']['id'],
245245
'preset': tvapi.CHANNEL_PRESET[api_channel['title']]
@@ -286,7 +286,7 @@ def showLiveTV(self):
286286
'icon': channel['item']['images']['logo'],
287287
'fanart': channel['item']['images']['logo']})
288288
item.addContextMenuItems(self.menuItems, False)
289-
url = self.api.get_channel_url(channel, bool_setting('enable.subtitles'))
289+
url = self.api.get_channel_url(channel, bool_setting('enable.livetv_subtitles'))
290290
item.setInfo('video', {
291291
'title': channel['title'],
292292
'plot': channel['schedule_str'],

plugin.video.drnu/resources/lib/tvapi.py

Lines changed: 135 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,20 @@
3232
from urllib.parse import urlparse, parse_qs, parse_qsl, urlencode
3333
from requests.adapters import HTTPAdapter
3434
from urllib3.util import Retry
35+
import secrets
36+
import base64
3537

3638
CHANNEL_IDS = [20875, 20876, 192099, 192100, 20892]
3739
CHANNEL_PRESET = {
3840
'DR1': 1,
3941
'DR2': 2,
4042
'DR Ramasjang': 3,
41-
'DRTV': 4,
43+
'TVA Live': 4,
4244
'DRTV Ekstra': 5
4345
}
4446
URL = 'https://production.dr-massive.com/api'
47+
URL2 = 'https://prod95.dr-massive.com/api'
48+
CLIENT_ID = "283ba39a2cf31d3b81e922b8"
4549
GET_TIMEOUT = 10
4650

4751

@@ -66,68 +70,106 @@ def fix_query(url, remove={}, add={}, remove_keys=[]):
6670
return o._replace(query=urlencode(qs)).geturl()
6771

6872

73+
def generate_code_verifier(length: int = 64) -> str:
74+
# Generate a secure random string (length between 43 and 128 chars)
75+
return secrets.token_urlsafe(length)[:length]
76+
77+
78+
def generate_code_challenge(code_verifier: str) -> str:
79+
# SHA256 hash of the verifier, then base64-url encode without padding
80+
sha256 = hashlib.sha256(code_verifier.encode()).digest()
81+
return base64.urlsafe_b64encode(sha256).decode().rstrip('=')
82+
83+
6984
def full_login(user, password):
7085
ses = requests.Session()
86+
7187
# start login flow
88+
code_verifier = generate_code_verifier()
89+
code_challenge = generate_code_challenge(code_verifier)
90+
7291
params = {
73-
'clientRedirectUrl':'https://www.dr.dk/drtv/callback',
74-
'signUp': 'false', 'localPath':'/', 'optout':'false', 'device':'web_browser'}
75-
headers = {
76-
'authority': 'production.dr-massive.com',
77-
'User-Agent': "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0",
92+
"client_id": CLIENT_ID,
93+
"code_challenge": code_challenge,
94+
"code_challenge_method": "S256",
95+
"redirect_uri": "https://www.dr.dk/drtv/callback",
96+
"state": f'{{"code_verifier":"{code_verifier}","logonRedirectPath":"/","optout":false}}',
97+
"response_type": "code",
98+
"scope": "openid roles tracking profile email offline_access"
7899
}
79-
url = URL + '/authorization?'
80-
res = ses.get(url, params=params, headers=headers, allow_redirects=True)
81-
100+
res = ses.get('https://login.dr.dk/oidc/authorize', params=params)
82101
if res.status_code != 200:
83102
return {'status_code': res.status_code, 'error': res.text}
84-
client_id = parse_qs(urlparse(res.history[1].url).query)['client_id'][0]
85-
referer = res.history[2].headers['Location']
86-
state = parse_qs(urlparse(referer).query)['state'][0]
87-
88-
# post credentials
89-
headers = {
90-
'authority': 'api.dr.dk',
91-
'User-Agent': "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0",
92-
'content-type': 'application/json',
93-
}
94-
95-
url = 'https://api.dr.dk/login/graphql'
96-
data = {"operationName":"InitializeLoginTransaction","variables":{"input":{"state":state,"clientID":client_id}}, "query":"mutation InitializeLoginTransaction($input: InitializeLoginTransactionInput!) {\n initializeLoginTransaction(input: $input) {\n id\n isValid\n isIdentified\n captcha {\n image\n __typename\n }\n proofOfWork {\n salt\n difficulty\n __typename\n }\n __typename\n }\n}"} # noqa: E501
97103

98-
u = ses.post(url, data=json.dumps(data), headers=headers)
99-
print(1, u.status_code)
100-
if u.status_code != 200:
101-
return {'status_code': u.status_code, 'error': u.text}
102-
transaction_id = u.json()['data']['initializeLoginTransaction']['id']
103-
104-
data = {"operationName":"Identify","variables":{"input":{"transaction":transaction_id,"email":user}},"query":"mutation Identify($input: IdentificationInput\u0021) {\n identify(input: $input) {\n id\n isValid\n isIdentified\n captcha {\n image\n __typename\n }\n proofOfWork {\n salt\n difficulty\n __typename\n }\n __typename\n }\n}"} # noqa: E501
105-
u2 = ses.post(url, data=json.dumps(data), headers=headers)
106-
print(2, u2.status_code)
107-
if u2.status_code != 200:
108-
return {'status_code': u2.status_code, 'error': u2.text}
104+
trans = urlparse(res.url).path.split('/')[-1]
105+
headers = {'content-type': 'application/json'}
106+
107+
transaction_fragment = "fragment useTransactionTransactionFragment on Transaction { ... on AuthenticatedAuthenticationTransaction { id email registration href __typename } ... on UnauthenticatedAuthenticationTransaction { id email __typename } ... on UnverifiedAuthenticationTransaction { id email name __typename } ... on UnrecognizedAuthenticationTransaction { id email statisticsConsentDefinition { id type version locale permissions headline summary body __typename } preferencesConsentDefinition { id type version locale permissions headline summary body __typename } newsletterConsentDefinition { id type version locale permissions headline summary body __typename } __typename } ... on UnidentifiedAuthenticationTransaction { id __typename } ... on CompletedEmailVerificationTransaction { id emailVerificationVariant: variant email __typename } ... on PendingEmailVerificationTransaction { id emailVerificationVariant: variant email __typename } ... on CompletedPasswordChangeTransaction { id passwordChangeVariant: variant __typename } ... on PendingPasswordChangeTransaction { id passwordChangeVariant: variant __typename } ... on PendingDeletionConfirmationTransaction { id __typename } ... on CompletedDeletionConfirmationTransaction { id __typename } ... on SettingsTransaction { id identity { id email name roles __typename } statisticsConsentDefinition { id type version locale permissions headline summary body __typename } preferencesConsentDefinition { id type version locale permissions headline summary body __typename } newsletterConsentDefinition { id type version locale permissions headline summary body __typename } statisticsConsentRevision { id status definition createdAt __typename } preferencesConsentRevision { id status definition createdAt __typename } newsletterConsentRevision { id status definition createdAt __typename } referBackUri referBackName sessionState expiresAt __typename } ... on PendingEUPTransaction { id href __typename } ... on CompletedEUPTransaction { id __typename } __typename }" # noqa: E501
108+
trans_query = "query useTransactionTransactionQuery($id: ID!) { transaction(id: $id) { ... on Node { id __typename } ...useTransactionTransactionFragment __typename } }" + transaction_fragment # noqa: E501
109+
identify_query = "mutation useTransactionIdentificationMutation($input: IdentificationInput!) { identify(input: $input) { ... on Node { id __typename } ... on Error { code message __typename } ...useTransactionTransactionFragment __typename } } " + transaction_fragment # noqa: E501
110+
authenticate_query = "mutation useTransactionAuthenticationMutation($input: AuthenticationInput!) { authenticate(input: $input) { ... on Node { id __typename } ... on Error { code message __typename } ...useTransactionTransactionFragment __typename } } " + transaction_fragment # noqa: E501
109111

110-
data={"variables":{"input":{"email":user,"password":password,"state":state}},"query":"mutation ($input: LoginInput!) {\n token: login(input: $input)\n}"} # noqa: E501
111-
u3 = ses.post(url, data=json.dumps(data), headers=headers)
112-
print(3, u3.status_code)
113-
if u3.status_code != 200:
114-
return {'status_code': u3.status_code, 'error': u3.text}
115-
data = u3.json()['data']
116-
117-
# post login to tokens
118-
headers = {
119-
'authority': 'login.dr.dk',
120-
'User-Agent': "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0",
121-
'content-type': 'application/x-www-form-urlencoded',
112+
trans_data = {
113+
"operationName": "useTransactionTransactionQuery",
114+
"variables": {"id": trans}, "query": trans_query
115+
}
116+
identify_data = {
117+
"operationName": "useTransactionIdentificationMutation",
118+
"variables": {"input": {"transaction": trans, "email": user }}, "query": identify_query
119+
}
120+
authenticate_data = {
121+
"operationName": "useTransactionAuthenticationMutation",
122+
"variables": {"input": {"transaction": trans, "password": password }}, "query": authenticate_query
123+
}
124+
125+
url = 'https://login.dr.dk/graphql'
126+
127+
u1 = ses.post(url, json=trans_data, headers=headers)
128+
print(u1.json())
129+
u2 = ses.post(url, json=identify_data, headers=headers)
130+
print(u2.json())
131+
132+
u3 = ses.post(url, json=authenticate_data, headers=headers)
133+
print(u3.json())
134+
135+
res2 = ses.get(u3.json()['data']['authenticate']['href'])
136+
if res2.status_code != 200:
137+
return {'status_code': res2.status_code, 'error': res2.text}
138+
code = parse_qs(urlparse(res2.url).query)['code'][0]
139+
140+
data = {
141+
"client_id": CLIENT_ID,
142+
"redirect_uri": "https://www.dr.dk/drtv/callback",
143+
"code_verifier": code_verifier, "code": code,
144+
"grant_type": "authorization_code",
145+
}
146+
return oidc_token(data)
147+
148+
149+
def oidc_token(data):
150+
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
151+
res = requests.post('https://login.dr.dk/oidc/token', data=data, headers=headers)
152+
if res.status_code != 200:
153+
return {'status_code': res.status_code, 'error': res.text}
154+
return res.json()
155+
156+
157+
def refresh_token(refresh_token):
158+
data = {"client_id": CLIENT_ID, "refresh_token": refresh_token, "grant_type": "refresh_token"}
159+
return oidc_token(data)
160+
161+
162+
def exchange_token(tokens):
163+
data = {
164+
"accessToken": tokens['access_token'], "identityToken": tokens['id_token'],
165+
"scopes": ["Catalog"], "device": "web_browser", "optout": False,
122166
}
123167

124-
url = 'https://login.dr.dk/oidc/interactions/login/continue'
125-
res = ses.post(url, data=data, headers=headers, allow_redirects=True)
168+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
169+
res = requests.post(URL + '/authorization/exchange', json=data, headers=headers)
126170
if res.status_code != 200:
127171
return {'status_code': res.status_code, 'error': res.text}
128-
o = parse_qs(urlparse(res.url).fragment)
129-
tokens = [o[label][0] for label in ['RefreshableUserAccount', 'RefreshableUserProfile']]
130-
return tokens
172+
return res.json()
131173

132174

133175
def deviceid():
@@ -161,6 +203,7 @@ def __init__(self, cachePath, getLocalizedString, get_setting):
161203
self.init_sqlite_db()
162204

163205
self.token_file = Path(f'{self.cachePath}/token.p')
206+
self.access_tokens = {}
164207
self._user_token = None
165208
self.user = get_setting('drtv_username')
166209
self.password = get_setting('drtv_password')
@@ -193,72 +236,68 @@ def init_sqlite_db(self):
193236
(self.cachePath/'requests_cleaned').write_text(str(datetime.now()))
194237

195238
def read_tokens(self, tokens):
196-
time_str = tokens[0]['expirationDate'].split('.')[0]
239+
if 'value' in tokens[0]:
240+
#old flow, anonymous
241+
time_str = tokens[0]['expirationDate'].split('.')[0]
242+
self._user_token = tokens[0]['value']
243+
self._profile_token = tokens[1]['value']
244+
self._user_name = 'anonymous'
245+
else:
246+
time_str = tokens[0]['Expires'].split('.')[0]
247+
self._user_token = tokens[0]['Token']
248+
self._profile_token = tokens[1]['Token']
249+
197250
try:
198251
self._token_expire = datetime.strptime(time_str + 'Z', '%Y-%m-%dT%H:%M:%S%z')
199252
except Exception:
200253
time_struct = time.strptime(time_str, '%Y-%m-%dT%H:%M:%S')
201254
self._token_expire = datetime(*time_struct[0:6], tzinfo=timezone.utc)
202-
self._user_token = tokens[0]['value']
203-
self._profile_token = tokens[1]['value']
204-
if self.user:
205-
tokens[0]['name'] = self.get_profile()['name']
206-
else:
207-
tokens[0]['name'] = 'anonymous'
208-
self._user_name = tokens[0]['name']
255+
if self.access_tokens:
256+
self._user_name = self.get_profile()['name']
209257

210258
def request_tokens(self):
211259
self._user_token = None
212260
self._profile_token = None
213261

214262
if self.user:
215-
tokens_pure = full_login(self.user, self.password)
216-
if isinstance(tokens_pure, dict):
217-
err = tokens_pure['error']
263+
access_tokens = full_login(self.user, self.password)
264+
if 'error' in access_tokens:
265+
err = access_tokens['error']
218266
return err
219-
tokens = [self.refresh_token(t) for t in tokens_pure]
220-
self.read_tokens(tokens)
267+
self.access_tokens = access_tokens
268+
tokens = exchange_token(access_tokens)
221269
else:
222270
tokens = anonymous_tokens()
223-
self.read_tokens(tokens)
271+
self.read_tokens(tokens)
224272
with self.token_file.open('wb') as fh:
225-
pickle.dump(tokens, fh)
273+
pickle.dump([tokens, self.access_tokens], fh)
226274
return None
227275

228-
def refresh_token(self, token):
229-
url = 'https://production.dr-massive.com/api/authorization/refresh'
230-
params = {'ff':'idp,ldp,rpt', 'supportFallbackToken': True}
231-
headers = {
232-
'Host': 'production.dr-massive.com',
233-
'User-Agent': "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0",
234-
'content-type': 'application/json',
235-
}
236-
data = {
237-
'token': token, 'optout': False
238-
}
239-
res = self.session.post(url, params=params, headers=headers, json=data)
240-
if res.status_code != 200:
241-
return {'status_code': res.status_code, 'error': res.text}
242-
return res.json()
243-
244276
def refresh_tokens(self):
245277
if self._user_token is None:
246278
if self.token_file.exists():
247279
with self.token_file.open('rb') as fh:
248-
self.read_tokens(pickle.load(fh))
249-
else:
250-
err = self.request_tokens()
251-
if err:
252-
raise ApiException(f'Login failed with: "{err}"')
253-
return
254-
if (self._token_expire - datetime.now(timezone.utc)).total_seconds() < 120:
280+
[tokens, self.access_tokens] = pickle.load(fh)
281+
if isinstance(tokens, list):
282+
self.read_tokens(tokens)
283+
284+
if self._user_token is None:
285+
err = self.request_tokens()
286+
if err:
287+
raise ApiException(f'Login failed with: "{err}"')
288+
return
289+
290+
if (self._token_expire - datetime.now(timezone.utc)) < timedelta(hours=10):
255291
failed_refresh = False
256292
tokens = []
257-
for t in [self._user_token, self._profile_token]:
258-
newtoken = self.refresh_token(t)
259-
tokens.append(newtoken)
260-
if 'error' in newtoken:
261-
failed_refresh = True
293+
# oidc flow
294+
access_tokens = refresh_token(self.access_tokens['refresh_token'])
295+
if 'error' in access_tokens:
296+
failed_refresh = True
297+
else:
298+
tokens = exchange_token(access_tokens)
299+
self.access_tokens = access_tokens
300+
262301
if failed_refresh:
263302
self.request_tokens()
264303
err = self.request_tokens()
@@ -267,7 +306,7 @@ def refresh_tokens(self):
267306
else:
268307
self.read_tokens(tokens)
269308
with self.token_file.open('wb') as fh:
270-
pickle.dump(tokens, fh)
309+
pickle.dump([tokens, self.access_tokens], fh)
271310

272311
def user_token(self):
273312
self.refresh_tokens()
@@ -381,7 +420,8 @@ def get_continue(self, use_cache=False):
381420
def get_profile(self, use_cache=False):
382421
url = URL + '/account/profile'
383422
headers = {'X-Authorization': 'Bearer ' + self.profile_token()}
384-
return self._request_get(url, headers=headers, use_cache=use_cache)
423+
params = {"ff": "idp,ldp,rpt", "lang": "da"}
424+
return self._request_get(url, headers=headers, params=params, use_cache=use_cache)
385425

386426
def kids_item(self, item):
387427
if 'classification' in item:

plugin.video.drnu/resources/settings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@
9292
<setting id="enable.subtitles" label="30207" type="boolean">
9393
<level>0</level> <default>false</default> <control type="toggle"/>
9494
</setting>
95+
<setting id="enable.livetv_subtitles" label="30208" type="boolean">
96+
<level>0</level> <default>false</default> <control type="toggle"/>
97+
</setting>
9598
<setting id="enable.localsubtitles" label="30214" type="boolean">
9699
<level>2</level> <default>false</default> <control type="toggle"/>
97100
</setting>

0 commit comments

Comments
 (0)