1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 # dbsqlite.py -- SQLite interface
20 # Justin Forest <justin.forest@gmail.com> 2008-06-13
25 from sqlite3
import dbapi2
as sqlite
28 from pysqlite2
import dbapi2
as sqlite
32 # TODO: show a message box
34 print "Please install pysqlite2 or upgrade to Python 2.5."
38 from gpodder
.liblogger
import log
39 from email
.Utils
import mktime_tz
40 from email
.Utils
import parsedate_tz
41 from email
.Utils
import formatdate
42 from threading
import RLock
45 class Storage(object):
46 (STATE_NORMAL
, STATE_DOWNLOADED
, STATE_DELETED
) = range(3)
56 def setup(self
, settings
):
57 self
.settings
= settings
60 def log(self
, message
, *args
, **kwargs
):
61 if self
.settings
['gl'].config
.log_sqlite
:
63 message
= message
% args
64 log('%s', message
, sender
=self
)
66 log('Exception in log(): %s: %s', e
, message
, sender
=self
)
68 def purge(self
, max_episodes
, channel_id
):
70 Deletes old episodes. Should be called
71 before adding new episodes to a channel.
73 cur
= self
.cursor(lock
=True)
75 self
.log("purge(%s)", channel_id
)
81 (SELECT id FROM episodes WHERE channel_id = ?
82 ORDER BY pubDate DESC LIMIT ?)"""
83 cur
.execute(sql
, (channel_id
, self
.STATE_DOWNLOADED
, channel_id
, max_episodes
))
91 self
._db
= sqlite
.connect(self
.settings
['database'], check_same_thread
=False)
92 self
._db
.create_collation("UNICODE", lambda a
, b
: cmp(a
.lower().replace('the ', ''), b
.lower().replace('the ', '')))
96 def cursor(self
, lock
=False):
99 return self
.db
.cursor()
106 except ProgrammingError
, e
:
107 log('Error commiting changes: %s', e
, sender
=self
, traceback
=True)
110 def __check_schema(self
):
112 Creates all necessary tables and indexes that don't exist.
114 self
.log('Setting up tables and views')
116 cur
= self
.cursor(lock
=True)
118 self
.upgrade_table("channels", (
119 ("id", "INTEGER PRIMARY KEY"),
122 ("override_title", "TEXT"),
124 ("description", "TEXT"),
126 ("pubDate", "INTEGER"),
127 ("sync_to_devices", "INTEGER"),
128 ("device_playlist_name", "TEXT"),
129 ("username", "TEXT"),
130 ("password", "TEXT"),
131 ("last_modified", "TEXT"),
133 ("deleted", "INTEGER"),
136 self
.upgrade_table("episodes", (
137 ("id", "INTEGER PRIMARY KEY"),
138 ("channel_id", "INTEGER"),
141 ("length", "INTEGER"),
142 ("mimetype", "TEXT"),
144 ("description", "TEXT"),
146 ("pubDate", "INTEGER"),
147 ("state", "INTEGER"),
148 ("played", "INTEGER"),
149 ("locked", "INTEGER"),
152 cur
.execute("""CREATE UNIQUE INDEX IF NOT EXISTS idx_url ON channels (url)""")
153 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_sync_to_devices ON channels (sync_to_devices)""")
154 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_title ON channels (title)""")
155 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_deleted ON channels (deleted)""")
157 cur
.execute("""CREATE UNIQUE INDEX IF NOT EXISTS idx_guid ON episodes (guid)""")
158 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_channel_id ON episodes (channel_id)""")
159 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_pubDate ON episodes (pubDate)""")
160 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_state ON episodes (state)""")
161 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_played ON episodes (played)""")
162 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_locked ON episodes (locked)""")
164 cur
.execute("""CREATE TEMPORARY VIEW episodes_downloaded AS SELECT channel_id, COUNT(*) AS count FROM episodes WHERE state = 1 GROUP BY channel_id""")
165 cur
.execute("""CREATE TEMPORARY VIEW episodes_new AS SELECT channel_id, COUNT(*) AS count FROM episodes WHERE state = 0 AND played = 0 GROUP BY channel_id""")
166 cur
.execute("""CREATE TEMPORARY VIEW episodes_unplayed AS SELECT channel_id, COUNT(*) AS count FROM episodes WHERE played = 0 AND state = %d GROUP BY channel_id""" % self
.STATE_DOWNLOADED
)
168 # Make sure deleted episodes are played, to simplify querying statistics.
169 cur
.execute("UPDATE episodes SET played = 1 WHERE state = ?", (self
.STATE_DELETED
, ))
174 def get_channel_stat(self
, url_or_id
, state
=None, is_played
=None, is_locked
=None):
175 where
, params
= ((),())
177 if state
is not None:
178 where
+= ("state = ?", )
180 if is_played
is not None:
181 where
+= ("played = ?", )
182 params
+= (is_played
, )
183 if is_locked
is not None:
184 where
+= ("locked = ?", )
185 params
+= (is_locked
, )
186 if isinstance(url_or_id
, int):
187 where
+= ("channel_id = ?", )
188 params
+= (url_or_id
, )
190 where
+= ("channel_id IN (SELECT id FROM channels WHERE url = ?)", )
191 params
+= (url_or_id
, )
193 self
.log("get_channel_stats(%s)", url_or_id
)
196 return self
.__get
__("SELECT COUNT(*) FROM episodes WHERE %s" % (' AND '.join(where
)), params
)
200 def load_channels(self
, factory
=None, url
=None):
202 Returns channel descriptions as a list of dictionaries or objects,
203 returned by the factory() function, which receives the dictionary
204 as the only argument.
207 self
.log("load_channels()")
209 cur
= self
.cursor(lock
=True)
221 device_playlist_name,
229 (deleted IS NULL OR deleted = 0)
231 title COLLATE UNICODE
237 for row
in cur
.fetchall():
242 'override_title': row
[3],
244 'description': row
[5],
246 'pubDate': self
.__formatdate
__(row
[7]),
247 'sync_to_devices': row
[8],
248 'device_playlist_name': row
[9],
251 'last_modified': row
[12],
256 channel
['count_downloaded'] = stats
[row
[0]][0]
257 channel
['count_new'] = stats
[row
[0]][1]
258 channel
['count_unplayed'] = stats
[row
[0]][2]
261 # Maintain url/id relation for faster updates (otherwise
262 # we'd need to issue an extra query to find the channel id).
263 self
.channel_map
[channel
['url']] = channel
['id']
265 if url
is None or url
== channel
['url']:
267 result
.append(channel
)
269 result
.append(factory(channel
))
277 cur
= self
.cursor(lock
=True)
280 SELECT c.id, d.count, n.count, u.count
282 LEFT JOIN episodes_downloaded d ON d.channel_id = c.id
283 LEFT JOIN episodes_new n ON n.channel_id = c.id
284 LEFT JOIN episodes_unplayed u ON u.channel_id = c.id
290 for row
in cur
.fetchall():
291 data
[row
[0]] = (row
[1] or 0, row
[2] or 0, row
[3] or 0)
298 def save_channel(self
, c
, bulk
=False):
300 c
.id = self
.find_channel_id(c
.url
)
302 cur
= self
.cursor(lock
=True)
303 self
.log("save_channel((%s)%s)", c
.id or "new", c
.url
)
306 cur
.execute("INSERT INTO channels (url, title, override_title, link, description, image, pubDate, sync_to_devices, device_playlist_name, username, password, last_modified, etag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (c
.url
, c
.title
, c
.override_title
, c
.link
, c
.description
, c
.image
, self
.__mktime
__(c
.pubDate
), c
.sync_to_devices
, c
.device_playlist_name
, c
.username
, c
.password
, c
.last_modified
, c
.etag
, ))
307 self
.channel_map
[c
.url
] = cur
.lastrowid
309 cur
.execute("UPDATE channels SET url = ?, title = ?, override_title = ?, link = ?, description = ?, image = ?, pubDate = ?, sync_to_devices = ?, device_playlist_name = ?, username = ?, password = ?, last_modified = ?, etag = ?, deleted = 0 WHERE id = ?", (c
.url
, c
.title
, c
.override_title
, c
.link
, c
.description
, c
.image
, self
.__mktime
__(c
.pubDate
), c
.sync_to_devices
, c
.device_playlist_name
, c
.username
, c
.password
, c
.last_modified
, c
.etag
, c
.id, ))
314 def delete_channel(self
, channel
, purge
=False):
315 if channel
.id is None:
316 channel
.id = self
.find_channel_id(channel
.url
)
318 cur
= self
.cursor(lock
=True)
319 self
.log("delete_channel((%d)%s), purge=%d", channel
.id, channel
.url
, purge
)
322 cur
.execute("DELETE FROM channels WHERE id = ?", (channel
.id, ))
323 cur
.execute("DELETE FROM episodes WHERE channel_id = ?", (channel
.id, ))
324 if channel
.url
in self
.channel_map
:
325 del self
.channel_map
[channel
.url
]
327 cur
.execute("UPDATE channels SET deleted = 1 WHERE id = ?", (channel
.id, ))
328 cur
.execute("DELETE FROM episodes WHERE channel_id = ? AND state <> ?", (channel
.id, self
.STATE_DELETED
))
333 def __read_episodes(self
, factory
=None, where
=None, params
=None, commit
=True):
334 sql
= "SELECT url, title, length, mimetype, guid, description, link, pubDate, state, played, locked, id FROM episodes"
337 sql
= "%s %s" % (sql
, where
)
342 cur
= self
.cursor(lock
=True)
343 cur
.execute(sql
, params
)
346 for row
in cur
.fetchall():
353 'description': row
[5],
358 'is_locked': row
[10],
361 if episode
['state'] is None:
362 episode
['state'] = self
.STATE_NORMAL
364 result
.append(episode
)
366 result
.append(factory(episode
))
372 def load_episodes(self
, channel
, factory
=None, limit
=1000, state
=None):
373 if channel
.id is None:
374 channel
.id = self
.find_channel_id(channel
.url
)
376 self
.log("load_episodes((%d)%s)", channel
.id, channel
.url
)
379 return self
.__read
_episodes
(factory
= factory
, where
= """
380 WHERE channel_id = ? AND state = ? OR id IN
381 (SELECT id FROM episodes WHERE channel_id = ?
382 ORDER BY pubDate DESC LIMIT ?)
383 ORDER BY pubDate DESC
384 """, params
= (channel
.id, self
.STATE_DOWNLOADED
, channel
.id, limit
, ))
386 return self
.__read
_episodes
(factory
= factory
, where
= " WHERE channel_id = ? AND state = ? ORDER BY pubDate DESC LIMIT ?", params
= (channel
.id, state
, limit
, ))
388 def load_episode(self
, url
, factory
=None):
389 self
.log("load_episode(%s)", url
)
390 list = self
.__read
_episodes
(factory
= factory
, where
= " WHERE url = ?", params
= (url
, ))
394 def save_episode(self
, e
, bulk
=False):
396 log('Refusing to save an episode without guid: %s', e
)
401 self
.log("save_episode((%s)%s)", e
.id, e
.guid
)
405 channel_id
= self
.find_channel_id(e
.channel
.url
)
408 e
.id = self
.__get
__("SELECT id FROM episodes WHERE guid = ?", (e
.guid
, ))
409 self
.log("save_episode() -- looking up id")
412 cur
.execute("INSERT INTO episodes (channel_id, url, title, length, mimetype, guid, description, link, pubDate, state, played, locked) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (channel_id
, e
.url
, e
.title
, e
.length
, e
.mimetype
, e
.guid
, e
.description
, e
.link
, self
.__mktime
__(e
.pubDate
), e
.state
, e
.is_played
, e
.is_locked
, ))
415 cur
.execute("UPDATE episodes SET title = ?, length = ?, mimetype = ?, description = ?, link = ?, pubDate = ?, state = ?, played = ?, locked = ? WHERE id = ?", (e
.title
, e
.length
, e
.mimetype
, e
.description
, e
.link
, self
.__mktime
__(e
.pubDate
), e
.state
, e
.is_played
, e
.is_locked
, e
.id, ))
417 log('save_episode() failed: %s', e
, sender
=self
)
422 def mark_episode(self
, url
, state
=None, is_played
=None, is_locked
=None, toggle
=False):
423 cur
= self
.cursor(lock
=True)
424 cur
.execute("SELECT state, played, locked FROM episodes WHERE url = ?", (url
, ))
426 self
.log("mark_episode(%s, state=%s, played=%s, locked=%s)", url
, state
, is_played
, is_locked
)
429 ( cur_state
, cur_played
, cur_locked
) = cur
.fetchone()
431 # This only happens when we try to mark an unknown episode,
432 # which is typical for database upgrade, so we just ignore it.
439 cur_played
= not cur_played
441 cur_locked
= not cur_locked
443 if state
is not None:
445 if is_played
is not None:
446 cur_played
= is_played
447 if is_locked
is not None:
448 cur_locked
= is_locked
453 cur
.execute("UPDATE episodes SET state = ?, played = ?, locked = ? WHERE url = ?", (cur_state
, cur_played
, cur_locked
, url
, ))
458 def __get__(self
, sql
, params
=None):
460 Returns the first cell of a query result, useful for COUNT()s.
462 cur
= self
.cursor(lock
=True)
464 self
.log("__get__(): %s", sql
)
469 cur
.execute(sql
, params
)
480 def __mktime__(self
, date
):
481 if isinstance(date
, float) or isinstance(date
, int):
483 if date
is None or '' == date
:
486 return mktime_tz(parsedate_tz(date
))
488 log('Could not convert "%s" to a unix timestamp.', date
)
491 def __formatdate__(self
, date
):
493 return formatdate(date
, localtime
=1)
495 log('Could not convert "%s" to a string date.', date
)
498 def find_channel_id(self
, url
):
500 Looks up the channel id in the map (which lists all undeleted
501 channels), then tries to look it up in the database, including
504 if url
in self
.channel_map
.keys():
505 return self
.channel_map
[url
]
507 self
.log("find_channel_id(%s)", url
)
508 return self
.__get
__("SELECT id FROM channels WHERE url = ?", (url
, ))
510 def force_last_new(self
, channel
):
511 old
= self
.__get
__("""SELECT COUNT(*) FROM episodes WHERE channel_id = ?
512 AND state IN (?, ?)""", (channel
.id, self
.STATE_DOWNLOADED
,
515 cur
= self
.cursor(lock
=True)
517 self
.log("force_last_new((%d)%s)", channel
.id, channel
.url
)
521 UPDATE episodes SET played = 1 WHERE channel_id = ?
522 AND played = 0 AND pubDate < (SELECT MAX(pubDate)
523 FROM episodes WHERE channel_id = ? AND state IN (?, ?))""",
524 (channel
.id, channel
.id, self
.STATE_DOWNLOADED
,
525 self
.STATE_DELETED
, ))
528 UPDATE episodes SET played = 1 WHERE channel_id = ?
529 AND pubDate <> (SELECT MAX(pubDate) FROM episodes
530 WHERE channel_id = ?)""", (channel
.id, channel
.id, ))
535 def upgrade_table(self
, table_name
, fields
):
537 Creates a table or adds fields to it.
539 cur
= self
.cursor(lock
=True)
541 cur
.execute("PRAGMA table_info(%s)" % table_name
)
542 available
= cur
.fetchall()
544 if not len(available
):
545 log('Creating table %s', table_name
, sender
=self
)
546 sql
= "CREATE TABLE %s (%s)" % (table_name
, ", ".join([a
+" "+b
for (a
,b
) in fields
]))
549 available
= [row
[1] for row
in available
]
551 for field_name
, field_type
in fields
:
552 if field_name
not in available
:
553 log('Adding column %s to %s (%s)', table_name
, field_name
, field_type
, sender
=self
)
554 cur
.execute("ALTER TABLE %s ADD COLUMN %s %s" % (table_name
, field_name
, field_type
))