2007-08-29 20:30:26 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
2005-11-21 19:21:25 +01:00
|
|
|
#
|
2007-08-29 20:30:26 +02:00
|
|
|
# gPodder - A media aggregator and podcast client
|
2010-01-02 17:35:42 +01:00
|
|
|
# Copyright (c) 2005-2010 Thomas Perl and the gPodder Team
|
2005-11-21 19:21:25 +01:00
|
|
|
#
|
2007-08-29 20:30:26 +02:00
|
|
|
# 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.
|
2006-04-07 22:22:30 +02:00
|
|
|
#
|
2007-08-29 20:30:26 +02:00
|
|
|
# gPodder is distributed in the hope that it will be useful,
|
2006-04-07 22:22:30 +02:00
|
|
|
# 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
|
2007-08-29 20:30:26 +02:00
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2006-04-07 22:22:30 +02:00
|
|
|
#
|
|
|
|
|
2005-11-21 19:21:25 +01:00
|
|
|
|
|
|
|
#
|
2009-08-13 23:36:18 +02:00
|
|
|
# gpodder.model - Core model classes for gPodder (2009-08-13)
|
|
|
|
# Based on libpodcasts.py (thp, 2005-10-29)
|
2005-11-21 19:21:25 +01:00
|
|
|
#
|
2007-08-07 20:11:31 +02:00
|
|
|
|
2008-04-22 21:57:02 +02:00
|
|
|
import gpodder
|
2007-08-07 20:11:31 +02:00
|
|
|
from gpodder import util
|
2009-06-12 00:51:13 +02:00
|
|
|
from gpodder import feedcore
|
2009-08-24 17:02:35 +02:00
|
|
|
from gpodder import youtube
|
2009-06-12 02:44:04 +02:00
|
|
|
from gpodder import corestats
|
2007-09-15 16:29:37 +02:00
|
|
|
|
2008-03-02 14:22:29 +01:00
|
|
|
from gpodder.liblogger import log
|
2006-02-04 11:37:23 +01:00
|
|
|
|
2006-12-06 21:25:26 +01:00
|
|
|
import os
|
2009-10-13 14:19:40 +02:00
|
|
|
import re
|
2006-12-06 21:25:26 +01:00
|
|
|
import glob
|
|
|
|
import shutil
|
2007-08-30 20:49:53 +02:00
|
|
|
import time
|
2008-04-22 21:57:02 +02:00
|
|
|
import datetime
|
2008-07-14 18:46:59 +02:00
|
|
|
import rfc822
|
2008-12-27 13:24:21 +01:00
|
|
|
import hashlib
|
2008-06-17 14:50:27 +02:00
|
|
|
import feedparser
|
2009-08-13 23:36:18 +02:00
|
|
|
import xml.sax.saxutils
|
2007-07-05 23:07:16 +02:00
|
|
|
|
2009-05-07 16:26:07 +02:00
|
|
|
_ = gpodder.gettext
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2008-03-02 13:56:16 +01:00
|
|
|
|
2009-11-04 15:18:07 +01:00
|
|
|
class CustomFeed(feedcore.ExceptionWithData): pass
|
|
|
|
|
2009-06-12 00:51:13 +02:00
|
|
|
class gPodderFetcher(feedcore.Fetcher):
|
|
|
|
"""
|
|
|
|
This class extends the feedcore Fetcher with the gPodder User-Agent and the
|
|
|
|
Proxy handler based on the current settings in gPodder and provides a
|
|
|
|
convenience method (fetch_channel) for use by PodcastChannel objects.
|
|
|
|
"""
|
2009-11-04 15:18:07 +01:00
|
|
|
custom_handlers = []
|
2009-06-12 00:51:13 +02:00
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
feedcore.Fetcher.__init__(self, gpodder.user_agent)
|
|
|
|
|
|
|
|
def fetch_channel(self, channel):
|
|
|
|
etag = channel.etag
|
|
|
|
modified = feedparser._parse_date(channel.last_modified)
|
|
|
|
# If we have a username or password, rebuild the url with them included
|
|
|
|
# Note: using a HTTPBasicAuthHandler would be pain because we need to
|
|
|
|
# know the realm. It can be done, but I think this method works, too
|
2009-08-24 13:04:11 +02:00
|
|
|
url = channel.authenticate_url(channel.url)
|
2009-11-04 15:18:07 +01:00
|
|
|
for handler in self.custom_handlers:
|
2009-11-18 00:01:15 +01:00
|
|
|
custom_feed = handler.handle_url(url)
|
|
|
|
if custom_feed is not None:
|
|
|
|
raise CustomFeed(custom_feed)
|
2009-06-12 00:51:13 +02:00
|
|
|
self.fetch(url, etag, modified)
|
|
|
|
|
|
|
|
def _resolve_url(self, url):
|
2009-08-24 17:02:35 +02:00
|
|
|
return youtube.get_real_channel_url(url)
|
2009-06-12 00:51:13 +02:00
|
|
|
|
2009-11-18 00:01:15 +01:00
|
|
|
@classmethod
|
|
|
|
def register(cls, handler):
|
|
|
|
cls.custom_handlers.append(handler)
|
|
|
|
|
2009-07-06 15:29:09 +02:00
|
|
|
# def _get_handlers(self):
|
|
|
|
# # Add a ProxyHandler for fetching data via a proxy server
|
|
|
|
# proxies = {'http': 'http://proxy.example.org:8080'}
|
|
|
|
# return[urllib2.ProxyHandler(proxies))]
|
2008-03-02 13:56:16 +01:00
|
|
|
|
2009-11-18 00:01:15 +01:00
|
|
|
# The "register" method is exposed here for external usage
|
|
|
|
register_custom_handler = gPodderFetcher.register
|
2009-03-10 14:59:01 +01:00
|
|
|
|
|
|
|
class PodcastModelObject(object):
|
|
|
|
"""
|
|
|
|
A generic base class for our podcast model providing common helper
|
|
|
|
and utility functions.
|
|
|
|
"""
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def create_from_dict(cls, d, *args):
|
|
|
|
"""
|
|
|
|
Create a new object, passing "args" to the constructor
|
|
|
|
and then updating the object with the values from "d".
|
|
|
|
"""
|
|
|
|
o = cls(*args)
|
|
|
|
o.update_from_dict(d)
|
|
|
|
return o
|
|
|
|
|
|
|
|
def update_from_dict(self, d):
|
|
|
|
"""
|
|
|
|
Updates the attributes of this object with values from the
|
|
|
|
dictionary "d" by using the keys found in "d".
|
|
|
|
"""
|
|
|
|
for k in d:
|
|
|
|
if hasattr(self, k):
|
|
|
|
setattr(self, k, d[k])
|
|
|
|
|
|
|
|
|
|
|
|
class PodcastChannel(PodcastModelObject):
|
2006-03-03 21:04:25 +01:00
|
|
|
"""holds data for a complete channel"""
|
2009-02-06 15:54:28 +01:00
|
|
|
MAX_FOLDERNAME_LENGTH = 150
|
2010-03-11 19:41:29 +01:00
|
|
|
SECONDS_PER_WEEK = 7*24*60*60
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2009-06-12 00:51:13 +02:00
|
|
|
feed_fetcher = gPodderFetcher()
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2007-11-12 20:29:53 +01:00
|
|
|
@classmethod
|
2009-08-13 20:39:00 +02:00
|
|
|
def build_factory(cls, download_dir):
|
|
|
|
def factory(dict, db):
|
|
|
|
return cls.create_from_dict(dict, db, download_dir)
|
|
|
|
return factory
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def load_from_db(cls, db, download_dir):
|
|
|
|
return db.load_channels(factory=cls.build_factory(download_dir))
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def load(cls, db, url, create=True, authentication_tokens=None,\
|
2009-12-12 14:04:28 +01:00
|
|
|
max_episodes=0, download_dir=None, allow_empty_feeds=False):
|
2008-06-30 03:10:18 +02:00
|
|
|
if isinstance(url, unicode):
|
|
|
|
url = url.encode('utf-8')
|
2007-11-12 20:29:53 +01:00
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
tmp = db.load_channels(factory=cls.build_factory(download_dir), url=url)
|
2008-06-30 03:10:18 +02:00
|
|
|
if len(tmp):
|
|
|
|
return tmp[0]
|
|
|
|
elif create:
|
2009-08-18 17:48:09 +02:00
|
|
|
tmp = PodcastChannel(db, download_dir)
|
|
|
|
tmp.url = url
|
2008-10-20 06:17:22 +02:00
|
|
|
if authentication_tokens is not None:
|
|
|
|
tmp.username = authentication_tokens[0]
|
|
|
|
tmp.password = authentication_tokens[1]
|
2009-06-12 00:51:13 +02:00
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
tmp.update(max_episodes)
|
2008-06-30 03:10:18 +02:00
|
|
|
tmp.save()
|
|
|
|
db.force_last_new(tmp)
|
2009-12-12 14:04:28 +01:00
|
|
|
# Subscribing to empty feeds should yield an error (except if
|
|
|
|
# the user specifically allows empty feeds in the config UI)
|
|
|
|
if sum(tmp.get_statistics()) == 0 and not allow_empty_feeds:
|
2009-09-09 19:53:26 +02:00
|
|
|
tmp.delete()
|
|
|
|
raise Exception(_('No downloadable episodes in feed'))
|
2008-06-30 03:10:18 +02:00
|
|
|
return tmp
|
2008-05-10 13:43:43 +02:00
|
|
|
|
2009-08-10 23:40:31 +02:00
|
|
|
def episode_factory(self, d, db__parameter_is_unused=None):
|
2009-03-10 14:59:01 +01:00
|
|
|
"""
|
|
|
|
This function takes a dictionary containing key-value pairs for
|
|
|
|
episodes and returns a new PodcastEpisode object that is connected
|
|
|
|
to this PodcastChannel object.
|
|
|
|
|
|
|
|
Returns: A new PodcastEpisode object
|
|
|
|
"""
|
|
|
|
return PodcastEpisode.create_from_dict(d, self)
|
2008-06-30 03:10:18 +02:00
|
|
|
|
2009-11-04 15:18:07 +01:00
|
|
|
def _consume_custom_feed(self, custom_feed, max_episodes=0):
|
|
|
|
self.title = custom_feed.get_title()
|
|
|
|
self.link = custom_feed.get_link()
|
|
|
|
self.description = custom_feed.get_description()
|
|
|
|
self.image = custom_feed.get_image()
|
|
|
|
self.pubDate = time.time()
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
guids = [episode.guid for episode in self.get_all_episodes()]
|
2010-04-29 13:47:19 +02:00
|
|
|
|
|
|
|
# Insert newly-found episodes into the database
|
|
|
|
custom_feed.get_new_episodes(self, guids)
|
|
|
|
|
2009-11-04 15:18:07 +01:00
|
|
|
self.save()
|
|
|
|
|
|
|
|
self.db.purge(max_episodes, self.id)
|
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
def _consume_updated_feed(self, feed, max_episodes=0):
|
2009-06-12 00:51:13 +02:00
|
|
|
self.parse_error = feed.get('bozo_exception', None)
|
|
|
|
|
|
|
|
self.title = feed.feed.get('title', self.url)
|
|
|
|
self.link = feed.feed.get('link', self.link)
|
|
|
|
self.description = feed.feed.get('subtitle', self.description)
|
|
|
|
# Start YouTube-specific title FIX
|
|
|
|
YOUTUBE_PREFIX = 'Uploads by '
|
|
|
|
if self.title.startswith(YOUTUBE_PREFIX):
|
|
|
|
self.title = self.title[len(YOUTUBE_PREFIX):] + ' on YouTube'
|
|
|
|
# End YouTube-specific title FIX
|
|
|
|
|
|
|
|
try:
|
|
|
|
self.pubDate = rfc822.mktime_tz(feed.feed.get('updated_parsed', None+(0,)))
|
|
|
|
except:
|
2008-06-30 03:10:18 +02:00
|
|
|
self.pubDate = time.time()
|
2009-06-12 00:51:13 +02:00
|
|
|
|
|
|
|
if hasattr(feed.feed, 'image'):
|
2010-01-07 23:16:40 +01:00
|
|
|
for attribute in ('href', 'url'):
|
|
|
|
new_value = getattr(feed.feed.image, attribute, None)
|
|
|
|
if new_value is not None:
|
|
|
|
log('Found cover art in %s: %s', attribute, new_value)
|
|
|
|
self.image = new_value
|
|
|
|
|
|
|
|
if hasattr(feed.feed, 'icon'):
|
|
|
|
self.image = feed.feed.icon
|
2008-06-30 03:10:18 +02:00
|
|
|
|
2009-06-12 00:51:13 +02:00
|
|
|
self.save()
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2008-10-06 22:07:38 +02:00
|
|
|
# Load all episodes to update them properly.
|
|
|
|
existing = self.get_all_episodes()
|
|
|
|
|
2008-03-20 11:20:41 +01:00
|
|
|
# We can limit the maximum number of entries that gPodder will parse
|
2009-08-13 20:39:00 +02:00
|
|
|
if max_episodes > 0 and len(feed.entries) > max_episodes:
|
|
|
|
entries = feed.entries[:max_episodes]
|
|
|
|
else:
|
|
|
|
entries = feed.entries
|
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
# Title + PubDate hashes for existing episodes
|
|
|
|
existing_dupes = dict((e.duplicate_id(), e) for e in existing)
|
|
|
|
|
|
|
|
# GUID-based existing episode list
|
|
|
|
existing_guids = dict((e.guid, e) for e in existing)
|
|
|
|
|
2010-03-11 19:41:29 +01:00
|
|
|
# Get most recent pubDate of all episodes
|
|
|
|
last_pubdate = self.db.get_last_pubdate(self) or 0
|
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
# Search all entries for new episodes
|
|
|
|
for entry in entries:
|
2007-08-25 17:40:18 +02:00
|
|
|
try:
|
2009-03-10 14:59:01 +01:00
|
|
|
episode = PodcastEpisode.from_feedparser_entry(entry, self)
|
2010-02-28 04:10:39 +01:00
|
|
|
if episode is not None and not episode.title:
|
|
|
|
episode.title, ext = os.path.splitext(os.path.basename(episode.url))
|
2008-06-30 03:10:18 +02:00
|
|
|
except Exception, e:
|
2010-02-28 04:10:39 +01:00
|
|
|
log('Cannot instantiate episode: %s. Skipping.', e, sender=self, traceback=True)
|
|
|
|
continue
|
|
|
|
|
|
|
|
if episode is None:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Detect (and update) existing episode based on GUIDs
|
|
|
|
existing_episode = existing_guids.get(episode.guid, None)
|
|
|
|
if existing_episode:
|
|
|
|
existing_episode.update_from(episode)
|
|
|
|
existing_episode.save()
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Detect (and update) existing episode based on duplicate ID
|
|
|
|
existing_episode = existing_dupes.get(episode.duplicate_id(), None)
|
|
|
|
if existing_episode:
|
|
|
|
if existing_episode.is_duplicate(episode):
|
|
|
|
existing_episode.update_from(episode)
|
|
|
|
existing_episode.save()
|
|
|
|
continue
|
2008-10-06 22:07:38 +02:00
|
|
|
|
2010-03-11 19:41:29 +01:00
|
|
|
# Workaround for bug 340: If the episode has been
|
|
|
|
# published earlier than one week before the most
|
|
|
|
# recent existing episode, do not mark it as new.
|
|
|
|
if episode.pubDate < last_pubdate - self.SECONDS_PER_WEEK:
|
|
|
|
log('Episode with old date: %s', episode.title, sender=self)
|
|
|
|
episode.is_played = True
|
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
episode.save()
|
2007-08-25 08:11:19 +02:00
|
|
|
|
2009-09-28 15:00:38 +02:00
|
|
|
# Remove "unreachable" episodes - episodes that have not been
|
|
|
|
# downloaded and that the feed does not list as downloadable anymore
|
|
|
|
if self.id is not None:
|
|
|
|
seen_guids = set(e.guid for e in feed.entries if hasattr(e, 'guid'))
|
|
|
|
episodes_to_purge = (e for e in existing if \
|
|
|
|
e.state != gpodder.STATE_DOWNLOADED and \
|
|
|
|
e.guid not in seen_guids and e.guid is not None)
|
|
|
|
for episode in episodes_to_purge:
|
|
|
|
log('Episode removed from feed: %s (%s)', episode.title, \
|
|
|
|
episode.guid, sender=self)
|
|
|
|
self.db.delete_episode_by_guid(episode.guid, self.id)
|
|
|
|
|
2009-05-14 17:25:04 +02:00
|
|
|
# This *might* cause episodes to be skipped if there were more than
|
|
|
|
# max_episodes_per_feed items added to the feed between updates.
|
|
|
|
# The benefit is that it prevents old episodes from apearing as new
|
|
|
|
# in certain situations (see bug #340).
|
2009-08-13 20:39:00 +02:00
|
|
|
self.db.purge(max_episodes, self.id)
|
2009-06-12 00:51:13 +02:00
|
|
|
|
2009-08-10 23:14:35 +02:00
|
|
|
def update_channel_lock(self):
|
2009-08-10 23:40:31 +02:00
|
|
|
self.db.update_channel_lock(self)
|
2009-08-10 23:14:35 +02:00
|
|
|
|
2009-06-12 00:51:13 +02:00
|
|
|
def _update_etag_modified(self, feed):
|
2009-06-12 02:44:04 +02:00
|
|
|
self.updated_timestamp = time.time()
|
|
|
|
self.calculate_publish_behaviour()
|
2009-06-12 00:51:13 +02:00
|
|
|
self.etag = feed.headers.get('etag', self.etag)
|
|
|
|
self.last_modified = feed.headers.get('last-modified', self.last_modified)
|
|
|
|
|
2009-09-01 14:18:24 +02:00
|
|
|
def query_automatic_update(self):
|
|
|
|
"""Query if this channel should be updated automatically
|
|
|
|
|
|
|
|
Returns True if the update should happen in automatic
|
|
|
|
mode or False if this channel should be skipped (timeout
|
|
|
|
not yet reached or release not expected right now).
|
|
|
|
"""
|
|
|
|
updated = self.updated_timestamp
|
|
|
|
expected = self.release_expected
|
2009-06-12 02:44:04 +02:00
|
|
|
|
2009-09-01 14:18:24 +02:00
|
|
|
now = time.time()
|
|
|
|
one_day_ago = now - 60*60*24
|
|
|
|
lastcheck = now - 60*10
|
|
|
|
|
|
|
|
return updated < one_day_ago or \
|
|
|
|
(expected < now and updated < lastcheck)
|
|
|
|
|
|
|
|
def update(self, max_episodes=0):
|
2009-06-12 00:51:13 +02:00
|
|
|
try:
|
|
|
|
self.feed_fetcher.fetch_channel(self)
|
2009-11-04 15:18:07 +01:00
|
|
|
except CustomFeed, updated:
|
|
|
|
custom_feed = updated.data
|
|
|
|
self._consume_custom_feed(custom_feed, max_episodes)
|
|
|
|
self.save()
|
2009-06-12 00:51:13 +02:00
|
|
|
except feedcore.UpdatedFeed, updated:
|
|
|
|
feed = updated.data
|
2009-08-13 20:39:00 +02:00
|
|
|
self._consume_updated_feed(feed, max_episodes)
|
2009-06-12 00:51:13 +02:00
|
|
|
self._update_etag_modified(feed)
|
|
|
|
self.save()
|
|
|
|
except feedcore.NewLocation, updated:
|
|
|
|
feed = updated.data
|
|
|
|
self.url = feed.href
|
2009-08-13 20:39:00 +02:00
|
|
|
self._consume_updated_feed(feed, max_episodes)
|
2009-06-12 00:51:13 +02:00
|
|
|
self._update_etag_modified(feed)
|
|
|
|
self.save()
|
|
|
|
except feedcore.NotModified, updated:
|
|
|
|
feed = updated.data
|
|
|
|
self._update_etag_modified(feed)
|
|
|
|
self.save()
|
|
|
|
except Exception, e:
|
|
|
|
# "Not really" errors
|
|
|
|
#feedcore.AuthenticationRequired
|
|
|
|
# Temporary errors
|
|
|
|
#feedcore.Offline
|
|
|
|
#feedcore.BadRequest
|
|
|
|
#feedcore.InternalServerError
|
|
|
|
#feedcore.WifiLogin
|
|
|
|
# Permanent errors
|
|
|
|
#feedcore.Unsubscribe
|
|
|
|
#feedcore.NotFound
|
|
|
|
#feedcore.InvalidFeed
|
|
|
|
#feedcore.UnknownStatusCode
|
|
|
|
raise
|
|
|
|
|
2009-08-10 23:40:31 +02:00
|
|
|
self.db.commit()
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2010-04-24 18:51:19 +02:00
|
|
|
def delete(self):
|
|
|
|
self.db.delete_channel(self)
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
def save(self):
|
2009-08-10 23:40:31 +02:00
|
|
|
self.db.save_channel(self)
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2009-09-01 18:56:30 +02:00
|
|
|
def get_statistics(self):
|
|
|
|
if self.id is None:
|
|
|
|
return (0, 0, 0, 0, 0)
|
|
|
|
else:
|
|
|
|
return self.db.get_channel_count(int(self.id))
|
2006-12-06 21:25:26 +01:00
|
|
|
|
2009-08-24 13:04:11 +02:00
|
|
|
def authenticate_url(self, url):
|
|
|
|
return util.url_add_authentication(url, self.username, self.password)
|
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
def __init__(self, db, download_dir):
|
2009-08-10 23:40:31 +02:00
|
|
|
self.db = db
|
2009-08-13 20:39:00 +02:00
|
|
|
self.download_dir = download_dir
|
2008-06-30 03:10:18 +02:00
|
|
|
self.id = None
|
2009-08-13 20:39:00 +02:00
|
|
|
self.url = None
|
|
|
|
self.title = ''
|
|
|
|
self.link = ''
|
|
|
|
self.description = ''
|
2006-03-03 21:04:25 +01:00
|
|
|
self.image = None
|
2008-06-14 18:53:16 +02:00
|
|
|
self.pubDate = 0
|
2008-03-29 16:33:18 +01:00
|
|
|
self.parse_error = None
|
2009-02-06 15:54:28 +01:00
|
|
|
self.foldername = None
|
|
|
|
self.auto_foldername = 1 # automatically generated foldername
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2006-04-07 03:43:06 +02:00
|
|
|
# should this channel be synced to devices? (ex: iPod)
|
|
|
|
self.sync_to_devices = True
|
2008-04-22 21:57:02 +02:00
|
|
|
# to which playlist should be synced
|
2006-04-08 11:09:15 +02:00
|
|
|
self.device_playlist_name = 'gPodder'
|
2007-03-08 13:11:10 +01:00
|
|
|
# if set, this overrides the channel-provided title
|
|
|
|
self.override_title = ''
|
2007-07-19 14:44:12 +02:00
|
|
|
self.username = ''
|
|
|
|
self.password = ''
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
self.last_modified = None
|
|
|
|
self.etag = None
|
|
|
|
|
2008-03-20 11:17:31 +01:00
|
|
|
self.save_dir_size = 0
|
2008-06-05 18:17:09 +02:00
|
|
|
self.__save_dir_size_set = False
|
2007-11-14 21:57:31 +01:00
|
|
|
|
2008-11-19 17:05:19 +01:00
|
|
|
self.channel_is_locked = False
|
|
|
|
|
2009-06-12 02:44:04 +02:00
|
|
|
self.release_expected = time.time()
|
|
|
|
self.release_deviation = 0
|
|
|
|
self.updated_timestamp = 0
|
|
|
|
|
|
|
|
def calculate_publish_behaviour(self):
|
2009-08-10 23:40:31 +02:00
|
|
|
episodes = self.db.load_episodes(self, factory=self.episode_factory, limit=30)
|
2009-06-12 02:44:04 +02:00
|
|
|
if len(episodes) < 3:
|
|
|
|
return
|
|
|
|
|
|
|
|
deltas = []
|
|
|
|
latest = max(e.pubDate for e in episodes)
|
|
|
|
for index in range(len(episodes)-1):
|
|
|
|
if episodes[index].pubDate != 0 and episodes[index+1].pubDate != 0:
|
|
|
|
deltas.append(episodes[index].pubDate - episodes[index+1].pubDate)
|
|
|
|
|
|
|
|
if len(deltas) > 1:
|
|
|
|
stats = corestats.Stats(deltas)
|
|
|
|
self.release_expected = min([latest+stats.stdev(), latest+(stats.min()+stats.avg())*.5])
|
|
|
|
self.release_deviation = stats.stdev()
|
|
|
|
else:
|
|
|
|
self.release_expected = latest
|
|
|
|
self.release_deviation = 0
|
|
|
|
|
2008-06-05 18:17:09 +02:00
|
|
|
def request_save_dir_size(self):
|
|
|
|
if not self.__save_dir_size_set:
|
|
|
|
self.update_save_dir_size()
|
|
|
|
self.__save_dir_size_set = True
|
|
|
|
|
2007-11-14 21:57:31 +01:00
|
|
|
def update_save_dir_size(self):
|
|
|
|
self.save_dir_size = util.calculate_size(self.save_dir)
|
2006-08-02 20:24:48 +02:00
|
|
|
|
|
|
|
def get_title( self):
|
2007-03-08 13:11:10 +01:00
|
|
|
if self.override_title:
|
|
|
|
return self.override_title
|
|
|
|
elif not self.__title.strip():
|
|
|
|
return self.url
|
|
|
|
else:
|
|
|
|
return self.__title
|
2006-08-02 20:24:48 +02:00
|
|
|
|
|
|
|
def set_title( self, value):
|
|
|
|
self.__title = value.strip()
|
|
|
|
|
|
|
|
title = property(fget=get_title,
|
|
|
|
fset=set_title)
|
2007-03-08 13:11:10 +01:00
|
|
|
|
|
|
|
def set_custom_title( self, custom_title):
|
|
|
|
custom_title = custom_title.strip()
|
2009-07-29 22:15:15 +02:00
|
|
|
|
|
|
|
# if the custom title is the same as we have
|
|
|
|
if custom_title == self.override_title:
|
|
|
|
return
|
|
|
|
|
|
|
|
# if custom title is the same as channel title and we didn't have a custom title
|
|
|
|
if custom_title == self.__title and self.override_title == '':
|
|
|
|
return
|
2007-03-08 13:11:10 +01:00
|
|
|
|
2009-02-06 15:54:28 +01:00
|
|
|
# make sure self.foldername is initialized
|
|
|
|
self.get_save_dir()
|
|
|
|
|
|
|
|
# rename folder if custom_title looks sane
|
|
|
|
new_folder_name = self.find_unique_folder_name(custom_title)
|
|
|
|
if len(new_folder_name) > 0 and new_folder_name != self.foldername:
|
|
|
|
log('Changing foldername based on custom title: %s', custom_title, sender=self)
|
2009-08-13 20:39:00 +02:00
|
|
|
new_folder = os.path.join(self.download_dir, new_folder_name)
|
|
|
|
old_folder = os.path.join(self.download_dir, self.foldername)
|
2009-02-06 15:54:28 +01:00
|
|
|
if os.path.exists(old_folder):
|
|
|
|
if not os.path.exists(new_folder):
|
|
|
|
# Old folder exists, new folder does not -> simply rename
|
|
|
|
log('Renaming %s => %s', old_folder, new_folder, sender=self)
|
|
|
|
os.rename(old_folder, new_folder)
|
|
|
|
else:
|
|
|
|
# Both folders exist -> move files and delete old folder
|
|
|
|
log('Moving files from %s to %s', old_folder, new_folder, sender=self)
|
|
|
|
for file in glob.glob(os.path.join(old_folder, '*')):
|
|
|
|
shutil.move(file, new_folder)
|
|
|
|
log('Removing %s', old_folder, sender=self)
|
|
|
|
shutil.rmtree(old_folder, ignore_errors=True)
|
|
|
|
self.foldername = new_folder_name
|
|
|
|
self.save()
|
|
|
|
|
2007-03-08 13:11:10 +01:00
|
|
|
if custom_title != self.__title:
|
|
|
|
self.override_title = custom_title
|
|
|
|
else:
|
|
|
|
self.override_title = ''
|
2006-04-07 03:43:06 +02:00
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
def get_downloaded_episodes(self):
|
2009-08-10 23:40:31 +02:00
|
|
|
return self.db.load_episodes(self, factory=self.episode_factory, state=gpodder.STATE_DOWNLOADED)
|
2007-11-27 23:04:15 +01:00
|
|
|
|
2009-04-01 13:34:19 +02:00
|
|
|
def get_new_episodes(self, downloading=lambda e: False):
|
|
|
|
"""
|
|
|
|
Get a list of new episodes. You can optionally specify
|
|
|
|
"downloading" as a callback that takes an episode as
|
|
|
|
a parameter and returns True if the episode is currently
|
|
|
|
being downloaded or False if not.
|
|
|
|
|
|
|
|
By default, "downloading" is implemented so that it
|
|
|
|
reports all episodes as not downloading.
|
|
|
|
"""
|
2009-08-10 23:40:31 +02:00
|
|
|
return [episode for episode in self.db.load_episodes(self, \
|
2010-04-26 22:38:02 +02:00
|
|
|
factory=self.episode_factory, state=gpodder.STATE_NORMAL) if \
|
2009-09-21 23:34:12 +02:00
|
|
|
episode.check_is_new(downloading=downloading)]
|
2007-07-05 23:07:16 +02:00
|
|
|
|
2009-10-13 18:43:54 +02:00
|
|
|
def get_playlist_filename(self):
|
2009-08-13 20:39:00 +02:00
|
|
|
# If the save_dir doesn't end with a slash (which it really should
|
|
|
|
# not, if the implementation is correct, we can just append .m3u :)
|
|
|
|
assert self.save_dir[-1] != '/'
|
2009-10-13 18:43:54 +02:00
|
|
|
return self.save_dir+'.m3u'
|
|
|
|
|
|
|
|
def update_m3u_playlist(self):
|
|
|
|
m3u_filename = self.get_playlist_filename()
|
2009-07-06 16:14:36 +02:00
|
|
|
|
2009-10-13 18:43:54 +02:00
|
|
|
downloaded_episodes = self.get_downloaded_episodes()
|
|
|
|
if not downloaded_episodes:
|
|
|
|
log('No episodes - removing %s', m3u_filename, sender=self)
|
|
|
|
util.delete_file(m3u_filename)
|
|
|
|
return
|
|
|
|
|
|
|
|
log('Writing playlist to %s', m3u_filename, sender=self)
|
2009-07-06 16:14:36 +02:00
|
|
|
f = open(m3u_filename, 'w')
|
|
|
|
f.write('#EXTM3U\n')
|
|
|
|
|
2010-02-05 21:03:34 +01:00
|
|
|
for episode in PodcastEpisode.sort_by_pubdate(downloaded_episodes):
|
2009-07-06 16:14:36 +02:00
|
|
|
if episode.was_downloaded(and_exists=True):
|
|
|
|
filename = episode.local_filename(create=False)
|
|
|
|
assert filename is not None
|
|
|
|
|
|
|
|
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()
|
2007-03-15 22:33:23 +01:00
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
def get_all_episodes(self):
|
2009-08-10 23:40:31 +02:00
|
|
|
return self.db.load_episodes(self, factory=self.episode_factory)
|
2007-11-27 23:04:15 +01:00
|
|
|
|
2009-08-10 23:40:31 +02:00
|
|
|
def find_unique_folder_name(self, foldername):
|
2009-12-12 14:23:51 +01:00
|
|
|
# Remove trailing dots to avoid errors on Windows (bug 600)
|
|
|
|
foldername = foldername.strip().rstrip('.')
|
|
|
|
|
|
|
|
current_try = util.sanitize_filename(foldername, \
|
|
|
|
self.MAX_FOLDERNAME_LENGTH)
|
2009-02-06 15:54:28 +01:00
|
|
|
next_try_id = 2
|
|
|
|
|
2009-08-02 19:46:49 +02:00
|
|
|
while True:
|
|
|
|
if self.db.channel_foldername_exists(current_try):
|
|
|
|
current_try = '%s (%d)' % (foldername, next_try_id)
|
|
|
|
next_try_id += 1
|
|
|
|
else:
|
|
|
|
return current_try
|
2009-02-06 15:54:28 +01:00
|
|
|
|
2006-03-03 21:04:25 +01:00
|
|
|
def get_save_dir(self):
|
2009-02-06 15:54:28 +01:00
|
|
|
urldigest = hashlib.md5(self.url).hexdigest()
|
|
|
|
sanitizedurl = util.sanitize_filename(self.url, self.MAX_FOLDERNAME_LENGTH)
|
2009-02-18 13:41:35 +01:00
|
|
|
if self.foldername is None or (self.auto_foldername and (self.foldername == urldigest or self.foldername.startswith(sanitizedurl))):
|
2009-02-06 15:54:28 +01:00
|
|
|
# we must change the folder name, because it has not been set manually
|
|
|
|
fn_template = util.sanitize_filename(self.title, self.MAX_FOLDERNAME_LENGTH)
|
|
|
|
|
|
|
|
# if this is an empty string, try the basename
|
|
|
|
if len(fn_template) == 0:
|
|
|
|
log('That is one ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
|
|
|
|
fn_template = util.sanitize_filename(os.path.basename(self.url), self.MAX_FOLDERNAME_LENGTH)
|
|
|
|
|
|
|
|
# If the basename is also empty, use the first 6 md5 hexdigest chars of the URL
|
|
|
|
if len(fn_template) == 0:
|
|
|
|
log('That is one REALLY ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
|
|
|
|
fn_template = urldigest # no need for sanitize_filename here
|
|
|
|
|
|
|
|
# Find a unique folder name for this podcast
|
|
|
|
wanted_foldername = self.find_unique_folder_name(fn_template)
|
|
|
|
|
|
|
|
# if the foldername has not been set, check if the (old) md5 filename exists
|
2009-08-13 20:39:00 +02:00
|
|
|
if self.foldername is None and os.path.exists(os.path.join(self.download_dir, urldigest)):
|
2009-02-09 23:26:47 +01:00
|
|
|
log('Found pre-0.15.0 download folder for %s: %s', self.title, urldigest, sender=self)
|
2009-02-06 15:54:28 +01:00
|
|
|
self.foldername = urldigest
|
|
|
|
|
|
|
|
# we have a valid, new folder name in "current_try" -> use that!
|
|
|
|
if self.foldername is not None and wanted_foldername != self.foldername:
|
|
|
|
# there might be an old download folder crawling around - move it!
|
2009-08-13 20:39:00 +02:00
|
|
|
new_folder_name = os.path.join(self.download_dir, wanted_foldername)
|
|
|
|
old_folder_name = os.path.join(self.download_dir, self.foldername)
|
2009-02-06 15:54:28 +01:00
|
|
|
if os.path.exists(old_folder_name):
|
|
|
|
if not os.path.exists(new_folder_name):
|
|
|
|
# Old folder exists, new folder does not -> simply rename
|
|
|
|
log('Renaming %s => %s', old_folder_name, new_folder_name, sender=self)
|
|
|
|
os.rename(old_folder_name, new_folder_name)
|
|
|
|
else:
|
|
|
|
# Both folders exist -> move files and delete old folder
|
|
|
|
log('Moving files from %s to %s', old_folder_name, new_folder_name, sender=self)
|
|
|
|
for file in glob.glob(os.path.join(old_folder_name, '*')):
|
|
|
|
shutil.move(file, new_folder_name)
|
|
|
|
log('Removing %s', old_folder_name, sender=self)
|
|
|
|
shutil.rmtree(old_folder_name, ignore_errors=True)
|
|
|
|
log('Updating foldername of %s to "%s".', self.url, wanted_foldername, sender=self)
|
|
|
|
self.foldername = wanted_foldername
|
|
|
|
self.save()
|
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
save_dir = os.path.join(self.download_dir, self.foldername)
|
2005-11-21 19:21:25 +01:00
|
|
|
|
2006-12-06 21:25:26 +01:00
|
|
|
# Create save_dir if it does not yet exist
|
2007-08-07 20:11:31 +02:00
|
|
|
if not util.make_directory( save_dir):
|
2007-08-22 01:00:49 +02:00
|
|
|
log( 'Could not create save_dir: %s', save_dir, sender = self)
|
2006-04-14 14:56:16 +02:00
|
|
|
|
2006-12-06 21:25:26 +01:00
|
|
|
return save_dir
|
|
|
|
|
|
|
|
save_dir = property(fget=get_save_dir)
|
2006-03-03 21:04:25 +01:00
|
|
|
|
2006-12-06 21:25:26 +01:00
|
|
|
def remove_downloaded( self):
|
|
|
|
shutil.rmtree( self.save_dir, True)
|
2006-03-03 21:04:25 +01:00
|
|
|
|
2009-08-24 16:47:59 +02:00
|
|
|
@property
|
|
|
|
def cover_file(self):
|
2009-12-27 14:12:39 +01:00
|
|
|
new_name = os.path.join(self.save_dir, 'folder.jpg')
|
|
|
|
if not os.path.exists(new_name):
|
|
|
|
old_names = ('cover', '.cover')
|
|
|
|
for old_name in old_names:
|
|
|
|
filename = os.path.join(self.save_dir, old_name)
|
|
|
|
if os.path.exists(filename):
|
|
|
|
shutil.move(filename, new_name)
|
|
|
|
return new_name
|
|
|
|
|
|
|
|
return new_name
|
2007-04-03 08:27:46 +02:00
|
|
|
|
2010-04-26 21:17:35 +02:00
|
|
|
def delete_episode(self, episode):
|
|
|
|
filename = episode.local_filename(create=False, check_only=True)
|
|
|
|
if filename is not None:
|
|
|
|
util.delete_file(filename)
|
2006-12-06 21:25:26 +01:00
|
|
|
|
2010-04-26 21:17:35 +02:00
|
|
|
episode.set_state(gpodder.STATE_DELETED)
|
2007-03-14 20:35:15 +01:00
|
|
|
|
2006-03-24 20:08:59 +01:00
|
|
|
|
2009-03-10 14:59:01 +01:00
|
|
|
class PodcastEpisode(PodcastModelObject):
|
2006-03-03 21:04:25 +01:00
|
|
|
"""holds data for one object in a channel"""
|
2009-02-09 23:26:47 +01:00
|
|
|
MAX_FILENAME_LENGTH = 200
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2010-04-24 18:51:19 +02:00
|
|
|
def _get_played(self):
|
|
|
|
return self.is_played
|
|
|
|
|
|
|
|
def _set_played(self, played):
|
|
|
|
self.is_played = played
|
|
|
|
|
|
|
|
# Alias "is_played" to "played" for DB column mapping
|
|
|
|
played = property(fget=_get_played, fset=_set_played)
|
|
|
|
|
|
|
|
def _get_locked(self):
|
|
|
|
return self.is_locked
|
|
|
|
|
|
|
|
def _set_locked(self, locked):
|
|
|
|
self.is_locked = locked
|
|
|
|
|
|
|
|
# Alias "is_locked" to "locked" for DB column mapping
|
|
|
|
locked = property(fget=_get_locked, fset=_set_locked)
|
|
|
|
|
|
|
|
def _get_channel_id(self):
|
|
|
|
return self.channel.id
|
|
|
|
|
|
|
|
def _set_channel_id(self, channel_id):
|
|
|
|
assert self.channel.id == channel_id
|
|
|
|
|
|
|
|
# Accessor for the "channel_id" DB column
|
|
|
|
channel_id = property(fget=_get_channel_id, fset=_set_channel_id)
|
|
|
|
|
2010-02-05 21:03:34 +01:00
|
|
|
@staticmethod
|
|
|
|
def sort_by_pubdate(episodes, reverse=False):
|
|
|
|
"""Sort a list of PodcastEpisode objects chronologically
|
|
|
|
|
|
|
|
Returns a iterable, sorted sequence of the episodes
|
|
|
|
"""
|
|
|
|
key_pubdate = lambda e: e.pubDate
|
|
|
|
return sorted(episodes, key=key_pubdate, reverse=reverse)
|
|
|
|
|
2009-03-10 14:59:01 +01:00
|
|
|
def reload_from_db(self):
|
|
|
|
"""
|
|
|
|
Re-reads all episode details for this object from the
|
|
|
|
database and updates this object accordingly. Can be
|
|
|
|
used to refresh existing objects when the database has
|
|
|
|
been updated (e.g. the filename has been set after a
|
|
|
|
download where it was not set before the download)
|
|
|
|
"""
|
2010-04-26 22:17:22 +02:00
|
|
|
d = self.db.load_episode(self.id)
|
|
|
|
self.update_from_dict(d or {})
|
2009-03-10 14:59:01 +01:00
|
|
|
return self
|
2008-06-30 03:10:18 +02:00
|
|
|
|
2009-12-22 01:26:44 +01:00
|
|
|
def has_website_link(self):
|
|
|
|
return bool(self.link) and (self.link != self.url)
|
|
|
|
|
2007-08-20 15:45:46 +02:00
|
|
|
@staticmethod
|
2010-02-28 04:10:39 +01:00
|
|
|
def from_feedparser_entry(entry, channel):
|
|
|
|
episode = PodcastEpisode(channel)
|
|
|
|
|
|
|
|
episode.title = entry.get('title', '')
|
|
|
|
episode.link = entry.get('link', '')
|
|
|
|
episode.description = entry.get('summary', '')
|
|
|
|
|
|
|
|
# Fallback to subtitle if summary is not available0
|
|
|
|
if not episode.description:
|
|
|
|
episode.description = entry.get('subtitle', '')
|
|
|
|
|
|
|
|
episode.guid = entry.get('id', '')
|
|
|
|
if entry.get('updated_parsed', None):
|
2008-07-14 18:46:59 +02:00
|
|
|
episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2010-05-18 11:28:55 +02:00
|
|
|
enclosures = entry.get('enclosures', ())
|
|
|
|
audio_available = any(e.get('type', '').startswith('audio/') \
|
|
|
|
for e in enclosures)
|
|
|
|
video_available = any(e.get('type', '').startswith('video/') \
|
|
|
|
for e in enclosures)
|
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
# Enclosures
|
2010-05-18 11:28:55 +02:00
|
|
|
for e in enclosures:
|
2010-02-28 04:10:39 +01:00
|
|
|
episode.mimetype = e.get('type', 'application/octet-stream')
|
2010-04-29 13:04:16 +02:00
|
|
|
if episode.mimetype == '':
|
|
|
|
# See Maemo bug 10036
|
|
|
|
log('Fixing empty mimetype in ugly feed', sender=episode)
|
|
|
|
episode.mimetype = 'application/octet-stream'
|
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
if '/' not in episode.mimetype:
|
|
|
|
continue
|
2010-05-18 11:28:55 +02:00
|
|
|
|
|
|
|
# Skip images in feeds if audio or video is available (bug 979)
|
|
|
|
if episode.mimetype.startswith('image/') and \
|
|
|
|
(audio_available or video_available):
|
|
|
|
continue
|
2009-09-09 19:53:26 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
episode.url = util.normalize_feed_url(e.get('href', ''))
|
|
|
|
if not episode.url:
|
|
|
|
continue
|
2009-09-09 19:53:26 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
try:
|
|
|
|
episode.length = int(e.length) or -1
|
|
|
|
except:
|
|
|
|
episode.length = -1
|
|
|
|
|
|
|
|
return episode
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
# Media RSS content
|
|
|
|
for m in entry.get('media_content', ()):
|
|
|
|
episode.mimetype = m.get('type', 'application/octet-stream')
|
|
|
|
if '/' not in episode.mimetype:
|
|
|
|
continue
|
|
|
|
|
|
|
|
episode.url = util.normalize_feed_url(m.get('url', ''))
|
|
|
|
if not episode.url:
|
|
|
|
continue
|
2008-03-02 13:56:16 +01:00
|
|
|
|
|
|
|
try:
|
2010-02-28 04:10:39 +01:00
|
|
|
episode.length = int(m.fileSize) or -1
|
|
|
|
except:
|
2009-03-11 03:51:32 +01:00
|
|
|
episode.length = -1
|
2008-03-02 13:56:16 +01:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
return episode
|
|
|
|
|
|
|
|
# Brute-force detection of any links
|
|
|
|
for l in entry.get('links', ()):
|
|
|
|
episode.url = util.normalize_feed_url(l.get('href', ''))
|
|
|
|
if not episode.url:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if youtube.is_video_link(episode.url):
|
|
|
|
return episode
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
# Check if we can resolve this link to a audio/video file
|
|
|
|
filename, extension = util.filename_from_url(episode.url)
|
|
|
|
file_type = util.file_type_by_extension(extension)
|
|
|
|
if file_type is None and hasattr(l, 'type'):
|
|
|
|
extension = util.extension_from_mimetype(l.type)
|
|
|
|
file_type = util.file_type_by_extension(extension)
|
|
|
|
|
|
|
|
# The link points to a audio or video file - use it!
|
|
|
|
if file_type is not None:
|
|
|
|
return episode
|
2007-08-27 00:04:50 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
# Scan MP3 links in description text
|
|
|
|
mp3s = re.compile(r'http://[^"]*\.mp3')
|
|
|
|
for content in entry.get('content', ()):
|
|
|
|
html = content.value
|
|
|
|
for match in mp3s.finditer(html):
|
|
|
|
episode.url = match.group(0)
|
|
|
|
return episode
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
return None
|
2007-08-20 15:45:46 +02:00
|
|
|
|
2009-08-10 23:40:31 +02:00
|
|
|
def __init__(self, channel):
|
|
|
|
self.db = channel.db
|
2008-06-30 03:10:18 +02:00
|
|
|
# Used by Storage for faster saving
|
|
|
|
self.id = None
|
2007-08-19 15:01:15 +02:00
|
|
|
self.url = ''
|
|
|
|
self.title = ''
|
|
|
|
self.length = 0
|
2007-08-31 23:40:15 +02:00
|
|
|
self.mimetype = 'application/octet-stream'
|
2007-08-19 15:01:15 +02:00
|
|
|
self.guid = ''
|
|
|
|
self.description = ''
|
|
|
|
self.link = ''
|
2007-08-22 01:00:49 +02:00
|
|
|
self.channel = channel
|
2008-12-12 14:58:22 +01:00
|
|
|
self.pubDate = 0
|
2009-02-06 15:54:28 +01:00
|
|
|
self.filename = None
|
|
|
|
self.auto_filename = 1 # automatically generated filename
|
2008-06-30 03:10:18 +02:00
|
|
|
|
2009-08-10 23:14:35 +02:00
|
|
|
self.state = gpodder.STATE_NORMAL
|
2008-06-30 03:10:18 +02:00
|
|
|
self.is_played = False
|
2010-02-23 15:44:17 +01:00
|
|
|
|
|
|
|
# Initialize the "is_locked" property
|
|
|
|
self._is_locked = False
|
2008-11-19 17:05:19 +01:00
|
|
|
self.is_locked = channel.channel_is_locked
|
2008-06-30 03:10:18 +02:00
|
|
|
|
2010-04-24 18:51:19 +02:00
|
|
|
# Time attributes
|
|
|
|
self.total_time = 0
|
|
|
|
self.current_position = 0
|
|
|
|
self.current_position_updated = time.time()
|
|
|
|
|
2010-02-23 15:44:17 +01:00
|
|
|
def get_is_locked(self):
|
|
|
|
return self._is_locked
|
|
|
|
|
|
|
|
def set_is_locked(self, is_locked):
|
|
|
|
self._is_locked = bool(is_locked)
|
|
|
|
|
|
|
|
is_locked = property(fget=get_is_locked, fset=set_is_locked)
|
|
|
|
|
2009-06-12 00:51:13 +02:00
|
|
|
def save(self):
|
2009-08-10 23:14:35 +02:00
|
|
|
if self.state != gpodder.STATE_DOWNLOADED and self.file_exists():
|
|
|
|
self.state = gpodder.STATE_DOWNLOADED
|
2009-08-10 23:40:31 +02:00
|
|
|
self.db.save_episode(self)
|
2008-06-30 03:10:18 +02:00
|
|
|
|
2010-04-26 21:41:50 +02:00
|
|
|
def on_downloaded(self, filename):
|
|
|
|
self.state = gpodder.STATE_DOWNLOADED
|
|
|
|
self.is_played = False
|
|
|
|
self.length = os.path.getsize(filename)
|
|
|
|
self.db.save_downloaded_episode(self)
|
|
|
|
self.db.commit()
|
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
def set_state(self, state):
|
|
|
|
self.state = state
|
2010-04-24 18:51:19 +02:00
|
|
|
self.db.update_episode_state(self)
|
2008-06-30 03:10:18 +02:00
|
|
|
|
|
|
|
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
|
2010-04-24 18:51:19 +02:00
|
|
|
self.db.update_episode_state(self)
|
2008-03-02 13:56:16 +01:00
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
@property
|
|
|
|
def title_markup(self):
|
2009-12-03 23:53:16 +01:00
|
|
|
return '%s\n<small>%s</small>' % (xml.sax.saxutils.escape(self.title),
|
|
|
|
xml.sax.saxutils.escape(self.channel.title))
|
2009-08-13 20:39:00 +02:00
|
|
|
|
2009-09-08 21:35:36 +02:00
|
|
|
@property
|
|
|
|
def maemo_markup(self):
|
2010-03-23 14:34:17 +01:00
|
|
|
if self.length > 0:
|
|
|
|
length_str = '%s; ' % self.filesize_prop
|
|
|
|
else:
|
|
|
|
length_str = ''
|
|
|
|
return ('<b>%s</b>\n<small>%s'+_('released %s')+ \
|
2009-09-08 21:35:36 +02:00
|
|
|
'; '+_('from %s')+'</small>') % (\
|
|
|
|
xml.sax.saxutils.escape(self.title), \
|
2010-03-23 14:34:17 +01:00
|
|
|
xml.sax.saxutils.escape(length_str), \
|
2009-09-08 21:35:36 +02:00
|
|
|
xml.sax.saxutils.escape(self.pubdate_prop), \
|
|
|
|
xml.sax.saxutils.escape(self.channel.title))
|
|
|
|
|
|
|
|
@property
|
|
|
|
def maemo_remove_markup(self):
|
|
|
|
if self.is_played:
|
|
|
|
played_string = _('played')
|
|
|
|
else:
|
|
|
|
played_string = _('unplayed')
|
|
|
|
downloaded_string = self.get_age_string()
|
|
|
|
if not downloaded_string:
|
|
|
|
downloaded_string = _('today')
|
|
|
|
return ('<b>%s</b>\n<small>%s; %s; '+_('downloaded %s')+ \
|
|
|
|
'; '+_('from %s')+'</small>') % (\
|
|
|
|
xml.sax.saxutils.escape(self.title), \
|
|
|
|
xml.sax.saxutils.escape(self.filesize_prop), \
|
|
|
|
xml.sax.saxutils.escape(played_string), \
|
|
|
|
xml.sax.saxutils.escape(downloaded_string), \
|
|
|
|
xml.sax.saxutils.escape(self.channel.title))
|
|
|
|
|
2007-12-10 09:41:17 +01:00
|
|
|
def age_in_days(self):
|
2009-09-28 16:03:15 +02:00
|
|
|
return util.file_age_in_days(self.local_filename(create=False, \
|
|
|
|
check_only=True))
|
2007-12-10 09:41:17 +01:00
|
|
|
|
|
|
|
def get_age_string(self):
|
2008-01-28 12:38:53 +01:00
|
|
|
return util.file_age_to_string(self.age_in_days())
|
2007-12-10 09:41:17 +01:00
|
|
|
|
|
|
|
age_prop = property(fget=get_age_string)
|
|
|
|
|
2006-11-20 12:51:20 +01:00
|
|
|
def one_line_description( self):
|
2008-08-03 21:09:03 +02:00
|
|
|
lines = util.remove_html_tags(self.description).strip().splitlines()
|
2006-11-20 12:51:20 +01:00
|
|
|
if not lines or lines[0] == '':
|
|
|
|
return _('No description available')
|
|
|
|
else:
|
2008-12-14 17:38:35 +01:00
|
|
|
return ' '.join(lines)
|
2006-12-06 21:25:26 +01:00
|
|
|
|
2007-12-18 10:18:33 +01:00
|
|
|
def delete_from_disk(self):
|
|
|
|
try:
|
2010-04-26 21:17:35 +02:00
|
|
|
self.channel.delete_episode(self)
|
2007-12-18 10:18:33 +01:00
|
|
|
except:
|
2008-04-22 22:24:19 +02:00
|
|
|
log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
|
2007-11-08 20:11:57 +01:00
|
|
|
|
2009-08-10 23:40:31 +02:00
|
|
|
def find_unique_file_name(self, url, filename, extension):
|
|
|
|
current_try = util.sanitize_filename(filename, self.MAX_FILENAME_LENGTH)+extension
|
2009-02-09 23:26:47 +01:00
|
|
|
next_try_id = 2
|
|
|
|
lookup_url = None
|
|
|
|
|
2009-09-15 14:23:38 +02:00
|
|
|
if self.filename == current_try and current_try is not None:
|
2009-09-09 19:53:26 +02:00
|
|
|
# We already have this filename - good!
|
|
|
|
return current_try
|
|
|
|
|
2009-08-10 23:40:31 +02:00
|
|
|
while self.db.episode_filename_exists(current_try):
|
2009-02-09 23:26:47 +01:00
|
|
|
current_try = '%s (%d)%s' % (filename, next_try_id, extension)
|
|
|
|
next_try_id += 1
|
|
|
|
|
|
|
|
return current_try
|
|
|
|
|
2009-09-06 16:38:40 +02:00
|
|
|
def local_filename(self, create, force_update=False, check_only=False,
|
|
|
|
template=None):
|
2009-02-09 23:26:47 +01:00
|
|
|
"""Get (and possibly generate) the local saving filename
|
|
|
|
|
|
|
|
Pass create=True if you want this function to generate a
|
|
|
|
new filename if none exists. You only want to do this when
|
|
|
|
planning to create/download the file after calling this function.
|
|
|
|
|
|
|
|
Normally, you should pass create=False. This will only
|
|
|
|
create a filename when the file already exists from a previous
|
|
|
|
version of gPodder (where we used md5 filenames). If the file
|
|
|
|
does not exist (and the filename also does not exist), this
|
|
|
|
function will return None.
|
|
|
|
|
|
|
|
If you pass force_update=True to this function, it will try to
|
|
|
|
find a new (better) filename and move the current file if this
|
|
|
|
is the case. This is useful if (during the download) you get
|
|
|
|
more information about the file, e.g. the mimetype and you want
|
|
|
|
to include this information in the file name generation process.
|
|
|
|
|
2009-02-14 13:31:27 +01:00
|
|
|
If check_only=True is passed to this function, it will never try
|
|
|
|
to rename the file, even if would be a good idea. Use this if you
|
|
|
|
only want to check if a file exists.
|
|
|
|
|
2009-09-06 16:38:40 +02:00
|
|
|
If "template" is specified, it should be a filename that is to
|
|
|
|
be used as a template for generating the "real" filename.
|
|
|
|
|
2009-02-09 23:26:47 +01:00
|
|
|
The generated filename is stored in the database for future access.
|
|
|
|
"""
|
2009-09-08 23:36:44 +02:00
|
|
|
ext = self.extension(may_call_local_filename=False).encode('utf-8', 'ignore')
|
2008-07-03 01:36:39 +02:00
|
|
|
|
2009-02-09 23:26:47 +01:00
|
|
|
# For compatibility with already-downloaded episodes, we
|
|
|
|
# have to know md5 filenames if they are downloaded already
|
|
|
|
urldigest = hashlib.md5(self.url).hexdigest()
|
|
|
|
|
|
|
|
if not create and self.filename is None:
|
|
|
|
urldigest_filename = os.path.join(self.channel.save_dir, urldigest+ext)
|
|
|
|
if os.path.exists(urldigest_filename):
|
|
|
|
# The file exists, so set it up in our database
|
|
|
|
log('Recovering pre-0.15.0 file: %s', urldigest_filename, sender=self)
|
|
|
|
self.filename = urldigest+ext
|
|
|
|
self.auto_filename = 1
|
|
|
|
self.save()
|
|
|
|
return urldigest_filename
|
|
|
|
return None
|
|
|
|
|
2009-02-14 13:31:27 +01:00
|
|
|
# We only want to check if the file exists, so don't try to
|
|
|
|
# rename the file, even if it would be reasonable. See also:
|
|
|
|
# http://bugs.gpodder.org/attachment.cgi?id=236
|
|
|
|
if check_only:
|
|
|
|
if self.filename is None:
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
return os.path.join(self.channel.save_dir, self.filename)
|
|
|
|
|
2009-02-09 23:26:47 +01:00
|
|
|
if self.filename is None or force_update or (self.auto_filename and self.filename == urldigest+ext):
|
|
|
|
# Try to find a new filename for the current file
|
2009-09-06 16:38:40 +02:00
|
|
|
if template is not None:
|
|
|
|
# If template is specified, trust the template's extension
|
|
|
|
episode_filename, ext = os.path.splitext(template)
|
|
|
|
else:
|
|
|
|
episode_filename, extension_UNUSED = util.filename_from_url(self.url)
|
2009-02-09 23:26:47 +01:00
|
|
|
fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
|
|
|
|
|
2009-09-06 16:38:40 +02:00
|
|
|
if 'redirect' in fn_template and template is None:
|
2009-02-09 23:26:47 +01:00
|
|
|
# This looks like a redirection URL - force URL resolving!
|
|
|
|
log('Looks like a redirection to me: %s', self.url, sender=self)
|
2009-08-24 13:04:11 +02:00
|
|
|
url = util.get_real_url(self.channel.authenticate_url(self.url))
|
2009-02-09 23:26:47 +01:00
|
|
|
log('Redirection resolved to: %s', url, sender=self)
|
|
|
|
(episode_filename, extension_UNUSED) = util.filename_from_url(url)
|
|
|
|
fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
|
|
|
|
|
2009-05-08 14:55:48 +02:00
|
|
|
# Use the video title for YouTube downloads
|
|
|
|
for yt_url in ('http://youtube.com/', 'http://www.youtube.com/'):
|
|
|
|
if self.url.startswith(yt_url):
|
2010-02-05 20:45:52 +01:00
|
|
|
fn_template = util.sanitize_filename(os.path.basename(self.title), self.MAX_FILENAME_LENGTH)
|
2009-05-08 14:55:48 +02:00
|
|
|
|
2009-02-09 23:26:47 +01:00
|
|
|
# If the basename is empty, use the md5 hexdigest of the URL
|
|
|
|
if len(fn_template) == 0 or fn_template.startswith('redirect.'):
|
|
|
|
log('Report to bugs.gpodder.org: Podcast at %s with episode URL: %s', self.channel.url, self.url, sender=self)
|
|
|
|
fn_template = urldigest
|
|
|
|
|
|
|
|
# Find a unique filename for this episode
|
|
|
|
wanted_filename = self.find_unique_file_name(self.url, fn_template, ext)
|
|
|
|
|
|
|
|
# We populate the filename field the first time - does the old file still exist?
|
|
|
|
if self.filename is None and os.path.exists(os.path.join(self.channel.save_dir, urldigest+ext)):
|
|
|
|
log('Found pre-0.15.0 downloaded file: %s', urldigest, sender=self)
|
|
|
|
self.filename = urldigest+ext
|
|
|
|
|
|
|
|
# The old file exists, but we have decided to want a different filename
|
|
|
|
if self.filename is not None and wanted_filename != self.filename:
|
|
|
|
# there might be an old download folder crawling around - move it!
|
|
|
|
new_file_name = os.path.join(self.channel.save_dir, wanted_filename)
|
|
|
|
old_file_name = os.path.join(self.channel.save_dir, self.filename)
|
|
|
|
if os.path.exists(old_file_name) and not os.path.exists(new_file_name):
|
|
|
|
log('Renaming %s => %s', old_file_name, new_file_name, sender=self)
|
|
|
|
os.rename(old_file_name, new_file_name)
|
2009-02-25 14:12:48 +01:00
|
|
|
elif force_update and not os.path.exists(old_file_name):
|
|
|
|
# When we call force_update, the file might not yet exist when we
|
|
|
|
# call it from the downloading code before saving the file
|
|
|
|
log('Choosing new filename: %s', new_file_name, sender=self)
|
2009-02-09 23:26:47 +01:00
|
|
|
else:
|
|
|
|
log('Warning: %s exists or %s does not.', new_file_name, old_file_name, sender=self)
|
2009-09-09 19:53:26 +02:00
|
|
|
log('Updating filename of %s to "%s".', self.url, wanted_filename, sender=self)
|
2009-09-15 14:23:38 +02:00
|
|
|
elif self.filename is None:
|
|
|
|
log('Setting filename to "%s".', wanted_filename, sender=self)
|
2009-09-09 19:53:26 +02:00
|
|
|
else:
|
2009-09-15 14:23:38 +02:00
|
|
|
log('Should update filename. Stays the same (%s). Good!', \
|
|
|
|
wanted_filename, sender=self)
|
2009-02-09 23:26:47 +01:00
|
|
|
self.filename = wanted_filename
|
|
|
|
self.save()
|
2009-08-10 23:40:31 +02:00
|
|
|
self.db.commit()
|
2009-02-09 23:26:47 +01:00
|
|
|
|
|
|
|
return os.path.join(self.channel.save_dir, self.filename)
|
2007-08-22 01:00:49 +02:00
|
|
|
|
2009-09-06 16:38:40 +02:00
|
|
|
def set_mimetype(self, mimetype, commit=False):
|
|
|
|
"""Sets the mimetype for this episode"""
|
|
|
|
self.mimetype = mimetype
|
|
|
|
if commit:
|
|
|
|
self.db.commit()
|
|
|
|
|
2009-09-08 23:36:44 +02:00
|
|
|
def extension(self, may_call_local_filename=True):
|
|
|
|
filename, ext = util.filename_from_url(self.url)
|
|
|
|
if may_call_local_filename:
|
|
|
|
filename = self.local_filename(create=False)
|
|
|
|
if filename is not None:
|
|
|
|
filename, ext = os.path.splitext(filename)
|
|
|
|
# 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)
|
|
|
|
return ext
|
2008-07-03 01:36:39 +02:00
|
|
|
|
2009-09-21 23:34:12 +02:00
|
|
|
def check_is_new(self, downloading=lambda e: False):
|
|
|
|
"""
|
|
|
|
Returns True if this episode is to be considered new.
|
|
|
|
"Downloading" should be a callback that gets an episode
|
|
|
|
as its parameter and returns True if the episode is
|
|
|
|
being downloaded at the moment.
|
|
|
|
"""
|
|
|
|
return self.state == gpodder.STATE_NORMAL and \
|
|
|
|
not self.is_played and \
|
|
|
|
not downloading(self)
|
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
def mark_new(self):
|
2009-08-10 23:14:35 +02:00
|
|
|
self.state = gpodder.STATE_NORMAL
|
2008-06-30 03:10:18 +02:00
|
|
|
self.is_played = False
|
2010-04-24 18:51:19 +02:00
|
|
|
self.db.update_episode_state(self)
|
2008-06-30 03:10:18 +02:00
|
|
|
|
|
|
|
def mark_old(self):
|
|
|
|
self.is_played = True
|
2010-04-24 18:51:19 +02:00
|
|
|
self.db.update_episode_state(self)
|
2008-06-30 03:10:18 +02:00
|
|
|
|
|
|
|
def file_exists(self):
|
2009-02-14 13:31:27 +01:00
|
|
|
filename = self.local_filename(create=False, check_only=True)
|
2009-02-09 23:26:47 +01:00
|
|
|
if filename is None:
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
return os.path.exists(filename)
|
2008-06-30 03:10:18 +02:00
|
|
|
|
|
|
|
def was_downloaded(self, and_exists=False):
|
2009-08-10 23:14:35 +02:00
|
|
|
if self.state != gpodder.STATE_DOWNLOADED:
|
2008-06-30 03:10:18 +02:00
|
|
|
return False
|
|
|
|
if and_exists and not self.file_exists():
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
2009-08-13 20:39:00 +02:00
|
|
|
def sync_filename(self, use_custom=False, custom_format=None):
|
|
|
|
if use_custom:
|
|
|
|
return util.object_string_formatter(custom_format,
|
|
|
|
episode=self, podcast=self.channel)
|
2007-10-23 09:29:19 +02:00
|
|
|
else:
|
|
|
|
return self.title
|
|
|
|
|
2010-04-03 00:39:43 +02:00
|
|
|
def file_type(self):
|
|
|
|
# Assume all YouTube links are video files
|
|
|
|
if youtube.is_video_link(self.url):
|
|
|
|
return 'video'
|
|
|
|
|
|
|
|
return util.file_type_by_extension(self.extension())
|
2007-09-08 16:49:54 +02:00
|
|
|
|
|
|
|
@property
|
|
|
|
def basename( self):
|
|
|
|
return os.path.splitext( os.path.basename( self.url))[0]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def published( self):
|
2008-12-24 11:54:21 +01:00
|
|
|
"""
|
|
|
|
Returns published date as YYYYMMDD (or 00000000 if not available)
|
|
|
|
"""
|
2007-09-08 16:49:54 +02:00
|
|
|
try:
|
2008-06-14 18:53:16 +02:00
|
|
|
return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
|
2007-09-08 16:49:54 +02:00
|
|
|
except:
|
|
|
|
log( 'Cannot format pubDate for "%s".', self.title, sender = self)
|
|
|
|
return '00000000'
|
2008-12-24 11:54:21 +01:00
|
|
|
|
|
|
|
@property
|
|
|
|
def pubtime(self):
|
|
|
|
"""
|
|
|
|
Returns published time as HHMM (or 0000 if not available)
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
return datetime.datetime.fromtimestamp(self.pubDate).strftime('%H%M')
|
|
|
|
except:
|
|
|
|
log('Cannot format pubDate (time) for "%s".', self.title, sender=self)
|
|
|
|
return '0000'
|
2007-08-22 01:00:49 +02:00
|
|
|
|
2008-06-30 03:10:18 +02:00
|
|
|
def cute_pubdate(self):
|
2008-06-14 18:53:16 +02:00
|
|
|
result = util.format_date(self.pubDate)
|
2008-04-19 19:01:09 +02:00
|
|
|
if result is None:
|
|
|
|
return '(%s)' % _('unknown')
|
|
|
|
else:
|
|
|
|
return result
|
2007-11-08 20:11:57 +01:00
|
|
|
|
|
|
|
pubdate_prop = property(fget=cute_pubdate)
|
2006-12-09 01:41:58 +01:00
|
|
|
|
2007-08-22 01:00:49 +02:00
|
|
|
def calculate_filesize( self):
|
2009-02-09 23:26:47 +01:00
|
|
|
filename = self.local_filename(create=False)
|
|
|
|
if filename is None:
|
|
|
|
log('calculate_filesized called, but filename is None!', sender=self)
|
2006-12-09 01:41:58 +01:00
|
|
|
try:
|
2009-02-09 23:26:47 +01:00
|
|
|
self.length = os.path.getsize(filename)
|
2006-12-09 01:41:58 +01:00
|
|
|
except:
|
|
|
|
log( 'Could not get filesize for %s.', self.url)
|
2007-11-08 20:11:57 +01:00
|
|
|
|
2009-07-06 16:05:59 +02:00
|
|
|
def get_filesize_string(self):
|
|
|
|
return util.format_filesize(self.length)
|
2007-11-08 20:11:57 +01:00
|
|
|
|
|
|
|
filesize_prop = property(fget=get_filesize_string)
|
|
|
|
|
|
|
|
def get_played_string( self):
|
2008-06-30 03:10:18 +02:00
|
|
|
if not self.is_played:
|
2007-11-08 20:11:57 +01:00
|
|
|
return _('Unplayed')
|
|
|
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
played_prop = property(fget=get_played_string)
|
2010-02-28 04:10:39 +01:00
|
|
|
|
|
|
|
def is_duplicate(self, episode):
|
2009-05-13 19:46:50 +02:00
|
|
|
if self.title == episode.title and self.pubDate == episode.pubDate:
|
|
|
|
log('Possible duplicate detected: %s', self.title)
|
|
|
|
return True
|
|
|
|
return False
|
2006-08-02 20:24:48 +02:00
|
|
|
|
2010-02-28 04:10:39 +01:00
|
|
|
def duplicate_id(self):
|
|
|
|
return hash((self.title, self.pubDate))
|
|
|
|
|
|
|
|
def update_from(self, episode):
|
|
|
|
for k in ('title', 'url', 'description', 'link', 'pubDate', 'guid'):
|
|
|
|
setattr(self, k, getattr(episode, k))
|
|
|
|
|