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 LockingCursor(sqlite
.Cursor
):
47 This custom cursor implementation provides thread safety.
48 Only one thread at a time can work with cursors, as many
49 as needed. Using a custom cursor makes this transparent.
51 One possible alternative is to use a queue and a single
52 SQLite worker thread, which sounds right but can introduce
53 serious problems, e.g. when a thread puts something in
54 queue and dies, refusing to fetch the results. Example
57 http://code.activestate.com/recipes/526618/
61 def __init__(self
, *args
, **kwargs
):
63 sqlite
.Cursor
.__init
__(self
, *args
, **kwargs
)
68 class Storage(object):
69 (STATE_NORMAL
, STATE_DOWNLOADED
, STATE_DELETED
) = range(3)
75 LockingCursor
.lock
= RLock()
77 def setup(self
, settings
):
78 self
.settings
= settings
84 self
._db
= sqlite
.connect(self
.settings
['database'], check_same_thread
=False)
85 self
._db
.create_collation("unicode", lambda a
, b
: cmp(a
.lower(), b
.lower()))
86 log('SQLite connected', sender
=self
)
90 return self
.db
.cursor(factory
=LockingCursor
)
93 # grab a cursor to lock threads
97 def __check_schema(self
):
99 Creates all necessary tables and indexes that don't exist.
101 log('Setting up SQLite database', sender
=self
)
105 cur
.execute("""CREATE TABLE IF NOT EXISTS channels (
106 id INTEGER PRIMARY KEY,
114 sync_to_devices INTEGER,
115 device_playlist_name TEXT,
122 cur
.execute("""CREATE UNIQUE INDEX IF NOT EXISTS idx_url ON channels (url)""")
123 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_sync_to_devices ON channels (sync_to_devices)""")
124 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_title ON channels (title)""")
125 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_deleted ON channels (deleted)""")
128 CREATE TABLE IF NOT EXISTS episodes (
129 id INTEGER PRIMARY KEY,
144 cur
.execute("""CREATE UNIQUE INDEX IF NOT EXISTS idx_guid ON episodes (guid)""")
145 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_channel_id ON episodes (channel_id)""")
146 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_pubDate ON episodes (pubDate)""")
147 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_state ON episodes (state)""")
148 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_played ON episodes (played)""")
149 cur
.execute("""CREATE INDEX IF NOT EXISTS idx_locked ON episodes (locked)""")
153 def get_channel_stat(self
, url_or_id
, state
=None, is_played
=None, is_locked
=None):
154 where
, params
= ((),())
156 if state
is not None:
157 where
+= ("state = ?", )
159 if is_played
is not None:
160 where
+= ("played = ?", )
161 params
+= (is_played
, )
162 if is_locked
is not None:
163 where
+= ("locked = ?", )
164 params
+= (is_locked
, )
165 if isinstance(url_or_id
, int):
166 where
+= ("channel_id = ?", )
167 params
+= (url_or_id
, )
169 where
+= ("channel_id IN (SELECT id FROM channels WHERE url = ?)", )
170 params
+= (url_or_id
, )
173 return self
.__get
__("SELECT COUNT(*) FROM episodes WHERE %s" % (' AND '.join(where
)), params
)
177 def load_channels(self
, factory
=None, url
=None):
179 Returns channel descriptions as a list of dictionaries or objects,
180 returned by the factory() function, which receives the dictionary
181 as the only argument.
196 device_playlist_name,
204 (deleted IS NULL OR deleted = 0)
206 title COLLATE unicode
210 for row
in cur
.fetchall():
215 'override_title': row
[3],
217 'description': row
[5],
219 'pubDate': self
.__formatdate
__(row
[7]),
220 'sync_to_devices': row
[8],
221 'device_playlist_name': row
[9],
224 'last_modified': row
[12],
229 # Maintain url/id relation for faster updates (otherwise
230 # we'd need to issue an extra query to find the channel id).
231 self
.channel_map
[channel
['url']] = channel
['id']
233 if url
is None or url
== channel
['url']:
235 result
.append(channel
)
237 result
.append(factory(channel
))
242 log('Channel list read, %d entries.', len(result
), sender
=self
)
244 log('Channel %s read from db', url
, sender
=self
)
248 def save_channel(self
, c
, bulk
=False):
250 c
.id = self
.find_channel_id(c
.url
)
255 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
, ))
256 self
.channel_map
[c
.url
] = cur
.lastrowid
257 log('Added channel %s[%d]', c
.url
, self
.channel_map
[c
.url
], sender
=self
)
259 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, ))
264 def delete_channel(self
, channel
, purge
=False):
265 if channel
.id is None:
266 channel
.id = self
.find_channel_id(channel
.url
)
271 cur
.execute("DELETE FROM channels WHERE id = ?", (channel
.id, ))
272 cur
.execute("DELETE FROM episodes WHERE channel_id = ?", (channel
.id, ))
273 if channel
.url
in self
.channel_map
:
274 del self
.channel_map
[channel
.url
]
276 cur
.execute("UPDATE channels SET deleted = 1 WHERE id = ?", (channel
.id, ))
277 cur
.execute("DELETE FROM episodes WHERE channel_id = ? AND state <> ?", (channel
.id, self
.STATE_DELETED
))
281 def __read_episodes(self
, factory
=None, where
=None, params
=None, commit
=True):
282 sql
= "SELECT url, title, length, mimetype, guid, description, link, pubDate, state, played, locked FROM episodes"
285 sql
= "%s %s" % (sql
, where
)
291 cur
.execute(sql
, params
)
294 for row
in cur
.fetchall():
301 'description': row
[5],
306 'is_locked': row
[10],
308 if episode
['state'] is None:
309 episode
['state'] = self
.STATE_NORMAL
311 result
.append(episode
)
313 result
.append(factory(episode
))
317 def load_episodes(self
, channel
, factory
=None, limit
=1000, state
=None):
318 if channel
.id is None:
319 channel
.id = self
.find_channel_id(channel
.url
)
322 return self
.__read
_episodes
(factory
= factory
, where
= """
323 WHERE channel_id = ? AND state = ? OR id IN
324 (SELECT id FROM episodes WHERE channel_id = ?
325 ORDER BY pubDate DESC LIMIT ?)
326 ORDER BY pubDate DESC
327 """, params
= (channel
.id, self
.STATE_DOWNLOADED
, channel
.id, limit
, ))
329 return self
.__read
_episodes
(factory
= factory
, where
= " WHERE channel_id = ? AND state = ? ORDER BY pubDate DESC LIMIT ?", params
= (channel
.id, state
, limit
, ))
331 def load_episode(self
, url
, factory
=None):
332 list = self
.__read
_episodes
(factory
= factory
, where
= " WHERE url = ?", params
= (url
, ))
336 def save_episode(self
, e
, bulk
=False):
338 log('Refusing to save an episode without guid: %s', e
)
343 channel_id
= self
.find_channel_id(e
.channel
.url
)
346 e
.id = self
.__get
__("SELECT id FROM episodes WHERE guid = ?", (e
.guid
, ))
349 log('Episode added: %s', e
.title
)
350 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
, ))
353 log('Episode updated: %s', e
.title
)
354 cur
.execute("UPDATE episodes SET title = ?, length = ?, mimetype = ?, description = ?, link = ?, pubDate = ? WHERE id = ?", (e
.title
, e
.length
, e
.mimetype
, e
.description
, e
.link
, self
.__mktime
__(e
.pubDate
), e
.id, ))
356 log('save_episode() failed: %s', e
, sender
=self
)
360 def mark_episode(self
, url
, state
=None, is_played
=None, is_locked
=None, toggle
=False):
362 cur
.execute("SELECT state, played, locked FROM episodes WHERE url = ?", (url
, ))
365 ( cur_state
, cur_played
, cur_locked
) = cur
.fetchone()
367 # This only happens when we try to mark an unknown episode,
368 # which is typical for database upgrade, so we just ignore it.
373 cur_played
= not cur_played
375 cur_locked
= not cur_locked
377 if state
is not None:
379 if is_played
is not None:
380 cur_played
= is_played
381 if is_locked
is not None:
382 cur_locked
= is_locked
384 cur
.execute("UPDATE episodes SET state = ?, played = ?, locked = ? WHERE url = ?", (cur_state
, cur_played
, cur_locked
, url
, ))
387 def __get__(self
, sql
, params
=None):
389 Returns the first cell of a query result, useful for COUNT()s.
396 cur
.execute(sql
, params
)
405 def __mktime__(self
, date
):
406 if isinstance(date
, float) or isinstance(date
, int):
408 if date
is None or '' == date
:
411 return mktime_tz(parsedate_tz(date
))
413 log('Could not convert "%s" to a unix timestamp.', date
)
416 def __formatdate__(self
, date
):
418 return formatdate(date
, localtime
=1)
420 log('Could not convert "%s" to a string date.', date
)
423 def find_channel_id(self
, url
):
425 Looks up the channel id in the map (which lists all undeleted
426 channels), then tries to look it up in the database, including
429 if url
in self
.channel_map
.keys():
430 return self
.channel_map
[url
]
432 return self
.__get
__("SELECT id FROM channels WHERE url = ?", (url
, ))
434 def force_last_new(self
, channel
):
435 old
= self
.__get
__("""SELECT COUNT(*) FROM episodes WHERE channel_id = ?
436 AND state IN (?, ?)""", (channel
.id, self
.STATE_DOWNLOADED
,
438 log('old episodes in (%d)%s: %d', channel
.id, channel
.url
, old
)
441 self
.cursor().execute("""
442 UPDATE episodes SET played = 1 WHERE channel_id = ?
443 AND played = 0 AND pubDate < (SELECT MAX(pubDate)
444 FROM episodes WHERE channel_id = ? AND state IN (?, ?))""",
445 (channel
.id, channel
.id, self
.STATE_DOWNLOADED
,
446 self
.STATE_DELETED
, ))
448 self
.cursor().execute("""
449 UPDATE episodes SET played = 1 WHERE channel_id = ?
450 AND pubDate <> (SELECT MAX(pubDate) FROM episodes
451 WHERE channel_id = ?)""", (channel
.id, channel
.id, ))