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/__init__.py
ADDED
|
File without changes
|
episoder/database.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# episoder, https://code.ott.net/episoder
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import Column, Integer, Text, Date, ForeignKey, Sequence
|
|
23
|
+
from sqlalchemy import DateTime, Boolean
|
|
24
|
+
from sqlalchemy.orm import backref, declarative_base, relationship
|
|
25
|
+
|
|
26
|
+
Base = declarative_base()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Show(Base):
|
|
30
|
+
|
|
31
|
+
RUNNING = 1
|
|
32
|
+
SUSPENDED = 2
|
|
33
|
+
ENDED = 3
|
|
34
|
+
|
|
35
|
+
__tablename__ = "shows"
|
|
36
|
+
|
|
37
|
+
id = Column("show_id", Integer, Sequence("shows_show_id_seq"),
|
|
38
|
+
primary_key=True)
|
|
39
|
+
name = Column("show_name", Text)
|
|
40
|
+
url = Column(Text, unique=True)
|
|
41
|
+
updated = Column(DateTime)
|
|
42
|
+
enabled = Column(Boolean)
|
|
43
|
+
status = Column(Integer, default=RUNNING)
|
|
44
|
+
|
|
45
|
+
def __init__(self, name, url="", updated=datetime.fromtimestamp(0)):
|
|
46
|
+
|
|
47
|
+
self.name = name
|
|
48
|
+
self.url = url
|
|
49
|
+
self.updated = updated
|
|
50
|
+
self.status = Show.RUNNING
|
|
51
|
+
self.episodes = []
|
|
52
|
+
self.enabled = True
|
|
53
|
+
|
|
54
|
+
def __str__(self):
|
|
55
|
+
|
|
56
|
+
return f"Show: {self.name}"
|
|
57
|
+
|
|
58
|
+
def __repr__(self):
|
|
59
|
+
|
|
60
|
+
return f'Show("{self.name}", "{self.url}")'
|
|
61
|
+
|
|
62
|
+
def __eq__(self, other):
|
|
63
|
+
|
|
64
|
+
return self.name == other.name and self.url == other.url
|
|
65
|
+
|
|
66
|
+
def remove_episodes_before(self, store, date):
|
|
67
|
+
|
|
68
|
+
logging.debug("Removing episodes from before %s", date)
|
|
69
|
+
store.remove_before(date, show=self)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Episode(Base):
|
|
73
|
+
|
|
74
|
+
__tablename__ = "episodes"
|
|
75
|
+
|
|
76
|
+
show_id = Column(Integer, ForeignKey(Show.id), primary_key=True)
|
|
77
|
+
episode = Column("num", Integer, primary_key=True)
|
|
78
|
+
airdate = Column(Date)
|
|
79
|
+
season = Column(Integer, primary_key=True)
|
|
80
|
+
title = Column(Text)
|
|
81
|
+
totalnum = Column(Integer)
|
|
82
|
+
prodnum = Column(Text)
|
|
83
|
+
notified = Column(Date)
|
|
84
|
+
show = relationship(Show, backref=backref("episodes", cascade="all"))
|
|
85
|
+
|
|
86
|
+
# pylint: disable=too-many-arguments
|
|
87
|
+
def __init__(self, name, season, episode, aired, prodnum, total):
|
|
88
|
+
|
|
89
|
+
self.title = name
|
|
90
|
+
self.season = int(season)
|
|
91
|
+
self.episode = int(episode)
|
|
92
|
+
self.airdate = aired
|
|
93
|
+
self.prodnum = prodnum
|
|
94
|
+
self.totalnum = int(total)
|
|
95
|
+
|
|
96
|
+
def __lt__(self, other):
|
|
97
|
+
|
|
98
|
+
return ((self.season < other.season)
|
|
99
|
+
or ((self.season == other.season)
|
|
100
|
+
and (self.episode < other.episode)))
|
|
101
|
+
|
|
102
|
+
def __str__(self):
|
|
103
|
+
|
|
104
|
+
return f"{self.show.name} {self.season}x{self.episode:02d}: " \
|
|
105
|
+
f"{self.title}"
|
|
106
|
+
|
|
107
|
+
def __repr__(self):
|
|
108
|
+
|
|
109
|
+
d = self.airdate
|
|
110
|
+
|
|
111
|
+
return f'Episode("{self.title}", {self.season}, {self.episode}, ' \
|
|
112
|
+
f'date({d.year}, {d.month}, {d.day}), "{self.prodnum}", ' \
|
|
113
|
+
f'{self.totalnum})'
|
|
114
|
+
|
|
115
|
+
def __eq__(self, other):
|
|
116
|
+
|
|
117
|
+
return (self.show_id == other.show_id
|
|
118
|
+
and self.season == other.season
|
|
119
|
+
and self.episode == other.episode)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Meta(Base):
|
|
123
|
+
|
|
124
|
+
__tablename__ = "meta"
|
|
125
|
+
key = Column(Text, primary_key=True)
|
|
126
|
+
value = Column(Text)
|
|
127
|
+
|
|
128
|
+
def __str__(self):
|
|
129
|
+
|
|
130
|
+
return f"Meta: {self.key} = {self.value}"
|
|
131
|
+
|
|
132
|
+
def __repr__(self):
|
|
133
|
+
|
|
134
|
+
return str(self)
|
episoder/episoder.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# episoder, https://code.ott.net/episoder
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2004-2024 Stefan Ott. All rights reserved.
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from datetime import date, timedelta
|
|
20
|
+
|
|
21
|
+
import sqlite3
|
|
22
|
+
from sqlalchemy import create_engine, or_, and_, select, inspect
|
|
23
|
+
from sqlalchemy.orm import sessionmaker
|
|
24
|
+
|
|
25
|
+
from .database import Episode, Show, Meta
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Database:
|
|
29
|
+
|
|
30
|
+
def __init__(self, path):
|
|
31
|
+
|
|
32
|
+
self._path = path
|
|
33
|
+
self.logger = logging.getLogger("Database")
|
|
34
|
+
|
|
35
|
+
self.open()
|
|
36
|
+
self._initdb()
|
|
37
|
+
|
|
38
|
+
def __str__(self):
|
|
39
|
+
|
|
40
|
+
return f"Episoder Database at {self._path}"
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
|
|
44
|
+
return f"Database({self._path})"
|
|
45
|
+
|
|
46
|
+
def _initdb(self):
|
|
47
|
+
|
|
48
|
+
inspector = inspect(self.engine)
|
|
49
|
+
|
|
50
|
+
# Initialize the database if all tables are missing
|
|
51
|
+
names = [Show, Episode, Meta]
|
|
52
|
+
tables = [t for t in names if inspector.has_table(t.__table__.name)]
|
|
53
|
+
|
|
54
|
+
if len(tables) < 1:
|
|
55
|
+
Show.__table__.create(bind=self.engine)
|
|
56
|
+
Episode.__table__.create(bind=self.engine)
|
|
57
|
+
Meta.__table__.create(bind=self.engine)
|
|
58
|
+
self.set_schema_version(4)
|
|
59
|
+
|
|
60
|
+
def open(self):
|
|
61
|
+
|
|
62
|
+
if self._path.find("://") > -1:
|
|
63
|
+
self.engine = create_engine(self._path)
|
|
64
|
+
else:
|
|
65
|
+
self.engine = create_engine(f"sqlite:///{self._path}")
|
|
66
|
+
|
|
67
|
+
sm = sessionmaker(self.engine)
|
|
68
|
+
self.session = sm()
|
|
69
|
+
self.session.begin()
|
|
70
|
+
|
|
71
|
+
def close(self):
|
|
72
|
+
|
|
73
|
+
self.session.commit()
|
|
74
|
+
self.session.close()
|
|
75
|
+
self.engine.dispose()
|
|
76
|
+
|
|
77
|
+
def set_schema_version(self, version):
|
|
78
|
+
|
|
79
|
+
meta = Meta()
|
|
80
|
+
meta.key = "schema"
|
|
81
|
+
meta.value = f"{version}"
|
|
82
|
+
|
|
83
|
+
with self.session.begin_nested():
|
|
84
|
+
self.session.merge(meta)
|
|
85
|
+
|
|
86
|
+
def get_schema_version(self):
|
|
87
|
+
|
|
88
|
+
inspector = inspect(self.engine)
|
|
89
|
+
if not inspector.has_table(Meta.__table__.name):
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
res = self.session.scalars(
|
|
93
|
+
select(Meta).where(Meta.key == "schema")).first()
|
|
94
|
+
if res:
|
|
95
|
+
return int(res.value)
|
|
96
|
+
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
def clear(self):
|
|
100
|
+
|
|
101
|
+
episodes = self.session.query(Episode).all()
|
|
102
|
+
|
|
103
|
+
for episode in episodes:
|
|
104
|
+
self.session.delete(episode)
|
|
105
|
+
|
|
106
|
+
self.session.flush()
|
|
107
|
+
|
|
108
|
+
def migrate(self):
|
|
109
|
+
|
|
110
|
+
schema_version = self.get_schema_version()
|
|
111
|
+
self.logger.debug("Found schema version %s", schema_version)
|
|
112
|
+
|
|
113
|
+
if schema_version < 0:
|
|
114
|
+
|
|
115
|
+
self.logger.debug("Automatic schema updates disabled")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if schema_version == 1:
|
|
119
|
+
|
|
120
|
+
# Upgrades from version 1 are rather harsh, we
|
|
121
|
+
# simply drop and re-create the tables
|
|
122
|
+
self.logger.debug("Upgrading to schema version 2")
|
|
123
|
+
|
|
124
|
+
upgrade = sqlite3.connect(self._path)
|
|
125
|
+
upgrade.execute("DROP TABLE episodes")
|
|
126
|
+
upgrade.execute("DROP TABLE shows")
|
|
127
|
+
upgrade.close()
|
|
128
|
+
|
|
129
|
+
Show.__table__.create(bind=self.engine)
|
|
130
|
+
Episode.__table__.create(bind=self.engine)
|
|
131
|
+
Meta.__table__.create(bind=self.engine)
|
|
132
|
+
|
|
133
|
+
schema_version = 4
|
|
134
|
+
self.set_schema_version(schema_version)
|
|
135
|
+
|
|
136
|
+
if schema_version == 2:
|
|
137
|
+
|
|
138
|
+
# Add two new columns to the shows table
|
|
139
|
+
self.logger.debug("Upgrading to schema version 3")
|
|
140
|
+
|
|
141
|
+
# We can only do this with sqlite databases
|
|
142
|
+
assert self.engine.driver == "pysqlite"
|
|
143
|
+
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
upgrade = sqlite3.connect(self._path)
|
|
147
|
+
upgrade.execute("ALTER TABLE shows "
|
|
148
|
+
"ADD COLUMN enabled TYPE boolean")
|
|
149
|
+
upgrade.execute("ALTER TABLE shows "
|
|
150
|
+
"ADD COLUMN status TYPE integer")
|
|
151
|
+
upgrade.close()
|
|
152
|
+
|
|
153
|
+
self.open()
|
|
154
|
+
schema_version = 3
|
|
155
|
+
self.set_schema_version(schema_version)
|
|
156
|
+
|
|
157
|
+
if schema_version == 3:
|
|
158
|
+
|
|
159
|
+
# Add a new column to the episodes table
|
|
160
|
+
self.logger.debug("Upgrading to schema version 4")
|
|
161
|
+
|
|
162
|
+
# We can only do this with sqlite databases
|
|
163
|
+
assert self.engine.driver == "pysqlite"
|
|
164
|
+
|
|
165
|
+
self.close()
|
|
166
|
+
|
|
167
|
+
upgrade = sqlite3.connect(self._path)
|
|
168
|
+
upgrade.execute("ALTER TABLE episodes "
|
|
169
|
+
"ADD COLUMN notified TYPE date")
|
|
170
|
+
upgrade.close()
|
|
171
|
+
|
|
172
|
+
self.open()
|
|
173
|
+
schema_version = 4
|
|
174
|
+
self.set_schema_version(schema_version)
|
|
175
|
+
|
|
176
|
+
def get_expired_shows(self, today=date.today()):
|
|
177
|
+
|
|
178
|
+
delta_running = timedelta(2) # 2 days
|
|
179
|
+
delta_suspended = timedelta(7) # 1 week
|
|
180
|
+
delta_ended = timedelta(14) # 2 weeks
|
|
181
|
+
|
|
182
|
+
shows = self.session.query(Show).filter(or_(
|
|
183
|
+
and_(
|
|
184
|
+
Show.enabled,
|
|
185
|
+
Show.status == Show.RUNNING,
|
|
186
|
+
Show.updated < today - delta_running
|
|
187
|
+
),
|
|
188
|
+
and_(
|
|
189
|
+
Show.enabled,
|
|
190
|
+
Show.status == Show.SUSPENDED,
|
|
191
|
+
Show.updated < today - delta_suspended
|
|
192
|
+
),
|
|
193
|
+
and_(
|
|
194
|
+
Show.enabled,
|
|
195
|
+
Show.status == Show.ENDED,
|
|
196
|
+
Show.updated < today - delta_ended
|
|
197
|
+
)
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
return shows.all()
|
|
201
|
+
|
|
202
|
+
def get_enabled_shows(self):
|
|
203
|
+
|
|
204
|
+
shows = self.session.query(Show).filter(Show.enabled)
|
|
205
|
+
return shows.all()
|
|
206
|
+
|
|
207
|
+
def get_show_by_url(self, url):
|
|
208
|
+
|
|
209
|
+
shows = self.session.query(Show).filter(Show.url == url)
|
|
210
|
+
|
|
211
|
+
if shows.count() < 1:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
return shows.first()
|
|
215
|
+
|
|
216
|
+
def get_show_by_id(self, show_id):
|
|
217
|
+
|
|
218
|
+
return self.session.scalars(
|
|
219
|
+
select(Show).where(Show.id == show_id)).first()
|
|
220
|
+
|
|
221
|
+
def add_show(self, show):
|
|
222
|
+
|
|
223
|
+
show = self.session.merge(show)
|
|
224
|
+
self.session.flush()
|
|
225
|
+
return show
|
|
226
|
+
|
|
227
|
+
def remove_show(self, show_id):
|
|
228
|
+
|
|
229
|
+
show = self.session.scalars(
|
|
230
|
+
select(Show).where(Show.id == show_id)).first()
|
|
231
|
+
|
|
232
|
+
if not show:
|
|
233
|
+
self.logger.error("No such show")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
episodes = self.session.scalars(select(Episode))
|
|
237
|
+
|
|
238
|
+
hits = [e for e in episodes if e.show_id == show.id]
|
|
239
|
+
for episode in hits:
|
|
240
|
+
self.session.delete(episode)
|
|
241
|
+
|
|
242
|
+
self.session.delete(show)
|
|
243
|
+
self.session.flush()
|
|
244
|
+
|
|
245
|
+
def get_shows(self):
|
|
246
|
+
|
|
247
|
+
return self.session.query(Show).all()
|
|
248
|
+
|
|
249
|
+
def add_episode(self, episode, show):
|
|
250
|
+
|
|
251
|
+
episode.show_id = show.id
|
|
252
|
+
self.session.merge(episode)
|
|
253
|
+
self.session.flush()
|
|
254
|
+
|
|
255
|
+
def get_episodes(self, basedate=date.today(), n_days=0):
|
|
256
|
+
|
|
257
|
+
enddate = basedate + timedelta(n_days)
|
|
258
|
+
|
|
259
|
+
return self.session.query(Episode). \
|
|
260
|
+
filter(Episode.airdate >= basedate). \
|
|
261
|
+
filter(Episode.airdate <= enddate). \
|
|
262
|
+
order_by(Episode.airdate).all()
|
|
263
|
+
|
|
264
|
+
def search(self, search):
|
|
265
|
+
|
|
266
|
+
q = self.session.query(Episode).join(Episode.show)
|
|
267
|
+
return q.filter(or_(
|
|
268
|
+
Episode.title.like(f"%%{search}%%"),
|
|
269
|
+
Show.name.like(f"%%{search}%%"))). \
|
|
270
|
+
order_by(Episode.airdate.asc()).all()
|
|
271
|
+
|
|
272
|
+
def commit(self):
|
|
273
|
+
|
|
274
|
+
self.session.commit()
|
|
275
|
+
self.session.begin()
|
|
276
|
+
|
|
277
|
+
def rollback(self):
|
|
278
|
+
|
|
279
|
+
self.session.rollback()
|
|
280
|
+
self.session.begin()
|
|
281
|
+
|
|
282
|
+
def remove_before(self, then, show=None):
|
|
283
|
+
|
|
284
|
+
eps = self.session.query(Episode).filter(Episode.airdate < then)
|
|
285
|
+
|
|
286
|
+
if show:
|
|
287
|
+
eps = eps.filter(Episode.show == show)
|
|
288
|
+
|
|
289
|
+
for episode in eps:
|
|
290
|
+
self.session.delete(episode)
|
|
291
|
+
|
|
292
|
+
self.commit()
|