episoder 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (49) hide show
  1. episoder-1.1.0/MANIFEST.in +2 -0
  2. {episoder-1.0.0 → episoder-1.1.0}/PKG-INFO +13 -8
  3. {episoder-1.0.0 → episoder-1.1.0}/README.md +7 -5
  4. episoder-1.1.0/episoder/cache.py +49 -0
  5. {episoder-1.0.0 → episoder-1.1.0}/episoder/config.py +1 -1
  6. {episoder-1.0.0 → episoder-1.1.0}/episoder/database.py +3 -3
  7. episoder-1.1.0/episoder/errors.py +9 -0
  8. {episoder-1.0.0 → episoder-1.1.0}/episoder/main.py +15 -12
  9. {episoder-1.0.0 → episoder-1.1.0}/episoder/sources/__init__.py +6 -3
  10. {episoder-1.0.0 → episoder-1.1.0}/episoder/sources/epguides.py +1 -1
  11. episoder-1.1.0/episoder/sources/thetvdb.py +20 -0
  12. episoder-1.0.0/episoder/sources/thetvdb.py → episoder-1.1.0/episoder/sources/thetvdb_legacy.py +32 -29
  13. episoder-1.1.0/episoder/sources/thetvdb_v4.py +325 -0
  14. {episoder-1.0.0 → episoder-1.1.0}/episoder/version.py +1 -1
  15. episoder-1.1.0/episoder.1 +260 -0
  16. {episoder-1.0.0 → episoder-1.1.0}/episoder.egg-info/PKG-INFO +13 -8
  17. {episoder-1.0.0 → episoder-1.1.0}/episoder.egg-info/SOURCES.txt +10 -2
  18. {episoder-1.0.0 → episoder-1.1.0}/episoder.egg-info/requires.txt +4 -1
  19. episoder-1.1.0/examples/home.episoder +30 -0
  20. {episoder-1.0.0 → episoder-1.1.0}/setup.cfg +5 -2
  21. episoder-1.1.0/test/test_cache.py +79 -0
  22. {episoder-1.0.0 → episoder-1.1.0}/test/test_config.py +1 -1
  23. {episoder-1.0.0 → episoder-1.1.0}/test/test_main.py +8 -15
  24. {episoder-1.0.0 → episoder-1.1.0}/test/test_sources.py +36 -9
  25. episoder-1.0.0/test/test_tvdb.py → episoder-1.1.0/test/test_tvdb_legacy.py +14 -13
  26. episoder-1.1.0/test/test_tvdb_v4.py +544 -0
  27. episoder-1.0.0/setup.py +0 -6
  28. {episoder-1.0.0 → episoder-1.1.0}/COPYING +0 -0
  29. {episoder-1.0.0 → episoder-1.1.0}/episoder/__init__.py +0 -0
  30. {episoder-1.0.0 → episoder-1.1.0}/episoder/episoder.py +0 -0
  31. {episoder-1.0.0 → episoder-1.1.0}/episoder/options.py +0 -0
  32. {episoder-1.0.0 → episoder-1.1.0}/episoder/output.py +0 -0
  33. {episoder-1.0.0 → episoder-1.1.0}/episoder/sources/parser.py +0 -0
  34. {episoder-1.0.0 → episoder-1.1.0}/episoder/sources/tvcom.py +0 -0
  35. {episoder-1.0.0 → episoder-1.1.0}/episoder.egg-info/dependency_links.txt +0 -0
  36. {episoder-1.0.0 → episoder-1.1.0}/episoder.egg-info/entry_points.txt +0 -0
  37. {episoder-1.0.0 → episoder-1.1.0}/episoder.egg-info/top_level.txt +0 -0
  38. {episoder-1.0.0 → episoder-1.1.0}/pyproject.toml +0 -0
  39. {episoder-1.0.0 → episoder-1.1.0}/test/test_commands.py +0 -0
  40. {episoder-1.0.0 → episoder-1.1.0}/test/test_database.py +0 -0
  41. {episoder-1.0.0 → episoder-1.1.0}/test/test_epguides_parser.py +0 -0
  42. {episoder-1.0.0 → episoder-1.1.0}/test/test_episode.py +0 -0
  43. {episoder-1.0.0 → episoder-1.1.0}/test/test_metainfo.py +0 -0
  44. {episoder-1.0.0 → episoder-1.1.0}/test/test_migration.py +0 -0
  45. {episoder-1.0.0 → episoder-1.1.0}/test/test_notify.py +0 -0
  46. {episoder-1.0.0 → episoder-1.1.0}/test/test_options.py +0 -0
  47. {episoder-1.0.0 → episoder-1.1.0}/test/test_output.py +0 -0
  48. {episoder-1.0.0 → episoder-1.1.0}/test/test_show.py +0 -0
  49. {episoder-1.0.0 → episoder-1.1.0}/test/test_tvcom.py +0 -0
