1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 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/>.
22 # libpodcasts.py -- data classes for gpodder
23 # thomas perl <thp@perli.net> 20051029
25 # Contains code based on:
26 # liblocdbwriter.py (2006-01-09)
27 # liblocdbreader.py (2006-01-10)
35 from gpodder
import util
36 from gpodder
import opml
37 from gpodder
import cache
38 from gpodder
import services
39 from gpodder
import draw
40 from gpodder
import libtagupdate
41 from gpodder
import dumbshelve
42 from gpodder
import resolver
44 from gpodder
.liblogger
import log
45 from gpodder
.libgpodder
import gl
46 from gpodder
.dbsqlite
import db
59 import xml
.dom
.minidom
62 from xml
.sax
import saxutils
65 if gpodder
.interface
== gpodder
.MAEMO
:
66 ICON_AUDIO_FILE
= 'gnome-mime-audio-mp3'
67 ICON_VIDEO_FILE
= 'gnome-mime-video-mp4'
68 ICON_DOWNLOADING
= 'qgn_toolb_messagin_moveto'
69 ICON_DELETED
= 'qgn_toolb_gene_deletebutton'
70 ICON_NEW
= 'qgn_list_gene_favor'
72 ICON_AUDIO_FILE
= 'audio-x-generic'
73 ICON_VIDEO_FILE
= 'video-x-generic'
74 ICON_DOWNLOADING
= gtk
.STOCK_GO_DOWN
75 ICON_DELETED
= gtk
.STOCK_DELETE
76 ICON_NEW
= gtk
.STOCK_ABOUT
79 class HTTPAuthError(Exception): pass
81 class podcastChannel(object):
82 """holds data for a complete channel"""
83 SETTINGS
= ('sync_to_devices', 'device_playlist_name','override_title','username','password')
84 MAX_FOLDERNAME_LENGTH
= 150
90 def load(cls
, url
, create
=True, authentication_tokens
=None):
91 if isinstance(url
, unicode):
92 url
= url
.encode('utf-8')
94 tmp
= db
.load_channels(factory
=lambda d
: cls
.create_from_dict(d
), url
=url
)
98 tmp
= podcastChannel(url
)
99 if authentication_tokens
is not None:
100 tmp
.username
= authentication_tokens
[0]
101 tmp
.password
= authentication_tokens
[1]
102 success
, error_code
= tmp
.update()
104 if error_code
== 401:
109 db
.force_last_new(tmp
)
113 def create_from_dict(d
):
117 setattr(c
, key
, d
[key
])
121 (updated
, c
) = self
.fc
.fetch(self
.url
, self
)
124 return ( False, None )
127 return ( False, 401 )
129 if self
.url
!= c
.url
:
130 log('Updating channel URL from %s to %s', self
.url
, c
.url
, sender
=self
)
133 # update the cover if it's not there
136 # If we have an old instance of this channel, and
137 # feedcache says the feed hasn't changed, return old
139 log('Channel %s is up to date', self
.url
)
140 return ( True, None )
142 # Save etag and last-modified for later reuse
143 if c
.headers
.get('etag'):
144 self
.etag
= c
.headers
.get('etag')
145 if c
.headers
.get('last-modified'):
146 self
.last_modified
= c
.headers
.get('last-modified')
148 self
.parse_error
= c
.get('bozo_exception', None)
150 if hasattr(c
.feed
, 'title'):
151 self
.title
= c
.feed
.title
152 # Start YouTube-specific title FIX
153 YOUTUBE_PREFIX
= 'Videos uploaded by '
154 if self
.title
.startswith(YOUTUBE_PREFIX
):
155 self
.title
= self
.title
[len(YOUTUBE_PREFIX
):] + ' on YouTube'
156 # End YouTube-specific title FIX
158 self
.title
= self
.url
159 if hasattr( c
.feed
, 'link'):
160 self
.link
= c
.feed
.link
161 if hasattr( c
.feed
, 'subtitle'):
162 self
.description
= c
.feed
.subtitle
164 if hasattr(c
.feed
, 'updated_parsed') and c
.feed
.updated_parsed
is not None:
165 self
.pubDate
= rfc822
.mktime_tz(c
.feed
.updated_parsed
+(0,))
167 self
.pubDate
= time
.time()
168 if hasattr( c
.feed
, 'image'):
169 if hasattr(c
.feed
.image
, 'href') and c
.feed
.image
.href
:
171 self
.image
= c
.feed
.image
.href
172 if old
!= self
.image
:
173 self
.update_cover(force
=True)
175 # Marked as bulk because we commit after importing episodes.
176 db
.save_channel(self
, bulk
=True)
178 # Remove old episodes before adding the new ones. This helps
179 # deal with hyperactive channels, such as TV news, when there
180 # can be more new episodes than the user wants in the list.
181 # By cleaning up old episodes before receiving the new ones we
182 # ensure that the user doesn't miss any.
183 db
.purge(gl
.config
.max_episodes_per_feed
, self
.id)
185 # Load all episodes to update them properly.
186 existing
= self
.get_all_episodes()
188 # We can limit the maximum number of entries that gPodder will parse
189 # via the "max_episodes_per_feed" configuration option.
190 if len(c
.entries
) > gl
.config
.max_episodes_per_feed
:
191 log('Limiting number of episodes for %s to %d', self
.title
, gl
.config
.max_episodes_per_feed
)
192 for entry
in c
.entries
[:min(gl
.config
.max_episodes_per_feed
, len(c
.entries
))]:
196 episode
= podcastItem
.from_feedparser_entry(entry
, self
)
198 log('Cannot instantiate episode "%s": %s. Skipping.', entry
.get('id', '(no id available)'), e
, sender
=self
, traceback
=True)
204 if ex
.guid
== episode
.guid
:
205 for k
in ('title', 'title', 'description', 'link', 'pubDate'):
206 setattr(ex
, k
, getattr(episode
, k
))
210 episode
.save(bulk
=True)
213 return ( True, None )
215 def update_cover(self
, force
=False):
216 if self
.cover_file
is None or not os
.path
.exists(self
.cover_file
) or force
:
217 if self
.image
is not None:
218 services
.cover_downloader
.request_cover(self
)
221 db
.delete_channel(self
)
224 db
.save_channel(self
)
226 def stat(self
, state
=None, is_played
=None, is_locked
=None):
227 return db
.get_channel_stat(self
.url
, state
=state
, is_played
=is_played
, is_locked
=is_locked
)
229 def __init__( self
, url
= "", title
= "", link
= "", description
= ""):
234 self
.description
= description
237 self
.parse_error
= None
238 self
.newest_pubdate_cached
= None
239 self
.update_flag
= False # channel is updating or to be updated
241 self
.foldername
= None
242 self
.auto_foldername
= 1 # automatically generated foldername
244 # should this channel be synced to devices? (ex: iPod)
245 self
.sync_to_devices
= True
246 # to which playlist should be synced
247 self
.device_playlist_name
= 'gPodder'
248 # if set, this overrides the channel-provided title
249 self
.override_title
= ''
253 self
.last_modified
= None
256 self
.save_dir_size
= 0
257 self
.__save
_dir
_size
_set
= False
259 self
.count_downloaded
= 0
261 self
.count_unplayed
= 0
263 self
.channel_is_locked
= False
265 def request_save_dir_size(self
):
266 if not self
.__save
_dir
_size
_set
:
267 self
.update_save_dir_size()
268 self
.__save
_dir
_size
_set
= True
270 def update_save_dir_size(self
):
271 self
.save_dir_size
= util
.calculate_size(self
.save_dir
)
273 def get_title( self
):
274 if self
.override_title
:
275 return self
.override_title
276 elif not self
.__title
.strip():
281 def set_title( self
, value
):
282 self
.__title
= value
.strip()
284 title
= property(fget
=get_title
,
287 def set_custom_title( self
, custom_title
):
288 custom_title
= custom_title
.strip()
290 # make sure self.foldername is initialized
293 # rename folder if custom_title looks sane
294 new_folder_name
= self
.find_unique_folder_name(custom_title
)
295 if len(new_folder_name
) > 0 and new_folder_name
!= self
.foldername
:
296 log('Changing foldername based on custom title: %s', custom_title
, sender
=self
)
297 new_folder
= os
.path
.join(gl
.downloaddir
, new_folder_name
)
298 old_folder
= os
.path
.join(gl
.downloaddir
, self
.foldername
)
299 if os
.path
.exists(old_folder
):
300 if not os
.path
.exists(new_folder
):
301 # Old folder exists, new folder does not -> simply rename
302 log('Renaming %s => %s', old_folder
, new_folder
, sender
=self
)
303 os
.rename(old_folder
, new_folder
)
305 # Both folders exist -> move files and delete old folder
306 log('Moving files from %s to %s', old_folder
, new_folder
, sender
=self
)
307 for file in glob
.glob(os
.path
.join(old_folder
, '*')):
308 shutil
.move(file, new_folder
)
309 log('Removing %s', old_folder
, sender
=self
)
310 shutil
.rmtree(old_folder
, ignore_errors
=True)
311 self
.foldername
= new_folder_name
314 if custom_title
!= self
.__title
:
315 self
.override_title
= custom_title
317 self
.override_title
= ''
319 def get_downloaded_episodes(self
):
320 return db
.load_episodes(self
, factory
=lambda c
: podcastItem
.create_from_dict(c
, self
), state
=db
.STATE_DOWNLOADED
)
322 def save_settings(self
):
323 db
.save_channel(self
)
325 def get_new_episodes( self
):
326 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
and not services
.download_status_manager
.is_download_in_progress(episode
.url
)]
328 def update_m3u_playlist(self
):
329 if gl
.config
.create_m3u_playlists
:
330 downloaded_episodes
= self
.get_downloaded_episodes()
331 fn
= util
.sanitize_filename(self
.title
)
333 fn
= os
.path
.basename(self
.save_dir
)
334 m3u_filename
= os
.path
.join(gl
.downloaddir
, fn
+'.m3u')
335 log('Writing playlist to %s', m3u_filename
, sender
=self
)
336 f
= open(m3u_filename
, 'w')
339 for episode
in downloaded_episodes
:
340 if episode
.was_downloaded(and_exists
=True):
341 filename
= episode
.local_filename(create
=False)
342 assert filename
is not None
344 if os
.path
.dirname(filename
).startswith(os
.path
.dirname(m3u_filename
)):
345 filename
= filename
[len(os
.path
.dirname(m3u_filename
)+os
.sep
):]
346 f
.write('#EXTINF:0,'+self
.title
+' - '+episode
.title
+' ('+episode
.cute_pubdate()+')\n')
347 f
.write(filename
+'\n')
350 def addDownloadedItem(self
, item
):
351 log('addDownloadedItem(%s)', item
.url
)
353 if not item
.was_downloaded():
354 item
.mark_downloaded(save
=True)
356 # Update metadata on file (if possible and wanted)
357 if gl
.config
.update_tags
and libtagupdate
.tagging_supported():
358 filename
= item
.local_filename(create
=False)
359 assert filename
is not None
362 libtagupdate
.update_metadata_on_file(filename
, title
=item
.title
, artist
=self
.title
, genre
='Podcast')
364 log('Error while calling update_metadata_on_file(): %s', e
)
366 self
.update_m3u_playlist()
368 def get_all_episodes(self
):
369 return db
.load_episodes(self
, factory
= lambda d
: podcastItem
.create_from_dict(d
, self
))
371 def iter_set_downloading_columns( self
, model
, iter, episode
=None):
372 global ICON_AUDIO_FILE
, ICON_VIDEO_FILE
373 global ICON_DOWNLOADING
, ICON_DELETED
, ICON_NEW
376 url
= model
.get_value( iter, 0)
377 episode
= db
.load_episode(url
, factory
=lambda x
: podcastItem
.create_from_dict(x
, self
))
381 if gl
.config
.episode_list_descriptions
or gpodder
.interface
== gpodder
.MAEMO
:
386 if services
.download_status_manager
.is_download_in_progress(url
):
387 status_icon
= util
.get_tree_icon(ICON_DOWNLOADING
, icon_cache
=self
.icon_cache
, icon_size
=icon_size
)
389 if episode
.state
== db
.STATE_NORMAL
:
390 if episode
.is_played
:
393 status_icon
= util
.get_tree_icon(ICON_NEW
, icon_cache
=self
.icon_cache
, icon_size
=icon_size
)
394 elif episode
.was_downloaded():
395 missing
= not episode
.file_exists()
398 log('Episode missing: %s (before drawing an icon)', episode
.url
, sender
=self
)
400 file_type
= util
.file_type_by_extension( model
.get_value( iter, 9))
401 if file_type
== 'audio':
402 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
)
403 elif file_type
== 'video':
404 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
)
406 status_icon
= util
.get_tree_icon('unknown', not episode
.is_played
, episode
.is_locked
, not episode
.file_exists(), self
.icon_cache
, icon_size
)
407 elif episode
.state
== db
.STATE_DELETED
or episode
.state
== db
.STATE_DOWNLOADED
:
408 status_icon
= util
.get_tree_icon(ICON_DELETED
, not episode
.is_played
, icon_cache
=self
.icon_cache
, icon_size
=icon_size
)
410 log('Warning: Cannot determine status icon.', sender
=self
)
413 model
.set( iter, 4, status_icon
)
415 def get_tree_model(self
):
417 Return a gtk.ListStore containing episodes for this channel
419 new_model
= gtk
.ListStore( gobject
.TYPE_STRING
, gobject
.TYPE_STRING
, gobject
.TYPE_STRING
,
420 gobject
.TYPE_BOOLEAN
, gtk
.gdk
.Pixbuf
, gobject
.TYPE_STRING
, gobject
.TYPE_STRING
,
421 gobject
.TYPE_STRING
, gobject
.TYPE_STRING
, gobject
.TYPE_STRING
)
423 log('Returning TreeModel for %s', self
.url
, sender
= self
)
425 for item
in self
.get_all_episodes():
426 description
= item
.title_and_description
429 filelength
= gl
.format_filesize(item
.length
, 1)
433 new_iter
= new_model
.append((item
.url
, item
.title
, filelength
,
434 True, None, item
.cute_pubdate(), description
, util
.remove_html_tags(item
.description
),
435 'XXXXXXXXXXXXXUNUSEDXXXXXXXXXXXXXXXXXXX', item
.extension()))
436 self
.iter_set_downloading_columns( new_model
, new_iter
, episode
=item
)
437 urls
.append(item
.url
)
439 self
.update_save_dir_size()
440 return (new_model
, urls
)
442 def find_episode( self
, url
):
443 return db
.load_episode(url
, factory
=lambda x
: podcastItem
.create_from_dict(x
, self
))
446 def find_unique_folder_name(cls
, foldername
):
447 current_try
= util
.sanitize_filename(foldername
, cls
.MAX_FOLDERNAME_LENGTH
)
450 while db
.channel_foldername_exists(current_try
) and \
451 not os
.path
.exists(os
.path
.join(gl
.downloaddir
, current_try
)):
452 current_try
= '%s (%d)' % (foldername
, next_try_id
)
457 def get_save_dir(self
):
458 urldigest
= hashlib
.md5(self
.url
).hexdigest()
459 sanitizedurl
= util
.sanitize_filename(self
.url
, self
.MAX_FOLDERNAME_LENGTH
)
460 if self
.foldername
is None or (self
.auto_foldername
and (self
.foldername
== urldigest
or self
.foldername
== sanitizedurl
)):
461 # we must change the folder name, because it has not been set manually
462 fn_template
= util
.sanitize_filename(self
.title
, self
.MAX_FOLDERNAME_LENGTH
)
464 # if this is an empty string, try the basename
465 if len(fn_template
) == 0:
466 log('That is one ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self
.url
, sender
=self
)
467 fn_template
= util
.sanitize_filename(os
.path
.basename(self
.url
), self
.MAX_FOLDERNAME_LENGTH
)
469 # If the basename is also empty, use the first 6 md5 hexdigest chars of the URL
470 if len(fn_template
) == 0:
471 log('That is one REALLY ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self
.url
, sender
=self
)
472 fn_template
= urldigest
# no need for sanitize_filename here
474 # Find a unique folder name for this podcast
475 wanted_foldername
= self
.find_unique_folder_name(fn_template
)
477 # if the foldername has not been set, check if the (old) md5 filename exists
478 if self
.foldername
is None and os
.path
.exists(os
.path
.join(gl
.downloaddir
, urldigest
)):
479 log('Found pre-0.15.0 download folder for %s: %s', self
.title
, urldigest
, sender
=self
)
480 self
.foldername
= urldigest
482 # we have a valid, new folder name in "current_try" -> use that!
483 if self
.foldername
is not None and wanted_foldername
!= self
.foldername
:
484 # there might be an old download folder crawling around - move it!
485 new_folder_name
= os
.path
.join(gl
.downloaddir
, wanted_foldername
)
486 old_folder_name
= os
.path
.join(gl
.downloaddir
, self
.foldername
)
487 if os
.path
.exists(old_folder_name
):
488 if not os
.path
.exists(new_folder_name
):
489 # Old folder exists, new folder does not -> simply rename
490 log('Renaming %s => %s', old_folder_name
, new_folder_name
, sender
=self
)
491 os
.rename(old_folder_name
, new_folder_name
)
493 # Both folders exist -> move files and delete old folder
494 log('Moving files from %s to %s', old_folder_name
, new_folder_name
, sender
=self
)
495 for file in glob
.glob(os
.path
.join(old_folder_name
, '*')):
496 shutil
.move(file, new_folder_name
)
497 log('Removing %s', old_folder_name
, sender
=self
)
498 shutil
.rmtree(old_folder_name
, ignore_errors
=True)
499 log('Updating foldername of %s to "%s".', self
.url
, wanted_foldername
, sender
=self
)
500 self
.foldername
= wanted_foldername
503 save_dir
= os
.path
.join(gl
.downloaddir
, self
.foldername
)
505 # Create save_dir if it does not yet exist
506 if not util
.make_directory( save_dir
):
507 log( 'Could not create save_dir: %s', save_dir
, sender
= self
)
511 save_dir
= property(fget
=get_save_dir
)
513 def remove_downloaded( self
):
514 shutil
.rmtree( self
.save_dir
, True)
516 def get_index_file(self
):
517 # gets index xml filename for downloaded channels list
518 return os
.path
.join( self
.save_dir
, 'index.xml')
520 index_file
= property(fget
=get_index_file
)
522 def get_cover_file( self
):
523 # gets cover filename for cover download cache
524 return os
.path
.join( self
.save_dir
, 'cover')
526 cover_file
= property(fget
=get_cover_file
)
528 def delete_episode_by_url(self
, url
):
529 episode
= db
.load_episode(url
, lambda c
: podcastItem
.create_from_dict(c
, self
))
531 if episode
is not None:
532 filename
= episode
.local_filename(create
=False)
533 if filename
is not None:
534 util
.delete_file(filename
)
536 log('Cannot delete episode: %s (I have no filename!)', episode
.title
, sender
=self
)
537 episode
.set_state(db
.STATE_DELETED
)
539 self
.update_m3u_playlist()
542 class podcastItem(object):
543 """holds data for one object in a channel"""
544 MAX_FILENAME_LENGTH
= 200
547 def load(url
, channel
):
548 e
= podcastItem(channel
)
549 d
= db
.load_episode(url
)
551 for k
, v
in d
.iteritems():
557 def from_feedparser_entry( entry
, channel
):
558 episode
= podcastItem( channel
)
560 episode
.title
= entry
.get( 'title', util
.get_first_line( util
.remove_html_tags( entry
.get( 'summary', ''))))
561 episode
.link
= entry
.get( 'link', '')
562 episode
.description
= entry
.get( 'summary', entry
.get( 'link', entry
.get( 'title', '')))
563 episode
.guid
= entry
.get( 'id', '')
564 if entry
.get( 'updated_parsed', None):
565 episode
.pubDate
= rfc822
.mktime_tz(entry
.updated_parsed
+(0,))
567 if episode
.title
== '':
568 log( 'Warning: Episode has no title, adding anyways.. (Feed Is Buggy!)', sender
= episode
)
571 if hasattr(entry
, 'enclosures') and len(entry
.enclosures
) > 0:
572 enclosure
= entry
.enclosures
[0]
573 if len(entry
.enclosures
) > 1:
574 for e
in entry
.enclosures
:
575 if hasattr( e
, 'href') and hasattr( e
, 'length') and hasattr( e
, 'type') and (e
.type.startswith('audio/') or e
.type.startswith('video/')):
576 if util
.normalize_feed_url(e
.href
) is not None:
577 log( 'Selected enclosure: %s', e
.href
, sender
= episode
)
580 episode
.url
= util
.normalize_feed_url( enclosure
.get( 'href', ''))
581 elif hasattr(entry
, 'link'):
582 (filename
, extension
) = util
.filename_from_url(entry
.link
)
583 if extension
== '' and hasattr( entry
, 'type'):
584 extension
= util
.extension_from_mimetype(e
.type)
585 file_type
= util
.file_type_by_extension(extension
)
586 if file_type
is not None:
587 log('Adding episode with link to file type "%s".', file_type
, sender
=episode
)
588 episode
.url
= entry
.link
591 if not episode
.url
and hasattr(entry
, 'links') and len(entry
.links
) and hasattr(entry
.links
[0], 'href'):
592 episode
.url
= entry
.links
[0].href
595 log('Episode has no URL')
596 log('Episode: %s', episode
)
597 log('Entry: %s', entry
)
598 # This item in the feed has no downloadable enclosure
601 if not episode
.pubDate
:
602 metainfo
= util
.get_episode_info_from_url(episode
.url
)
603 if 'pubdate' in metainfo
:
605 episode
.pubDate
= int(float(metainfo
['pubdate']))
607 log('Cannot convert pubDate "%s" in from_feedparser_entry.', str(metainfo
['pubdate']), traceback
=True)
609 if hasattr(enclosure
, 'length'):
611 episode
.length
= int(enclosure
.length
)
615 if hasattr( enclosure
, 'type'):
616 episode
.mimetype
= enclosure
.type
618 if episode
.title
== '':
619 ( filename
, extension
) = os
.path
.splitext( os
.path
.basename( episode
.url
))
620 episode
.title
= filename
625 def __init__( self
, channel
):
626 # Used by Storage for faster saving
631 self
.mimetype
= 'application/octet-stream'
633 self
.description
= ''
635 self
.channel
= channel
638 self
.auto_filename
= 1 # automatically generated filename
640 self
.state
= db
.STATE_NORMAL
641 self
.is_played
= False
642 self
.is_locked
= channel
.channel_is_locked
644 def save(self
, bulk
=False):
645 if self
.state
!= db
.STATE_DOWNLOADED
and self
.file_exists():
646 self
.state
= db
.STATE_DOWNLOADED
647 db
.save_episode(self
, bulk
=bulk
)
649 def set_state(self
, state
):
651 db
.mark_episode(self
.url
, state
=self
.state
, is_played
=self
.is_played
, is_locked
=self
.is_locked
)
653 def mark(self
, state
=None, is_played
=None, is_locked
=None):
654 if state
is not None:
656 if is_played
is not None:
657 self
.is_played
= is_played
658 if is_locked
is not None:
659 self
.is_locked
= is_locked
660 db
.mark_episode(self
.url
, state
=state
, is_played
=is_played
, is_locked
=is_locked
)
662 def mark_downloaded(self
, save
=False):
663 self
.state
= db
.STATE_DOWNLOADED
664 self
.is_played
= False
670 def create_from_dict(d
, channel
):
671 e
= podcastItem(channel
)
674 setattr(e
, key
, d
[key
])
678 def title_and_description(self
):
680 Returns Pango markup for displaying in a TreeView, and
681 disables the description when the config variable
682 "episode_list_descriptions" is not set.
684 if gl
.config
.episode_list_descriptions
and gpodder
.interface
!= gpodder
.MAEMO
:
685 return '%s\n<small>%s</small>' % (saxutils
.escape(self
.title
), saxutils
.escape(self
.one_line_description()))
687 return saxutils
.escape(self
.title
)
689 def age_in_days(self
):
690 return util
.file_age_in_days(self
.local_filename(create
=False))
693 return self
.age_in_days() > gl
.config
.episode_old_age
695 def get_age_string(self
):
696 return util
.file_age_to_string(self
.age_in_days())
698 age_prop
= property(fget
=get_age_string
)
700 def one_line_description( self
):
701 lines
= util
.remove_html_tags(self
.description
).strip().splitlines()
702 if not lines
or lines
[0] == '':
703 return _('No description available')
705 return ' '.join(lines
)
707 def delete_from_disk(self
):
709 self
.channel
.delete_episode_by_url(self
.url
)
711 log('Cannot delete episode from disk: %s', self
.title
, traceback
=True, sender
=self
)
714 def find_unique_file_name(cls
, url
, filename
, extension
):
715 current_try
= util
.sanitize_filename(filename
, cls
.MAX_FILENAME_LENGTH
)+extension
719 while db
.episode_filename_exists(current_try
):
721 # If we arrive here, current_try has a collision, so
722 # try to resolve the URL for a better basename
723 log('Filename collision: %s - trying to resolve...', current_try
)
724 url
= util
.get_real_url(url
)
725 (episode_filename
, extension_UNUSED
) = util
.filename_from_url(url
)
726 current_try
= util
.sanitize_filename(episode_filename
, cls
.MAX_FILENAME_LENGTH
)
727 if not db
.episode_filename_exists(current_try
):
728 log('Filename %s is available - collision resolved.', current_try
)
731 log('Continuing search with %s as basename...', current_try
)
733 current_try
= '%s (%d)%s' % (filename
, next_try_id
, extension
)
738 def local_filename(self
, create
, force_update
=False):
739 """Get (and possibly generate) the local saving filename
741 Pass create=True if you want this function to generate a
742 new filename if none exists. You only want to do this when
743 planning to create/download the file after calling this function.
745 Normally, you should pass create=False. This will only
746 create a filename when the file already exists from a previous
747 version of gPodder (where we used md5 filenames). If the file
748 does not exist (and the filename also does not exist), this
749 function will return None.
751 If you pass force_update=True to this function, it will try to
752 find a new (better) filename and move the current file if this
753 is the case. This is useful if (during the download) you get
754 more information about the file, e.g. the mimetype and you want
755 to include this information in the file name generation process.
757 The generated filename is stored in the database for future access.
759 ext
= self
.extension()
761 # For compatibility with already-downloaded episodes, we
762 # have to know md5 filenames if they are downloaded already
763 urldigest
= hashlib
.md5(self
.url
).hexdigest()
765 if not create
and self
.filename
is None:
766 urldigest_filename
= os
.path
.join(self
.channel
.save_dir
, urldigest
+ext
)
767 if os
.path
.exists(urldigest_filename
):
768 # The file exists, so set it up in our database
769 log('Recovering pre-0.15.0 file: %s', urldigest_filename
, sender
=self
)
770 self
.filename
= urldigest
+ext
771 self
.auto_filename
= 1
773 return urldigest_filename
776 if self
.filename
is None or force_update
or (self
.auto_filename
and self
.filename
== urldigest
+ext
):
777 # Try to find a new filename for the current file
778 (episode_filename
, extension_UNUSED
) = util
.filename_from_url(self
.url
)
779 fn_template
= util
.sanitize_filename(episode_filename
, self
.MAX_FILENAME_LENGTH
)
781 if 'redirect' in fn_template
:
782 # This looks like a redirection URL - force URL resolving!
783 log('Looks like a redirection to me: %s', self
.url
, sender
=self
)
784 url
= util
.get_real_url(self
.url
)
785 log('Redirection resolved to: %s', url
, sender
=self
)
786 (episode_filename
, extension_UNUSED
) = util
.filename_from_url(url
)
787 fn_template
= util
.sanitize_filename(episode_filename
, self
.MAX_FILENAME_LENGTH
)
789 # If the basename is empty, use the md5 hexdigest of the URL
790 if len(fn_template
) == 0 or fn_template
.startswith('redirect.'):
791 log('Report to bugs.gpodder.org: Podcast at %s with episode URL: %s', self
.channel
.url
, self
.url
, sender
=self
)
792 fn_template
= urldigest
794 # Find a unique filename for this episode
795 wanted_filename
= self
.find_unique_file_name(self
.url
, fn_template
, ext
)
797 # We populate the filename field the first time - does the old file still exist?
798 if self
.filename
is None and os
.path
.exists(os
.path
.join(self
.channel
.save_dir
, urldigest
+ext
)):
799 log('Found pre-0.15.0 downloaded file: %s', urldigest
, sender
=self
)
800 self
.filename
= urldigest
+ext
802 # The old file exists, but we have decided to want a different filename
803 if self
.filename
is not None and wanted_filename
!= self
.filename
:
804 # there might be an old download folder crawling around - move it!
805 new_file_name
= os
.path
.join(self
.channel
.save_dir
, wanted_filename
)
806 old_file_name
= os
.path
.join(self
.channel
.save_dir
, self
.filename
)
807 if os
.path
.exists(old_file_name
) and not os
.path
.exists(new_file_name
):
808 log('Renaming %s => %s', old_file_name
, new_file_name
, sender
=self
)
809 os
.rename(old_file_name
, new_file_name
)
811 log('Warning: %s exists or %s does not.', new_file_name
, old_file_name
, sender
=self
)
812 log('Updating filename of %s to "%s".', self
.url
, wanted_filename
, sender
=self
)
813 self
.filename
= wanted_filename
816 return os
.path
.join(self
.channel
.save_dir
, self
.filename
)
818 def extension( self
):
819 ( filename
, ext
) = util
.filename_from_url(self
.url
)
820 # if we can't detect the extension from the url fallback on the mimetype
821 if ext
== '' or util
.file_type_by_extension(ext
) is None:
822 ext
= util
.extension_from_mimetype(self
.mimetype
)
823 #log('Getting extension from mimetype for: %s (mimetype: %s)' % (self.title, ext), sender=self)
827 self
.state
= db
.STATE_NORMAL
828 self
.is_played
= False
829 db
.mark_episode(self
.url
, state
=self
.state
, is_played
=self
.is_played
)
832 self
.is_played
= True
833 db
.mark_episode(self
.url
, is_played
=True)
835 def file_exists(self
):
836 filename
= self
.local_filename(create
=False)
840 return os
.path
.exists(filename
)
842 def was_downloaded(self
, and_exists
=False):
843 if self
.state
!= db
.STATE_DOWNLOADED
:
845 if and_exists
and not self
.file_exists():
849 def sync_filename( self
):
850 if gl
.config
.custom_sync_name_enabled
:
851 if '{channel' in gl
.config
.custom_sync_name
:
852 log('Fixing OLD syntax {channel.*} => {podcast.*} in custom_sync_name.', sender
=self
)
853 gl
.config
.custom_sync_name
= gl
.config
.custom_sync_name
.replace('{channel.', '{podcast.')
854 return util
.object_string_formatter(gl
.config
.custom_sync_name
, episode
=self
, podcast
=self
.channel
)
858 def file_type( self
):
859 return util
.file_type_by_extension( self
.extension() )
863 return os
.path
.splitext( os
.path
.basename( self
.url
))[0]
866 def published( self
):
868 Returns published date as YYYYMMDD (or 00000000 if not available)
871 return datetime
.datetime
.fromtimestamp(self
.pubDate
).strftime('%Y%m%d')
873 log( 'Cannot format pubDate for "%s".', self
.title
, sender
= self
)
879 Returns published time as HHMM (or 0000 if not available)
882 return datetime
.datetime
.fromtimestamp(self
.pubDate
).strftime('%H%M')
884 log('Cannot format pubDate (time) for "%s".', self
.title
, sender
=self
)
887 def cute_pubdate(self
):
888 result
= util
.format_date(self
.pubDate
)
890 return '(%s)' % _('unknown')
894 pubdate_prop
= property(fget
=cute_pubdate
)
896 def calculate_filesize( self
):
897 filename
= self
.local_filename(create
=False)
899 log('calculate_filesized called, but filename is None!', sender
=self
)
901 self
.length
= os
.path
.getsize(filename
)
903 log( 'Could not get filesize for %s.', self
.url
)
905 def get_filesize_string( self
):
906 return gl
.format_filesize( self
.length
)
908 filesize_prop
= property(fget
=get_filesize_string
)
910 def get_channel_title( self
):
911 return self
.channel
.title
913 channel_prop
= property(fget
=get_channel_title
)
915 def get_played_string( self
):
916 if not self
.is_played
:
921 played_prop
= property(fget
=get_played_string
)
925 def update_channel_model_by_iter( model
, iter, channel
, color_dict
,
926 cover_cache
=None, max_width
=0, max_height
=0, initialize_all
=False):
928 count_downloaded
= channel
.stat(state
=db
.STATE_DOWNLOADED
)
929 count_new
= channel
.stat(state
=db
.STATE_NORMAL
, is_played
=False)
930 count_unplayed
= channel
.stat(state
=db
.STATE_DOWNLOADED
, is_played
=False)
934 model
.set(iter, 0, channel
.url
)
936 model
.set(iter, 1, channel
.title
)
937 title_markup
= saxutils
.escape(channel
.title
)
938 description_markup
= saxutils
.escape(util
.get_first_line(channel
.description
) or _('No description available'))
941 d
.append('<span weight="bold">')
942 d
.append(title_markup
)
946 description
= ''.join(d
+['\n', '<small>', description_markup
, '</small>'])
947 model
.set(iter, 2, description
)
949 if channel
.parse_error
is not None:
950 model
.set(iter, 6, channel
.parse_error
)
951 color
= color_dict
['parse_error']
953 color
= color_dict
['default']
955 if channel
.update_flag
:
956 color
= color_dict
['updating']
958 model
.set(iter, 8, color
)
960 if count_unplayed
> 0 or count_downloaded
> 0:
961 model
.set(iter, 3, draw
.draw_pill_pixbuf(str(count_unplayed
), str(count_downloaded
)))
962 model
.set(iter, 7, True)
964 model
.set(iter, 7, False)
967 # Load the cover if we have it, but don't download
968 # it if it's not available (to avoid blocking here)
969 pixbuf
= services
.cover_downloader
.get_cover(channel
, avoid_downloading
=True)
971 if pixbuf
is not None:
972 new_pixbuf
= util
.resize_pixbuf_keep_ratio(pixbuf
, max_width
, max_height
, channel
.url
, cover_cache
)
973 model
.set(iter, 5, new_pixbuf
or pixbuf
)
975 def channels_to_model(channels
, color_dict
, cover_cache
=None, max_width
=0, max_height
=0):
976 new_model
= gtk
.ListStore( str, str, str, gtk
.gdk
.Pixbuf
, int,
977 gtk
.gdk
.Pixbuf
, str, bool, str )
980 for channel
in channels
:
981 update_channel_model_by_iter(new_model
, new_model
.append(), channel
,
982 color_dict
, cover_cache
, max_width
, max_height
, True)
983 urls
.append(channel
.url
)
985 return (new_model
, urls
)
989 return db
.load_channels(lambda d
: podcastChannel
.create_from_dict(d
))
991 def update_channels(callback_proc
=None, callback_error
=None, is_cancelled_cb
=None):
992 log('Updating channels....')
994 channels
= load_channels()
997 for channel
in channels
:
998 if is_cancelled_cb
is not None and is_cancelled_cb():
1000 callback_proc
and callback_proc(count
, len(channels
))
1006 def save_channels( channels
):
1007 exporter
= opml
.Exporter(gl
.channel_opml_file
)
1008 return exporter
.write(channels
)
1010 def can_restore_from_opml():
1012 if len(opml
.Importer(gl
.channel_opml_file
).items
):
1013 return gl
.channel_opml_file
1019 class LocalDBReader( object):
1021 DEPRECATED - Only used for migration to SQLite
1023 def __init__( self
, url
):
1026 def get_text( self
, nodelist
):
1027 return ''.join( [ node
.data
for node
in nodelist
if node
.nodeType
== node
.TEXT_NODE
])
1029 def get_text_by_first_node( self
, element
, name
):
1030 return self
.get_text( element
.getElementsByTagName( name
)[0].childNodes
)
1032 def get_episode_from_element( self
, channel
, element
):
1033 episode
= podcastItem( channel
)
1034 episode
.title
= self
.get_text_by_first_node( element
, 'title')
1035 episode
.description
= self
.get_text_by_first_node( element
, 'description')
1036 episode
.url
= self
.get_text_by_first_node( element
, 'url')
1037 episode
.link
= self
.get_text_by_first_node( element
, 'link')
1038 episode
.guid
= self
.get_text_by_first_node( element
, 'guid')
1040 if not episode
.guid
:
1041 for k
in ('url', 'link'):
1042 if getattr(episode
, k
) is not None:
1043 episode
.guid
= getattr(episode
, k
)
1044 log('Notice: episode has no guid, using %s', episode
.guid
)
1047 episode
.pubDate
= float(self
.get_text_by_first_node(element
, 'pubDate'))
1049 log('Looks like you have an old pubDate in your LocalDB -> converting it')
1050 episode
.pubDate
= self
.get_text_by_first_node(element
, 'pubDate')
1051 log('FYI: pubDate value is: "%s"', episode
.pubDate
, sender
=self
)
1052 pubdate
= feedparser
._parse
_date
(episode
.pubDate
)
1054 log('Error converting the old pubDate - sorry!', sender
=self
)
1057 log('PubDate converted successfully - yay!', sender
=self
)
1058 episode
.pubDate
= time
.mktime(pubdate
)
1060 episode
.mimetype
= self
.get_text_by_first_node( element
, 'mimetype')
1062 log('No mimetype info for %s', episode
.url
, sender
=self
)
1063 episode
.calculate_filesize()
1066 def load_and_clean( self
, filename
):
1068 Clean-up a LocalDB XML file that could potentially contain
1069 "unbound prefix" XML elements (generated by the old print-based
1070 LocalDB code). The code removes those lines to make the new
1073 This should be removed in a future version.
1076 for line
in open(filename
).read().split('\n'):
1077 if not line
.startswith('<gpodder:info'):
1080 return '\n'.join( lines
)
1082 def read( self
, filename
):
1083 doc
= xml
.dom
.minidom
.parseString( self
.load_and_clean( filename
))
1084 rss
= doc
.getElementsByTagName('rss')[0]
1086 channel_element
= rss
.getElementsByTagName('channel')[0]
1088 channel
= podcastChannel( url
= self
.url
)
1089 channel
.title
= self
.get_text_by_first_node( channel_element
, 'title')
1090 channel
.description
= self
.get_text_by_first_node( channel_element
, 'description')
1091 channel
.link
= self
.get_text_by_first_node( channel_element
, 'link')
1094 for episode_element
in rss
.getElementsByTagName('item'):
1095 episode
= self
.get_episode_from_element( channel
, episode_element
)
1096 episodes
.append(episode
)