gpodder/src/gpodder/libpodcasts.py

833 lines
30 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2008 Thomas Perl and the gPodder Team
#
# gPodder is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# gPodder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#
# libpodcasts.py -- data classes for gpodder
# thomas perl <thp@perli.net> 20051029
#
# Contains code based on:
# liblocdbwriter.py (2006-01-09)
# liblocdbreader.py (2006-01-10)
#
import gtk
import gobject
import pango
import gpodder
from gpodder import util
from gpodder import opml
from gpodder import cache
from gpodder import services
from gpodder import draw
from gpodder import libtagupdate
from gpodder import dumbshelve
from gpodder.liblogger import log
from gpodder.libgpodder import gl
from gpodder.dbsqlite import db
import os.path
import os
import glob
import shutil
import sys
import urllib
import urlparse
import time
import datetime
import rfc822
import md5
import xml.dom.minidom
import feedparser
from xml.sax import saxutils
if gpodder.interface == gpodder.MAEMO:
Sun, 06 Apr 2008 02:05:34 +0200 <thp@perli.net> Initial upstream support for the Maemo platform (Nokia Internet Tablets) * bin/gpodder: Add "--maemo/-m" option to enable running as a Maemo application (this is only useful on Nokia Internet Tablets or in the Maemo SDK environment); determine interface type and set the correct variables on startup (gpodder.interface) * data/gpodder.glade: Increase the default size of some widgets to better fit the screens on Maemo (it won't do any harm on the "big" Desktop screen * data/icons/26/gpodder.png: Added * data/icons/40/gpodder.png: Added * data/maemo/gpodder.desktop: Added * Makefile: Help2man variable; new "make mtest" target that runs gPodder in Maemo scratchbox (probably useless for all other things); update the command descriptions; don't run the "generators" target from the "install" target; don't run "gen_graphics" from the "generators" target, but make it depend on the 24-pixel logo, which itself depends on the 22-pixel logo; this way, all should work out well when trying to install on systems without ImageMagick installed; remove *.pyo files on "make clean" * setup.py: Support for build targets; use "TARGET=maemo" to enable Maemo-specific installation options and files * src/gpodder/config.py: Increase the WRITE_TO_DISK_TIMEOUT to 60 seconds, so we don't unnecessarily stress memory cards (on ITs); modify default path variables on Maemo (/media/mmc2) * src/gpodder/gui.py: Maemo-specific changes; clean-up the main window a bit and make message and confirmation dialogs Hildon-compatible * src/gpodder/__init__.py: Add enums for interface types: CLI, GUI and MAEMO; remove the "interface_is_gui" variable and replace with "interface", which is now used to determine where we are running * src/gpodder/libgpodder.py: Use /media/mmc2/gpodder/ as configuration folder on Maemo; use Nokia's Media player to playback files on Maemo * src/gpodder/libpodcasts.py: Icon name changes (Maemo-specific) * src/gpodder/trayicon.py: Maemo support; swap popup menu on Maemo; Add support for hildon banners instead of pynotify on Maemo * src/gpodder/util.py: Icon name changes (Maemo-specific); use new gpodder.interface variable in idle_add git-svn-id: svn://svn.berlios.de/gpodder/trunk@654 b0d088ad-0a06-0410-aad2-9ed5178a7e87
2008-04-06 02:19:03 +02:00
ICON_AUDIO_FILE = 'gnome-mime-audio-mp3'
ICON_VIDEO_FILE = 'gnome-mime-video-mp4'
ICON_BITTORRENT = 'qgn_toolb_browser_web'
ICON_DOWNLOADING = 'qgn_toolb_messagin_moveto'
ICON_DELETED = 'qgn_toolb_gene_deletebutton'
ICON_NEW = 'qgn_list_gene_favor'
else:
ICON_AUDIO_FILE = 'audio-x-generic'
ICON_VIDEO_FILE = 'video-x-generic'
ICON_BITTORRENT = 'applications-internet'
ICON_DOWNLOADING = gtk.STOCK_GO_DOWN
ICON_DELETED = gtk.STOCK_DELETE
ICON_NEW = gtk.STOCK_ABOUT
class podcastChannel(object):
"""holds data for a complete channel"""
SETTINGS = ('sync_to_devices', 'device_playlist_name','override_title','username','password')
icon_cache = {}
fc = cache.Cache()
@classmethod
def load(cls, url, create=True):
if isinstance(url, unicode):
url = url.encode('utf-8')
tmp = db.load_channels(factory=lambda d: cls.create_from_dict(d), url=url)
if len(tmp):
return tmp[0]
elif create:
tmp = podcastChannel(url)
tmp.update()
tmp.save()
db.force_last_new(tmp)
return tmp
@staticmethod
def create_from_dict(d):
c = podcastChannel()
for key in d:
if hasattr(c, key):
setattr(c, key, d[key])
return c
def update(self):
(updated, c) = self.fc.fetch(self.url, self)
# update the cover if it's not there
self.update_cover()
# If we have an old instance of this channel, and
# feedcache says the feed hasn't changed, return old
if not updated:
log('Channel %s is up to date', self.url)
return
# Save etag and last-modified for later reuse
if c.headers.get('etag'):
self.etag = c.headers.get('etag')
if c.headers.get('last-modified'):
self.last_modified = c.headers.get('last-modified')
self.parse_error = c.get('bozo_exception', None)
if hasattr(c.feed, 'title'):
self.title = c.feed.title
else:
self.title = self.url
if hasattr( c.feed, 'link'):
self.link = c.feed.link
if hasattr( c.feed, 'subtitle'):
self.description = util.remove_html_tags(c.feed.subtitle)
if hasattr(c.feed, 'updated_parsed') and c.feed.updated_parsed is not None:
self.pubDate = rfc822.mktime_tz(c.feed.updated_parsed+(0,))
else:
self.pubDate = time.time()
if hasattr( c.feed, 'image'):
if c.feed.image.href:
old = self.image
self.image = c.feed.image.href
if old != self.image:
self.update_cover(force=True)
# Marked as bulk because we commit after importing episodes.
db.save_channel(self, bulk=True)
# We can limit the maximum number of entries that gPodder will parse
# via the "max_episodes_per_feed" configuration option.
if len(c.entries) > gl.config.max_episodes_per_feed:
log('Limiting number of episodes for %s to %d', self.title, gl.config.max_episodes_per_feed)
for entry in c.entries[:min(gl.config.max_episodes_per_feed, len(c.entries))]:
episode = None
try:
episode = podcastItem.from_feedparser_entry(entry, self)
except Exception, e:
log('Cannot instantiate episode "%s": %s. Skipping.', entry.get('id', '(no id available)'), e, sender=self, traceback=True)
if episode:
episode.save(bulk=True)
# Now we can flush the updates.
db.commit()
def update_cover(self, force=False):
if self.cover_file is None or not os.path.exists(self.cover_file) or force:
if self.image is not None:
services.cover_downloader.request_cover(self)
def delete(self):
db.delete_channel(self)
def save(self):
db.save_channel(self)
def stat(self, state=None, is_played=None, is_locked=None):
return db.get_channel_stat(self.url, state=state, is_played=is_played, is_locked=is_locked)
def __init__( self, url = "", title = "", link = "", description = ""):
self.id = None
self.url = url
self.title = title
self.link = link
self.description = util.remove_html_tags( description)
self.image = None
self.pubDate = 0
self.parse_error = None
self.newest_pubdate_cached = None
# should this channel be synced to devices? (ex: iPod)
self.sync_to_devices = True
# to which playlist should be synced
self.device_playlist_name = 'gPodder'
# if set, this overrides the channel-provided title
self.override_title = ''
self.username = ''
self.password = ''
self.last_modified = None
self.etag = None
self.save_dir_size = 0
self.__save_dir_size_set = False
def request_save_dir_size(self):
if not self.__save_dir_size_set:
self.update_save_dir_size()
self.__save_dir_size_set = True
def update_save_dir_size(self):
self.save_dir_size = util.calculate_size(self.save_dir)
def get_filename( self):
"""Return the MD5 sum of the channel URL"""
return md5.new( self.url).hexdigest()
filename = property(fget=get_filename)
def get_title( self):
if self.override_title:
return self.override_title
elif not self.__title.strip():
return self.url
else:
return self.__title
def set_title( self, value):
self.__title = value.strip()
title = property(fget=get_title,
fset=set_title)
def set_custom_title( self, custom_title):
custom_title = custom_title.strip()
if custom_title != self.__title:
self.override_title = custom_title
else:
self.override_title = ''
def get_downloaded_episodes(self):
return db.load_episodes(self, factory=lambda c: podcastItem.create_from_dict(c, self), state=db.STATE_DOWNLOADED)
def save_settings(self):
db.save_channel(self)
def get_new_episodes( self):
return [episode for episode in db.load_episodes(self, factory=lambda x: podcastItem.create_from_dict(x, self)) if episode.state == db.STATE_NORMAL and not episode.is_played]
def update_m3u_playlist(self):
if gl.config.create_m3u_playlists:
downloaded_episodes = self.get_downloaded_episodes()
fn = util.sanitize_filename(self.title)
if len(fn) == 0:
fn = os.path.basename(self.save_dir)
m3u_filename = os.path.join(gl.downloaddir, fn+'.m3u')
log('Writing playlist to %s', m3u_filename, sender=self)
f = open(m3u_filename, 'w')
f.write('#EXTM3U\n')
for episode in downloaded_episodes:
filename = episode.local_filename()
if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
f.write('#EXTINF:0,'+self.title+' - '+episode.title+' ('+episode.cute_pubdate()+')\n')
f.write(filename+'\n')
f.close()
def addDownloadedItem(self, item):
log('addDownloadedItem(%s)', item.url)
if not item.was_downloaded():
item.mark(is_played=False, state=db.STATE_DOWNLOADED)
# Update metadata on file (if possible and wanted)
if gl.config.update_tags and libtagupdate.tagging_supported():
filename = item.local_filename()
try:
libtagupdate.update_metadata_on_file(filename, title=item.title, artist=self.title)
except Exception, e:
log('Error while calling update_metadata_on_file(): %s', e)
self.update_m3u_playlist()
if item.file_type() == 'torrent':
torrent_filename = item.local_filename()
destination_filename = util.torrent_filename( torrent_filename)
gl.invoke_torrent(item.url, torrent_filename, destination_filename)
def get_all_episodes(self):
return db.load_episodes(self, factory = lambda d: podcastItem.create_from_dict(d, self), limit=gl.config.max_episodes_per_feed)
# not used anymore
def update_model( self):
self.update_save_dir_size()
model = self.tree_model
iter = model.get_iter_first()
while iter is not None:
self.iter_set_downloading_columns(model, iter)
iter = model.iter_next( iter)
@property
def tree_model( self):
log('Returning TreeModel for %s', self.url, sender = self)
return self.items_liststore()
def iter_set_downloading_columns( self, model, iter):
Sun, 06 Apr 2008 02:05:34 +0200 <thp@perli.net> Initial upstream support for the Maemo platform (Nokia Internet Tablets) * bin/gpodder: Add "--maemo/-m" option to enable running as a Maemo application (this is only useful on Nokia Internet Tablets or in the Maemo SDK environment); determine interface type and set the correct variables on startup (gpodder.interface) * data/gpodder.glade: Increase the default size of some widgets to better fit the screens on Maemo (it won't do any harm on the "big" Desktop screen * data/icons/26/gpodder.png: Added * data/icons/40/gpodder.png: Added * data/maemo/gpodder.desktop: Added * Makefile: Help2man variable; new "make mtest" target that runs gPodder in Maemo scratchbox (probably useless for all other things); update the command descriptions; don't run the "generators" target from the "install" target; don't run "gen_graphics" from the "generators" target, but make it depend on the 24-pixel logo, which itself depends on the 22-pixel logo; this way, all should work out well when trying to install on systems without ImageMagick installed; remove *.pyo files on "make clean" * setup.py: Support for build targets; use "TARGET=maemo" to enable Maemo-specific installation options and files * src/gpodder/config.py: Increase the WRITE_TO_DISK_TIMEOUT to 60 seconds, so we don't unnecessarily stress memory cards (on ITs); modify default path variables on Maemo (/media/mmc2) * src/gpodder/gui.py: Maemo-specific changes; clean-up the main window a bit and make message and confirmation dialogs Hildon-compatible * src/gpodder/__init__.py: Add enums for interface types: CLI, GUI and MAEMO; remove the "interface_is_gui" variable and replace with "interface", which is now used to determine where we are running * src/gpodder/libgpodder.py: Use /media/mmc2/gpodder/ as configuration folder on Maemo; use Nokia's Media player to playback files on Maemo * src/gpodder/libpodcasts.py: Icon name changes (Maemo-specific) * src/gpodder/trayicon.py: Maemo support; swap popup menu on Maemo; Add support for hildon banners instead of pynotify on Maemo * src/gpodder/util.py: Icon name changes (Maemo-specific); use new gpodder.interface variable in idle_add git-svn-id: svn://svn.berlios.de/gpodder/trunk@654 b0d088ad-0a06-0410-aad2-9ed5178a7e87
2008-04-06 02:19:03 +02:00
global ICON_AUDIO_FILE, ICON_VIDEO_FILE, ICON_BITTORRENT
global ICON_DOWNLOADING, ICON_DELETED, ICON_NEW
url = model.get_value( iter, 0)
episode = db.load_episode(url, factory=lambda x: podcastItem.create_from_dict(x, self))
if gl.config.episode_list_descriptions:
icon_size = 32
else:
icon_size = 16
if services.download_status_manager.is_download_in_progress(url):
Sun, 06 Apr 2008 02:05:34 +0200 <thp@perli.net> Initial upstream support for the Maemo platform (Nokia Internet Tablets) * bin/gpodder: Add "--maemo/-m" option to enable running as a Maemo application (this is only useful on Nokia Internet Tablets or in the Maemo SDK environment); determine interface type and set the correct variables on startup (gpodder.interface) * data/gpodder.glade: Increase the default size of some widgets to better fit the screens on Maemo (it won't do any harm on the "big" Desktop screen * data/icons/26/gpodder.png: Added * data/icons/40/gpodder.png: Added * data/maemo/gpodder.desktop: Added * Makefile: Help2man variable; new "make mtest" target that runs gPodder in Maemo scratchbox (probably useless for all other things); update the command descriptions; don't run the "generators" target from the "install" target; don't run "gen_graphics" from the "generators" target, but make it depend on the 24-pixel logo, which itself depends on the 22-pixel logo; this way, all should work out well when trying to install on systems without ImageMagick installed; remove *.pyo files on "make clean" * setup.py: Support for build targets; use "TARGET=maemo" to enable Maemo-specific installation options and files * src/gpodder/config.py: Increase the WRITE_TO_DISK_TIMEOUT to 60 seconds, so we don't unnecessarily stress memory cards (on ITs); modify default path variables on Maemo (/media/mmc2) * src/gpodder/gui.py: Maemo-specific changes; clean-up the main window a bit and make message and confirmation dialogs Hildon-compatible * src/gpodder/__init__.py: Add enums for interface types: CLI, GUI and MAEMO; remove the "interface_is_gui" variable and replace with "interface", which is now used to determine where we are running * src/gpodder/libgpodder.py: Use /media/mmc2/gpodder/ as configuration folder on Maemo; use Nokia's Media player to playback files on Maemo * src/gpodder/libpodcasts.py: Icon name changes (Maemo-specific) * src/gpodder/trayicon.py: Maemo support; swap popup menu on Maemo; Add support for hildon banners instead of pynotify on Maemo * src/gpodder/util.py: Icon name changes (Maemo-specific); use new gpodder.interface variable in idle_add git-svn-id: svn://svn.berlios.de/gpodder/trunk@654 b0d088ad-0a06-0410-aad2-9ed5178a7e87
2008-04-06 02:19:03 +02:00
status_icon = util.get_tree_icon(ICON_DOWNLOADING, icon_cache=self.icon_cache, icon_size=icon_size)
else:
if episode.state != db.STATE_DOWNLOADED and episode.file_exists():
episode.mark(state=db.STATE_DOWNLOADED)
log('Resurrected episode %s', episode.guid)
elif episode.state == db.STATE_DOWNLOADED and not episode.file_exists():
episode.mark(state=db.STATE_DELETED)
log('Burried episode %s', episode.guid)
if episode.state == db.STATE_NORMAL:
if episode.is_played:
status_icon = None
else:
status_icon = util.get_tree_icon(ICON_NEW, icon_cache=self.icon_cache, icon_size=icon_size)
elif episode.was_downloaded(and_exists=True):
missing = not episode.file_exists()
if missing:
log('Episode missing: %s (before drawing an icon)', episode.url, sender=self)
file_type = util.file_type_by_extension( model.get_value( iter, 9))
if file_type == 'audio':
status_icon = util.get_tree_icon(ICON_AUDIO_FILE, not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
elif file_type == 'video':
status_icon = util.get_tree_icon(ICON_VIDEO_FILE, not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
elif file_type == 'torrent':
status_icon = util.get_tree_icon(ICON_BITTORRENT, not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
else:
status_icon = util.get_tree_icon('unknown', not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
elif episode.state == db.STATE_DELETED or episode.state == db.STATE_DOWNLOADED:
status_icon = util.get_tree_icon(ICON_DELETED, icon_cache=self.icon_cache, icon_size=icon_size)
else:
log('Warning: Cannot determine status icon.', sender=self)
status_icon = None
model.set( iter, 4, status_icon)
def items_liststore( self):
"""
Return a gtk.ListStore containing episodes for this channel
"""
new_model = gtk.ListStore( gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING,
gobject.TYPE_BOOLEAN, gtk.gdk.Pixbuf, gobject.TYPE_STRING, gobject.TYPE_STRING,
gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING )
for item in self.get_all_episodes():
if gl.config.episode_list_descriptions:
description = '%s\n<small>%s</small>' % (saxutils.escape(item.title), saxutils.escape(item.one_line_description()))
else:
description = saxutils.escape(item.title)
if item.length:
filelength = gl.format_filesize(item.length, 1)
else:
filelength = None
new_iter = new_model.append((item.url, item.title, filelength,
True, None, item.cute_pubdate(), description, item.description,
item.local_filename(), item.extension()))
self.iter_set_downloading_columns( new_model, new_iter)
self.update_save_dir_size()
return new_model
def find_episode( self, url):
return db.load_episode(url, factory=lambda x: podcastItem.create_from_dict(x, self))
def get_save_dir(self):
save_dir = os.path.join(gl.downloaddir, self.filename, '')
# Create save_dir if it does not yet exist
if not util.make_directory( save_dir):
log( 'Could not create save_dir: %s', save_dir, sender = self)
return save_dir
save_dir = property(fget=get_save_dir)
def remove_downloaded( self):
shutil.rmtree( self.save_dir, True)
def get_index_file(self):
# gets index xml filename for downloaded channels list
return os.path.join( self.save_dir, 'index.xml')
index_file = property(fget=get_index_file)
def get_cover_file( self):
# gets cover filename for cover download cache
return os.path.join( self.save_dir, 'cover')
cover_file = property(fget=get_cover_file)
def delete_episode_by_url(self, url):
episode = db.load_episode(url, lambda c: podcastItem.create_from_dict(c, self))
if episode is not None:
util.delete_file(episode.local_filename())
episode.set_state(db.STATE_DELETED)
self.update_m3u_playlist()
class podcastItem(object):
"""holds data for one object in a channel"""
@staticmethod
def load(url, channel):
e = podcastItem(channel)
d = db.load_episode(url)
if d is not None:
for k, v in d.iteritems():
if hasattr(e, k):
setattr(e, k, v)
return e
@staticmethod
def from_feedparser_entry( entry, channel):
episode = podcastItem( channel)
episode.title = entry.get( 'title', util.get_first_line( util.remove_html_tags( entry.get( 'summary', ''))))
episode.link = entry.get( 'link', '')
episode.description = util.remove_html_tags( entry.get( 'summary', entry.get( 'link', entry.get( 'title', ''))))
episode.guid = entry.get( 'id', '')
if entry.get( 'updated_parsed', None):
episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
if episode.title == '':
log( 'Warning: Episode has no title, adding anyways.. (Feed Is Buggy!)', sender = episode)
enclosure = None
if hasattr(entry, 'enclosures') and len(entry.enclosures) > 0:
enclosure = entry.enclosures[0]
if len(entry.enclosures) > 1:
for e in entry.enclosures:
if hasattr( e, 'href') and hasattr( e, 'length') and hasattr( e, 'type') and (e.type.startswith('audio/') or e.type.startswith('video/')):
if util.normalize_feed_url(e.href) is not None:
log( 'Selected enclosure: %s', e.href, sender = episode)
enclosure = e
break
episode.url = util.normalize_feed_url( enclosure.get( 'href', ''))
elif hasattr(entry, 'link'):
(filename, extension) = util.filename_from_url(entry.link)
if extension == '' and hasattr( entry, 'type'):
extension = util.extension_from_mimetype(e.type)
file_type = util.file_type_by_extension(extension)
if file_type is not None:
log('Adding episode with link to file type "%s".', file_type, sender=episode)
episode.url = entry.link
if not episode.url:
# This item in the feed has no downloadable enclosure
return None
if not episode.pubDate:
metainfo = util.get_episode_info_from_url(episode.url)
if 'pubdate' in metainfo:
try:
episode.pubDate = int(float(metainfo['pubdate']))
except:
log('Cannot convert pubDate "%s" in from_feedparser_entry.', str(metainfo['pubdate']), traceback=True)
if hasattr( enclosure, 'length'):
try:
episode.length = int(enclosure.length)
except:
episode.length = -1
if hasattr( enclosure, 'type'):
episode.mimetype = enclosure.type
if episode.title == '':
( filename, extension ) = os.path.splitext( os.path.basename( episode.url))
episode.title = filename
return episode
def __init__( self, channel):
# Used by Storage for faster saving
self.id = None
self.url = ''
self.title = ''
self.length = 0
self.mimetype = 'application/octet-stream'
self.guid = ''
self.description = ''
self.link = ''
self.channel = channel
self.pubDate = None
self.state = db.STATE_NORMAL
self.is_played = False
self.is_locked = False
def save(self, bulk=False):
if self.state != db.STATE_DOWNLOADED and self.file_exists():
self.state = db.STATE_DOWNLOADED
db.save_episode(self, bulk=bulk)
def set_state(self, state):
self.state = state
db.mark_episode(self.url, state=self.state, is_played=self.is_played, is_locked=self.is_locked)
def mark(self, state=None, is_played=None, is_locked=None):
if state is not None:
self.state = state
if is_played is not None:
self.is_played = is_played
if is_locked is not None:
self.is_locked = is_locked
db.mark_episode(self.url, state=state, is_played=is_played, is_locked=is_locked)
@staticmethod
def create_from_dict(d, channel):
e = podcastItem(channel)
for key in d:
if hasattr(e, key):
setattr(e, key, d[key])
return e
def age_in_days(self):
return util.file_age_in_days(self.local_filename())
def is_old(self):
return self.age_in_days() > gl.config.episode_old_age
def get_age_string(self):
return util.file_age_to_string(self.age_in_days())
age_prop = property(fget=get_age_string)
def one_line_description( self):
lines = self.description.strip().splitlines()
if not lines or lines[0] == '':
return _('No description available')
else:
return ' '.join((l.strip() for l in lines if l.strip() != ''))
def delete_from_disk(self):
try:
self.channel.delete_episode_by_url(self.url)
except:
log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
def local_filename( self):
ext = self.extension()
# For compatibility with already-downloaded episodes,
# we accept md5 filenames if they are downloaded now.
md5_filename = os.path.join(self.channel.save_dir, md5.new(self.url).hexdigest()+ext)
if os.path.exists(md5_filename) or not gl.config.experimental_file_naming:
return md5_filename
# If the md5 filename does not exist,
( episode, e ) = util.filename_from_url(self.url)
episode = util.sanitize_filename(episode) + ext
# If the episode filename looks suspicious,
# we still return the md5 filename to be on
# the safe side of the fence ;)
if len(episode) == 0 or episode.startswith('redirect.'):
return md5_filename
filename = os.path.join(self.channel.save_dir, episode)
return filename
def extension( self):
( filename, ext ) = util.filename_from_url(self.url)
# if we can't detect the extension from the url fallback on the mimetype
if ext == '' or util.file_type_by_extension(ext) is None:
ext = util.extension_from_mimetype(self.mimetype)
#log('Getting extension from mimetype for: %s (mimetype: %s)' % (self.title, ext), sender=self)
return ext
def mark_new(self):
self.state = db.STATE_NORMAL
self.is_played = False
db.mark_episode(self.url, state=self.state, is_played=self.is_played)
def mark_old(self):
self.is_played = True
db.mark_episode(self.url, is_played=True)
def file_exists(self):
return os.path.exists(self.local_filename())
def was_downloaded(self, and_exists=False):
if self.state != db.STATE_DOWNLOADED:
return False
if and_exists and not self.file_exists():
return False
return True
def sync_filename( self):
if gl.config.custom_sync_name_enabled:
return util.object_string_formatter(gl.config.custom_sync_name, episode=self, channel=self.channel)
else:
return self.title
def file_type( self):
return util.file_type_by_extension( self.extension() )
@property
def basename( self):
return os.path.splitext( os.path.basename( self.url))[0]
@property
def published( self):
try:
return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
except:
log( 'Cannot format pubDate for "%s".', self.title, sender = self)
return '00000000'
def cute_pubdate(self):
result = util.format_date(self.pubDate)
if result is None:
return '(%s)' % _('unknown')
else:
return result
pubdate_prop = property(fget=cute_pubdate)
def calculate_filesize( self):
try:
self.length = os.path.getsize(self.local_filename())
except:
log( 'Could not get filesize for %s.', self.url)
def get_filesize_string( self):
return gl.format_filesize( self.length)
filesize_prop = property(fget=get_filesize_string)
def get_channel_title( self):
return self.channel.title
channel_prop = property(fget=get_channel_title)
def get_played_string( self):
if not self.is_played:
return _('Unplayed')
return ''
played_prop = property(fget=get_played_string)
Fri, 13 Jun 2008 21:32:57 +0200 <thp@perli.net> Automatically download channel cover file; improve channel cover handling * data/gpodder.glade: Simplify and clean-up the podcast editor dialog, especially with respect to the cover art stuff * src/gpodder/config.py: Add configuration option "podcast_list_icon_size" that determines the pixel size of the cover art displayed in the podcast list * src/gpodder/gui.py: Add cover cache, register with the cover downloader service in the main window, handle messages from the cover downloader (removed and download finished); request covers for channels when refreshing the channel list; make sure drag'n'drop of image files to the channel list works directly and sets the corresponding channel cover; Rework cover download handling and add an open dialog as suggested by the May 2008 Usability Evaluation * src/gpodder/libgpodder.py: Remove old, attic image downloading code from gPodderLib, because it now has its own service class * src/gpodder/libpodcasts.py: Remove unneeded get_cover_pixbuf helper function for podcastChannel; improve channels_to_model to take advantage of the new cover downloader service * src/gpodder/services.py: Add CoverDownloader service that acts as a central hub for all downloading and modifying of channel cover art, including notification of observers (through ObservableService) * src/gpodder/util.py: Add resize_pixbuf_keep_ratio helper function to resize a gtk pixbuf while keeping the aspect radio (with optional caching support through a dictionary parameter) (Closes: http://bugs.gpodder.org/show_bug.cgi?id=88) Fri, 13 Jun 2008 20:10:13 +0200 <thp@perli.net> Fix a bug in the experimental file naming support * src/gpodder/util.py: Fix bug that stopped the experimental file naming patch from working; thanks to Shane Donohoe for reporting git-svn-id: svn://svn.berlios.de/gpodder/trunk@737 b0d088ad-0a06-0410-aad2-9ed5178a7e87
2008-06-14 13:43:53 +02:00
def channels_to_model(channels, cover_cache=None, max_width=0, max_height=0):
new_model = gtk.ListStore(str, str, str, gtk.gdk.Pixbuf, int, gtk.gdk.Pixbuf, str, bool)
for channel in channels:
count_downloaded = channel.stat(state=db.STATE_DOWNLOADED)
count_new = channel.stat(state=db.STATE_NORMAL, is_played=False)
count_unplayed = channel.stat(state=db.STATE_DOWNLOADED, is_played=False)
new_iter = new_model.append()
new_model.set(new_iter, 0, channel.url)
new_model.set(new_iter, 1, channel.title)
title_markup = saxutils.escape(channel.title)
description_markup = saxutils.escape(util.get_first_line(channel.description) or _('No description available'))
d = []
if count_new:
d.append('<span weight="bold">')
d.append(title_markup)
if count_new:
d.append('</span>')
description = ''.join(d+['\n', '<small>', description_markup, '</small>'])
if channel.parse_error is not None:
description = ''.join(['<span foreground="#ff0000">', description, '</span>'])
new_model.set(new_iter, 6, channel.parse_error)
new_model.set(new_iter, 2, description)
if count_unplayed > 0 or count_downloaded > 0:
new_model.set(new_iter, 3, draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded)))
new_model.set(new_iter, 7, True)
else:
new_model.set(new_iter, 7, False)
Fri, 13 Jun 2008 21:32:57 +0200 <thp@perli.net> Automatically download channel cover file; improve channel cover handling * data/gpodder.glade: Simplify and clean-up the podcast editor dialog, especially with respect to the cover art stuff * src/gpodder/config.py: Add configuration option "podcast_list_icon_size" that determines the pixel size of the cover art displayed in the podcast list * src/gpodder/gui.py: Add cover cache, register with the cover downloader service in the main window, handle messages from the cover downloader (removed and download finished); request covers for channels when refreshing the channel list; make sure drag'n'drop of image files to the channel list works directly and sets the corresponding channel cover; Rework cover download handling and add an open dialog as suggested by the May 2008 Usability Evaluation * src/gpodder/libgpodder.py: Remove old, attic image downloading code from gPodderLib, because it now has its own service class * src/gpodder/libpodcasts.py: Remove unneeded get_cover_pixbuf helper function for podcastChannel; improve channels_to_model to take advantage of the new cover downloader service * src/gpodder/services.py: Add CoverDownloader service that acts as a central hub for all downloading and modifying of channel cover art, including notification of observers (through ObservableService) * src/gpodder/util.py: Add resize_pixbuf_keep_ratio helper function to resize a gtk pixbuf while keeping the aspect radio (with optional caching support through a dictionary parameter) (Closes: http://bugs.gpodder.org/show_bug.cgi?id=88) Fri, 13 Jun 2008 20:10:13 +0200 <thp@perli.net> Fix a bug in the experimental file naming support * src/gpodder/util.py: Fix bug that stopped the experimental file naming patch from working; thanks to Shane Donohoe for reporting git-svn-id: svn://svn.berlios.de/gpodder/trunk@737 b0d088ad-0a06-0410-aad2-9ed5178a7e87
2008-06-14 13:43:53 +02:00
# Load the cover if we have it, but don't download
# it if it's not available (to avoid blocking here)
pixbuf = services.cover_downloader.get_cover(channel, avoid_downloading=True)
new_pixbuf = None
if pixbuf is not None:
new_pixbuf = util.resize_pixbuf_keep_ratio(pixbuf, max_width, max_height, channel.url, cover_cache)
new_model.set(new_iter, 5, new_pixbuf or pixbuf)
return new_model
def load_channels():
return db.load_channels(lambda d: podcastChannel.create_from_dict(d))
def update_channels(callback_proc=None, callback_error=None, is_cancelled_cb=None):
log('Updating channels....')
channels = load_channels()
count = 0
for channel in channels:
if is_cancelled_cb is not None and is_cancelled_cb():
return channels
callback_proc and callback_proc(count, len(channels))
channel.update()
count += 1
return channels
def save_channels( channels):
exporter = opml.Exporter(gl.channel_opml_file)
return exporter.write(channels)
def can_restore_from_opml():
try:
if len(opml.Importer(gl.channel_opml_file).items):
return gl.channel_opml_file
except:
return None
class LocalDBReader( object):
"""
DEPRECATED - Only used for migration to SQLite
"""
def __init__( self, url):
self.url = url
def get_text( self, nodelist):
return ''.join( [ node.data for node in nodelist if node.nodeType == node.TEXT_NODE ])
def get_text_by_first_node( self, element, name):
return self.get_text( element.getElementsByTagName( name)[0].childNodes)
def get_episode_from_element( self, channel, element):
episode = podcastItem( channel)
episode.title = self.get_text_by_first_node( element, 'title')
episode.description = self.get_text_by_first_node( element, 'description')
episode.url = self.get_text_by_first_node( element, 'url')
episode.link = self.get_text_by_first_node( element, 'link')
episode.guid = self.get_text_by_first_node( element, 'guid')
if not episode.guid:
for k in ('url', 'link'):
if getattr(episode, k) is not None:
episode.guid = getattr(episode, k)
log('Notice: episode has no guid, using %s', episode.guid)
break
try:
episode.pubDate = float(self.get_text_by_first_node(element, 'pubDate'))
except:
log('Looks like you have an old pubDate in your LocalDB -> converting it')
episode.pubDate = self.get_text_by_first_node(element, 'pubDate')
log('FYI: pubDate value is: "%s"', episode.pubDate, sender=self)
pubdate = feedparser._parse_date(episode.pubDate)
if pubdate is None:
log('Error converting the old pubDate - sorry!', sender=self)
episode.pubDate = 0
else:
log('PubDate converted successfully - yay!', sender=self)
episode.pubDate = time.mktime(pubdate)
try:
episode.mimetype = self.get_text_by_first_node( element, 'mimetype')
except:
log('No mimetype info for %s', episode.url, sender=self)
episode.calculate_filesize()
return episode
def load_and_clean( self, filename):
"""
Clean-up a LocalDB XML file that could potentially contain
"unbound prefix" XML elements (generated by the old print-based
LocalDB code). The code removes those lines to make the new
DOM parser happy.
This should be removed in a future version.
"""
lines = []
for line in open(filename).read().split('\n'):
if not line.startswith('<gpodder:info'):
lines.append( line)
return '\n'.join( lines)
def read( self, filename):
doc = xml.dom.minidom.parseString( self.load_and_clean( filename))
rss = doc.getElementsByTagName('rss')[0]
channel_element = rss.getElementsByTagName('channel')[0]
channel = podcastChannel( url = self.url)
channel.title = self.get_text_by_first_node( channel_element, 'title')
channel.description = self.get_text_by_first_node( channel_element, 'description')
channel.link = self.get_text_by_first_node( channel_element, 'link')
episodes = []
for episode_element in rss.getElementsByTagName('item'):
episode = self.get_episode_from_element( channel, episode_element)
episodes.append(episode)
return episodes