
The core has already implemented parsing OPML files from URLs and files, it only needs to be exposed by the UI which this parch does.
582 lines
21 KiB
Python
582 lines
21 KiB
Python
#
|
|
# gPodder QML UI Reference Implementation
|
|
# Copyright (c) 2013, Thomas Perl <m@thp.io>
|
|
#
|
|
# Permission to use, copy, modify, and/or distribute this software for any
|
|
# purpose with or without fee is hereby granted, provided that the above
|
|
# copyright notice and this permission notice appear in all copies.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
# PERFORMANCE OF THIS SOFTWARE.
|
|
#
|
|
|
|
# Version of the QML UI implementation, this is usually the same as the version
|
|
# of gpodder-core, but we might have a different release schedule later on. If
|
|
# we decide to have parallel releases, we can at least start using this version
|
|
# to check if the core version is compatible with the QML UI version.
|
|
__version__ = '4.6.0'
|
|
|
|
import sys
|
|
import os
|
|
|
|
import pyotherside
|
|
import gpodder
|
|
import podcastparser
|
|
|
|
from gpodder.api import core
|
|
from gpodder.api import util
|
|
from gpodder.api import query
|
|
from gpodder.api import registry
|
|
from gpodder import opml
|
|
|
|
import logging
|
|
import functools
|
|
import time
|
|
import datetime
|
|
import re
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def run_in_background_thread(f):
|
|
"""Decorator for functions that take longer to finish
|
|
|
|
The function will be run in its own thread, and control
|
|
will be returned to the caller right away, which allows
|
|
other Python code to run while this function finishes.
|
|
|
|
The function cannot return a value (control is usually
|
|
returned to the caller before execution is finished).
|
|
"""
|
|
@functools.wraps(f)
|
|
def wrapper(*args):
|
|
util.run_in_background(lambda: f(*args))
|
|
|
|
return wrapper
|
|
|
|
|
|
class gPotherSide:
|
|
ALL_PODCASTS = -1
|
|
|
|
def __init__(self):
|
|
self.core = None
|
|
self._checking_for_new_episodes = False
|
|
|
|
def initialize(self, progname):
|
|
assert self.core is None, 'Already initialized'
|
|
|
|
self.core = core.Core(progname=progname)
|
|
pyotherside.send('podcast-list-changed')
|
|
|
|
self.core.config.add_observer(self._config_option_changed)
|
|
|
|
def atexit(self):
|
|
self.core.shutdown()
|
|
|
|
def _config_option_changed(self, name, old_value, new_value):
|
|
logger.warn('Config option changed: %s = %s -> %s', name, old_value, new_value)
|
|
pyotherside.send('config-changed', name, new_value)
|
|
|
|
def _get_episode_by_id(self, episode_id):
|
|
for podcast in self.core.model.get_podcasts():
|
|
for episode in podcast.episodes:
|
|
if episode.id == episode_id:
|
|
return episode
|
|
|
|
def _get_podcast_by_id(self, podcast_id):
|
|
for podcast in self.core.model.get_podcasts():
|
|
if podcast.id == podcast_id:
|
|
return podcast
|
|
|
|
def _episode_state_changed(self, episode):
|
|
pyotherside.send('updated-episode', self.convert_episode(episode))
|
|
pyotherside.send('updated-podcast', self.convert_podcast(episode.podcast))
|
|
pyotherside.send('update-stats')
|
|
|
|
def get_stats(self):
|
|
podcasts = self.core.model.get_podcasts()
|
|
|
|
total, deleted, new, downloaded, unplayed = 0, 0, 0, 0, 0
|
|
for podcast in podcasts:
|
|
to, de, ne, do, un = podcast.get_statistics()
|
|
total += to
|
|
deleted += de
|
|
new += ne
|
|
downloaded += do
|
|
unplayed += un
|
|
|
|
return {
|
|
'podcasts': len(podcasts),
|
|
'episodes': total,
|
|
'newEpisodes': new,
|
|
'downloaded': downloaded,
|
|
'unplayed': unplayed,
|
|
}
|
|
|
|
def _get_cover(self, podcast):
|
|
filename = self.core.cover_downloader.get_cover(podcast)
|
|
if not filename:
|
|
return ''
|
|
return 'file://' + filename
|
|
|
|
def _get_playback_progress(self, episode):
|
|
if episode.total_time > 0 and episode.current_position > 0:
|
|
return float(episode.current_position) / float(episode.total_time)
|
|
|
|
return 0
|
|
|
|
def convert_podcast(self, podcast):
|
|
total, deleted, new, downloaded, unplayed = podcast.get_statistics()
|
|
|
|
return {
|
|
'id': podcast.id,
|
|
'title': podcast.title,
|
|
'description': podcast.one_line_description(),
|
|
'newEpisodes': new,
|
|
'downloaded': downloaded,
|
|
'unplayed': unplayed,
|
|
'coverart': self._get_cover(podcast),
|
|
'updating': podcast._updating,
|
|
'section': podcast.section,
|
|
'url': podcast.url,
|
|
}
|
|
|
|
def _get_podcasts_sorted(self):
|
|
sort_key = self.core.model.podcast_sort_key
|
|
return sorted(self.core.model.get_podcasts(), key=lambda podcast: (podcast.section, sort_key(podcast)))
|
|
|
|
def load_podcasts(self):
|
|
podcasts = self._get_podcasts_sorted()
|
|
return [self.convert_podcast(podcast) for podcast in podcasts]
|
|
|
|
def _get_subtitle(self, episode):
|
|
for line in util.remove_html_tags(episode.subtitle).strip().splitlines():
|
|
return line
|
|
return ''
|
|
|
|
def convert_episode(self, episode):
|
|
now = datetime.datetime.now()
|
|
tnow = time.time()
|
|
return {
|
|
'id': episode.id,
|
|
'title': episode.trimmed_title,
|
|
'subtitle': self._get_subtitle(episode),
|
|
'progress': episode.download_progress(),
|
|
'downloadState': episode.state,
|
|
'isNew': episode.is_new,
|
|
'playbackProgress': self._get_playback_progress(episode),
|
|
'published': util.format_date(episode.published),
|
|
'section': self._format_published_section(now, tnow, episode.published),
|
|
'hasShownotes': episode.description != '',
|
|
}
|
|
|
|
def _format_published_section(self, now, tnow, published):
|
|
diff = (tnow - published)
|
|
|
|
if diff < 60 * 60 * 24 * 7:
|
|
return util.format_date(published)
|
|
|
|
dt = datetime.datetime.fromtimestamp(published)
|
|
if dt.year == now.year:
|
|
return dt.strftime('%B %Y')
|
|
|
|
return dt.strftime('%Y')
|
|
|
|
def load_episodes(self, id=ALL_PODCASTS, eql=None):
|
|
if id is not None and id != self.ALL_PODCASTS:
|
|
podcasts = [self._get_podcast_by_id(id)]
|
|
else:
|
|
podcasts = self.core.model.get_podcasts()
|
|
|
|
if eql:
|
|
filter_func = query.EQL(eql).filter
|
|
else:
|
|
filter_func = lambda episodes: episodes
|
|
|
|
result = []
|
|
|
|
for podcast in podcasts:
|
|
result.extend(filter_func(podcast.episodes))
|
|
|
|
if id == self.ALL_PODCASTS:
|
|
result.sort(key=lambda e: e.published, reverse=True)
|
|
|
|
return [self.convert_episode(episode) for episode in result]
|
|
|
|
def get_fresh_episodes_summary(self, count):
|
|
summary = []
|
|
for podcast in self.core.model.get_podcasts():
|
|
_, _, new, _, _ = podcast.get_statistics()
|
|
if new:
|
|
summary.append({
|
|
'title': podcast.title,
|
|
'coverart': self._get_cover(podcast),
|
|
'newEpisodes': new,
|
|
})
|
|
|
|
summary.sort(key=lambda e: e['newEpisodes'], reverse=True)
|
|
return summary[:int(count)]
|
|
|
|
@run_in_background_thread
|
|
def import_opml(self, url):
|
|
"""Import subscriptions from an OPML file
|
|
|
|
import http://example.com/subscriptions.opml
|
|
|
|
Import subscriptions from the given URL
|
|
|
|
import ./feeds.opml
|
|
|
|
Import subscriptions from a local file
|
|
"""
|
|
for channel in opml.Importer(url).items:
|
|
self.subscribe(channel['url'])
|
|
|
|
@run_in_background_thread
|
|
def subscribe(self, url):
|
|
url = self.core.model.normalize_feed_url(url)
|
|
# TODO: Check if subscription already exists
|
|
|
|
# Kludge: After one second, update the podcast list,
|
|
# so that we see the podcast that is being updated
|
|
@run_in_background_thread
|
|
def show_loading():
|
|
time.sleep(1)
|
|
pyotherside.send('podcast-list-changed')
|
|
show_loading()
|
|
|
|
self.core.model.load_podcast(url, create=True)
|
|
self.core.save()
|
|
pyotherside.send('podcast-list-changed')
|
|
pyotherside.send('update-stats')
|
|
# TODO: Return True/False for reporting success
|
|
|
|
def rename_podcast(self, podcast_id, new_title):
|
|
podcast = self._get_podcast_by_id(podcast_id)
|
|
podcast.rename(new_title)
|
|
self.core.save()
|
|
pyotherside.send('podcast-list-changed')
|
|
|
|
def change_section(self, podcast_id, new_section):
|
|
podcast = self._get_podcast_by_id(podcast_id)
|
|
podcast.section = new_section
|
|
podcast.save()
|
|
self.core.save()
|
|
pyotherside.send('podcast-list-changed')
|
|
|
|
def unsubscribe(self, podcast_id):
|
|
podcast = self._get_podcast_by_id(podcast_id)
|
|
podcast.unsubscribe()
|
|
self.core.save()
|
|
pyotherside.send('podcast-list-changed')
|
|
pyotherside.send('update-stats')
|
|
|
|
@run_in_background_thread
|
|
def download_episode(self, episode_id):
|
|
episode = self._get_episode_by_id(episode_id)
|
|
if episode.state == gpodder.STATE_DOWNLOADED:
|
|
return
|
|
|
|
def progress_callback(progress):
|
|
self._episode_state_changed(episode)
|
|
|
|
# TODO: Handle the case where there is already a DownloadTask
|
|
episode.download(progress_callback)
|
|
self.core.save()
|
|
self._episode_state_changed(episode)
|
|
|
|
def delete_episode(self, episode_id):
|
|
episode = self._get_episode_by_id(episode_id)
|
|
episode.delete_download()
|
|
self.core.save()
|
|
self._episode_state_changed(episode)
|
|
|
|
def toggle_new(self, episode_id):
|
|
episode = self._get_episode_by_id(episode_id)
|
|
episode.is_new = not episode.is_new
|
|
if episode.is_new and episode.state == gpodder.STATE_DELETED:
|
|
episode.state = gpodder.STATE_NORMAL
|
|
episode.save()
|
|
self.core.save()
|
|
self._episode_state_changed(episode)
|
|
|
|
def mark_episodes_as_old(self, podcast_id):
|
|
podcast = self._get_podcast_by_id(podcast_id)
|
|
|
|
any_changed = False
|
|
for episode in podcast.episodes:
|
|
if episode.is_new and episode.state == gpodder.STATE_NORMAL:
|
|
any_changed = True
|
|
episode.is_new = False
|
|
episode.save()
|
|
|
|
if any_changed:
|
|
pyotherside.send('episode-list-changed', podcast_id)
|
|
pyotherside.send('updated-podcast', self.convert_podcast(podcast))
|
|
pyotherside.send('update-stats')
|
|
|
|
self.core.save()
|
|
|
|
def save_playback_state(self):
|
|
self.core.save()
|
|
|
|
@run_in_background_thread
|
|
def check_for_episodes(self, url=None):
|
|
if self._checking_for_new_episodes:
|
|
return
|
|
|
|
self._checking_for_new_episodes = True
|
|
pyotherside.send('refreshing', True)
|
|
podcasts = [podcast for podcast in self._get_podcasts_sorted() if url is None or podcast.url == url]
|
|
for index, podcast in enumerate(podcasts):
|
|
pyotherside.send('refresh-progress', index, len(podcasts))
|
|
pyotherside.send('updating-podcast', podcast.id)
|
|
try:
|
|
podcast.update()
|
|
except Exception as e:
|
|
logger.warn('Could not update %s: %s', podcast.url, e, exc_info=True)
|
|
pyotherside.send('updated-podcast', self.convert_podcast(podcast))
|
|
pyotherside.send('update-stats')
|
|
|
|
self.core.save()
|
|
self._checking_for_new_episodes = False
|
|
pyotherside.send('refreshing', False)
|
|
|
|
def play_episode(self, episode_id):
|
|
episode = self._get_episode_by_id(episode_id)
|
|
episode.playback_mark()
|
|
self.core.save()
|
|
self._episode_state_changed(episode)
|
|
return {
|
|
'title': episode.title,
|
|
'podcast_title': episode.podcast.title,
|
|
'source': episode.local_filename(False) if episode.state == gpodder.STATE_DOWNLOADED else episode.url,
|
|
'position': episode.current_position,
|
|
'total': episode.total_time,
|
|
'video': episode.file_type() == 'video',
|
|
'chapters': getattr(episode, 'chapters', []),
|
|
}
|
|
|
|
def report_playback_event(self, episode_id, position_from, position_to, duration):
|
|
episode = self._get_episode_by_id(episode_id)
|
|
print('Played', episode.title, 'from', position_from, 'to', position_to, 'of', duration)
|
|
episode.report_playback_event(position_from, position_to, duration)
|
|
pyotherside.send('playback-progress', episode_id, self._get_playback_progress(episode))
|
|
|
|
def show_episode(self, episode_id):
|
|
episode = self._get_episode_by_id(episode_id)
|
|
if episode is None:
|
|
return {}
|
|
|
|
return {
|
|
'title': episode.trimmed_title,
|
|
'description': episode.description_html or episode.description,
|
|
'metadata': ' | '.join(self._format_metadata(episode)),
|
|
'link': episode.link if episode.link != episode.url else '',
|
|
'chapters': getattr(episode, 'chapters', []),
|
|
}
|
|
|
|
def _format_metadata(self, episode):
|
|
if episode.published:
|
|
yield datetime.datetime.fromtimestamp(episode.published).strftime('%Y-%m-%d')
|
|
|
|
if episode.file_size > 0:
|
|
yield '%.2f MiB' % (episode.file_size / (1024 * 1024))
|
|
|
|
if episode.total_time > 0:
|
|
yield '%02d:%02d:%02d' % (episode.total_time / (60 * 60),
|
|
(episode.total_time / 60) % 60,
|
|
episode.total_time % 60)
|
|
|
|
def show_podcast(self, podcast_id):
|
|
podcast = self._get_podcast_by_id(podcast_id)
|
|
if podcast is None:
|
|
return {}
|
|
|
|
return {
|
|
'title': podcast.title,
|
|
'description': util.remove_html_tags(podcast.description),
|
|
'link': podcast.link,
|
|
'url': podcast.url,
|
|
'section': podcast.section,
|
|
'coverart': self._get_cover(podcast),
|
|
}
|
|
|
|
def set_config_value(self, option, value):
|
|
self.core.config.update_field(option, value)
|
|
|
|
def get_config_value(self, option):
|
|
return self.core.config.get_field(option)
|
|
|
|
def get_directory_providers(self):
|
|
def select_provider(p):
|
|
return p.kind in (p.PROVIDER_SEARCH, p.PROVIDER_STATIC)
|
|
|
|
def provider_sort_key(p):
|
|
return p.priority
|
|
|
|
return [{
|
|
'label': provider.name,
|
|
'can_search': provider.kind == provider.PROVIDER_SEARCH
|
|
} for provider in sorted(registry.directory.select(select_provider), key=provider_sort_key, reverse=True)]
|
|
|
|
def get_directory_entries(self, provider, query):
|
|
def match_provider(p):
|
|
return p.name == provider
|
|
|
|
for provider in registry.directory.select(match_provider):
|
|
return [{
|
|
'title': e.title,
|
|
'url': e.url,
|
|
'image': e.image,
|
|
'subscribers': e.subscribers,
|
|
'description': e.description,
|
|
} for e in provider.on_string(query)]
|
|
|
|
return []
|
|
|
|
|
|
PILL_TEMPLATE = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="{height}" width="{width}"
|
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
<defs>
|
|
<linearGradient x1="0" y1="0" x2="1" y2="1" id="rightGradient">
|
|
<stop offset="0.0" style="stop-color: #333333; stop-opacity: 0.9;" />
|
|
<stop offset="0.4" style="stop-color: #333333; stop-opacity: 0.8;" />
|
|
<stop offset="0.6" style="stop-color: #333333; stop-opacity: 0.6;" />
|
|
<stop offset="0.9" style="stop-color: #333333; stop-opacity: 0.7;" />
|
|
<stop offset="1.0" style="stop-color: #333333; stop-opacity: 0.5;" />
|
|
</linearGradient>
|
|
|
|
<linearGradient x1="0" y1="0" x2="1" y2="1" id="leftGradient">
|
|
<stop offset="0.0" style="stop-color: #cccccc; stop-opacity: 0.5;" />
|
|
<stop offset="0.4" style="stop-color: #cccccc; stop-opacity: 0.7;" />
|
|
<stop offset="0.6" style="stop-color: #cccccc; stop-opacity: 0.6;" />
|
|
<stop offset="0.9" style="stop-color: #cccccc; stop-opacity: 0.8;" />
|
|
<stop offset="1.0" style="stop-color: #cccccc; stop-opacity: 0.9;" />
|
|
</linearGradient>
|
|
|
|
<path id="rightPath" d="M {width/2} 0 l {width/2-radius-1} 0
|
|
s {radius} 0 {radius} {radius} l 0 {height-radius*2}
|
|
s 0 {radius} {-radius} {radius} l {-(width/2-radius-1)} 0 z" />
|
|
<path id="rightPathOuter" d="M {width/2+0.5} {0.5} l {width/2-radius-2} 0
|
|
s {radius} 0 {radius} {radius} l 0 {height-radius*2-1}
|
|
s 0 {radius} {-radius} {radius} l {-(width/2-radius-2)} 0 z" />
|
|
<path id="rightPathInner" d="M {width/2+1.5} {1.5} l {width/2-radius-4} 0
|
|
s {radius} 0 {radius} {radius} l 0 {height-radius*2-3}
|
|
s 0 {radius} {-radius} {radius} l {-(width/2-radius-4)} 0 z" />
|
|
|
|
<path id="leftPath" d="M {width/2} 0 l {-(width/2-radius-1)} 0
|
|
s {-radius} 0 {-radius} {radius} l 0 {height-radius*2}
|
|
s 0 {radius} {radius} {radius} l {width/2-radius-1} 0 z" />
|
|
<path id="leftPathOuter" d="M {width/2-0.5} {0.5} l {-(width/2-radius-2)} 0
|
|
s {-radius} 0 {-radius} {radius} l 0 {height-radius*2-1}
|
|
s 0 {radius} {radius} {radius} l {width/2-radius-2} 0 z" />
|
|
<path id="leftPathInner" d="M {width/2-1.5} {1.5} l {-(width/2-radius-4)} 0
|
|
s {-radius} 0 {-radius} {radius} l 0 {height-radius*2-3}
|
|
s 0 {radius} {radius} {radius} l {width/2-radius-4} 0 z" />
|
|
</defs>
|
|
|
|
<g style="font-family: sans-serif; font-size: {font_size}px; font-weight: bold;">
|
|
<g style="display: {'inline' if left_text else 'none'};">
|
|
<use xlink:href="#leftPath" style="fill:url(#leftGradient);"/>
|
|
<use xlink:href="#leftPathOuter" style="{outer_style}" />
|
|
<use xlink:href="#leftPathInner" style="{inner_style}" />
|
|
<text x="{lx+1}" y="{height/2+font_size/3+1}" fill="black">{left_text}</text>
|
|
<text x="{lx}" y="{height/2+font_size/3}" fill="white">{left_text}</text>
|
|
</g>
|
|
|
|
<g style="display: {'inline' if right_text else 'none'};">
|
|
<use xlink:href="#rightPath" style="fill:url(#rightGradient);"/>
|
|
<use xlink:href="#rightPathOuter" style="{outer_style}" />
|
|
<use xlink:href="#rightPathInner" style="{inner_style}" />
|
|
<text x="{rx+1}" y="{height/2+font_size/3+1}" fill="black">{right_text}</text>
|
|
<text x="{rx}" y="{height/2+font_size/3}" fill="white">{right_text}</text>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
class PillExpression(object):
|
|
def __init__(self, **kwargs):
|
|
self.kwargs = kwargs
|
|
|
|
def __call__(self, matchobj):
|
|
return str(eval(matchobj.group(1), self.kwargs))
|
|
|
|
|
|
def pill_image_provider(image_id, requested_size):
|
|
left_text, right_text = (int(x) for x in image_id.split('/'))
|
|
|
|
width = 44
|
|
height = 24
|
|
radius = 6
|
|
font_size = 13
|
|
|
|
text_lx = width / 4
|
|
text_rx = width * 3 / 4
|
|
|
|
charheight = font_size
|
|
charwidth = font_size / 1.3
|
|
|
|
if left_text:
|
|
text_lx -= charwidth * len(str(left_text)) / 2
|
|
if right_text:
|
|
text_rx -= charwidth * len(str(right_text)) / 2
|
|
|
|
outer_style = 'stroke: #333333; stroke-width: 1; fill-opacity: 0; stroke-opacity: 0.6;'
|
|
inner_style = 'stroke: #ffffff; stroke-width: 1; fill-opacity: 0; stroke-opacity: 0.3;'
|
|
|
|
expression = PillExpression(height=height, width=width, left_text=left_text,
|
|
right_text=right_text, radius=radius,
|
|
lx=text_lx, rx=text_rx, font_size=font_size,
|
|
outer_style=outer_style, inner_style=inner_style)
|
|
svg = re.sub(r'[{]([^}]+)[}]', expression, PILL_TEMPLATE)
|
|
return bytearray(svg.encode('utf-8')), (width, height), pyotherside.format_data
|
|
|
|
|
|
@pyotherside.set_image_provider
|
|
def gpotherside_image_provider(image_id, requested_size):
|
|
provider, args = image_id.split('/', 1)
|
|
if provider == 'pill':
|
|
return pill_image_provider(args, requested_size)
|
|
|
|
raise ValueError('Unknown provider: %s' % (provider,))
|
|
|
|
|
|
gpotherside = gPotherSide()
|
|
pyotherside.atexit(gpotherside.atexit)
|
|
|
|
pyotherside.send('hello', gpodder.__version__, __version__, podcastparser.__version__)
|
|
|
|
# Exposed API Endpoints for calls from QML
|
|
initialize = gpotherside.initialize
|
|
load_podcasts = gpotherside.load_podcasts
|
|
load_episodes = gpotherside.load_episodes
|
|
show_episode = gpotherside.show_episode
|
|
play_episode = gpotherside.play_episode
|
|
import_opml = gpotherside.import_opml
|
|
subscribe = gpotherside.subscribe
|
|
unsubscribe = gpotherside.unsubscribe
|
|
check_for_episodes = gpotherside.check_for_episodes
|
|
get_stats = gpotherside.get_stats
|
|
get_fresh_episodes_summary = gpotherside.get_fresh_episodes_summary
|
|
download_episode = gpotherside.download_episode
|
|
delete_episode = gpotherside.delete_episode
|
|
toggle_new = gpotherside.toggle_new
|
|
rename_podcast = gpotherside.rename_podcast
|
|
change_section = gpotherside.change_section
|
|
report_playback_event = gpotherside.report_playback_event
|
|
mark_episodes_as_old = gpotherside.mark_episodes_as_old
|
|
save_playback_state = gpotherside.save_playback_state
|
|
set_config_value = gpotherside.set_config_value
|
|
get_config_value = gpotherside.get_config_value
|
|
get_directory_providers = gpotherside.get_directory_providers
|
|
get_directory_entries = gpotherside.get_directory_entries
|
|
show_podcast = gpotherside.show_podcast
|