1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 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 # gpodder.model - Core model classes for gPodder (2009-08-13)
23 # Based on libpodcasts.py (thp, 2005-10-29)
27 from gpodder
import util
28 from gpodder
import feedcore
29 from gpodder
import youtube
30 from gpodder
import corestats
32 from gpodder
.liblogger
import log
45 import xml
.sax
.saxutils
50 class CustomFeed(feedcore
.ExceptionWithData
): pass
52 class gPodderFetcher(feedcore
.Fetcher
):
54 This class extends the feedcore Fetcher with the gPodder User-Agent and the
55 Proxy handler based on the current settings in gPodder and provides a
56 convenience method (fetch_channel) for use by PodcastChannel objects.
61 feedcore
.Fetcher
.__init
__(self
, gpodder
.user_agent
)
63 def fetch_channel(self
, channel
):
65 modified
= feedparser
._parse
_date
(channel
.last_modified
)
66 # If we have a username or password, rebuild the url with them included
67 # Note: using a HTTPBasicAuthHandler would be pain because we need to
68 # know the realm. It can be done, but I think this method works, too
69 url
= channel
.authenticate_url(channel
.url
)
70 for handler
in self
.custom_handlers
:
71 custom_feed
= handler
.handle_url(url
)
72 if custom_feed
is not None:
73 raise CustomFeed(custom_feed
)
74 self
.fetch(url
, etag
, modified
)
76 def _resolve_url(self
, url
):
77 return youtube
.get_real_channel_url(url
)
80 def register(cls
, handler
):
81 cls
.custom_handlers
.append(handler
)
83 # def _get_handlers(self):
84 # # Add a ProxyHandler for fetching data via a proxy server
85 # proxies = {'http': 'http://proxy.example.org:8080'}
86 # return[urllib2.ProxyHandler(proxies))]
88 # The "register" method is exposed here for external usage
89 register_custom_handler
= gPodderFetcher
.register
91 class PodcastModelObject(object):
93 A generic base class for our podcast model providing common helper
94 and utility functions.
98 def create_from_dict(cls
, d
, *args
):
100 Create a new object, passing "args" to the constructor
101 and then updating the object with the values from "d".
104 o
.update_from_dict(d
)
107 def update_from_dict(self
, d
):
109 Updates the attributes of this object with values from the
110 dictionary "d" by using the keys found in "d".
114 setattr(self
, k
, d
[k
])
117 class PodcastChannel(PodcastModelObject
):
118 """holds data for a complete channel"""
119 MAX_FOLDERNAME_LENGTH
= 150
121 feed_fetcher
= gPodderFetcher()
124 def build_factory(cls
, download_dir
):
125 def factory(dict, db
):
126 return cls
.create_from_dict(dict, db
, download_dir
)
130 def load_from_db(cls
, db
, download_dir
):
131 return db
.load_channels(factory
=cls
.build_factory(download_dir
))
134 def load(cls
, db
, url
, create
=True, authentication_tokens
=None,\
135 max_episodes
=0, download_dir
=None, allow_empty_feeds
=False):
136 if isinstance(url
, unicode):
137 url
= url
.encode('utf-8')
139 tmp
= db
.load_channels(factory
=cls
.build_factory(download_dir
), url
=url
)
143 tmp
= PodcastChannel(db
, download_dir
)
145 if authentication_tokens
is not None:
146 tmp
.username
= authentication_tokens
[0]
147 tmp
.password
= authentication_tokens
[1]
149 tmp
.update(max_episodes
)
151 db
.force_last_new(tmp
)
152 # Subscribing to empty feeds should yield an error (except if
153 # the user specifically allows empty feeds in the config UI)
154 if sum(tmp
.get_statistics()) == 0 and not allow_empty_feeds
:
156 raise Exception(_('No downloadable episodes in feed'))
159 def episode_factory(self
, d
, db__parameter_is_unused
=None):
161 This function takes a dictionary containing key-value pairs for
162 episodes and returns a new PodcastEpisode object that is connected
163 to this PodcastChannel object.
165 Returns: A new PodcastEpisode object
167 return PodcastEpisode
.create_from_dict(d
, self
)
169 def _consume_custom_feed(self
, custom_feed
, max_episodes
=0):
170 self
.title
= custom_feed
.get_title()
171 self
.link
= custom_feed
.get_link()
172 self
.description
= custom_feed
.get_description()
173 self
.image
= custom_feed
.get_image()
174 self
.pubDate
= time
.time()
177 guids
= [episode
.guid
for episode
in self
.get_all_episodes()]
178 self
.count_new
+= custom_feed
.get_new_episodes(self
, guids
)
181 self
.db
.purge(max_episodes
, self
.id)
183 def _consume_updated_feed(self
, feed
, max_episodes
=0):
184 self
.parse_error
= feed
.get('bozo_exception', None)
186 self
.title
= feed
.feed
.get('title', self
.url
)
187 self
.link
= feed
.feed
.get('link', self
.link
)
188 self
.description
= feed
.feed
.get('subtitle', self
.description
)
189 # Start YouTube-specific title FIX
190 YOUTUBE_PREFIX
= 'Uploads by '
191 if self
.title
.startswith(YOUTUBE_PREFIX
):
192 self
.title
= self
.title
[len(YOUTUBE_PREFIX
):] + ' on YouTube'
193 # End YouTube-specific title FIX
196 self
.pubDate
= rfc822
.mktime_tz(feed
.feed
.get('updated_parsed', None+(0,)))
198 self
.pubDate
= time
.time()
200 if hasattr(feed
.feed
, 'image'):
201 for attribute
in ('href', 'url'):
202 new_value
= getattr(feed
.feed
.image
, attribute
, None)
203 if new_value
is not None:
204 log('Found cover art in %s: %s', attribute
, new_value
)
205 self
.image
= new_value
207 if hasattr(feed
.feed
, 'icon'):
208 self
.image
= feed
.feed
.icon
212 # Load all episodes to update them properly.
213 existing
= self
.get_all_episodes()
215 # We can limit the maximum number of entries that gPodder will parse
216 if max_episodes
> 0 and len(feed
.entries
) > max_episodes
:
217 entries
= feed
.entries
[:max_episodes
]
219 entries
= feed
.entries
221 # Title + PubDate hashes for existing episodes
222 existing_dupes
= dict((e
.duplicate_id(), e
) for e
in existing
)
224 # GUID-based existing episode list
225 existing_guids
= dict((e
.guid
, e
) for e
in existing
)
227 # Search all entries for new episodes
228 for entry
in entries
:
230 episode
= PodcastEpisode
.from_feedparser_entry(entry
, self
)
231 if episode
is not None and not episode
.title
:
232 episode
.title
, ext
= os
.path
.splitext(os
.path
.basename(episode
.url
))
234 log('Cannot instantiate episode: %s. Skipping.', e
, sender
=self
, traceback
=True)
240 # Detect (and update) existing episode based on GUIDs
241 existing_episode
= existing_guids
.get(episode
.guid
, None)
243 existing_episode
.update_from(episode
)
244 existing_episode
.save()
247 # Detect (and update) existing episode based on duplicate ID
248 existing_episode
= existing_dupes
.get(episode
.duplicate_id(), None)
250 if existing_episode
.is_duplicate(episode
):
251 existing_episode
.update_from(episode
)
252 existing_episode
.save()
255 # Otherwise we have found a new episode to store in the DB
259 # Remove "unreachable" episodes - episodes that have not been
260 # downloaded and that the feed does not list as downloadable anymore
261 if self
.id is not None:
262 seen_guids
= set(e
.guid
for e
in feed
.entries
if hasattr(e
, 'guid'))
263 episodes_to_purge
= (e
for e
in existing
if \
264 e
.state
!= gpodder
.STATE_DOWNLOADED
and \
265 e
.guid
not in seen_guids
and e
.guid
is not None)
266 for episode
in episodes_to_purge
:
267 log('Episode removed from feed: %s (%s)', episode
.title
, \
268 episode
.guid
, sender
=self
)
269 self
.db
.delete_episode_by_guid(episode
.guid
, self
.id)
271 # This *might* cause episodes to be skipped if there were more than
272 # max_episodes_per_feed items added to the feed between updates.
273 # The benefit is that it prevents old episodes from apearing as new
274 # in certain situations (see bug #340).
275 self
.db
.purge(max_episodes
, self
.id)
277 def update_channel_lock(self
):
278 self
.db
.update_channel_lock(self
)
280 def _update_etag_modified(self
, feed
):
281 self
.updated_timestamp
= time
.time()
282 self
.calculate_publish_behaviour()
283 self
.etag
= feed
.headers
.get('etag', self
.etag
)
284 self
.last_modified
= feed
.headers
.get('last-modified', self
.last_modified
)
286 def query_automatic_update(self
):
287 """Query if this channel should be updated automatically
289 Returns True if the update should happen in automatic
290 mode or False if this channel should be skipped (timeout
291 not yet reached or release not expected right now).
293 updated
= self
.updated_timestamp
294 expected
= self
.release_expected
297 one_day_ago
= now
- 60*60*24
298 lastcheck
= now
- 60*10
300 return updated
< one_day_ago
or \
301 (expected
< now
and updated
< lastcheck
)
303 def update(self
, max_episodes
=0):
305 self
.feed_fetcher
.fetch_channel(self
)
306 except CustomFeed
, updated
:
307 custom_feed
= updated
.data
308 self
._consume
_custom
_feed
(custom_feed
, max_episodes
)
310 except feedcore
.UpdatedFeed
, updated
:
312 self
._consume
_updated
_feed
(feed
, max_episodes
)
313 self
._update
_etag
_modified
(feed
)
315 except feedcore
.NewLocation
, updated
:
318 self
._consume
_updated
_feed
(feed
, max_episodes
)
319 self
._update
_etag
_modified
(feed
)
321 except feedcore
.NotModified
, updated
:
323 self
._update
_etag
_modified
(feed
)
326 # "Not really" errors
327 #feedcore.AuthenticationRequired
331 #feedcore.InternalServerError
334 #feedcore.Unsubscribe
336 #feedcore.InvalidFeed
337 #feedcore.UnknownStatusCode
342 def delete(self
, purge
=True):
343 self
.db
.delete_channel(self
, purge
)
346 self
.db
.save_channel(self
)
348 def get_statistics(self
):
350 return (0, 0, 0, 0, 0)
352 return self
.db
.get_channel_count(int(self
.id))
354 def authenticate_url(self
, url
):
355 return util
.url_add_authentication(url
, self
.username
, self
.password
)
357 def __init__(self
, db
, download_dir
):
359 self
.download_dir
= download_dir
364 self
.description
= ''
367 self
.parse_error
= None
368 self
.newest_pubdate_cached
= None
369 self
.foldername
= None
370 self
.auto_foldername
= 1 # automatically generated foldername
372 # should this channel be synced to devices? (ex: iPod)
373 self
.sync_to_devices
= True
374 # to which playlist should be synced
375 self
.device_playlist_name
= 'gPodder'
376 # if set, this overrides the channel-provided title
377 self
.override_title
= ''
381 self
.last_modified
= None
384 self
.save_dir_size
= 0
385 self
.__save
_dir
_size
_set
= False
387 self
.count_downloaded
= 0
389 self
.count_unplayed
= 0
391 self
.channel_is_locked
= False
393 self
.release_expected
= time
.time()
394 self
.release_deviation
= 0
395 self
.updated_timestamp
= 0
397 def calculate_publish_behaviour(self
):
398 episodes
= self
.db
.load_episodes(self
, factory
=self
.episode_factory
, limit
=30)
399 if len(episodes
) < 3:
403 latest
= max(e
.pubDate
for e
in episodes
)
404 for index
in range(len(episodes
)-1):
405 if episodes
[index
].pubDate
!= 0 and episodes
[index
+1].pubDate
!= 0:
406 deltas
.append(episodes
[index
].pubDate
- episodes
[index
+1].pubDate
)
409 stats
= corestats
.Stats(deltas
)
410 self
.release_expected
= min([latest
+stats
.stdev(), latest
+(stats
.min()+stats
.avg())*.5])
411 self
.release_deviation
= stats
.stdev()
413 self
.release_expected
= latest
414 self
.release_deviation
= 0
416 def request_save_dir_size(self
):
417 if not self
.__save
_dir
_size
_set
:
418 self
.update_save_dir_size()
419 self
.__save
_dir
_size
_set
= True
421 def update_save_dir_size(self
):
422 self
.save_dir_size
= util
.calculate_size(self
.save_dir
)
424 def get_title( self
):
425 if self
.override_title
:
426 return self
.override_title
427 elif not self
.__title
.strip():
432 def set_title( self
, value
):
433 self
.__title
= value
.strip()
435 title
= property(fget
=get_title
,
438 def set_custom_title( self
, custom_title
):
439 custom_title
= custom_title
.strip()
441 # if the custom title is the same as we have
442 if custom_title
== self
.override_title
:
445 # if custom title is the same as channel title and we didn't have a custom title
446 if custom_title
== self
.__title
and self
.override_title
== '':
449 # make sure self.foldername is initialized
452 # rename folder if custom_title looks sane
453 new_folder_name
= self
.find_unique_folder_name(custom_title
)
454 if len(new_folder_name
) > 0 and new_folder_name
!= self
.foldername
:
455 log('Changing foldername based on custom title: %s', custom_title
, sender
=self
)
456 new_folder
= os
.path
.join(self
.download_dir
, new_folder_name
)
457 old_folder
= os
.path
.join(self
.download_dir
, self
.foldername
)
458 if os
.path
.exists(old_folder
):
459 if not os
.path
.exists(new_folder
):
460 # Old folder exists, new folder does not -> simply rename
461 log('Renaming %s => %s', old_folder
, new_folder
, sender
=self
)
462 os
.rename(old_folder
, new_folder
)
464 # Both folders exist -> move files and delete old folder
465 log('Moving files from %s to %s', old_folder
, new_folder
, sender
=self
)
466 for file in glob
.glob(os
.path
.join(old_folder
, '*')):
467 shutil
.move(file, new_folder
)
468 log('Removing %s', old_folder
, sender
=self
)
469 shutil
.rmtree(old_folder
, ignore_errors
=True)
470 self
.foldername
= new_folder_name
473 if custom_title
!= self
.__title
:
474 self
.override_title
= custom_title
476 self
.override_title
= ''
478 def get_downloaded_episodes(self
):
479 return self
.db
.load_episodes(self
, factory
=self
.episode_factory
, state
=gpodder
.STATE_DOWNLOADED
)
481 def get_new_episodes(self
, downloading
=lambda e
: False):
483 Get a list of new episodes. You can optionally specify
484 "downloading" as a callback that takes an episode as
485 a parameter and returns True if the episode is currently
486 being downloaded or False if not.
488 By default, "downloading" is implemented so that it
489 reports all episodes as not downloading.
491 return [episode
for episode
in self
.db
.load_episodes(self
, \
492 factory
=self
.episode_factory
) if \
493 episode
.check_is_new(downloading
=downloading
)]
495 def get_playlist_filename(self
):
496 # If the save_dir doesn't end with a slash (which it really should
497 # not, if the implementation is correct, we can just append .m3u :)
498 assert self
.save_dir
[-1] != '/'
499 return self
.save_dir
+'.m3u'
501 def update_m3u_playlist(self
):
502 m3u_filename
= self
.get_playlist_filename()
504 downloaded_episodes
= self
.get_downloaded_episodes()
505 if not downloaded_episodes
:
506 log('No episodes - removing %s', m3u_filename
, sender
=self
)
507 util
.delete_file(m3u_filename
)
510 log('Writing playlist to %s', m3u_filename
, sender
=self
)
511 f
= open(m3u_filename
, 'w')
514 for episode
in PodcastEpisode
.sort_by_pubdate(downloaded_episodes
):
515 if episode
.was_downloaded(and_exists
=True):
516 filename
= episode
.local_filename(create
=False)
517 assert filename
is not None
519 if os
.path
.dirname(filename
).startswith(os
.path
.dirname(m3u_filename
)):
520 filename
= filename
[len(os
.path
.dirname(m3u_filename
)+os
.sep
):]
521 f
.write('#EXTINF:0,'+self
.title
+' - '+episode
.title
+' ('+episode
.cute_pubdate()+')\n')
522 f
.write(filename
+'\n')
526 def addDownloadedItem(self
, item
):
527 log('addDownloadedItem(%s)', item
.url
)
529 if not item
.was_downloaded():
530 item
.mark_downloaded(save
=True)
531 self
.update_m3u_playlist()
533 def get_all_episodes(self
):
534 return self
.db
.load_episodes(self
, factory
=self
.episode_factory
)
536 def find_unique_folder_name(self
, foldername
):
537 # Remove trailing dots to avoid errors on Windows (bug 600)
538 foldername
= foldername
.strip().rstrip('.')
540 current_try
= util
.sanitize_filename(foldername
, \
541 self
.MAX_FOLDERNAME_LENGTH
)
545 if not os
.path
.exists(os
.path
.join(self
.download_dir
, current_try
)):
546 self
.db
.remove_foldername_if_deleted_channel(current_try
)
548 if self
.db
.channel_foldername_exists(current_try
):
549 current_try
= '%s (%d)' % (foldername
, next_try_id
)
554 def get_save_dir(self
):
555 urldigest
= hashlib
.md5(self
.url
).hexdigest()
556 sanitizedurl
= util
.sanitize_filename(self
.url
, self
.MAX_FOLDERNAME_LENGTH
)
557 if self
.foldername
is None or (self
.auto_foldername
and (self
.foldername
== urldigest
or self
.foldername
.startswith(sanitizedurl
))):
558 # we must change the folder name, because it has not been set manually
559 fn_template
= util
.sanitize_filename(self
.title
, self
.MAX_FOLDERNAME_LENGTH
)
561 # if this is an empty string, try the basename
562 if len(fn_template
) == 0:
563 log('That is one ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self
.url
, sender
=self
)
564 fn_template
= util
.sanitize_filename(os
.path
.basename(self
.url
), self
.MAX_FOLDERNAME_LENGTH
)
566 # If the basename is also empty, use the first 6 md5 hexdigest chars of the URL
567 if len(fn_template
) == 0:
568 log('That is one REALLY ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self
.url
, sender
=self
)
569 fn_template
= urldigest
# no need for sanitize_filename here
571 # Find a unique folder name for this podcast
572 wanted_foldername
= self
.find_unique_folder_name(fn_template
)
574 # if the foldername has not been set, check if the (old) md5 filename exists
575 if self
.foldername
is None and os
.path
.exists(os
.path
.join(self
.download_dir
, urldigest
)):
576 log('Found pre-0.15.0 download folder for %s: %s', self
.title
, urldigest
, sender
=self
)
577 self
.foldername
= urldigest
579 # we have a valid, new folder name in "current_try" -> use that!
580 if self
.foldername
is not None and wanted_foldername
!= self
.foldername
:
581 # there might be an old download folder crawling around - move it!
582 new_folder_name
= os
.path
.join(self
.download_dir
, wanted_foldername
)
583 old_folder_name
= os
.path
.join(self
.download_dir
, self
.foldername
)
584 if os
.path
.exists(old_folder_name
):
585 if not os
.path
.exists(new_folder_name
):
586 # Old folder exists, new folder does not -> simply rename
587 log('Renaming %s => %s', old_folder_name
, new_folder_name
, sender
=self
)
588 os
.rename(old_folder_name
, new_folder_name
)
590 # Both folders exist -> move files and delete old folder
591 log('Moving files from %s to %s', old_folder_name
, new_folder_name
, sender
=self
)
592 for file in glob
.glob(os
.path
.join(old_folder_name
, '*')):
593 shutil
.move(file, new_folder_name
)
594 log('Removing %s', old_folder_name
, sender
=self
)
595 shutil
.rmtree(old_folder_name
, ignore_errors
=True)
596 log('Updating foldername of %s to "%s".', self
.url
, wanted_foldername
, sender
=self
)
597 self
.foldername
= wanted_foldername
600 save_dir
= os
.path
.join(self
.download_dir
, self
.foldername
)
602 # Create save_dir if it does not yet exist
603 if not util
.make_directory( save_dir
):
604 log( 'Could not create save_dir: %s', save_dir
, sender
= self
)
608 save_dir
= property(fget
=get_save_dir
)
610 def remove_downloaded( self
):
611 shutil
.rmtree( self
.save_dir
, True)
614 def cover_file(self
):
615 new_name
= os
.path
.join(self
.save_dir
, 'folder.jpg')
616 if not os
.path
.exists(new_name
):
617 old_names
= ('cover', '.cover')
618 for old_name
in old_names
:
619 filename
= os
.path
.join(self
.save_dir
, old_name
)
620 if os
.path
.exists(filename
):
621 shutil
.move(filename
, new_name
)
626 def delete_episode_by_url(self
, url
):
627 episode
= self
.db
.load_episode(url
, factory
=self
.episode_factory
)
629 if episode
is not None:
630 filename
= episode
.local_filename(create
=False)
631 if filename
is not None:
632 util
.delete_file(filename
)
634 log('Cannot delete episode: %s (I have no filename!)', episode
.title
, sender
=self
)
635 episode
.set_state(gpodder
.STATE_DELETED
)
637 self
.update_m3u_playlist()
640 class PodcastEpisode(PodcastModelObject
):
641 """holds data for one object in a channel"""
642 MAX_FILENAME_LENGTH
= 200
645 def sort_by_pubdate(episodes
, reverse
=False):
646 """Sort a list of PodcastEpisode objects chronologically
648 Returns a iterable, sorted sequence of the episodes
650 key_pubdate
= lambda e
: e
.pubDate
651 return sorted(episodes
, key
=key_pubdate
, reverse
=reverse
)
653 def reload_from_db(self
):
655 Re-reads all episode details for this object from the
656 database and updates this object accordingly. Can be
657 used to refresh existing objects when the database has
658 been updated (e.g. the filename has been set after a
659 download where it was not set before the download)
661 d
= self
.db
.load_episode(self
.url
)
663 self
.update_from_dict(d
)
667 def has_website_link(self
):
668 return bool(self
.link
) and (self
.link
!= self
.url
)
671 def from_feedparser_entry(entry
, channel
):
672 episode
= PodcastEpisode(channel
)
674 episode
.title
= entry
.get('title', '')
675 episode
.link
= entry
.get('link', '')
676 episode
.description
= entry
.get('summary', '')
678 # Fallback to subtitle if summary is not available0
679 if not episode
.description
:
680 episode
.description
= entry
.get('subtitle', '')
682 episode
.guid
= entry
.get('id', '')
683 if entry
.get('updated_parsed', None):
684 episode
.pubDate
= rfc822
.mktime_tz(entry
.updated_parsed
+(0,))
687 for e
in entry
.get('enclosures', ()):
688 episode
.mimetype
= e
.get('type', 'application/octet-stream')
689 if '/' not in episode
.mimetype
:
692 episode
.url
= util
.normalize_feed_url(e
.get('href', ''))
697 episode
.length
= int(e
.length
) or -1
704 for m
in entry
.get('media_content', ()):
705 episode
.mimetype
= m
.get('type', 'application/octet-stream')
706 if '/' not in episode
.mimetype
:
709 episode
.url
= util
.normalize_feed_url(m
.get('url', ''))
714 episode
.length
= int(m
.fileSize
) or -1
720 # Brute-force detection of any links
721 for l
in entry
.get('links', ()):
722 episode
.url
= util
.normalize_feed_url(l
.get('href', ''))
726 if youtube
.is_video_link(episode
.url
):
729 # Check if we can resolve this link to a audio/video file
730 filename
, extension
= util
.filename_from_url(episode
.url
)
731 file_type
= util
.file_type_by_extension(extension
)
732 if file_type
is None and hasattr(l
, 'type'):
733 extension
= util
.extension_from_mimetype(l
.type)
734 file_type
= util
.file_type_by_extension(extension
)
736 # The link points to a audio or video file - use it!
737 if file_type
is not None:
740 # Scan MP3 links in description text
741 mp3s
= re
.compile(r
'http://[^"]*\.mp3')
742 for content
in entry
.get('content', ()):
744 for match
in mp3s
.finditer(html
):
745 episode
.url
= match
.group(0)
750 def __init__(self
, channel
):
752 # Used by Storage for faster saving
757 self
.mimetype
= 'application/octet-stream'
759 self
.description
= ''
761 self
.channel
= channel
764 self
.auto_filename
= 1 # automatically generated filename
766 self
.state
= gpodder
.STATE_NORMAL
767 self
.is_played
= False
769 # Initialize the "is_locked" property
770 self
._is
_locked
= False
771 self
.is_locked
= channel
.channel_is_locked
773 def get_is_locked(self
):
774 return self
._is
_locked
776 def set_is_locked(self
, is_locked
):
777 self
._is
_locked
= bool(is_locked
)
779 is_locked
= property(fget
=get_is_locked
, fset
=set_is_locked
)
782 if self
.state
!= gpodder
.STATE_DOWNLOADED
and self
.file_exists():
783 self
.state
= gpodder
.STATE_DOWNLOADED
784 self
.db
.save_episode(self
)
786 def set_state(self
, state
):
788 self
.db
.mark_episode(self
.url
, state
=self
.state
, is_played
=self
.is_played
, is_locked
=self
.is_locked
)
790 def mark(self
, state
=None, is_played
=None, is_locked
=None):
791 if state
is not None:
793 if is_played
is not None:
794 self
.is_played
= is_played
795 if is_locked
is not None:
796 self
.is_locked
= is_locked
797 self
.db
.mark_episode(self
.url
, state
=state
, is_played
=is_played
, is_locked
=is_locked
)
799 def mark_downloaded(self
, save
=False):
800 self
.state
= gpodder
.STATE_DOWNLOADED
801 self
.is_played
= False
807 def title_markup(self
):
808 return '%s\n<small>%s</small>' % (xml
.sax
.saxutils
.escape(self
.title
),
809 xml
.sax
.saxutils
.escape(self
.channel
.title
))
812 def maemo_markup(self
):
813 return ('<b>%s</b>\n<small>%s; '+_('released %s')+ \
814 '; '+_('from %s')+'</small>') % (\
815 xml
.sax
.saxutils
.escape(self
.title
), \
816 xml
.sax
.saxutils
.escape(self
.filesize_prop
), \
817 xml
.sax
.saxutils
.escape(self
.pubdate_prop
), \
818 xml
.sax
.saxutils
.escape(self
.channel
.title
))
821 def maemo_remove_markup(self
):
823 played_string
= _('played')
825 played_string
= _('unplayed')
826 downloaded_string
= self
.get_age_string()
827 if not downloaded_string
:
828 downloaded_string
= _('today')
829 return ('<b>%s</b>\n<small>%s; %s; '+_('downloaded %s')+ \
830 '; '+_('from %s')+'</small>') % (\
831 xml
.sax
.saxutils
.escape(self
.title
), \
832 xml
.sax
.saxutils
.escape(self
.filesize_prop
), \
833 xml
.sax
.saxutils
.escape(played_string
), \
834 xml
.sax
.saxutils
.escape(downloaded_string
), \
835 xml
.sax
.saxutils
.escape(self
.channel
.title
))
837 def age_in_days(self
):
838 return util
.file_age_in_days(self
.local_filename(create
=False, \
841 def get_age_string(self
):
842 return util
.file_age_to_string(self
.age_in_days())
844 age_prop
= property(fget
=get_age_string
)
846 def one_line_description( self
):
847 lines
= util
.remove_html_tags(self
.description
).strip().splitlines()
848 if not lines
or lines
[0] == '':
849 return _('No description available')
851 return ' '.join(lines
)
853 def delete_from_disk(self
):
855 self
.channel
.delete_episode_by_url(self
.url
)
857 log('Cannot delete episode from disk: %s', self
.title
, traceback
=True, sender
=self
)
859 def find_unique_file_name(self
, url
, filename
, extension
):
860 current_try
= util
.sanitize_filename(filename
, self
.MAX_FILENAME_LENGTH
)+extension
864 if self
.filename
== current_try
and current_try
is not None:
865 # We already have this filename - good!
868 while self
.db
.episode_filename_exists(current_try
):
869 current_try
= '%s (%d)%s' % (filename
, next_try_id
, extension
)
874 def local_filename(self
, create
, force_update
=False, check_only
=False,
876 """Get (and possibly generate) the local saving filename
878 Pass create=True if you want this function to generate a
879 new filename if none exists. You only want to do this when
880 planning to create/download the file after calling this function.
882 Normally, you should pass create=False. This will only
883 create a filename when the file already exists from a previous
884 version of gPodder (where we used md5 filenames). If the file
885 does not exist (and the filename also does not exist), this
886 function will return None.
888 If you pass force_update=True to this function, it will try to
889 find a new (better) filename and move the current file if this
890 is the case. This is useful if (during the download) you get
891 more information about the file, e.g. the mimetype and you want
892 to include this information in the file name generation process.
894 If check_only=True is passed to this function, it will never try
895 to rename the file, even if would be a good idea. Use this if you
896 only want to check if a file exists.
898 If "template" is specified, it should be a filename that is to
899 be used as a template for generating the "real" filename.
901 The generated filename is stored in the database for future access.
903 ext
= self
.extension(may_call_local_filename
=False).encode('utf-8', 'ignore')
905 # For compatibility with already-downloaded episodes, we
906 # have to know md5 filenames if they are downloaded already
907 urldigest
= hashlib
.md5(self
.url
).hexdigest()
909 if not create
and self
.filename
is None:
910 urldigest_filename
= os
.path
.join(self
.channel
.save_dir
, urldigest
+ext
)
911 if os
.path
.exists(urldigest_filename
):
912 # The file exists, so set it up in our database
913 log('Recovering pre-0.15.0 file: %s', urldigest_filename
, sender
=self
)
914 self
.filename
= urldigest
+ext
915 self
.auto_filename
= 1
917 return urldigest_filename
920 # We only want to check if the file exists, so don't try to
921 # rename the file, even if it would be reasonable. See also:
922 # http://bugs.gpodder.org/attachment.cgi?id=236
924 if self
.filename
is None:
927 return os
.path
.join(self
.channel
.save_dir
, self
.filename
)
929 if self
.filename
is None or force_update
or (self
.auto_filename
and self
.filename
== urldigest
+ext
):
930 # Try to find a new filename for the current file
931 if template
is not None:
932 # If template is specified, trust the template's extension
933 episode_filename
, ext
= os
.path
.splitext(template
)
935 episode_filename
, extension_UNUSED
= util
.filename_from_url(self
.url
)
936 fn_template
= util
.sanitize_filename(episode_filename
, self
.MAX_FILENAME_LENGTH
)
938 if 'redirect' in fn_template
and template
is None:
939 # This looks like a redirection URL - force URL resolving!
940 log('Looks like a redirection to me: %s', self
.url
, sender
=self
)
941 url
= util
.get_real_url(self
.channel
.authenticate_url(self
.url
))
942 log('Redirection resolved to: %s', url
, sender
=self
)
943 (episode_filename
, extension_UNUSED
) = util
.filename_from_url(url
)
944 fn_template
= util
.sanitize_filename(episode_filename
, self
.MAX_FILENAME_LENGTH
)
946 # Use the video title for YouTube downloads
947 for yt_url
in ('http://youtube.com/', 'http://www.youtube.com/'):
948 if self
.url
.startswith(yt_url
):
949 fn_template
= util
.sanitize_filename(os
.path
.basename(self
.title
), self
.MAX_FILENAME_LENGTH
)
951 # If the basename is empty, use the md5 hexdigest of the URL
952 if len(fn_template
) == 0 or fn_template
.startswith('redirect.'):
953 log('Report to bugs.gpodder.org: Podcast at %s with episode URL: %s', self
.channel
.url
, self
.url
, sender
=self
)
954 fn_template
= urldigest
956 # Find a unique filename for this episode
957 wanted_filename
= self
.find_unique_file_name(self
.url
, fn_template
, ext
)
959 # We populate the filename field the first time - does the old file still exist?
960 if self
.filename
is None and os
.path
.exists(os
.path
.join(self
.channel
.save_dir
, urldigest
+ext
)):
961 log('Found pre-0.15.0 downloaded file: %s', urldigest
, sender
=self
)
962 self
.filename
= urldigest
+ext
964 # The old file exists, but we have decided to want a different filename
965 if self
.filename
is not None and wanted_filename
!= self
.filename
:
966 # there might be an old download folder crawling around - move it!
967 new_file_name
= os
.path
.join(self
.channel
.save_dir
, wanted_filename
)
968 old_file_name
= os
.path
.join(self
.channel
.save_dir
, self
.filename
)
969 if os
.path
.exists(old_file_name
) and not os
.path
.exists(new_file_name
):
970 log('Renaming %s => %s', old_file_name
, new_file_name
, sender
=self
)
971 os
.rename(old_file_name
, new_file_name
)
972 elif force_update
and not os
.path
.exists(old_file_name
):
973 # When we call force_update, the file might not yet exist when we
974 # call it from the downloading code before saving the file
975 log('Choosing new filename: %s', new_file_name
, sender
=self
)
977 log('Warning: %s exists or %s does not.', new_file_name
, old_file_name
, sender
=self
)
978 log('Updating filename of %s to "%s".', self
.url
, wanted_filename
, sender
=self
)
979 elif self
.filename
is None:
980 log('Setting filename to "%s".', wanted_filename
, sender
=self
)
982 log('Should update filename. Stays the same (%s). Good!', \
983 wanted_filename
, sender
=self
)
984 self
.filename
= wanted_filename
988 return os
.path
.join(self
.channel
.save_dir
, self
.filename
)
990 def set_mimetype(self
, mimetype
, commit
=False):
991 """Sets the mimetype for this episode"""
992 self
.mimetype
= mimetype
996 def extension(self
, may_call_local_filename
=True):
997 filename
, ext
= util
.filename_from_url(self
.url
)
998 if may_call_local_filename
:
999 filename
= self
.local_filename(create
=False)
1000 if filename
is not None:
1001 filename
, ext
= os
.path
.splitext(filename
)
1002 # if we can't detect the extension from the url fallback on the mimetype
1003 if ext
== '' or util
.file_type_by_extension(ext
) is None:
1004 ext
= util
.extension_from_mimetype(self
.mimetype
)
1007 def check_is_new(self
, downloading
=lambda e
: False):
1009 Returns True if this episode is to be considered new.
1010 "Downloading" should be a callback that gets an episode
1011 as its parameter and returns True if the episode is
1012 being downloaded at the moment.
1014 return self
.state
== gpodder
.STATE_NORMAL
and \
1015 not self
.is_played
and \
1016 not downloading(self
)
1019 self
.state
= gpodder
.STATE_NORMAL
1020 self
.is_played
= False
1021 self
.db
.mark_episode(self
.url
, state
=self
.state
, is_played
=self
.is_played
)
1024 self
.is_played
= True
1025 self
.db
.mark_episode(self
.url
, is_played
=True)
1027 def file_exists(self
):
1028 filename
= self
.local_filename(create
=False, check_only
=True)
1029 if filename
is None:
1032 return os
.path
.exists(filename
)
1034 def was_downloaded(self
, and_exists
=False):
1035 if self
.state
!= gpodder
.STATE_DOWNLOADED
:
1037 if and_exists
and not self
.file_exists():
1041 def sync_filename(self
, use_custom
=False, custom_format
=None):
1043 return util
.object_string_formatter(custom_format
,
1044 episode
=self
, podcast
=self
.channel
)
1048 def file_type( self
):
1049 return util
.file_type_by_extension( self
.extension() )
1052 def basename( self
):
1053 return os
.path
.splitext( os
.path
.basename( self
.url
))[0]
1056 def published( self
):
1058 Returns published date as YYYYMMDD (or 00000000 if not available)
1061 return datetime
.datetime
.fromtimestamp(self
.pubDate
).strftime('%Y%m%d')
1063 log( 'Cannot format pubDate for "%s".', self
.title
, sender
= self
)
1069 Returns published time as HHMM (or 0000 if not available)
1072 return datetime
.datetime
.fromtimestamp(self
.pubDate
).strftime('%H%M')
1074 log('Cannot format pubDate (time) for "%s".', self
.title
, sender
=self
)
1077 def cute_pubdate(self
):
1078 result
= util
.format_date(self
.pubDate
)
1080 return '(%s)' % _('unknown')
1084 pubdate_prop
= property(fget
=cute_pubdate
)
1086 def calculate_filesize( self
):
1087 filename
= self
.local_filename(create
=False)
1088 if filename
is None:
1089 log('calculate_filesized called, but filename is None!', sender
=self
)
1091 self
.length
= os
.path
.getsize(filename
)
1093 log( 'Could not get filesize for %s.', self
.url
)
1095 def get_filesize_string(self
):
1096 return util
.format_filesize(self
.length
)
1098 filesize_prop
= property(fget
=get_filesize_string
)
1100 def get_played_string( self
):
1101 if not self
.is_played
:
1102 return _('Unplayed')
1106 played_prop
= property(fget
=get_played_string
)
1108 def is_duplicate(self
, episode
):
1109 if self
.title
== episode
.title
and self
.pubDate
== episode
.pubDate
:
1110 log('Possible duplicate detected: %s', self
.title
)
1114 def duplicate_id(self
):
1115 return hash((self
.title
, self
.pubDate
))
1117 def update_from(self
, episode
):
1118 for k
in ('title', 'url', 'description', 'link', 'pubDate', 'guid'):
1119 setattr(self
, k
, getattr(episode
, k
))