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.
- episoder/__init__.py +0 -0
- episoder/database.py +134 -0
- episoder/episoder.py +292 -0
- episoder/main.py +469 -0
- episoder/output.py +186 -0
- episoder/sources/__init__.py +47 -0
- episoder/sources/epguides.py +174 -0
- episoder/sources/parser.py +30 -0
- episoder/sources/thetvdb.py +231 -0
- episoder/sources/tvcom.py +43 -0
- episoder/version.py +18 -0
- episoder-0.11.0.dist-info/COPYING +674 -0
- episoder-0.11.0.dist-info/METADATA +71 -0
- episoder-0.11.0.dist-info/RECORD +17 -0
- episoder-0.11.0.dist-info/WHEEL +5 -0
- episoder-0.11.0.dist-info/entry_points.txt +2 -0
- episoder-0.11.0.dist-info/top_level.txt +1 -0
episoder/main.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
# episoder, https://code.ott.net/episoder
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
|
|
6
|
+
#
|
|
7
|
+
# This program is free software: you can redistribute it and/or modify
|
|
8
|
+
# it under the terms of the GNU General Public License as published by
|
|
9
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
10
|
+
# (at your option) any later version.
|
|
11
|
+
#
|
|
12
|
+
# This program is distributed in the hope that it will be useful,
|
|
13
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
# GNU General Public License for more details.
|
|
16
|
+
#
|
|
17
|
+
# You should have received a copy of the GNU General Public License
|
|
18
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from argparse import ArgumentParser
|
|
23
|
+
from datetime import date, timedelta
|
|
24
|
+
from os import environ, path
|
|
25
|
+
from re import match
|
|
26
|
+
from sys import stderr
|
|
27
|
+
from traceback import print_exc
|
|
28
|
+
|
|
29
|
+
from episoder.database import Show
|
|
30
|
+
from episoder.episoder import Database
|
|
31
|
+
from episoder.sources import parser_for, setup_sources
|
|
32
|
+
from episoder.sources.thetvdb import TVDBShowNotFoundError
|
|
33
|
+
from episoder.output import ConsoleRenderer, EmailNotifier
|
|
34
|
+
from episoder.output import NewEpisodesNotification
|
|
35
|
+
from episoder.version import __version__
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EpisoderOptions:
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
|
|
42
|
+
self._parser = ArgumentParser()
|
|
43
|
+
self._add_options()
|
|
44
|
+
self._args = None
|
|
45
|
+
self._log = logging.getLogger("EpisoderOptions")
|
|
46
|
+
self.loglevel = logging.WARNING
|
|
47
|
+
|
|
48
|
+
def _add_options(self):
|
|
49
|
+
|
|
50
|
+
yesterday = date.today() - timedelta(1)
|
|
51
|
+
|
|
52
|
+
self._parser.set_defaults(func=None)
|
|
53
|
+
commands = self._parser.add_subparsers(help="commands")
|
|
54
|
+
|
|
55
|
+
# options for adding shows
|
|
56
|
+
parser = commands.add_parser("add", help="Add a show")
|
|
57
|
+
parser.add_argument("show", action="store",
|
|
58
|
+
help="show to add (TVDB ID or epguides.com URL)")
|
|
59
|
+
parser.set_defaults(func="add")
|
|
60
|
+
|
|
61
|
+
# options for removing shows
|
|
62
|
+
parser = commands.add_parser("remove",
|
|
63
|
+
help="Remove a show from the database")
|
|
64
|
+
parser.add_argument("show", action="store", type=int,
|
|
65
|
+
help="the show ID to remove")
|
|
66
|
+
parser.set_defaults(func="remove")
|
|
67
|
+
|
|
68
|
+
# options for enabling shows
|
|
69
|
+
parser = commands.add_parser("enable",
|
|
70
|
+
help="Enable updates for a show")
|
|
71
|
+
parser.add_argument("show", action="store", type=int,
|
|
72
|
+
help="the show ID to enable updates for")
|
|
73
|
+
parser.set_defaults(func="enable")
|
|
74
|
+
|
|
75
|
+
# options for disabling shows
|
|
76
|
+
parser = commands.add_parser("disable",
|
|
77
|
+
help="Disable updates for a show")
|
|
78
|
+
parser.add_argument("show", action="store", type=int,
|
|
79
|
+
help="the show ID to disable updates for")
|
|
80
|
+
parser.set_defaults(func="disable")
|
|
81
|
+
|
|
82
|
+
# options for showing episodes
|
|
83
|
+
parser = commands.add_parser("list",
|
|
84
|
+
help="Show upcoming episodes")
|
|
85
|
+
parser.add_argument("-C", "--nocolor", action="store_true",
|
|
86
|
+
help="do not use colors")
|
|
87
|
+
parser.add_argument("-d", metavar="YYYY-MM-DD|n", dest="date",
|
|
88
|
+
default=yesterday.strftime("%Y-%m-%d"),
|
|
89
|
+
help="only show episodes after this date "
|
|
90
|
+
"/ n days back")
|
|
91
|
+
parser.add_argument("-n", "--days", type=int, default=2,
|
|
92
|
+
help="number of future days to show (default: 2)")
|
|
93
|
+
parser.add_argument("-i", "--nodate", action="store_true",
|
|
94
|
+
help="ignore date, show all episodes")
|
|
95
|
+
parser.add_argument("-s", dest="search",
|
|
96
|
+
help="search episodes")
|
|
97
|
+
parser.set_defaults(func="episodes")
|
|
98
|
+
|
|
99
|
+
# options for searching shows
|
|
100
|
+
parser = commands.add_parser("search",
|
|
101
|
+
help="Find shows on TVDB")
|
|
102
|
+
parser.add_argument("keyword", action="store",
|
|
103
|
+
help="search string")
|
|
104
|
+
parser.set_defaults(func="search")
|
|
105
|
+
|
|
106
|
+
# options for listing shows
|
|
107
|
+
parser = commands.add_parser("shows",
|
|
108
|
+
help="List shows in the database")
|
|
109
|
+
parser.add_argument("-a", "--active", action="store_true",
|
|
110
|
+
help="only lists shows that are running/suspended")
|
|
111
|
+
parser.set_defaults(func="shows")
|
|
112
|
+
|
|
113
|
+
# options for database update
|
|
114
|
+
parser = commands.add_parser("update",
|
|
115
|
+
help="Update the database")
|
|
116
|
+
parser.add_argument("-d", metavar="YYYY-MM-DD|n", dest="date",
|
|
117
|
+
default=yesterday.strftime("%Y-%m-%d"),
|
|
118
|
+
help="remove episodes prior to this date "
|
|
119
|
+
"or n days back")
|
|
120
|
+
parser.add_argument("-f", "--force", action="store_true",
|
|
121
|
+
help="force update, disregard last update time")
|
|
122
|
+
parser.add_argument("-i", "--nodate", action="store_true",
|
|
123
|
+
help="ignore date, do not remove old episodes")
|
|
124
|
+
parser.add_argument("-s", "--show", metavar="id", type=int,
|
|
125
|
+
help="only update the show with this id")
|
|
126
|
+
parser.add_argument("-n", "--num", metavar="num", type=int,
|
|
127
|
+
help="update no more than num shows at a time")
|
|
128
|
+
parser.set_defaults(func="update")
|
|
129
|
+
|
|
130
|
+
# options for email notify
|
|
131
|
+
parser = commands.add_parser("notify",
|
|
132
|
+
help="Send e-mail notifications "
|
|
133
|
+
"about new episodes")
|
|
134
|
+
parser.add_argument("-d", metavar="YYYY-MM-DD|n", dest="date",
|
|
135
|
+
default=yesterday.strftime("%Y-%m-%d"),
|
|
136
|
+
help="only show episodes prior to this date "
|
|
137
|
+
"or n days back")
|
|
138
|
+
parser.add_argument("-n", "--days", type=int, default=2,
|
|
139
|
+
help="number of future days to show (default: 2)")
|
|
140
|
+
parser.add_argument("-s", "--show", metavar="id", type=int,
|
|
141
|
+
help="only notify for the show with this id")
|
|
142
|
+
parser.add_argument("--dryrun", action="store_true",
|
|
143
|
+
default=False, help="pretend, do not send email")
|
|
144
|
+
parser.set_defaults(func="notify")
|
|
145
|
+
|
|
146
|
+
# global options
|
|
147
|
+
group = self._parser.add_mutually_exclusive_group()
|
|
148
|
+
|
|
149
|
+
self._parser.add_argument("-c", metavar="file", action="store",
|
|
150
|
+
default=path.join(environ["HOME"],
|
|
151
|
+
".episoder"),
|
|
152
|
+
help="use configuration from file")
|
|
153
|
+
group.add_argument("-v", "--verbose", action="store_true",
|
|
154
|
+
help="verbose operation")
|
|
155
|
+
group.add_argument("-d", "--debug", action="store_true",
|
|
156
|
+
help="debug (very verbose) operation")
|
|
157
|
+
self._parser.add_argument("-l", metavar="file", dest="logfile",
|
|
158
|
+
action="store",
|
|
159
|
+
help="log to file instead of stdout")
|
|
160
|
+
self._parser.add_argument("-V", "--version", action="version",
|
|
161
|
+
version=f"episoder {__version__}",
|
|
162
|
+
help="show version information")
|
|
163
|
+
|
|
164
|
+
def _replace_parsed_date(self, args):
|
|
165
|
+
|
|
166
|
+
if args.date.isdigit():
|
|
167
|
+
daysback = int(args.date)
|
|
168
|
+
args.date = date.today() - timedelta(daysback)
|
|
169
|
+
elif match("^[0-9]{4}(-[0-9]{2}){2}$", args.date):
|
|
170
|
+
(year, month, day) = args.date.split("-")
|
|
171
|
+
args.date = date(int(year), int(month), int(day))
|
|
172
|
+
else:
|
|
173
|
+
self._parser.error(f"{args.date}: Wrong date format")
|
|
174
|
+
|
|
175
|
+
def _setup_logging(self):
|
|
176
|
+
|
|
177
|
+
if self._args.debug:
|
|
178
|
+
self.loglevel = logging.DEBUG
|
|
179
|
+
elif self._args.verbose:
|
|
180
|
+
self.loglevel = logging.INFO
|
|
181
|
+
|
|
182
|
+
level = self.loglevel
|
|
183
|
+
logfile = self._args.logfile
|
|
184
|
+
|
|
185
|
+
if logfile:
|
|
186
|
+
logging.basicConfig(level=level, filename=logfile)
|
|
187
|
+
else:
|
|
188
|
+
logging.basicConfig(level=level)
|
|
189
|
+
|
|
190
|
+
def parse_args(self):
|
|
191
|
+
|
|
192
|
+
self._args = self._parser.parse_args()
|
|
193
|
+
|
|
194
|
+
# this happens automatically in python 2 but not in 3.4
|
|
195
|
+
if self._args.func is None:
|
|
196
|
+
self._parser.print_usage()
|
|
197
|
+
stderr.write("error: too few arguments\n")
|
|
198
|
+
exit(1)
|
|
199
|
+
|
|
200
|
+
# turn our date string into a proper date object
|
|
201
|
+
if hasattr(self._args, "date"):
|
|
202
|
+
self._replace_parsed_date(self._args)
|
|
203
|
+
|
|
204
|
+
self._setup_logging()
|
|
205
|
+
|
|
206
|
+
def load_rcfile(self, file_):
|
|
207
|
+
|
|
208
|
+
def strip_comments(line):
|
|
209
|
+
|
|
210
|
+
return line.split("#")[0]
|
|
211
|
+
|
|
212
|
+
def valid(line):
|
|
213
|
+
|
|
214
|
+
return "=" in line
|
|
215
|
+
|
|
216
|
+
self._log.info("Loading config file")
|
|
217
|
+
lines = file_.readlines()
|
|
218
|
+
lines = [x.decode("utf8").strip() for x in lines]
|
|
219
|
+
lines = map(strip_comments, lines)
|
|
220
|
+
lines = filter(valid, lines)
|
|
221
|
+
return dict(line.split("=") for line in lines)
|
|
222
|
+
|
|
223
|
+
def load_config(self):
|
|
224
|
+
|
|
225
|
+
if path.exists(self.args.c):
|
|
226
|
+
with open(self.args.c, "rb") as file_:
|
|
227
|
+
cfg = self.load_rcfile(file_)
|
|
228
|
+
self._log.debug("Settings loaded: %s", cfg)
|
|
229
|
+
else:
|
|
230
|
+
stderr.write("No config file found, using defaults\n")
|
|
231
|
+
cfg = {}
|
|
232
|
+
|
|
233
|
+
if "src" in cfg:
|
|
234
|
+
stderr.write("Please remove src= lines from config\n")
|
|
235
|
+
|
|
236
|
+
self._args.agent = cfg.get("agent", "episoder/" + __version__)
|
|
237
|
+
|
|
238
|
+
datafile = path.join(environ["HOME"], ".episodes")
|
|
239
|
+
self._args.datafile = cfg.get("data", datafile)
|
|
240
|
+
self._args.dateformat = cfg.get("dateformat", "%a, %b %d, %Y")
|
|
241
|
+
format_ = "%airdate %show %seasonx%epnum"
|
|
242
|
+
self._args.format = cfg.get("format", format_)
|
|
243
|
+
self._args.tvdb_key = cfg.get("tvdb_key", "8F15287C4B23B36E")
|
|
244
|
+
self._args.email_to = cfg.get("email_to")
|
|
245
|
+
self._args.email_username = cfg.get("email_username")
|
|
246
|
+
self._args.email_password = cfg.get("email_password")
|
|
247
|
+
self._args.email_server = cfg.get("email_server", "localhost")
|
|
248
|
+
self._args.email_port = int(cfg.get("email_port", 587))
|
|
249
|
+
self._args.email_tls = bool(cfg.get("email_tls", 0))
|
|
250
|
+
|
|
251
|
+
self._log.info("Loaded configuration")
|
|
252
|
+
|
|
253
|
+
def _get_args(self):
|
|
254
|
+
|
|
255
|
+
return self._args
|
|
256
|
+
|
|
257
|
+
args = property(_get_args)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class Episoder:
|
|
261
|
+
|
|
262
|
+
def __init__(self, args):
|
|
263
|
+
|
|
264
|
+
self._args = args
|
|
265
|
+
self._log = logging.getLogger("Episoder")
|
|
266
|
+
|
|
267
|
+
def update(self):
|
|
268
|
+
|
|
269
|
+
database = Database(self._args.datafile)
|
|
270
|
+
database.migrate()
|
|
271
|
+
|
|
272
|
+
if self._args.show:
|
|
273
|
+
show = database.get_show_by_id(self._args.show)
|
|
274
|
+
|
|
275
|
+
if not show:
|
|
276
|
+
self._log.error("Show not found")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
shows = [show]
|
|
280
|
+
elif self._args.force:
|
|
281
|
+
shows = database.get_enabled_shows()
|
|
282
|
+
else:
|
|
283
|
+
shows = database.get_expired_shows()
|
|
284
|
+
|
|
285
|
+
for show in shows[0:self._args.num]:
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
parser = parser_for(show.url)
|
|
289
|
+
parser.parse(show, database)
|
|
290
|
+
except:
|
|
291
|
+
self._log.error("Error parsing %s", show)
|
|
292
|
+
print_exc()
|
|
293
|
+
database.rollback()
|
|
294
|
+
|
|
295
|
+
if not self._args.nodate:
|
|
296
|
+
basedate = self._args.date
|
|
297
|
+
show.remove_episodes_before(database, basedate)
|
|
298
|
+
|
|
299
|
+
if not shows:
|
|
300
|
+
self._log.info("None of your shows need to be updated")
|
|
301
|
+
|
|
302
|
+
def add(self):
|
|
303
|
+
|
|
304
|
+
url = self._args.show
|
|
305
|
+
|
|
306
|
+
database = Database(self._args.datafile)
|
|
307
|
+
database.migrate()
|
|
308
|
+
|
|
309
|
+
show = database.get_show_by_url(url)
|
|
310
|
+
|
|
311
|
+
if show:
|
|
312
|
+
stderr.write("A show with that url already exists\n")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
if not parser_for(url):
|
|
316
|
+
stderr.write(f"Invalid show url/id: {url}\n")
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
show = Show("Unknown Show", url=url)
|
|
320
|
+
|
|
321
|
+
database.add_show(show)
|
|
322
|
+
database.commit()
|
|
323
|
+
|
|
324
|
+
def remove(self):
|
|
325
|
+
|
|
326
|
+
database = Database(self._args.datafile)
|
|
327
|
+
database.migrate()
|
|
328
|
+
database.remove_show(self._args.show)
|
|
329
|
+
database.commit()
|
|
330
|
+
|
|
331
|
+
def _set_show_enabled(self, database, enabled):
|
|
332
|
+
|
|
333
|
+
show_id = self._args.show
|
|
334
|
+
show = database.get_show_by_id(show_id)
|
|
335
|
+
|
|
336
|
+
if not show:
|
|
337
|
+
stderr.write(f"There is no show with id={show_id}\n")
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
show.enabled = enabled
|
|
341
|
+
database.commit()
|
|
342
|
+
|
|
343
|
+
def enable(self):
|
|
344
|
+
|
|
345
|
+
database = Database(self._args.datafile)
|
|
346
|
+
database.migrate()
|
|
347
|
+
self._set_show_enabled(database, True)
|
|
348
|
+
|
|
349
|
+
def disable(self):
|
|
350
|
+
|
|
351
|
+
database = Database(self._args.datafile)
|
|
352
|
+
database.migrate()
|
|
353
|
+
self._set_show_enabled(database, False)
|
|
354
|
+
|
|
355
|
+
def shows(self):
|
|
356
|
+
|
|
357
|
+
database = Database(self._args.datafile)
|
|
358
|
+
database.migrate()
|
|
359
|
+
|
|
360
|
+
status_strings = ["?Invalid", "Running", "Suspended", "Ended"]
|
|
361
|
+
enabled_strings = ["Disabled", "Enabled"]
|
|
362
|
+
|
|
363
|
+
for show in database.get_shows():
|
|
364
|
+
|
|
365
|
+
if self._args.active and show.status == Show.ENDED:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
status = status_strings[show.status or 0]
|
|
369
|
+
enabled = enabled_strings[show.enabled]
|
|
370
|
+
|
|
371
|
+
print(f"[{show.id:4d}] {show.url}")
|
|
372
|
+
print(f" {show.name}, {status}, {enabled}")
|
|
373
|
+
print(f" Last update: {show.updated}")
|
|
374
|
+
print(f" Episodes: {len(show.episodes)}")
|
|
375
|
+
|
|
376
|
+
def notify(self):
|
|
377
|
+
|
|
378
|
+
opt = self._args
|
|
379
|
+
|
|
380
|
+
if opt.email_to is None:
|
|
381
|
+
stderr.write("No e-mail address configured\n")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
database = Database(opt.datafile)
|
|
385
|
+
database.migrate()
|
|
386
|
+
|
|
387
|
+
startdate = opt.date
|
|
388
|
+
n_days = opt.days
|
|
389
|
+
|
|
390
|
+
all_episodes = database.get_episodes(startdate, n_days)
|
|
391
|
+
fresh = [e for e in all_episodes if not e.notified]
|
|
392
|
+
|
|
393
|
+
if len(fresh) < 1:
|
|
394
|
+
self._log.info("No new episodes")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
msg = NewEpisodesNotification(fresh, opt.format, opt.dateformat)
|
|
398
|
+
notifier = EmailNotifier(opt.email_server, opt.email_port)
|
|
399
|
+
|
|
400
|
+
if opt.email_username:
|
|
401
|
+
notifier.login(opt.email_username, opt.email_password)
|
|
402
|
+
if opt.email_tls:
|
|
403
|
+
notifier.use_tls = True
|
|
404
|
+
|
|
405
|
+
msg.send(notifier, opt.email_to)
|
|
406
|
+
database.commit()
|
|
407
|
+
|
|
408
|
+
def episodes(self):
|
|
409
|
+
|
|
410
|
+
database = Database(self._args.datafile)
|
|
411
|
+
out = ConsoleRenderer(self._args.format, self._args.dateformat)
|
|
412
|
+
|
|
413
|
+
if self._args.nodate:
|
|
414
|
+
startdate = date(1900, 1, 1)
|
|
415
|
+
n_days = 109500 # should be fine until late 21xx :)
|
|
416
|
+
else:
|
|
417
|
+
startdate = self._args.date
|
|
418
|
+
n_days = self._args.days
|
|
419
|
+
|
|
420
|
+
if self._args.search:
|
|
421
|
+
episodes = database.search(self._args.search)
|
|
422
|
+
else:
|
|
423
|
+
episodes = database.get_episodes(startdate, n_days)
|
|
424
|
+
|
|
425
|
+
out.render(episodes, self._args.date, not self._args.nocolor)
|
|
426
|
+
|
|
427
|
+
def search(self):
|
|
428
|
+
|
|
429
|
+
tvdb = parser_for('123') # numeric IDs are TVDB entries
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
print("ID\tName\n-------\t--------------------")
|
|
433
|
+
for show in tvdb.lookup(self._args.keyword):
|
|
434
|
+
print(f"{show.url}\t{show.name}")
|
|
435
|
+
|
|
436
|
+
except TVDBShowNotFoundError:
|
|
437
|
+
print("Nothing found")
|
|
438
|
+
|
|
439
|
+
def check_data_file(self):
|
|
440
|
+
|
|
441
|
+
filename = self._args.datafile
|
|
442
|
+
if not path.exists(filename):
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
with open(filename, "rb") as file:
|
|
446
|
+
data = file.read(6)
|
|
447
|
+
|
|
448
|
+
if data != b"SQLite":
|
|
449
|
+
message = "%s: Please remove old data file\n"
|
|
450
|
+
stderr.write(message % filename)
|
|
451
|
+
exit(4)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def main():
|
|
455
|
+
|
|
456
|
+
options = EpisoderOptions()
|
|
457
|
+
options.parse_args()
|
|
458
|
+
options.load_config()
|
|
459
|
+
|
|
460
|
+
setup_sources(options.args.agent, options.args.tvdb_key)
|
|
461
|
+
|
|
462
|
+
episoder = Episoder(options.args)
|
|
463
|
+
episoder.check_data_file()
|
|
464
|
+
func = getattr(episoder, options.args.func)
|
|
465
|
+
func()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
if __name__ == "__main__":
|
|
469
|
+
main()
|
episoder/output.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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 sys import stdout
|
|
19
|
+
from datetime import date, timedelta
|
|
20
|
+
from smtplib import SMTP
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FormattingRenderer:
|
|
24
|
+
|
|
25
|
+
def __init__(self, fmt, dateformat):
|
|
26
|
+
|
|
27
|
+
self._format = fmt
|
|
28
|
+
self._dateformat = dateformat
|
|
29
|
+
|
|
30
|
+
def format(self, episode):
|
|
31
|
+
|
|
32
|
+
string = self._format
|
|
33
|
+
|
|
34
|
+
airdate = episode.airdate.strftime(self._dateformat)
|
|
35
|
+
string = string.replace("%airdate", airdate)
|
|
36
|
+
string = string.replace("%show", str(episode.show.name))
|
|
37
|
+
string = string.replace("%season", str(episode.season))
|
|
38
|
+
string = string.replace("%epnum", f"{episode.episode:02d}")
|
|
39
|
+
string = string.replace("%eptitle", str(episode.title))
|
|
40
|
+
string = string.replace("%totalep", str(episode.totalnum))
|
|
41
|
+
string = string.replace("%prodnum", str(episode.prodnum))
|
|
42
|
+
|
|
43
|
+
return string
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConsoleRenderer(FormattingRenderer):
|
|
47
|
+
|
|
48
|
+
RED = "\033[31;1m"
|
|
49
|
+
CYAN = "\033[36;1m"
|
|
50
|
+
GREY = "\033[30;0m"
|
|
51
|
+
GREEN = "\033[32;1m"
|
|
52
|
+
YELLOW = "\033[33;1m"
|
|
53
|
+
|
|
54
|
+
def __init__(self, fmt, dateformat, dest=stdout):
|
|
55
|
+
|
|
56
|
+
super().__init__(fmt, dateformat)
|
|
57
|
+
self._dest = dest
|
|
58
|
+
|
|
59
|
+
def __str__(self):
|
|
60
|
+
|
|
61
|
+
return "ConsoleRenderer"
|
|
62
|
+
|
|
63
|
+
def __repr__(self):
|
|
64
|
+
|
|
65
|
+
return f'ConsoleRenderer("{self._format}", "{self._dateformat}", <..>)'
|
|
66
|
+
|
|
67
|
+
def _render_color(self, text, col):
|
|
68
|
+
|
|
69
|
+
self._dest.write(f"{col}{text}{ConsoleRenderer.GREY}\n")
|
|
70
|
+
|
|
71
|
+
def _render_no_color(self, string, _):
|
|
72
|
+
|
|
73
|
+
self._dest.write(f"{string}\n")
|
|
74
|
+
|
|
75
|
+
def _render(self, episode, render, color):
|
|
76
|
+
|
|
77
|
+
render(self.format(episode), color)
|
|
78
|
+
|
|
79
|
+
def render(self, episodes, today, use_colors=True):
|
|
80
|
+
|
|
81
|
+
yesterday = today - timedelta(1)
|
|
82
|
+
tomorrow = today + timedelta(1)
|
|
83
|
+
|
|
84
|
+
if use_colors:
|
|
85
|
+
render = self._render_color
|
|
86
|
+
else:
|
|
87
|
+
render = self._render_no_color
|
|
88
|
+
|
|
89
|
+
for episode in episodes:
|
|
90
|
+
|
|
91
|
+
if episode.airdate == yesterday:
|
|
92
|
+
color = ConsoleRenderer.RED
|
|
93
|
+
elif episode.airdate == today:
|
|
94
|
+
color = ConsoleRenderer.YELLOW
|
|
95
|
+
elif episode.airdate == tomorrow:
|
|
96
|
+
color = ConsoleRenderer.GREEN
|
|
97
|
+
elif episode.airdate > tomorrow:
|
|
98
|
+
color = ConsoleRenderer.CYAN
|
|
99
|
+
else:
|
|
100
|
+
color = ConsoleRenderer.GREY
|
|
101
|
+
|
|
102
|
+
self._render(episode, render, color)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class NewEpisodesNotification(FormattingRenderer):
|
|
106
|
+
|
|
107
|
+
def __init__(self, episodes, fmt, datefmt):
|
|
108
|
+
|
|
109
|
+
super().__init__(fmt, datefmt)
|
|
110
|
+
self._episodes = episodes
|
|
111
|
+
|
|
112
|
+
def __str__(self):
|
|
113
|
+
|
|
114
|
+
return f"Notification: {len(self._episodes)} new episode(s)"
|
|
115
|
+
|
|
116
|
+
def __repr__(self):
|
|
117
|
+
|
|
118
|
+
eps = f"<({len(self._episodes)} x Episode)"
|
|
119
|
+
fmt = 'NewEpisodesNotification(%s, "%s", "%s")'
|
|
120
|
+
return fmt % (eps, self._format, self._dateformat)
|
|
121
|
+
|
|
122
|
+
def get(self):
|
|
123
|
+
|
|
124
|
+
return "".join("%s\n" % self.format(e) for e in self._episodes)
|
|
125
|
+
|
|
126
|
+
def send(self, notifier, to, pretend=False):
|
|
127
|
+
|
|
128
|
+
body = "Your upcoming episodes:\n\n"
|
|
129
|
+
|
|
130
|
+
for episode in self._episodes:
|
|
131
|
+
body += f"{self.format(episode)}\n"
|
|
132
|
+
|
|
133
|
+
if not pretend:
|
|
134
|
+
episode.notified = date.today()
|
|
135
|
+
|
|
136
|
+
notifier.send(body, to, pretend)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class EmailNotifier:
|
|
140
|
+
|
|
141
|
+
def __init__(self, server, port, smtp=SMTP):
|
|
142
|
+
|
|
143
|
+
self._server = server
|
|
144
|
+
self._port = port
|
|
145
|
+
self._smtp = smtp
|
|
146
|
+
self._user = None
|
|
147
|
+
self._password = None
|
|
148
|
+
|
|
149
|
+
self.use_tls = False
|
|
150
|
+
|
|
151
|
+
def __str__(self):
|
|
152
|
+
|
|
153
|
+
return "EmailNotifier"
|
|
154
|
+
|
|
155
|
+
def __repr__(self):
|
|
156
|
+
|
|
157
|
+
fmt = 'EmailNotifier("%s", %d, %s)'
|
|
158
|
+
return fmt % (self._server, self._port, str(self._smtp))
|
|
159
|
+
|
|
160
|
+
def _get_header(self, to, subject):
|
|
161
|
+
|
|
162
|
+
return f"From: {to}\nTo: {to}\nSubject: {subject}"
|
|
163
|
+
|
|
164
|
+
def login(self, user, password):
|
|
165
|
+
|
|
166
|
+
self._user = user
|
|
167
|
+
self._password = password
|
|
168
|
+
|
|
169
|
+
def send(self, body, to, pretend=False):
|
|
170
|
+
|
|
171
|
+
header = self._get_header(to, "Upcoming TV episodes")
|
|
172
|
+
message = f"{header}\n{body}"
|
|
173
|
+
|
|
174
|
+
if pretend:
|
|
175
|
+
print(message)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
server = self._smtp(self._server, self._port)
|
|
179
|
+
|
|
180
|
+
if self.use_tls:
|
|
181
|
+
server.starttls()
|
|
182
|
+
if self._user:
|
|
183
|
+
server.login(self._user, self._password)
|
|
184
|
+
|
|
185
|
+
server.sendmail(to, to, message)
|
|
186
|
+
server.quit()
|
|
@@ -0,0 +1,47 @@
|
|
|
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 typing import Optional, List
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from .epguides import Epguides
|
|
22
|
+
from .thetvdb import TVDB
|
|
23
|
+
from .tvcom import TVCom
|
|
24
|
+
from .parser import Parser
|
|
25
|
+
|
|
26
|
+
session = requests.Session()
|
|
27
|
+
|
|
28
|
+
PARSERS: List[Parser] = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def setup_sources(user_agent: str, tvdb_api_key: str) -> None:
|
|
32
|
+
session.headers.update({'User-Agent': user_agent})
|
|
33
|
+
|
|
34
|
+
while len(PARSERS) > 0:
|
|
35
|
+
PARSERS.pop()
|
|
36
|
+
|
|
37
|
+
PARSERS.append(TVDB(session, tvdb_api_key))
|
|
38
|
+
PARSERS.append(Epguides(session))
|
|
39
|
+
PARSERS.append(TVCom())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parser_for(url: str) -> Optional[Parser]:
|
|
43
|
+
"""Find the right parser for the given URL"""
|
|
44
|
+
for parser in PARSERS:
|
|
45
|
+
if parser.accept(url):
|
|
46
|
+
return parser
|
|
47
|
+
return None
|