@@ -0,0 +1,2 @@
1
+ include episoder.1
2
+ include examples/home.episoder
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: episoder
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: TV episode notifier
5
5
  Home-page: https://code.ott.net/episoder/
6
6
  Author: Stefan Ott
7
7
  Author-email: stefan@ott.net
8
+ License: GPL-3.0-or-later
8
9
  Project-URL: Source, https://git.ott.net/episoder/
9
10
  Classifier: Development Status :: 5 - Production/Stable
10
11
  Classifier: Environment :: Console
11
12
  Classifier: Intended Audience :: End Users/Desktop
12
- Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
13
13
  Classifier: Natural Language :: English
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python
@@ -18,8 +18,9 @@ Requires-Python: >=3.6
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: COPYING
20
20
  Requires-Dist: argparse
21
- Requires-Dist: requests
22
21
  Requires-Dist: beautifulsoup4
22
+ Requires-Dist: platformdirs
23
+ Requires-Dist: requests
23
24
  Requires-Dist: sqlalchemy>=2.0
24
25
  Provides-Extra: test
25
26
  Requires-Dist: mypy; extra == "test"
@@ -28,7 +29,9 @@ Requires-Dist: pytest; extra == "test"
28
29
  Requires-Dist: pyfakefs; extra == "test"
29
30
  Requires-Dist: types-requests; extra == "test"
30
31
  Provides-Extra: dev
32
+ Requires-Dist: build; extra == "dev"
31
33
  Requires-Dist: setuptools; extra == "dev"
34
+ Requires-Dist: twine; extra == "dev"
32
35
  Dynamic: license-file
33
36
 
34
37
  # episoder
@@ -55,8 +58,9 @@ The official website, bug tracker etc. for episoder can be found at
55
58
 
56
59
  ## Installation
57
60
 
58
- You can install episoder by running `pip install episoder` or you can download
59
- the tarball from the official website, extract it and run `setup.py install`.
61
+ You can install episoder from PyPI by running `pip install episoder` or you can
62
+ download the tarball from the official website and install it manually using
63
+ `pip install episoder-<version>.tar.gz`.
60
64
 
61
65
  ## Getting started
62
66
 
@@ -88,9 +92,10 @@ the future.
88
92
 
89
93
  ## Configuration
90
94
 
