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/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