episoder 0.11.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.
@@ -0,0 +1,174 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ from datetime import datetime, date
19
+ from logging import getLogger
20
+ from re import search
21
+ from typing import Optional
22
+
23
+ from bs4 import BeautifulSoup, element
24
+ from requests import Response, Session
25
+
26
+ from episoder.database import Episode, Show
27
+ from episoder.episoder import Database
28
+ from .parser import Parser
29
+
30
+
31
+ class Epguides(Parser):
32
+ """Parser for episodes from epguides.com"""
33
+
34
+ def __init__(self, session: Session) -> None:
35
+ self._logger = getLogger('Epguides')
36
+ self._session = session
37
+
38
+ def __str__(self) -> str:
39
+ return 'epguides.com parser'
40
+
41
+ def __repr__(self) -> str:
42
+ return 'Epguides()'
43
+
44
+ def accept(self, url: str) -> bool:
45
+ return 'epguides.com/' in url
46
+
47
+ def _guess_encoding(self, response: Response) -> str:
48
+ raw = response.raw.read()
49
+ text = raw.decode('iso-8859-1')
50
+
51
+ if 'charset=iso-8859-1' in text:
52
+ return 'iso-8859-1'
53
+
54
+ return 'utf8'
55
+
56
+ def parse(self, show: Show, db: Database) -> None:
57
+ response = self._session.get(show.url, timeout=10)
58
+ response.encoding = self._guess_encoding(response)
59
+
60
+ for line in response.text.split('\n'):
61
+ self._parse_line(line, show, db)
62
+
63
+ show.updated = datetime.now()
64
+ db.commit()
65
+
66
+ def _html_episode_title(self, soup: BeautifulSoup) -> str:
67
+ cell = soup.find('td', class_='eptitle')
68
+ if isinstance(cell, element.Tag):
69
+ link = cell.find('a')
70
+ if isinstance(link, element.Tag):
71
+ return link.text.strip()
72
+ return ''
73
+
74
+ def _update_show_title(self, show: Show, line: str) -> None:
75
+ # Name of the show
76
+ res = search(r'<title>(.*)</title>', line)
77
+ if res:
78
+ title = res.groups()[0]
79
+ show.name = title.split(' (a ')[0]
80
+
81
+ def _update_show_status(self, show: Show, line: str) -> None:
82
+ # Current status (running / ended)
83
+ res = search(r'<span class="status">(.*)</span>', line)
84
+ if res:
85
+ text = res.groups()[0]
86
+ if 'current' in text:
87
+ show.status = Show.RUNNING
88
+ else:
89
+ show.status = Show.ENDED
90
+ elif search(r'aired.*to.*[\d+]', line):
91
+ show.status = Show.ENDED
92
+
93
+ # Current status in new HTML version
94
+ res = search(r'Status: (.*)<br', line)
95
+ if res:
96
+ text = res.groups()[0]
97
+ if 'ended' in text:
98
+ show.status = Show.ENDED
99
+ else:
100
+ show.status = Show.RUNNING
101
+
102
+ def _get_date(self, val: str) -> date:
103
+ day = val.replace('/', ' ')
104
+ then = datetime.strptime(day, '%d %b %y')
105
+ return then.date()
106
+
107
+ def _load_html_episode(self, line: str) -> Optional[Episode]:
108
+ soup = BeautifulSoup(line, 'html.parser')
109
+ cells = soup.find_all('td', class_='epinfo')
110
+
111
+ # We expect 3 cells, otherwise we refuse to go on
112
+ if len(cells) < 3:
113
+ return None
114
+
115
+ # total episode number
116
+ total_episodes = cells[0].text.split('.', 1)[0]
117
+ try:
118
+ total = int(total_episodes)
119
+ except ValueError:
120
+ # Some shows have specials (numbered separately)
121
+ total = -1
122
+
123
+ # season and episode number
124
+ details = cells[1].text.split('-')
125
+ if len(details) < 2:
126
+ return None
127
+
128
+ [season, epnum] = details
129
+
130
+ # episode title
131
+ title = self._html_episode_title(soup)
132
+
133
+ # original air date
134
+ day = cells[2].text
135
+ if not day:
136
+ # Drop episodes without date
137
+ return None
138
+
139
+ then = self._get_date(day)
140
+
141
+ # prodnum is None, we don't have those
142
+ return Episode(title, season, epnum, then, None, total)
143
+
144
+ def _load_text_episode(self, line: str) -> Optional[Episode]:
145
+ # Known formatting supported by this fine regex:
146
+ # 4. 1-4 19 Jun 02 <a [..]>title</a>
147
+ # 1. 19- 1 01-01 5 Jan 88 <a [..]>title</a>
148
+ # 23 3-05 27/Mar/98 <a [..]>title</a>
149
+ # 65. 17-10 23 Apr 05 <a [..]>title</a>
150
+ # 101. 5-15 09 May 09 <a [..]>title</a>
151
+ # 254. - 5 05-254 15 Jan 92 <a [..]>title</a>
152
+ res = search(r'^ *(\d+)\.? +(\d*)- ?(\d+) +([a-zA-Z0-9-]*)'
153
+ r' +(\d{1,2}[ /][A-Z][a-z]{2}[ /]\d{2}) *<a.*>(.*)</a>',
154
+ line)
155
+
156
+ if not res:
157
+ return None
158
+
159
+ (total, season, epnum, prodnum, day, title) = res.groups()
160
+ then = self._get_date(day)
161
+ return Episode(title, season or 0, epnum, then, prodnum, total)
162
+
163
+ def _parse_line(self, line: str, show: Show, db: Database) -> None:
164
+ self._update_show_title(show, line)
165
+ self._update_show_status(show, line)
166
+
167
+ if search(r'<td class=.epinfo', line):
168
+ episode = self._load_html_episode(line)
169
+ else:
170
+ episode = self._load_text_episode(line)
171
+
172
+ if episode:
173
+ self._logger.debug('Found episode %s', episode.title)
174
+ db.add_episode(episode, show)
@@ -0,0 +1,30 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ from abc import ABC, abstractmethod
19
+
20
+ from episoder.episoder import Database, Show
21
+
22
+
23
+ class Parser(ABC):
24
+ @abstractmethod
25
+ def accept(self, url: str) -> bool:
26
+ """Test if this parser can load the given URL"""
27
+
28
+ @abstractmethod
29
+ def parse(self, show: Show, db: Database) -> None:
30
+ """Load episodes with this parser"""
@@ -0,0 +1,231 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ from abc import ABC, abstractmethod
19
+ from json import dumps
20
+ from logging import getLogger
21
+ from datetime import datetime, date
22
+ from typing import Dict, List, Tuple
23
+
24
+ from requests import Session
25
+
26
+ from episoder.database import Episode, Show
27
+ from episoder.episoder import Database
28
+ from .parser import Parser
29
+
30
+
31
+ class TVDBState(ABC):
32
+ @abstractmethod
33
+ def login(self, key: str) -> None:
34
+ """Authenticate to the backend"""
35
+
36
+ @abstractmethod
37
+ def lookup(self, text: str) -> List[Show]:
38
+ """Find show(s) by name"""
39
+
40
+ @abstractmethod
41
+ def parse(self, show: Show, db: Database) -> None:
42
+ """Load episodes with this parser"""
43
+
44
+
45
+ class InvalidLoginError(Exception):
46
+ pass
47
+
48
+
49
+ class TVDBShowNotFoundError(Exception):
50
+ pass
51
+
52
+
53
+ class TVDBNotLoggedInError(Exception):
54
+ pass
55
+
56
+
57
+ class TVDB(Parser):
58
+ """Parser for episodes from thetvdb.com"""
59
+
60
+ def __init__(self, session: Session, api_key: str) -> None:
61
+ self._session = session
62
+ self._api_key = api_key
63
+ self._state: TVDBState = TVDBOffline(self, session)
64
+
65
+ def __str__(self) -> str:
66
+ return str(self._state)
67
+
68
+ def __repr__(self) -> str:
69
+ return f'TVDB {repr(self._state)}'
70
+
71
+ def lookup(self, text: str) -> List[Show]:
72
+ self._state.login(self._api_key)
73
+ return self._state.lookup(text)
74
+
75
+ def parse(self, show: Show, db: Database) -> None:
76
+ self._state.login(self._api_key)
77
+ self._state.parse(show, db)
78
+
79
+ def change(self, state: TVDBState) -> None:
80
+ self._state = state
81
+
82
+ def accept(self, url: str) -> bool:
83
+ return url.isdigit()
84
+
85
+
86
+ class TVDBOffline(TVDBState):
87
+ def __init__(self, tvdb: TVDB, session: Session) -> None:
88
+ self._tvdb = tvdb
89
+ self._log = getLogger('TVDB (offline)')
90
+ self._session = session
91
+
92
+ def __str__(self) -> str:
93
+ return 'thetvdb.com parser (ready)'
94
+
95
+ def __repr__(self) -> str:
96
+ return '<TVDBOffline>'
97
+
98
+ def _post_login(self, data: Dict[str, str]) -> str:
99
+ url = 'https://api.thetvdb.com/login'
100
+ head = {'Content-type': 'application/json'}
101
+ body = dumps(data).encode('utf8')
102
+ response = self._session.post(url, body, headers=head, timeout=10)
103
+ data = response.json()
104
+
105
+ if response.status_code == 401:
106
+ raise InvalidLoginError(data.get('Error'))
107
+
108
+ self._log.info('Successful login')
109
+ return data['token']
110
+
111
+ def lookup(self, _: str) -> List[Show]:
112
+ raise TVDBNotLoggedInError()
113
+
114
+ def login(self, key: str) -> None:
115
+ body = {'apikey': key}
116
+ token = self._post_login(body)
117
+ self._tvdb.change(TVDBOnline(token, self._session))
118
+
119
+ def parse(self, _: Show, __: Database) -> None:
120
+ raise TVDBNotLoggedInError()
121
+
122
+
123
+ class TVDBOnline(TVDBState):
124
+ def __init__(self, token: str, session: Session) -> None:
125
+ self._token = token
126
+ self._log = getLogger('TVDB (online)')
127
+ self._session = session
128
+
129
+ def __str__(self) -> str:
130
+ return 'thetvdb.com parser (authorized)'
131
+
132
+ def __repr__(self) -> str:
133
+ return '<TVDBOnline>'
134
+
135
+ def _get(self, url: str, params: Dict) -> Dict:
136
+ url = f'https://api.thetvdb.com/{url}'
137
+ head = {'Content-type': 'application/json',
138
+ 'Authorization': f'Bearer {self._token}'}
139
+
140
+ response = self._session.get(url, headers=head, params=params,
141
+ timeout=10)
142
+ data = response.json()
143
+
144
+ if response.status_code == 404:
145
+ raise TVDBShowNotFoundError(data.get('Error'))
146
+
147
+ return data
148
+
149
+ def _get_episodes(self, show: Show,
150
+ page: int) -> Tuple[List[Episode], Dict]:
151
+ show_id = int(show.url)
152
+ opts = {'page': page}
153
+ result = self._get(f'series/{show_id}/episodes', opts)
154
+ return (result.get('data') or [], result.get('links', {}))
155
+
156
+ def lookup(self, text: str) -> List[Show]:
157
+ def mkshow(entry: Dict) -> Show:
158
+ name = entry.get('seriesName')
159
+ url = str(entry.get('id')).encode('utf8').decode('utf8')
160
+ return Show(name, url=url)
161
+
162
+ matches = self._get('search/series', {'name': text})
163
+ return list(map(mkshow, matches.get('data', {})))
164
+
165
+ def login(self, _: str) -> None:
166
+ pass
167
+
168
+ def _fetch_episodes(self, show: Show, page: int) -> List[Episode]:
169
+ def airdate(row: Dict) -> date:
170
+ aired = row.get('firstAired', '1970-01-01')
171
+
172
+ if aired == '0000-00-00':
173
+ return datetime(1970, 1, 1)
174
+
175
+ return datetime.strptime(aired, '%Y-%m-%d').date()
176
+
177
+ def mkepisode(row: Dict) -> Episode:
178
+ num = int(row.get("airedEpisodeNumber", "0") or 0)
179
+ name = row.get('episodeName') or 'Unnamed episode'
180
+ season = int(row.get('airedSeason', '0') or 0)
181
+ aired = airdate(row)
182
+ pnum = 'UNK'
183
+
184
+ self._log.debug('Found episode %s', name)
185
+ return Episode(name, season, num, aired, pnum, 0)
186
+
187
+ def is_valid(row: Dict) -> bool:
188
+ return row.get("firstAired") not in [None, ""]
189
+
190
+ (data, links) = self._get_episodes(show, page)
191
+ valid = filter(is_valid, data)
192
+ episodes = [mkepisode(row) for row in valid]
193
+
194
+ # handle pagination
195
+ next_ = links.get("next") or 0
196
+ if next_ > page:
197
+ try:
198
+ more = self._fetch_episodes(show, next_)
199
+ episodes.extend(more)
200
+ except TVDBShowNotFoundError:
201
+ msg = "Error parsing %s: failed to load page %d"
202
+ self._log.error(msg, show.name, next_)
203
+
204
+ return episodes
205
+
206
+ def parse(self, show: Show, db: Database) -> None:
207
+ result = self._get(f'series/{int(show.url)}', {})
208
+ data = result.get('data', {})
209
+
210
+ # update show data
211
+ show.name = data.get('seriesName', show.name)
212
+ show.updated = datetime.now()
213
+
214
+ self._log.debug("Updating show '%s'", show.name)
215
+
216
+ if data.get('status') == 'Continuing':
217
+ show.status = Show.RUNNING
218
+ else:
219
+ show.status = Show.ENDED
220
+
221
+ # load episodes
222
+ try:
223
+ episodes = self._fetch_episodes(show, 1)
224
+ except TVDBShowNotFoundError:
225
+ return
226
+
227
+ for (idx, episode) in enumerate(sorted(episodes)):
228
+ episode.totalnum = idx + 1
229
+ db.add_episode(episode, show)
230
+
231
+ db.commit()
@@ -0,0 +1,43 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ import logging
19
+ from re import match
20
+
21
+ from episoder.database import Show
22
+ from episoder.episoder import Database
23
+ from .parser import Parser
24
+
25
+
26
+ class TVCom(Parser):
27
+ """Parser for episodes from tv.com"""
28
+
29
+ def __init__(self) -> None:
30
+ self._log = logging.getLogger('TVCom')
31
+
32
+ def __str__(self) -> str:
33
+ return 'dummy tv.com parser to detect old urls'
34
+
35
+ def __repr__(self) -> str:
36
+ return 'TVCom()'
37
+
38
+ def accept(self, url: str) -> bool:
39
+ exp = 'http://(www.)?tv.com/.*'
40
+ return match(exp, url) is not None
41
+
42
+ def parse(self, show: Show, _: Database) -> None:
43
+ self._log.error("The url %s is no longer supported", show.url)
episoder/version.py ADDED
@@ -0,0 +1,18 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ __version__ = "0.11.0"