episoder 0.12.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 (95) hide show
  1. episoder-1.1.0/MANIFEST.in +2 -0
  2. episoder-1.1.0/PKG-INFO +115 -0
  3. episoder-1.1.0/README.md +79 -0
  4. episoder-1.1.0/episoder/cache.py +49 -0
  5. {episoder-0.12.0 → episoder-1.1.0}/episoder/config.py +2 -2
  6. {episoder-0.12.0 → episoder-1.1.0}/episoder/database.py +59 -55
  7. {episoder-0.12.0 → episoder-1.1.0}/episoder/episoder.py +17 -3
  8. episoder-1.1.0/episoder/errors.py +9 -0
  9. {episoder-0.12.0 → episoder-1.1.0}/episoder/main.py +17 -14
  10. {episoder-0.12.0 → episoder-1.1.0}/episoder/options.py +1 -1
  11. {episoder-0.12.0 → episoder-1.1.0}/episoder/output.py +1 -1
  12. {episoder-0.12.0 → episoder-1.1.0}/episoder/sources/__init__.py +7 -4
  13. {episoder-0.12.0 → episoder-1.1.0}/episoder/sources/epguides.py +2 -2
  14. {episoder-0.12.0 → episoder-1.1.0}/episoder/sources/parser.py +1 -1
  15. episoder-1.1.0/episoder/sources/thetvdb.py +20 -0
  16. episoder-0.12.0/episoder/sources/thetvdb.py → episoder-1.1.0/episoder/sources/thetvdb_legacy.py +43 -41
  17. episoder-1.1.0/episoder/sources/thetvdb_v4.py +325 -0
  18. {episoder-0.12.0 → episoder-1.1.0}/episoder/sources/tvcom.py +1 -1
  19. {episoder-0.12.0 → episoder-1.1.0}/episoder/version.py +2 -2
  20. {episoder-0.12.0 → episoder-1.1.0}/episoder.1 +13 -3
  21. episoder-1.1.0/episoder.egg-info/PKG-INFO +115 -0
  22. episoder-1.1.0/episoder.egg-info/SOURCES.txt +47 -0
  23. episoder-1.1.0/episoder.egg-info/requires.txt +17 -0
  24. {episoder-0.12.0 → episoder-1.1.0}/setup.cfg +11 -3
  25. episoder-1.1.0/test/test_cache.py +79 -0
  26. {episoder-0.12.0 → episoder-1.1.0}/test/test_config.py +1 -1
  27. {episoder-0.12.0 → episoder-1.1.0}/test/test_main.py +8 -15
  28. {episoder-0.12.0 → episoder-1.1.0}/test/test_sources.py +36 -9
  29. episoder-0.12.0/test/test_tvdb.py → episoder-1.1.0/test/test_tvdb_legacy.py +14 -13
  30. episoder-1.1.0/test/test_tvdb_v4.py +544 -0
  31. episoder-0.12.0/.gitignore +0 -2
  32. episoder-0.12.0/.gitlab-ci.yml +0 -150
  33. episoder-0.12.0/CHANGELOG +0 -228
  34. episoder-0.12.0/PKG-INFO +0 -74
  35. episoder-0.12.0/README.md +0 -47
  36. episoder-0.12.0/episoder.egg-info/PKG-INFO +0 -74
  37. episoder-0.12.0/episoder.egg-info/SOURCES.txt +0 -82
  38. episoder-0.12.0/episoder.egg-info/requires.txt +0 -8
  39. episoder-0.12.0/setup.py +0 -6
  40. episoder-0.12.0/test/__init__.py +0 -0
  41. episoder-0.12.0/test/fixtures/epguides_48_hours_mistery.html +0 -399
  42. episoder-0.12.0/test/fixtures/epguides_american_idol.html +0 -872
  43. episoder-0.12.0/test/fixtures/epguides_bsg.html +0 -538
  44. episoder-0.12.0/test/fixtures/epguides_buzzcocks.html +0 -464
  45. episoder-0.12.0/test/fixtures/epguides_doctor_who.html +0 -842
  46. episoder-0.12.0/test/fixtures/epguides_eureka.html +0 -362
  47. episoder-0.12.0/test/fixtures/epguides_futurama.html +0 -407
  48. episoder-0.12.0/test/fixtures/epguides_house.html +0 -490
  49. episoder-0.12.0/test/fixtures/epguides_kr2008.html +0 -256
  50. episoder-0.12.0/test/fixtures/epguides_lost.html +0 -478
  51. episoder-0.12.0/test/fixtures/epguides_lost_html.html +0 -602
  52. episoder-0.12.0/test/fixtures/epguides_lower_decks.html +0 -340
  53. episoder-0.12.0/test/fixtures/epguides_midsomer_murders.html +0 -398
  54. episoder-0.12.0/test/fixtures/epguides_picard.html +0 -380
  55. episoder-0.12.0/test/fixtures/epguides_test_iso_8859_1.html +0 -138
  56. episoder-0.12.0/test/fixtures/tvdb_error_404.json +0 -3
  57. episoder-0.12.0/test/fixtures/tvdb_search_frasier.json +0 -10
  58. episoder-0.12.0/test/fixtures/tvdb_search_friends.json +0 -28
  59. episoder-0.12.0/test/fixtures/tvdb_test_show_260.json +0 -1
  60. episoder-0.12.0/test/fixtures/tvdb_test_show_260_1_eps.json +0 -19
  61. episoder-0.12.0/test/fixtures/tvdb_test_show_261.json +0 -1
  62. episoder-0.12.0/test/fixtures/tvdb_test_show_261_1_eps.json +0 -23
  63. episoder-0.12.0/test/fixtures/tvdb_test_show_261_2_eps.json +0 -23
  64. episoder-0.12.0/test/fixtures/tvdb_test_show_261_3_eps.json +0 -18
  65. episoder-0.12.0/test/fixtures/tvdb_test_show_262.json +0 -1
  66. episoder-0.12.0/test/fixtures/tvdb_test_show_262_1_eps.json +0 -20
  67. episoder-0.12.0/test/fixtures/tvdb_test_show_263.json +0 -1
  68. episoder-0.12.0/test/fixtures/tvdb_test_show_268156.json +0 -1
  69. episoder-0.12.0/test/fixtures/tvdb_test_show_268156_1_eps.json +0 -520
  70. episoder-0.12.0/test/fixtures/tvdb_test_show_295640.json +0 -1
  71. episoder-0.12.0/test/fixtures/tvdb_test_show_295640_1_eps.json +0 -1
  72. episoder-0.12.0/test/fixtures/tvdb_test_show_295640_2_eps.json +0 -1
  73. episoder-0.12.0/test/fixtures/tvdb_test_show_73739.json +0 -1
  74. episoder-0.12.0/test/fixtures/tvdb_test_show_73739_1_eps.json +0 -14
  75. episoder-0.12.0/test/fixtures/tvdb_test_show_75397.json +0 -1
  76. episoder-0.12.0/test/fixtures/tvdb_test_show_75397_1_eps.json +0 -1
  77. episoder-0.12.0/test/util.py +0 -54
  78. {episoder-0.12.0 → episoder-1.1.0}/COPYING +0 -0
  79. {episoder-0.12.0 → episoder-1.1.0}/episoder/__init__.py +0 -0
  80. {episoder-0.12.0 → episoder-1.1.0}/episoder.egg-info/dependency_links.txt +0 -0
  81. {episoder-0.12.0 → episoder-1.1.0}/episoder.egg-info/entry_points.txt +0 -0
  82. {episoder-0.12.0 → episoder-1.1.0}/episoder.egg-info/top_level.txt +0 -0
  83. {episoder-0.12.0 → episoder-1.1.0}/examples/home.episoder +0 -0
  84. {episoder-0.12.0 → episoder-1.1.0}/pyproject.toml +0 -0
  85. {episoder-0.12.0 → episoder-1.1.0}/test/test_commands.py +0 -0
  86. {episoder-0.12.0 → episoder-1.1.0}/test/test_database.py +0 -0
  87. {episoder-0.12.0 → episoder-1.1.0}/test/test_epguides_parser.py +0 -0
  88. {episoder-0.12.0 → episoder-1.1.0}/test/test_episode.py +0 -0
  89. {episoder-0.12.0 → episoder-1.1.0}/test/test_metainfo.py +0 -0
  90. {episoder-0.12.0 → episoder-1.1.0}/test/test_migration.py +0 -0
  91. {episoder-0.12.0 → episoder-1.1.0}/test/test_notify.py +0 -0
  92. {episoder-0.12.0 → episoder-1.1.0}/test/test_options.py +0 -0
  93. {episoder-0.12.0 → episoder-1.1.0}/test/test_output.py +0 -0
  94. {episoder-0.12.0 → episoder-1.1.0}/test/test_show.py +0 -0
  95. {episoder-0.12.0 → episoder-1.1.0}/test/test_tvcom.py +0 -0
@@ -0,0 +1,2 @@
1
+ include episoder.1
2
+ include examples/home.episoder
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: episoder
3
+ Version: 1.1.0
4
+ Summary: TV episode notifier
5
+ Home-page: https://code.ott.net/episoder/
6
+ Author: Stefan Ott
7
+ Author-email: stefan@ott.net
8
+ License: GPL-3.0-or-later
9
+ Project-URL: Source, https://git.ott.net/episoder/
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Topic :: Multimedia :: Video
17
+ Requires-Python: >=3.6
18
+ Description-Content-Type: text/markdown
19
+ License-File: COPYING
20
+ Requires-Dist: argparse
21
+ Requires-Dist: beautifulsoup4
22
+ Requires-Dist: platformdirs
23
+ Requires-Dist: requests
24
+ Requires-Dist: sqlalchemy>=2.0
25
+ Provides-Extra: test
26
+ Requires-Dist: mypy; extra == "test"
27
+ Requires-Dist: flake8; extra == "test"
28
+ Requires-Dist: pytest; extra == "test"
29
+ Requires-Dist: pyfakefs; extra == "test"
30
+ Requires-Dist: types-requests; extra == "test"
31
+ Provides-Extra: dev
32
+ Requires-Dist: build; extra == "dev"
33
+ Requires-Dist: setuptools; extra == "dev"
34
+ Requires-Dist: twine; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # episoder
38
+
39
+ ![episoder](https://code.ott.net/episoder/screenshot.png "episoder 0.8.0")
40
+
41
+ Episoder is a command-line tool that tells you about new episodes of your
42
+ favourite TV shows. It gets its data from online TV episode guides such as
43
+ epguides.com and TheTVDB.
44
+
45
+ There is also a web frontend for episoder, called
46
+ [webisoder](https://code.ott.net/webisoder).
47
+
48
+ ## Dependencies
49
+
50
+ In order to use episoder, you need to have Python installed on your system. In
51
+ addition to the default distribution, episoder uses the `requests`, `argparse`,
52
+ `sqlalchemy` and `beautifulsoup4` packages.
53
+
54
+ ## Downloading
55
+
56
+ The official website, bug tracker etc. for episoder can be found at
57
+ [code.ott.net/episoder](https://code.ott.net/episoder/).
58
+
59
+ ## Installation
60
+
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`.
64
+
65
+ ## Getting started
66
+
67
+ First, find a show you're interested in:
68
+
69
+ $ episoder search firefly
70
+ ID Name
71
+ ------- --------------------
72
+ 78874 Firefly
73
+ 384248 Firefly Lane
74
+ 329088 Firefly (2017)
75
+
76
+ Add that show to episoder:
77
+
78
+ $ episoder add 78874
79
+
80
+ Update your database:
81
+
82
+ $ episoder update
83
+
84
+ And finally show your episodes:
85
+
86
+ $ episoder list
87
+
88
+ The list may be empty, by default episoder deletes old episodes and will only
89
+ show you the next few days. The `--nodate` option to the `update` and `list`
90
+ command will change that, in case you care about days long gone and/or far in
91
+ the future.
92
+
93
+ ## Configuration
94
+
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.
99
+
100
+ ## Using episoder
101
+
102
+ To use episoder you will typically:
103
+
104
+ * Create a cron job to update your database once a day using `episoder update`
105
+ * Add `episoder list` to your `~/.bashrc`, `~/.bash_profile`, `/etc/bash.bashrc`
106
+ or `/etc/profile` if you want to see your upcoming shows every time you start
107
+ a shell. Consult your shell's documentation to find out which file to use.
108
+ * Or run `episoder notify` at regular intervals to get notifications by e-mail.
109
+
110
+ ## More documentation
111
+
112
+ There is some documentation to help you get started:
113
+
114
+ * [Using episoder](https://code.ott.net/episoder/doc/use.html)
115
+ * [Episoder configuration](https://code.ott.net/episoder/doc/config.html)
@@ -0,0 +1,79 @@
1
+ # episoder
2
+
3
+ ![episoder](https://code.ott.net/episoder/screenshot.png "episoder 0.8.0")
4
+
5
+ Episoder is a command-line tool that tells you about new episodes of your
6
+ favourite TV shows. It gets its data from online TV episode guides such as
7
+ epguides.com and TheTVDB.
8
+
9
+ There is also a web frontend for episoder, called
10
+ [webisoder](https://code.ott.net/webisoder).
11
+
12
+ ## Dependencies
13
+
14
+ In order to use episoder, you need to have Python installed on your system. In
15
+ addition to the default distribution, episoder uses the `requests`, `argparse`,
16
+ `sqlalchemy` and `beautifulsoup4` packages.
17
+
18
+ ## Downloading
19
+
20
+ The official website, bug tracker etc. for episoder can be found at
21
+ [code.ott.net/episoder](https://code.ott.net/episoder/).
22
+
23
+ ## Installation
24
+
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`.
28
+
29
+ ## Getting started
30
+
31
+ First, find a show you're interested in:
32
+
33
+ $ episoder search firefly
34
+ ID Name
35
+ ------- --------------------
36
+ 78874 Firefly
37
+ 384248 Firefly Lane
38
+ 329088 Firefly (2017)
39
+
40
+ Add that show to episoder:
41
+
42
+ $ episoder add 78874
43
+
44
+ Update your database:
45
+
46
+ $ episoder update
47
+
48
+ And finally show your episodes:
49
+
50
+ $ episoder list
51
+
52
+ The list may be empty, by default episoder deletes old episodes and will only
53
+ show you the next few days. The `--nodate` option to the `update` and `list`
54
+ command will change that, in case you care about days long gone and/or far in
55
+ the future.
56
+
57
+ ## Configuration
58
+
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.
63
+
64
+ ## Using episoder
65
+
66
+ To use episoder you will typically:
67
+
68
+ * Create a cron job to update your database once a day using `episoder update`
69
+ * Add `episoder list` to your `~/.bashrc`, `~/.bash_profile`, `/etc/bash.bashrc`
70
+ or `/etc/profile` if you want to see your upcoming shows every time you start
71
+ a shell. Consult your shell's documentation to find out which file to use.
72
+ * Or run `episoder notify` at regular intervals to get notifications by e-mail.
73
+
74
+ ## More documentation
75
+
76
+ There is some documentation to help you get started:
77
+
78
+ * [Using episoder](https://code.ott.net/episoder/doc/use.html)
79
+ * [Episoder configuration](https://code.ott.net/episoder/doc/config.html)
@@ -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)
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2025 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -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
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -15,98 +15,105 @@
15
15
  # You should have received a copy of the GNU General Public License
16
16
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
17
 
18
- from datetime import datetime
18
+ from datetime import date, datetime
19
+ from typing import Any, Optional
19
20
 
20
- from sqlalchemy import Column, Integer, Text, Date, ForeignKey, Sequence
21
- from sqlalchemy import DateTime, Boolean
22
- from sqlalchemy.orm import backref, declarative_base, relationship
21
+ from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Text
22
+ from sqlalchemy import Sequence
23
+ from sqlalchemy.orm import backref, DeclarativeBase, relationship
24
+ from sqlalchemy.orm import mapped_column
23
25
 
24
- Base = declarative_base()
25
26
 
27
+ # pylint: disable=too-few-public-methods
28
+ class Base(DeclarativeBase):
29
+ pass
26
30
 
27
- class Show(Base):
28
31
 
32
+ class Show(Base):
29
33
  RUNNING = 1
30
34
  SUSPENDED = 2
31
35
  ENDED = 3
32
36
 
33
37
  __tablename__ = "shows"
34
38
 
35
- id = Column("show_id", Integer, Sequence("shows_show_id_seq"),
36
- primary_key=True)
37
- name = Column("show_name", Text)
38
- url = Column(Text, unique=True)
39
- updated = Column(DateTime)
40
- enabled = Column(Boolean)
41
- status = Column(Integer, default=RUNNING)
42
-
43
- def __init__(self, name, url="", updated=datetime.fromtimestamp(0)):
44
-
39
+ id = mapped_column("show_id", Integer, Sequence("shows_show_id_seq"),
40
+ primary_key=True)
41
+ name = mapped_column("show_name", Text)
42
+ url = mapped_column(Text, unique=True)
43
+ updated = mapped_column(DateTime)
44
+ enabled = mapped_column(Boolean)
45
+ status = mapped_column(Integer, default=RUNNING)
46
+
47
+ def __init__(self, name: str, url: str = "",
48
+ updated: datetime = datetime.fromtimestamp(0)) -> None:
49
+ super().__init__()
45
50
  self.name = name
46
51
  self.url = url
47
52
  self.updated = updated
48
53
  self.status = Show.RUNNING
49
- self.episodes = []
54
+ self.episodes: list[Episode] = []
50
55
  self.enabled = True
51
56
 
52
- def __str__(self):
53
-
57
+ def __str__(self) -> str:
54
58
  return f"Show: {self.name}"
55
59
 
56
- def __repr__(self):
57
-
60
+ def __repr__(self) -> str:
58
61
  return f'Show("{self.name}", "{self.url}")'
59
62
 
60
- def __eq__(self, other):
63
+ def __eq__(self, other: object) -> Any:
64
+ if not isinstance(other, Show):
65
+ return NotImplemented
61
66
 
62
67
  return self.name == other.name and self.url == other.url
63
68
 
64
69
 
65
70
  # pylint: disable=too-many-instance-attributes
66
71
  class Episode(Base):
67
-
68
72
  __tablename__ = "episodes"
69
73
 
70
- show_id = Column(Integer, ForeignKey(Show.id), primary_key=True)
71
- episode = Column("num", Integer, primary_key=True)
72
- airdate = Column(Date)
73
- season = Column(Integer, primary_key=True)
74
- title = Column(Text)
75
- totalnum = Column(Integer)
76
- prodnum = Column(Text)
77
- notified = Column(Date)
74
+ show_id = mapped_column(Integer, ForeignKey(Show.id), primary_key=True)
75
+ episode = mapped_column("num", Integer, primary_key=True)
76
+ airdate = mapped_column(Date)
77
+ season = mapped_column(Integer, primary_key=True)
78
+ title = mapped_column(Text)
79
+ totalnum = mapped_column(Integer)
80
+ prodnum = mapped_column(Text)
81
+ notified = mapped_column(Date)
78
82
  show = relationship(Show, backref=backref("episodes", cascade="all"))
79
83
 
80
84
  # pylint: disable=too-many-arguments,too-many-positional-arguments
81
- def __init__(self, name, season, episode, aired, prodnum, total):
82
-
85
+ def __init__(self, name: str, season: int, episode: int, aired: date,
86
+ prodnum: Optional[str], total: int) -> None:
87
+ super().__init__()
83
88
  self.title = name
84
- self.season = int(season)
85
- self.episode = int(episode)
89
+ self.season = season
90
+ self.episode = episode
86
91
  self.airdate = aired
87
92
  self.prodnum = prodnum
88
- self.totalnum = int(total)
89
-
90
- def __lt__(self, other):
91
-
92
- return ((self.season < other.season)
93
- or ((self.season == other.season)
94
- and (self.episode < other.episode)))
95
-
96
- def __str__(self):
93
+ self.totalnum = total
97
94
 
95
+ def __str__(self) -> str:
98
96
  return f"{self.show.name} {self.season}x{self.episode:02d}: " \
99
97
  f"{self.title}"
100
98
 
101
- def __repr__(self):
102
-
99
+ def __repr__(self) -> str:
103
100
  airdate = self.airdate
104
101
 
105
102
  return f'Episode("{self.title}", {self.season}, {self.episode}, ' \
106
103
  f'date({airdate.year}, {airdate.month}, {airdate.day}), ' \
107
104
  f'"{self.prodnum}", {self.totalnum})'
108
105
 
109
- def __eq__(self, other):
106
+ def __lt__(self, other: object) -> Any:
107
+ if not isinstance(other, Episode):
108
+ return NotImplemented
109
+
110
+ return ((self.season < other.season)
111
+ or ((self.season == other.season)
112
+ and (self.episode < other.episode)))
113
+
114
+ def __eq__(self, other: object) -> Any:
115
+ if not isinstance(other, Episode):
116
+ return NotImplemented
110
117
 
111
118
  return (self.show_id == other.show_id
112
119
  and self.season == other.season
@@ -114,15 +121,12 @@ class Episode(Base):
114
121
 
115
122
 
116
123
  class Meta(Base):
117
-
118
124
  __tablename__ = "meta"
119
- key = Column(Text, primary_key=True)
120
- value = Column(Text)
121
-
122
- def __str__(self):
125
+ key = mapped_column(Text, primary_key=True)
126
+ value = mapped_column(Text)
123
127
 
128
+ def __str__(self) -> str:
124
129
  return f"Meta: {self.key} = {self.value}"
125
130
 
126
- def __repr__(self):
127
-
131
+ def __repr__(self) -> str:
128
132
  return str(self)
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -20,7 +20,7 @@ from datetime import date, timedelta
20
20
  from typing import Optional
21
21
 
22
22
  import sqlite3
23
- from sqlalchemy import create_engine, or_, and_, select, inspect
23
+ from sqlalchemy import create_engine, or_, and_, select, inspect, Table
24
24
  from sqlalchemy.orm import sessionmaker
25
25
 
26
26
  from .database import Episode, Show, Meta
@@ -45,12 +45,19 @@ class Database:
45
45
 
46
46
  # Initialize the database if all tables are missing
47
47
  names = [Show, Episode, Meta]
48
- tables = [t for t in names if inspector.has_table(t.__table__.name)]
48
+ tables = [t for t in names
49
+ if inspector.has_table(t.__table__.name)] # type: ignore
49
50
 
50
51
  if len(tables) < 1:
52
+ assert isinstance(Show.__table__, Table)
51
53
  Show.__table__.create(bind=self.engine)
54
+
55
+ assert isinstance(Episode.__table__, Table)
52
56
  Episode.__table__.create(bind=self.engine)
57
+
58
+ assert isinstance(Meta.__table__, Table)
53
59
  Meta.__table__.create(bind=self.engine)
60
+
54
61
  self.set_schema_version(4)
55
62
 
56
63
  def open(self) -> None:
@@ -78,6 +85,8 @@ class Database:
78
85
 
79
86
  def get_schema_version(self) -> int:
80
87
  inspector = inspect(self.engine)
88
+
89
+ assert isinstance(Meta.__table__, Table)
81
90
  if not inspector.has_table(Meta.__table__.name):
82
91
  return 1
83
92
 
@@ -114,8 +123,13 @@ class Database:
114
123
  upgrade.execute("DROP TABLE shows")
115
124
  upgrade.close()
116
125
 
126
+ assert isinstance(Show.__table__, Table)
117
127
  Show.__table__.create(bind=self.engine)
128
+
129
+ assert isinstance(Episode.__table__, Table)
118
130
  Episode.__table__.create(bind=self.engine)
131
+
132
+ assert isinstance(Meta.__table__, Table)
119
133
  Meta.__table__.create(bind=self.engine)
120
134
 
121
135
  schema_version = 4
@@ -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')
@@ -2,7 +2,7 @@
2
2
 
3
3
  # episoder, https://code.ott.net/episoder
4
4
  #
5
- # Copyright (C) 2004-2025 Stefan Ott. All rights reserved.
5
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
6
6
  #
7
7
  # This program is free software: you can redistribute it and/or modify
8
8
  # it under the terms of the GNU General Public License as published by
@@ -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:
@@ -65,7 +69,7 @@ class DatabaseWrapper:
65
69
  self._conn.close()
66
70
 
67
71
 
68
- def with_db(func: Callable) -> Callable:
72
+ def with_db(func: Callable) -> Callable: # type: ignore
69
73
  @functools.wraps(func)
70
74
  def wrapped(*args: str, **kwargs: str) -> Any:
71
75
  self = args[0]
@@ -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)
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2025 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2025 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by
@@ -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:
@@ -1,6 +1,6 @@
1
1
  # episoder, https://code.ott.net/episoder
2
2
  #
3
- # Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
3
+ # Copyright (C) 2004-2026 Stefan Ott. All rights reserved.
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU General Public License as published by