91
- The configuration file at `~/.episoder` contains your default settings for
92
- episoder. After installing episoder, the man page will help you with the
93
- configuration.
95
+ Episoder does not need any configuration, it should run fine out of the box.
96
+ Still, some of its behaviour can be tweaked through a config file at
97
+ `~/.episoder`, the [man page](https://code.ott.net/episoder/doc/config.html) has
98
+ all the details.
94
99
 
95
100
  ## Using episoder
96
101
 
@@ -22,8 +22,9 @@ The official website, bug tracker etc. for episoder can be found at
22
22
 
23
23
  ## Installation
24
24
 
25
- You can install episoder by running `pip install episoder` or you can download
26
- the tarball from the official website, extract it and run `setup.py install`.
25
+ You can install episoder from PyPI by running `pip install episoder` or you can
26
+ download the tarball from the official website and install it manually using
27
+ `pip install episoder-<version>.tar.gz`.
27
28
 
28
29
  ## Getting started
29
30
 
@@ -55,9 +56,10 @@ the future.
55
56
 
56
57
  ## Configuration
57
58
 
58
- The configuration file at `~/.episoder` contains your default settings for
59
- episoder. After installing episoder, the man page will help you with the
60
- configuration.
59
+ Episoder does not need any configuration, it should run fine out of the box.
60
+ Still, some of its behaviour can be tweaked through a config file at
61
+ `~/.episoder`, the [man page](https://code.ott.net/episoder/doc/config.html) has
62
+ all the details.
61
63
 
62
64
  ## Using episoder
63
65
 
@@ -0,0 +1,49 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2026 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 os
19
+ from typing import Optional
20
+
21
+ _FILE_NAME_TVDB_TOKEN = "tvdb_token"
22
+
23
+
24
+ class Cache:
25
+ def __init__(self, path: str) -> None:
26
+ self._path = path
27
+
28
+ def set_tvdb_token(self, token: str) -> None:
29
+ if not os.path.exists(self._path):
30
+ os.mkdir(self._path)
31
+
32
+ fn = os.path.join(self._path, _FILE_NAME_TVDB_TOKEN)
33
+ with open(fn, 'w', encoding='utf8') as file:
34
+ file.write(token)
35
+
36
+ def get_tvdb_token(self) -> Optional[str]:
37
+ fn = os.path.join(self._path, _FILE_NAME_TVDB_TOKEN)
38
+ if not os.path.exists(fn):
39
+ return None
40
+
41
+ with open(fn, 'r', encoding='utf8') as file:
42
+ token = file.read()
43
+
44
+ return token
45
+
46
+ def clear_tvdb_token(self) -> None:
47
+ fn = os.path.join(self._path, _FILE_NAME_TVDB_TOKEN)
48
+ if os.path.exists(fn):
49
+ os.unlink(fn)
@@ -35,7 +35,7 @@ class EpisoderConfig:
35
35
  self.datafile = path.join(environ['HOME'], '.episodes')
36
36
  self.dateformat = '%a, %b %d, %Y'
37
37
  self.format = '%airdate %show %seasonx%epnum'
38
- self.tvdb_key = '8F15287C4B23B36E'
38
+ self.tvdb_key = '1f4c52fc-410a-4868-9724-4c3eba19bdc7'
39
39
 
40
40
  self.email_to: Optional[str] = None
41
41
  self.email_username: Optional[str] = None
@@ -86,11 +86,11 @@ class Episode(Base):
86
86
  prodnum: Optional[str], total: int) -> None:
87
87
  super().__init__()
88
88
  self.title = name
89
- self.season = int(season)
90
- self.episode = int(episode)
89
+ self.season = season
90
+ self.episode = episode
91
91
  self.airdate = aired
92
92
  self.prodnum = prodnum
93
- self.totalnum = int(total)
93
+ self.totalnum = total
94
94
 
95
95
  def __str__(self) -> str:
96
96
  return f"{self.show.name} {self.season}x{self.episode:02d}: " \
@@ -0,0 +1,9 @@
1
+
2
+
3
+ class RemoteError(Exception):
4
+ pass
5
+
6
+
7
+ class NotAuthenticated(Exception):
8
+ def __init__(self) -> None:
9
+ super().__init__('Not authenticated')
@@ -26,6 +26,9 @@ from datetime import date
26
26
  from traceback import print_exc
27
27
  from typing import Any, Callable, Optional
28
28
 
29
+ from platformdirs import PlatformDirs
30
+
31
+ from .cache import Cache
29
32
  from .config import ConfigLoader, EpisoderConfig
30
33
  from .database import Show
31
34
  from .episoder import Database
@@ -33,7 +36,8 @@ from .options import OptionsLoader
33
36
  from .output import ColorfulRenderer, ColorlessRenderer, EmailNotifier
34
37
  from .output import ConsoleRenderer, NewEpisodesNotification, SmtpTestMessage
35
38
  from .sources import parser_for, setup_sources
36
- from .sources.thetvdb import TVDB, TVDBShowNotFoundError
39
+ from .sources.thetvdb_legacy import TVDBLegacy
40
+ from .sources.thetvdb_v4 import TVDBv4
37
41
 
38
42
 
39
43
  def configure_logging(args: Namespace) -> None:
@@ -220,15 +224,12 @@ class Episoder:
220
224
 
221
225
  def search(self, keyword: str) -> None:
222
226
  tvdb = parser_for('123') # numeric IDs are TVDB entries
223
- assert isinstance(tvdb, TVDB)
224
-
225
- try:
226
- print('ID\tName\n-------\t--------------------')
227
- for show in tvdb.lookup(keyword):
228
- print(f'{show.url}\t{show.name}')
227
+ assert isinstance(tvdb, (TVDBLegacy, TVDBv4))
228
+ shows = tvdb.lookup(keyword)
229
229
 
230
- except TVDBShowNotFoundError:
231
- print('Nothing found')
230
+ print('ID\tName\n-------\t--------------------')
231
+ for show in shows:
232
+ print(f'{show.url}\t{show.name}')
232
233
 
233
234
  def _update_shows(self, shows: list[Show], after: Optional[date]) -> None:
234
235
  assert self.database is not None
@@ -237,8 +238,7 @@ class Episoder:
237
238
  parser = parser_for(show.url)
238
239
  assert parser is not None
239
240
  parser.parse(show, self.database)
240
- # pylint: disable=broad-exception-caught
241
- except Exception:
241
+ except Exception: # pylint: disable=broad-exception-caught
242
242
  self._log.error('Error parsing %s', show)
243
243
  print_exc()
244
244
  self.database.rollback()
@@ -358,6 +358,9 @@ class CommandWrapper:
358
358
 
359
359
 
360
360
  def main() -> None:
361
+ dirs = PlatformDirs('episoder', 'cockroach')
362
+ cache = Cache(dirs.user_cache_dir)
363
+
361
364
  loader = OptionsLoader()
362
365
  options = loader.parse_cmdline(sys.argv[1:])
363
366
  loader.validate(options)
@@ -367,7 +370,7 @@ def main() -> None:
367
370
  cfg = config_loader.load(options.c)
368
371
  config_loader.validate(cfg)
369
372
 
370
- setup_sources(cfg.agent, cfg.tvdb_key)
373
+ setup_sources(cfg.agent, cfg.tvdb_key, cache)
371
374
 
372
375
  command = CommandWrapper(options, cfg)
373
376
  command.run(options.func)
@@ -18,8 +18,10 @@
18
18
  from typing import Optional, List
19
19
  import requests
20
20
 
21
+ from episoder.cache import Cache
21
22
  from .epguides import Epguides
22
- from .thetvdb import TVDB
23
+ from .thetvdb_legacy import TVDBLegacy
24
+ from .thetvdb_v4 import TVDBv4
23
25
  from .tvcom import TVCom
24
26
  from .parser import Parser
25
27
 
@@ -28,13 +30,14 @@ session = requests.Session()
28
30
  PARSERS: List[Parser] = []
29
31
 
30
32
 
31
- def setup_sources(user_agent: str, tvdb_api_key: str) -> None:
33
+ def setup_sources(user_agent: str, tvdb_api_key: str, cache: Cache) -> None:
32
34
  session.headers.update({'User-Agent': user_agent})
33
35
 
34
36
  while len(PARSERS) > 0:
35
37
  PARSERS.pop()
36
38
 
37
- PARSERS.append(TVDB(session, tvdb_api_key))
39
+ PARSERS.append(TVDBv4(session, tvdb_api_key, cache))
40
+ PARSERS.append(TVDBLegacy(session, tvdb_api_key))
38
41
  PARSERS.append(Epguides(session))
39
42
  PARSERS.append(TVCom())
40
43
 
@@ -140,7 +140,7 @@ class Epguides(Parser):
140
140
  then = self._get_date(day)
141
141
 
142
142
  # prodnum is None, we don't have those
143
- return Episode(title, season, epnum, then, None, total)
143
+ return Episode(title, int(season), int(epnum), then, None, total)
144
144
 
145
145
  def _load_text_episode(self, line: str) -> Optional[Episode]:
146
146
  # Known formatting supported by this fine regex:
@@ -0,0 +1,20 @@
1
+ # episoder, https://code.ott.net/episoder
2
+ #
3
+ # Copyright (C) 2004-2026 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
+
19
+ # Legacy keys use 16 digits
20
+ KEYLEN_LEGACY = 16
@@ -20,14 +20,19 @@ from json import dumps
20
20
  from logging import getLogger
21
21
  from datetime import datetime, date
22
22
  from typing import Any, Dict, List, Tuple
23
+ from sys import stderr
23
24
 
24
25
  from requests import Session
25
26
 
26
27
  from episoder.database import Episode, Show
27
28
  from episoder.episoder import Database
29
+ from episoder.errors import RemoteError, NotAuthenticated
28
30
  from .parser import Parser
31
+ from .thetvdb import KEYLEN_LEGACY
29
32
 
30
33
 
34
+ # pylint: disable=duplicate-code
35
+
31
36
  class TVDBState(ABC):
32
37
  @abstractmethod
33
38
  def login(self, key: str) -> None:
@@ -42,23 +47,10 @@ class TVDBState(ABC):
42
47
  """Load episodes with this parser"""
43
48
 
44
49
 
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"""
50
+ class TVDBLegacy(Parser):
51
+ """Parser for episodes from thetvdb.com legacy API"""
59
52
 
60
53
  def __init__(self, session: Session, api_key: str) -> None:
61
- self._session = session
62
54
  self._api_key = api_key
63
55
  self._state: TVDBState = TVDBOffline(self, session)
64
56
 
@@ -66,7 +58,7 @@ class TVDB(Parser):
66
58
  return str(self._state)
67
59
 
68
60
  def __repr__(self) -> str:
69
- return f'TVDB {repr(self._state)}'
61
+ return f'TVDBLegacy {repr(self._state)}'
70
62
 
71
63
  def lookup(self, text: str) -> List[Show]:
72
64
  self._state.login(self._api_key)
@@ -80,13 +72,13 @@ class TVDB(Parser):
80
72
  self._state = state
81
73
 
82
74
  def accept(self, url: str) -> bool:
83
- return url.isdigit()
75
+ return url.isdigit() and len(self._api_key) <= KEYLEN_LEGACY
84
76
 
85
77
 
86
78
  class TVDBOffline(TVDBState):
87
- def __init__(self, tvdb: TVDB, session: Session) -> None:
79
+ def __init__(self, tvdb: TVDBLegacy, session: Session) -> None:
88
80
  self._tvdb = tvdb
89
- self._log = getLogger('TVDB (offline)')
81
+ self._log = getLogger('TVDBLegacy (offline)')
90
82
  self._session = session
91
83
 
92
84
  def __str__(self) -> str:
@@ -102,28 +94,36 @@ class TVDBOffline(TVDBState):
102
94
  response = self._session.post(url, body, headers=head, timeout=10)
103
95
  data = response.json()
104
96
 
105
- if response.status_code == 401:
106
- raise InvalidLoginError(data.get('Error'))
97
+ if response.status_code != 200:
98
+ raise RemoteError(data.get('Error'))
107
99
 
108
100
  self._log.info('Successful login')
109
101
  return data['token']
110
102
 
111
103
  def lookup(self, _: str) -> List[Show]:
112
- raise TVDBNotLoggedInError()
104
+ raise NotAuthenticated()
113
105
 
114
106
  def login(self, key: str) -> None:
107
+ print("""** WARNING **
108
+ You are using the legacy API for the TVDB. This API is deprecated and
109
+ frequently broken.
110
+
111
+ Please create a new API key for episoder on https://www.thetvdb.com/
112
+ and update your configuration.
113
+ """, file=stderr)
114
+
115
115
  body = {'apikey': key}
116
116
  token = self._post_login(body)
117
117
  self._tvdb.change(TVDBOnline(token, self._session))
118
118
 
119
119
  def parse(self, _: Show, __: Database) -> None:
120
- raise TVDBNotLoggedInError()
120
+ raise NotAuthenticated()
121
121
 
122
122
 
123
123
  class TVDBOnline(TVDBState):
124
124
  def __init__(self, token: str, session: Session) -> None:
125
125
  self._token = token
126
- self._log = getLogger('TVDB (online)')
126
+ self._log = getLogger('TVDBLegacy (online)')
127
127
  self._session = session
128
128
 
129
129
  def __str__(self) -> str:
@@ -142,7 +142,7 @@ class TVDBOnline(TVDBState):
142
142
  data: Dict[str, Any] = response.json()
143
143
 
144
144
  if response.status_code == 404:
145
- raise TVDBShowNotFoundError(data.get('Error'))
145
+ raise RemoteError(data.get('Error'))
146
146
 
147
147
  return data
148
148
 
@@ -160,8 +160,11 @@ class TVDBOnline(TVDBState):
160
160
  url = str(entry.get('id')).encode('utf8').decode('utf8')
161
161
  return Show(name, url=url)
162
162
 
163
- matches = self._get('search/series', {'name': text})
164
- return list(map(mkshow, matches.get('data', {})))
163
+ try:
164
+ matches = self._get('search/series', {'name': text})
165
+ return list(map(mkshow, matches.get('data', {})))
166
+ except RemoteError:
167
+ return []
165
168
 
166
169
  def login(self, _: str) -> None:
167
170
  pass
@@ -197,7 +200,7 @@ class TVDBOnline(TVDBState):
197
200
  try:
198
201
  more = self._fetch_episodes(show, next_)
199
202
  episodes.extend(more)
200
- except TVDBShowNotFoundError:
203
+ except RemoteError:
201
204
  msg = "Error parsing %s: failed to load page %d"
202
205
  self._log.error(msg, show.name, next_)
203
206
 
@@ -221,7 +224,7 @@ class TVDBOnline(TVDBState):
221
224
  # load episodes
222
225
  try:
223
226
  episodes = self._fetch_episodes(show, 1)
224
- except TVDBShowNotFoundError:
227
+ except RemoteError:
225
228
  return
226
229
 
227
230
  for (idx, episode) in enumerate(sorted(episodes)):