Merge branch 'master' into dev-adaptive

This commit is contained in:
Teemu Ikonen 2021-08-12 11:34:46 +03:00
commit 5eee09ce49
19 changed files with 206 additions and 99 deletions

32
.github/workflows/linttest.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: lint and test
on: [push, pull_request]
jobs:
linttest:
name: lint and unit tests
if: >-
github.event_name == 'push' ||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
sudo apt-get update -q
sudo apt-get install intltool desktop-file-utils
pip3 install pytest-cov minimock pycodestyle isort requests pytest pytest-httpserver
python3 tools/localdepends.py
- name: Lint
run: make lint
- name: Test
run: make releasetest

View File

@ -1,13 +0,0 @@
language: python
dist: focal
sudo: required
python:
- "3.8"
install:
- sudo apt-get update -q
- sudo apt-get install intltool desktop-file-utils
- pip3 install pytest-cov minimock pycodestyle isort requests pytest pytest-httpserver
- python3 tools/localdepends.py
script:
- make lint
- make releasetest

View File

@ -43,6 +43,7 @@ PyPI. With this, you get a self-contained gPodder CLI codebase.
### GTK3 UI - Additional Dependencies
- [PyGObject](https://wiki.gnome.org/PyGObject) 3.22.0 or newer
- [GTK+3](https://www.gtk.org/) 3.10 or newer
### Optional Dependencies

View File

@ -24,6 +24,7 @@ __category__ = 'post-download'
DefaultConfig = {
'add_sortdate': False, # Add the sortdate as prefix
'add_podcast_title': False, # Add the podcast title as prefix
'sortdate_after_podcast_title': False, # put the sortdate after podcast title
}
@ -50,10 +51,16 @@ class gPodderExtension:
new_basename = []
new_basename.append(title)
if self.config.add_podcast_title:
new_basename.insert(0, podcast_title)
if self.config.add_sortdate:
new_basename.insert(0, sortdate)
if self.config.sortdate_after_podcast_title:
if self.config.add_sortdate:
new_basename.insert(0, sortdate)
if self.config.add_podcast_title:
new_basename.insert(0, podcast_title)
else:
if self.config.add_podcast_title:
new_basename.insert(0, podcast_title)
if self.config.add_sortdate:
new_basename.insert(0, sortdate)
new_basename = ' - '.join(new_basename)
# Remove unwanted characters and shorten filename (#494)

View File

@ -15,7 +15,8 @@ from youtube_dl.utils import DownloadError, ExtractorError, sanitize_url
import gpodder
from gpodder import download, feedcore, model, registry, youtube
from gpodder.util import mimetype_from_extension, remove_html_tags
from gpodder.util import (mimetype_from_extension, nice_html_description,
remove_html_tags)
_ = gpodder.gettext
@ -213,7 +214,7 @@ class YoutubeFeed(model.Feed):
for en in self._ie_result['entries']:
guid = video_guid(en['id'])
description = remove_html_tags(en.get('description') or _('No description available'))
html_description = self.nice_html_description(en, description)
html_description = nice_html_description(en.get('thumbnail'), description)
if en.get('ext'):
mime_type = mimetype_from_extension('.{}'.format(en['ext']))
else:
@ -250,25 +251,6 @@ class YoutubeFeed(model.Feed):
"""
return None
@staticmethod
def nice_html_description(en, description):
"""
basic html formating + hyperlink highlighting + video thumbnail
"""
description = re.sub(r'''https?://[^\s]+''',
r'''<a href="\g<0>">\g<0></a>''',
description)
description = description.replace('\n', '<br>')
html = """<style type="text/css">
body > img { float: left; max-width: 30vw; margin: 0 1em 1em 0; }
</style>
"""
img = en.get('thumbnail')
if img:
html += '<img src="{}">'.format(img)
html += '<p>{}</p>'.format(description)
return html
class gPodderYoutubeDL(download.CustomDownloader):
def __init__(self, gpodder_config, my_config, force=False):

View File

@ -209,6 +209,10 @@
<attribute name="action">win.viewAlwaysShowNewEpisodes</attribute>
<attribute name="label" translatable="yes">Always show New Episodes</attribute>
</item>
<item>
<attribute name="action">win.viewCtrlClickToSortEpisodes</attribute>
<attribute name="label" translatable="yes">Require control click to sort episodes</attribute>
</item>
</section>
<submenu id="menuViewColumns">
<attribute name="label" translatable="yes">Visible columns</attribute>

View File

@ -26,7 +26,10 @@ __copyright__ = '© 2005-2021 The gPodder Team'
__license__ = 'GNU General Public License, version 3 or later'
__url__ = 'http://gpodder.org/'
# __version_info__ = tuple(int(x) for x in __version__.split('.'))
# Use public version part for __version_info__, see PEP 440
__public_version__, __local_version__ = next(
(v[0], v[1] if len(v) > 1 else '') for v in (__version__.split('+'),))
__version_info__ = tuple(int(x) for x in __public_version__.split('.'))
import gettext
import locale

View File

@ -169,6 +169,7 @@ defaults = {
'view_mode': 1,
'columns': int('110', 2), # bitfield of visible columns
'always_show_new': True,
'ctrl_click_to_sort': False,
},
'download_list': {

View File

@ -24,9 +24,10 @@ import gpodder
from gpodder import util
from gpodder.sync import (episode_filename_on_device,
episode_foldername_on_device)
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GLib, Gio
import gi # isort:skip
gi.require_version('Gtk', '3.0') # isort:skip
from gi.repository import Gio, GLib # isort:skip
_ = gpodder.gettext

View File

@ -435,8 +435,7 @@ class DownloadQueueManager(object):
work_count = self.tasks.available_work_count()
if self._config.max_downloads_enabled:
# always allow at least 1 download
max_downloads = max(int(self._config.max_downloads), 1)
spawn_limit = max_downloads - len(self.worker_threads)
spawn_limit = max(int(self._config.max_downloads), 1)
else:
spawn_limit = self._config.limit.downloads.concurrent_max
running = len(self.worker_threads)
@ -538,9 +537,9 @@ class DownloadTask(object):
The same thing works for failed downloads ("notify_as_failed()").
"""
# Possible states this download task can be in
STATUS_MESSAGE = (_('Added'), _('Queued'), _('Downloading'),
STATUS_MESSAGE = (_('Queued'), _('Downloading'),
_('Finished'), _('Failed'), _('Cancelled'), _('Paused'))
(INIT, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = list(range(7))
(QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = list(range(6))
# Wheter this task represents a file download or a device sync operation
ACTIVITY_DOWNLOAD, ACTIVITY_SYNCHRONIZE = list(range(2))
@ -612,7 +611,7 @@ class DownloadTask(object):
def __init__(self, episode, config, downloader=None):
assert episode.download_task is None
self.__status = DownloadTask.INIT
self.__status = DownloadTask.QUEUED
self.__activity = DownloadTask.ACTIVITY_DOWNLOAD
self.__status_changed = True
self.__episode = episode

View File

@ -26,7 +26,8 @@ from gi.repository import Gdk, Gtk, Pango
import gpodder
from gpodder import util, vimeo, youtube
from gpodder.gtkui.desktopfile import PlayerListModel
from gpodder.gtkui.interface.common import BuilderWidget, TreeViewHelper, show_message_dialog
from gpodder.gtkui.interface.common import (BuilderWidget, TreeViewHelper,
show_message_dialog)
from gpodder.gtkui.interface.configeditor import gPodderConfigEditor
logger = logging.getLogger(__name__)

View File

@ -34,6 +34,7 @@ from gpodder import download, util
_ = gpodder.gettext
class TaskQueue:
def __init__(self):
self.lock = threading.Lock()
@ -81,6 +82,7 @@ class TaskQueue:
except ValueError:
pass
class DownloadStatusModel:
# Symbolic names for our columns, so we know what we're up to
C_TASK, C_NAME, C_URL, C_PROGRESS, C_PROGRESS_TEXT, C_ICON_NAME = list(range(6))
@ -220,6 +222,7 @@ class DownloadStatusModel:
task.status = task.DOWNLOADING
return True
class DownloadTaskMonitor(object):
"""A helper class that abstracts download events"""
def __init__(self, episode, on_can_resume, on_can_pause, on_finished):

View File

@ -169,7 +169,8 @@ def draw_cake(percentage, text=None, emblem=None, size=None):
return surface
def draw_text_pill(left_text, right_text, x=0, y=0, border=2, radius=14, widget=None):
def draw_text_pill(left_text, right_text, x=0, y=0, border=2, radius=14,
widget=None, scale=1):
# Padding (in px) at the right edge of the image (for Ubuntu; bug 1533)
padding_right = 7
@ -200,10 +201,13 @@ def draw_text_pill(left_text, right_text, x=0, y=0, border=2, radius=14, widget=
left_side_width = width_left + x_border * 2
right_side_width = width_right + x_border * 2
image_height = int(y + text_height + border * 2)
image_width = int(x + left_side_width + right_side_width + padding_right)
image_height = int(scale * (y + text_height + border * 2))
image_width = int(scale * (x + left_side_width + right_side_width
+ padding_right))
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, image_width, image_height)
surface.set_device_scale(scale, scale)
ctx = cairo.Context(surface)
# Clip so as to not draw on the right padding (for Ubuntu; bug 1533)
@ -288,8 +292,9 @@ def draw_cake_pixbuf(percentage, text=None, emblem=None, size=None):
return cairo_surface_to_pixbuf(draw_cake(percentage, text, emblem, size=size))
def draw_pill_pixbuf(left_text, right_text, widget=None):
return cairo_surface_to_pixbuf(draw_text_pill(left_text, right_text, widget=widget))
def draw_pill_pixbuf(left_text, right_text, widget=None, scale=1):
return cairo_surface_to_pixbuf(draw_text_pill(left_text, right_text,
widget=widget, scale=scale))
def cake_size_from_widget(widget=None):
@ -479,3 +484,33 @@ def investigate_widget_colors(type_classes_and_widgets):
f.write("</dl></td></tr>\n")
f.write("</table></html>\n")
def draw_iconcell_scale(column, cell, model, iter, scale):
"""
Draw cell's pixbuf to a surface with proper scaling for high resolution
displays. To be used as gtk.TreeViewColumn.set_cell_data_func.
:param column: gtk.TreeViewColumn (ignored)
:param cell: gtk.CellRenderer
:param model: gtk.TreeModel (ignored)
:param iter: gtk.TreeIter (ignored)
:param scale: factor of the target display (e.g. 1 or 2)
"""
pixbuf = cell.props.pixbuf
if not pixbuf:
return
width = pixbuf.get_width()
height = pixbuf.get_height()
scale_inv = 1 / scale
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
surface.set_device_scale(scale, scale)
cr = cairo.Context(surface)
cr.scale(scale_inv, scale_inv)
Gdk.cairo_set_source_pixbuf(cr, cell.props.pixbuf, 0, 0)
cr.paint()
cell.props.surface = surface

View File

@ -48,7 +48,7 @@ from .desktop.welcome import gPodderWelcome
from .desktopfile import UserAppsReader
from .download import DownloadStatusModel
from .draw import (cake_size_from_widget, draw_cake_pixbuf,
draw_text_box_centered)
draw_iconcell_scale, draw_text_box_centered)
from .interface.addpodcast import gPodderAddPodcast
from .interface.common import (BuilderWidget, TreeViewHelper,
ExtensionMenuHelper, Dummy)
@ -332,6 +332,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
action.connect('activate', self.on_item_view_always_show_new_episodes_toggled)
g.add_action(action)
action = Gio.SimpleAction.new_stateful(
'viewCtrlClickToSortEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.ctrl_click_to_sort))
action.connect('activate', self.on_item_view_ctrl_click_to_sort_episodes_toggled)
g.add_action(action)
action = Gio.SimpleAction.new_stateful(
'searchAlwaysVisible', None, GLib.Variant.new_boolean(self.config.ui.gtk.search_always_visible))
action.connect('activate', self.on_item_view_search_always_visible_toggled)
@ -789,7 +794,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
def init_podcast_list_treeview(self):
size = cake_size_from_widget(self.treeChannels) * 2
self.podcast_list_model.set_max_image_size(size)
scale = self.treeChannels.get_scale_factor()
self.podcast_list_model.set_max_image_size(size, scale)
# Set up podcast channel tree view widget
column = Gtk.TreeViewColumn('')
iconcell = Gtk.CellRendererPixbuf()
@ -797,6 +803,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
column.pack_start(iconcell, False)
column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
column.add_attribute(iconcell, 'visible', PodcastListModel.C_COVER_VISIBLE)
if scale != 1:
column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
namecell = Gtk.CellRendererText()
namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
@ -808,6 +816,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
column.pack_start(iconcell, False)
column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
column.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
if scale != 1:
column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
self.treeChannels.append_column(column)
@ -995,11 +1005,13 @@ class gPodder(BuilderWidget, dbus.service.Object):
return True
def on_episode_list_header_clicked(self, button, event):
if event.button != 3:
return False
if self.episode_columns_menu is not None:
self.episode_columns_menu.popup(None, None, None, None, event.button, event.time)
if event.button == 1:
# Require control click to sort episodes, when enabled
if self.config.ui.gtk.episode_list.ctrl_click_to_sort and (event.state & Gdk.ModifierType.CONTROL_MASK) == 0:
return True
elif event.button == 3:
if self.episode_columns_menu is not None:
self.episode_columns_menu.popup(None, None, None, None, event.button, event.time)
return False
@ -1514,8 +1526,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
title.append(N_('%(queued)d task queued',
'%(queued)d tasks queued',
queued) % {'queued': queued})
if ((downloading + synchronizing + queued) == 0 and
self.things_adding_tasks == 0):
if (downloading + synchronizing + queued) == 0 and self.things_adding_tasks == 0:
self.set_download_progress(1.)
self.downloads_finished(self.download_tasks_seen)
gpodder.user_extensions.on_all_episodes_downloaded()
@ -3482,6 +3493,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.config.ui.gtk.episode_list.always_show_new = not state
action.set_state(GLib.Variant.new_boolean(not state))
def on_item_view_ctrl_click_to_sort_episodes_toggled(self, action, param):
state = action.get_state()
self.config.ui.gtk.episode_list.ctrl_click_to_sort = not state
action.set_state(GLib.Variant.new_boolean(not state))
def on_item_view_search_always_visible_toggled(self, action, param):
state = action.get_state()
self.config.ui.gtk.search_always_visible = not state
@ -4128,7 +4144,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
file.mount_enclosing_volume_finish(res)
except GLib.Error as err:
if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED) and
not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
logger.error('mounting volume %s failed: %s' % (file.get_uri(), err.message))
result = False
finally:

View File

@ -587,6 +587,7 @@ class PodcastListModel(Gtk.ListStore):
self._cover_cache = {}
self._max_image_side = 40
self._scale = 1
self._cover_downloader = cover_downloader
self.ICON_DISABLED = 'media-playback-pause'
@ -655,8 +656,9 @@ class PodcastListModel(Gtk.ListStore):
def get_search_term(self):
return self._search_term
def set_max_image_size(self, size):
self._max_image_side = size
def set_max_image_size(self, size, scale):
self._max_image_side = size * scale
self._scale = scale
self._cover_cache = {}
def _resize_pixbuf_keep_ratio(self, url, pixbuf):
@ -665,31 +667,27 @@ class PodcastListModel(Gtk.ListStore):
Returns None if the pixbuf does not need to be
resized or the newly resized pixbuf if it does.
"""
changed = False
result = None
if url in self._cover_cache:
return self._cover_cache[url]
# Resize if too wide
if pixbuf.get_width() > self._max_image_side:
f = float(self._max_image_side) / pixbuf.get_width()
(width, height) = (int(pixbuf.get_width() * f), int(pixbuf.get_height() * f))
pixbuf = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
changed = True
max_side = self._max_image_side
w_cur = pixbuf.get_width()
h_cur = pixbuf.get_height()
# Resize if too high
if pixbuf.get_height() > self._max_image_side:
f = float(self._max_image_side) / pixbuf.get_height()
(width, height) = (int(pixbuf.get_width() * f), int(pixbuf.get_height() * f))
pixbuf = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
changed = True
if w_cur <= max_side and h_cur <= max_side:
return None
if changed:
self._cover_cache[url] = pixbuf
result = pixbuf
f = max_side / (w_cur if w_cur >= h_cur else h_cur)
w_new = int(w_cur * f)
h_new = int(h_cur * f)
return result
logger.debug("Scaling cover image: url=%s from %ix%i to %ix%i",
url, w_cur, h_cur, w_new, h_new)
pixbuf = pixbuf.scale_simple(w_new, h_new,
GdkPixbuf.InterpType.BILINEAR)
self._cover_cache[url] = pixbuf
return pixbuf
def _resize_pixbuf(self, url, pixbuf):
if pixbuf is None:
@ -728,6 +726,7 @@ class PodcastListModel(Gtk.ListStore):
if self._max_image_side not in (pixbuf.get_width(), pixbuf.get_height()):
logger.debug("cached thumb wrong size: %r != %i", (pixbuf.get_width(), pixbuf.get_height()), self._max_image_side)
return None
return pixbuf
except Exception as e:
logger.warn('Could not load cached cover art for %s', channel.url, exc_info=True)
channel.cover_thumb = None
@ -774,7 +773,10 @@ class PodcastListModel(Gtk.ListStore):
def _get_pill_image(self, channel, count_downloaded, count_unplayed):
if count_unplayed > 0 or count_downloaded > 0:
return draw.draw_pill_pixbuf('{:n}'.format(count_unplayed), '{:n}'.format(count_downloaded), widget=self.widget)
return draw.draw_pill_pixbuf('{:n}'.format(count_unplayed),
'{:n}'.format(count_downloaded),
widget=self.widget,
scale=self._scale)
else:
return None

View File

@ -283,6 +283,10 @@ class PodcastEpisode(PodcastModelObject):
episode.description = entry['description']
if entry.get('description_html'):
episode.description_html = entry['description_html']
else:
thumbnail = entry.get('episode_art_url')
description = util.remove_html_tags(episode.description or _('No description available'))
episode.description_html = util.nice_html_description(thumbnail, description)
episode.total_time = entry['total_time']
episode.published = entry['published']

View File

@ -32,6 +32,7 @@ from urllib.parse import urlparse
import gpodder
from gpodder import download, services, util
import gi # isort:skip
gi.require_version('Gtk', '3.0') # isort:skip
from gi.repository import GLib, Gio, Gtk # isort:skip
@ -503,6 +504,7 @@ class iPodDevice(Device):
except:
logger.warning('Seems like your python-gpod is out-of-date.')
class MP3PlayerDevice(Device):
def __init__(self, config,
download_status_model,
@ -597,7 +599,8 @@ class MP3PlayerDevice(Device):
to_file.get_uri())
from_file = Gio.File.new_for_path(from_file)
try:
hookconvert = lambda current_bytes, total_bytes, user_data : reporthook(current_bytes, 1, total_bytes)
def hookconvert(current_bytes, total_bytes, user_data):
return reporthook(current_bytes, 1, total_bytes)
from_file.copy(to_file, Gio.FileCopyFlags.OVERWRITE, None, hookconvert, None)
except GLib.Error as err:
logger.error('Error copying %s to %s: %s', from_file.get_uri(), to_file.get_uri(), err.message)
@ -691,9 +694,9 @@ class SyncTask(download.DownloadTask):
# An object representing the synchronization task of an episode
# Possible states this sync task can be in
STATUS_MESSAGE = (_('Added'), _('Queued'), _('Synchronizing'),
STATUS_MESSAGE = (_('Queued'), _('Synchronizing'),
_('Finished'), _('Failed'), _('Cancelled'), _('Paused'))
(INIT, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = list(range(7))
(QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = list(range(6))
def __str__(self):
return self.__episode.title
@ -753,7 +756,7 @@ class SyncTask(download.DownloadTask):
pass
def __init__(self, episode):
self.__status = SyncTask.INIT
self.__status = SyncTask.QUEUED
self.__activity = SyncTask.ACTIVITY_SYNCHRONIZE
self.__status_changed = True
self.__episode = episode

View File

@ -218,7 +218,7 @@ class gPodderSyncUI(object):
message = _('The playlist on your MP3 player has been updated.')
self.notification(message, title)
# called from the main thread to complete adding tasks_
# called from the main thread to complete adding tasks
def add_downloads_complete():
self.set_download_list_state(gPodderSyncUI.DL_ADDED_TASKS)
@ -252,8 +252,7 @@ class gPodderSyncUI(object):
# if playlist doesn't exist (yet) episodes_in_playlist will be empty
if episodes_in_playlists:
for episode_filename in episodes_in_playlists:
if not playlist.mountpoint.resolve_relative_path(
episode_filename).query_exists():
if not playlist.mountpoint.resolve_relative_path(episode_filename).query_exists():
# episode was synced but no longer on device
# i.e. must have been deleted by user, so delete from gpodder
try:

View File

@ -61,10 +61,6 @@ import xml.dom.minidom
from html.entities import entitydefs, name2codepoint
from html.parser import HTMLParser
import gi
gi.require_version('Gtk', '3.0') # isort:skip
from gi.repository import Gio, GLib, Gtk # isort:skip
import requests
import requests.exceptions
from requests.packages.urllib3.util.retry import Retry
@ -152,6 +148,7 @@ _MIME_TYPE_LIST = [
('.wmv', 'video/x-ms-wmv'),
('.opus', 'audio/opus'),
('.webm', 'video/webm'),
('.webm', 'audio/webm'),
]
_MIME_TYPES = dict((k, v) for v, k in _MIME_TYPE_LIST)
@ -173,6 +170,8 @@ def new_gio_file(path):
"""
Create a new Gio.File given a path or uri
"""
from gi.repository import Gio
if is_absolute_url(path):
return Gio.File.new_for_uri(path)
else:
@ -185,6 +184,8 @@ def make_directory(path):
Returns True if the directory exists after the function
call, False otherwise.
"""
from gi.repository import Gio, GLib
if not isinstance(path, Gio.File):
path = new_gio_file(path)
@ -832,6 +833,24 @@ def extract_hyperlinked_text(html):
return ExtractHyperlinkedTextHTMLParser()(html)
def nice_html_description(img, description):
"""
basic html formating + hyperlink highlighting + video thumbnail
"""
description = re.sub(r'''https?://[^\s]+''',
r'''<a href="\g<0>">\g<0></a>''',
description)
description = description.replace('\n', '<br>')
html = """<style type="text/css">
body > img { float: left; max-width: 30vw; margin: 0 1em 1em 0; }
</style>
"""
if img:
html += '<img src="{}">'.format(img)
html += '<p>{}</p>'.format(description)
return html
def wrong_extension(extension):
"""
Determine if a given extension looks like it's
@ -1835,7 +1854,11 @@ def get_update_info():
days_since_release = (datetime.datetime.today() - release_parsed).days
def convert(s):
return tuple(int(x) for x in s.split('.'))
# Use both public and local version label, see PEP 440
pubv, locv = next(
(v[0], v[1] if len(v) > 1 else '') for v in (s.split('+'),))
return tuple(int(x) if x.isdigit() else x.lower()
for x in pubv.split('.') + (locv.split('.') if locv else []))
up_to_date = (convert(gpodder.__version__) >= convert(latest_version))
@ -2241,11 +2264,15 @@ def response_text(response, default_encoding='utf-8'):
return response.content.decode(default_encoding)
def mount_volume_for_file(file, op = None):
def mount_volume_for_file(file, op=None):
"""
Utility method to mount the enclosing volume for the given file in a blocking
fashion
"""
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, GLib, Gtk
result = True
message = None
@ -2256,7 +2283,7 @@ def mount_volume_for_file(file, op = None):
result = True
except GLib.Error as err:
if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED) and
not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
message = err.message
result = False
finally: