Merge tag '3.11.2' into dev-adaptive

gPodder 3.11.2 release
This commit is contained in:
Teemu Ikonen 2023-08-15 12:03:02 +03:00
commit 490d5695a9
77 changed files with 8824 additions and 8391 deletions

View File

@ -3,7 +3,7 @@ version: 2
jobs:
release-from-macos:
macos:
xcode: "13.2.1"
xcode: "13.4.1"
shell: /bin/bash --login -o pipefail
environment:
- BUNDLE_TAG: 22.8.27

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v3
@ -24,7 +24,7 @@ jobs:
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
pip3 install pytest-cov minimock pycodestyle isort codespell requests pytest pytest-httpserver
pip3 install podcastparser mygpoclient
- name: Lint
run: make lint

View File

@ -3,7 +3,7 @@
## Getting started <!-- omit in toc -->
Before you begin:
- Ensure you are using Python 3.5+
- Ensure you are using Python 3.7+
- Check out the [existing issues](https://github.com/gpodder/gpodder/issues)
Contributions are made to this repo via Issues and Pull Requests (PRs). Make sure to search for existing Issues and PRs before creating your own.

View File

@ -26,7 +26,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
## Dependencies
- [Python 3.5](http://python.org/) or newer
- [Python 3.7](http://python.org/) or newer
- [Podcastparser](http://gpodder.org/podcastparser/) 0.6.0 or newer
- [mygpoclient](http://gpodder.org/mygpoclient/) 1.7 or newer
- [requests](https://requests.readthedocs.io) 2.24.0 or newer
@ -135,7 +135,7 @@ into an alternative root (default /) and prefix (default /usr):
[*Debian*](https://wiki.debian.org/Python#Deviations_from_upstream) and *Ubuntu* use `dist-packages`
instead of `site-packages` for third party installs, so you'll want something like:
sudo python3 setup.py install --root / --prefix /usr/local --optimize=1 --install-lib=/usr/local/lib/python3.5/dist-packages
sudo python3 setup.py install --root / --prefix /usr/local --optimize=1 --install-lib=/usr/local/lib/python3.10/dist-packages
In fact, first try running `python -c "import sys; print(sys.path)"` to check what is the exact path.
It depends on your version of python.

View File

@ -69,8 +69,12 @@ unittest:
ISORTOPTS := -c share src/gpodder tools bin/* *.py
lint:
pycodestyle --version
pycodestyle share src/gpodder tools bin/* *.py
isort --version
isort -q $(ISORTOPTS) || isort --df $(ISORTOPTS)
codespell --quiet-level 3 --skip "./.git,*.po,./share/applications/gpodder.desktop"
release: distclean
$(PYTHON) setup.py sdist

488
po/ca.po

File diff suppressed because it is too large Load Diff

489
po/cs.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

490
po/da.po

File diff suppressed because it is too large Load Diff

490
po/de.po

File diff suppressed because it is too large Load Diff

490
po/el.po

File diff suppressed because it is too large Load Diff

490
po/es.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

490
po/eu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

490
po/fi.po

File diff suppressed because it is too large Load Diff

532
po/fr.po

File diff suppressed because it is too large Load Diff

490
po/gl.po

File diff suppressed because it is too large Load Diff

490
po/he.po

File diff suppressed because it is too large Load Diff

490
po/hu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

490
po/it.po

File diff suppressed because it is too large Load Diff

490
po/kk.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

490
po/nb.po

File diff suppressed because it is too large Load Diff

562
po/nl.po

File diff suppressed because it is too large Load Diff

540
po/nn.po

File diff suppressed because it is too large Load Diff

490
po/pl.po

File diff suppressed because it is too large Load Diff

490
po/pt.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

490
po/ro.po

File diff suppressed because it is too large Load Diff

490
po/ru.po

File diff suppressed because it is too large Load Diff

490
po/sk.po

File diff suppressed because it is too large Load Diff

490
po/sv.po

File diff suppressed because it is too large Load Diff

490
po/tr.po

File diff suppressed because it is too large Load Diff

490
po/uk.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@ from distutils.core import setup
installing = ('install' in sys.argv and '--help' not in sys.argv)
# distutils depends on setup.py beeing executed from the same dir.
# distutils depends on setup.py being executed from the same dir.
# Most of our custom commands work either way, but this makes
# it work in all cases.
os.chdir(os.path.dirname(os.path.realpath(__file__)))

View File

@ -60,7 +60,7 @@ class gPodderExtension:
#
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen
#
# This is expecially important for extensions responding to
# This is especially important for extensions responding to
# on_episode_downloaded(), which runs whenever a download finishes.
#
# Otherwise that process will inherit ALL file descriptors gPodder

View File

@ -242,7 +242,7 @@ RESUMERS = [
# with https://github.com/Serranya/deadbeef-mpris2-plugin
MPRISResumer('resume in deadbeef', 'DeaDBeeF', ['deadbeef'], 'org.mpris.MediaPlayer2.DeaDBeeF'),
# the gPodder Dowloads directory must be in gmusicbrowser's library
# the gPodder Downloads directory must be in gmusicbrowser's library
MPRISResumer('resume in gmusicbrowser', 'gmusicbrowser', ['gmusicbrowser'], 'org.mpris.MediaPlayer2.gmusicbrowser'),
# Audacious doesn't implement MPRIS2.OpenUri

View File

@ -100,7 +100,7 @@ try {{
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force # Delete this script temp file.
}} else {{
# use older Baloon notification when not on Windows 10
# use older Balloon notification when not on Windows 10
[System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
$o = New-Object System.Windows.Forms.NotifyIcon

View File

@ -39,7 +39,7 @@ class gPodderExtension:
device_folder = device.get_episode_folder_on_device(episode)
episode_art = os.path.join(episode_folder, "folder.jpg")
device_art = os.path.join(device_folder, self.config.art_name_on_device)
# make sure we have art to copy and it doesnt already exist
# make sure we have art to copy and it doesn't already exist
if os.path.isfile(episode_art) and not os.path.isfile(device_art):
logger.info('Syncing cover art for %s', episode.channel.title)
# copy and rename art

View File

@ -131,7 +131,7 @@ class AudioFile(object):
def get_cover_picture(self, cover):
""" Returns mutagen Picture class for the cover image
Usefull for OGG and FLAC format
Useful for OGG and FLAC format
Picture type = cover image
see http://flac.sourceforge.net/documentation_tools_flac.html#encoding_options

View File

@ -9,11 +9,12 @@ import os
import re
import sys
import time
from collections.abc import Iterable
try:
import yt_dlp as youtube_dl
program_name = 'yt-dlp'
want_ytdl_version = '2023.02.17'
want_ytdl_version = '2023.06.22'
except:
import youtube_dl
program_name = 'youtube-dl'
@ -41,7 +42,7 @@ __doc__ = 'https://gpodder.github.io/docs/extensions/youtubedl.html'
want_ytdl_version_msg = _('Your version of youtube-dl/yt-dlp %(have_version)s has known issues, please upgrade to %(want_version)s or newer.')
DefaultConfig = {
# youtube-dl downloads and parses each video page to get informations about it, which is very slow.
# youtube-dl downloads and parses each video page to get information about it, which is very slow.
# Set to False to fall back to the fast but limited (only 15 episodes) gpodder code
'manage_channel': True,
# If for some reason youtube-dl download doesn't work for you, you can fallback to gpodder code.
@ -108,7 +109,7 @@ class YoutubeCustomDownload(download.CustomDownload):
outtmpl = tempname.replace('%', '%%')
info, opts = self._ytdl.fetch_info(self._url, outtmpl, self._my_hook)
if program_name == 'yt-dlp':
default = opts['outtmpl']['default'] if type(opts['outtmpl']) == dict else opts['outtmpl']
default = opts['outtmpl']['default'] if isinstance(opts['outtmpl'], dict) else opts['outtmpl']
self.partial_filename = os.path.join(opts['paths']['home'], default) % info
elif program_name == 'youtube-dl':
self.partial_filename = opts['outtmpl'] % info
@ -296,9 +297,14 @@ class gPodderYoutubeDL(download.CustomDownloader):
os.makedirs(cachedir, exist_ok=True)
self._ydl_opts = {
'cachedir': cachedir,
'no_color': True, # prevent escape codes in desktop notifications on errors
'noprogress': True, # prevent progress bar from appearing in console
}
# prevent escape codes in desktop notifications on errors
if program_name == 'yt-dlp':
self._ydl_opts['color'] = 'no_color'
else:
self._ydl_opts['no_color'] = True
if gpodder.verbose:
self._ydl_opts['verbose'] = True
else:
@ -306,7 +312,7 @@ class gPodderYoutubeDL(download.CustomDownloader):
# Don't create downloaders for URLs supported by these youtube-dl extractors
self.ie_blacklist = ["Generic"]
# Cache URL regexes from youtube-dl matches here, seed with youtube regex
self.regex_cache = [re.compile(r'https://www.youtube.com/watch\?v=.+')]
self.regex_cache = [(re.compile(r'https://www.youtube.com/watch\?v=.+'),)]
# #686 on windows without a console, sys.stdout is None, causing exceptions
# when adding podcasts.
# See https://docs.python.org/3/library/sys.html#sys.__stderr__ Note
@ -384,7 +390,7 @@ class gPodderYoutubeDL(download.CustomDownloader):
"""
Fetch a channel or playlist contents.
Doesn't yet fetch video entry informations, so we only get the video id and title.
Doesn't yet fetch video entry information, so we only get the video id and title.
"""
# Duplicate a bit of the YoutubeDL machinery here because we only
# want to parse the channel/playlist first, not to fetch video entries.
@ -448,21 +454,22 @@ class gPodderYoutubeDL(download.CustomDownloader):
def is_supported_url(self, url):
if url is None:
return False
if self.regex_cache[0].match(url) is not None:
return True
for r in self.regex_cache[1:]:
if r.match(url) is not None:
self.regex_cache.remove(r)
self.regex_cache.insert(0, r)
for i, res in enumerate(self.regex_cache):
if next(filter(None, (r.match(url) for r in res)), None) is not None:
if i > 0:
self.regex_cache.remove(res)
self.regex_cache.insert(0, res)
return True
with youtube_dl.YoutubeDL(self._ydl_opts) as ydl:
# youtube-dl returns a list, yt-dlp returns a dict
ies = ydl._ies
if type(ydl._ies) == dict:
if isinstance(ydl._ies, dict):
ies = ydl._ies.values()
for ie in ies:
if ie.suitable(url) and ie.ie_key() not in self.ie_blacklist:
self.regex_cache.insert(0, ie._VALID_URL_RE)
self.regex_cache.insert(
0, (ie._VALID_URL_RE if isinstance(ie._VALID_URL_RE, Iterable)
else (ie._VALID_URL_RE,)))
return True
return False
@ -583,7 +590,7 @@ class gPodderExtension:
box.pack_start(Gtk.HSeparator(), False, False, 0)
checkbox = Gtk.CheckButton(_('Embed all available subtitles to downloaded video'))
checkbox = Gtk.CheckButton(_('Embed all available subtitles in downloaded video'))
checkbox.set_active(self.container.config.embed_subtitles)
checkbox.connect('toggled', self.toggle_embed_subtitles)
box.pack_start(checkbox, False, False, 0)

View File

@ -241,6 +241,14 @@
<attribute name="label" translatable="yes">Episode descriptions</attribute>
<attribute name="accel">&lt;Primary&gt;d</attribute>
</item>
<item>
<attribute name="action">win.viewShowEpisodeReleasedTime</attribute>
<attribute name="label" translatable="yes">Show episode released time</attribute>
</item>
<item>
<attribute name="action">win.viewRightAlignEpisodeReleasedColumn</attribute>
<attribute name="label" translatable="yes">Right align episode released column</attribute>
</item>
<item>
<attribute name="action">win.viewCtrlClickToSortEpisodes</attribute>
<attribute name="label" translatable="yes">Require control click to sort episodes</attribute>

View File

@ -1,4 +1,4 @@
.TH GPO "1" "February 2023" "gpodder 3.11.1" "User Commands"
.TH GPO "1" "August 2023" "gpodder 3.11.2" "User Commands"
.SH NAME
gpo \- Text mode interface of gPodder
.SH SYNOPSIS

View File

@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.48.5.
.TH GPODDER "1" "February 2023" "gpodder 3.11.1" "User Commands"
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3.
.TH GPODDER "1" "August 2023" "gpodder 3.11.2" "User Commands"
.SH NAME
gpodder \- Media aggregator and podcast client
.SH SYNOPSIS

View File

@ -44,13 +44,34 @@
<content_attribute id="money-purchasing">none</content_attribute>
<content_attribute id="money-gambling">none</content_attribute>
</content_rating>
<screenshots>
<screenshots>
<screenshot type="default">
<caption>The main window</caption>
<image>https://raw.githubusercontent.com/flathub/org.gpodder.gpodder/master/screenshot.png</image>
</screenshot>
</screenshots>
<releases>
<release version="3.11.2" date="2023-08-13">
<description>
<p>Major changes:</p>
<ul>
<li>show episode release time</li>
<li>fix crash when syncing</li>
<li>don't reposition maximised windows</li>
</ul>
</description>
</release>
<release version="3.11.1" date="2023-02-18">
<description>
<p>Major changes:</p>
<ul>
<li>new yt-dlp to fix the recent YouTube change</li>
<li>fix multiple bugs that caused gPodder to freeze or appear frozen</li>
<li>embed subtitles in videos with youtube-dl extension</li>
<li>performance improvements</li>
</ul>
</description>
</release>
<release version="3.11.0" date="2022-07-30">
<description>
<p>This release contains a year's worth of improvements. Major changes:</p>
@ -74,7 +95,7 @@
<release version="3.10.9" date="2019-06-09"/>
<release version="3.10.8" date="2019-04-08"/>
<release version="3.10.7" date="2019-02-02"/>
<release version="3.10.6" date="2018-12-29">
<release version="3.10.6" date="2018-12-29"/>
<release version="3.10.5" date="2018-09-15">
<description>
<p>This is a bugfix release, shortly after 3.10.4, for the Rename after Download extension.</p>
@ -85,5 +106,12 @@
<release version="3.10.2" date="2018-06-10"/>
<release version="3.10.1" date="2018-02-19"/>
<release version="3.10.0" date="2017-12-29"/>
<release version="3.9.6" date="2017-12-29"/>
<release version="3.9.5" date="2017-12-16"/>
<release version="3.9.4" date="2017-12-16"/>
<release version="3.9.3" date="2016-12-22"/>
<release version="3.9.2" date="2016-11-30"/>
<release version="3.9.1" date="2016-08-31"/>
<release version="3.9.0" date="2016-02-03"/>
</releases>
</component>

View File

@ -20,8 +20,8 @@
# This metadata block gets parsed by setup.py - use single quotes only
__tagline__ = 'Media aggregator and podcast client'
__author__ = 'Thomas Perl <thp@gpodder.org>'
__version__ = '3.11.1+1'
__date__ = '2023-02-28'
__version__ = '3.11.2'
__date__ = '2023-08-13'
__copyright__ = '© 2005-2023 The gPodder Team'
__license__ = 'GNU General Public License, version 3 or later'
__url__ = 'http://gpodder.org/'

View File

@ -171,6 +171,8 @@ defaults = {
'always_show_new': True,
'trim_title_prefix': True,
'descriptions': True,
'show_released_time': False,
'right_align_released_column': False,
'ctrl_click_to_sort': False,
'columns': int('110', 2), # bitfield of visible columns
},

View File

@ -86,8 +86,12 @@ def directory_entry_from_opml(url):
def directory_entry_from_mygpo_json(url):
r = util.urlopen(url)
if not r.ok:
raise Exception('%s: %d %s' % (url, r.status_code, r.reason))
return [DirectoryEntry(d['title'], d['url'], d['logo_url'], d['subscribers'], d['description'])
for d in util.urlopen(url).json()]
for d in r.json()]
class GPodderNetSearchProvider(Provider):
@ -150,7 +154,13 @@ class GPodderNetTagsProvider(Provider):
return directory_entry_from_mygpo_json('http://gpodder.net/api/2/tag/%s/50.json' % urllib.parse.quote(tag))
def get_tags(self):
return [DirectoryTag(d['tag'], d['usage']) for d in util.urlopen('http://gpodder.net/api/2/tags/40.json').json()]
url = 'http://gpodder.net/api/2/tags/40.json'
r = util.urlopen(url)
if not r.ok:
raise Exception('%s: %d %s' % (url, r.status_code, r.reason))
return [DirectoryTag(d['tag'], d['usage']) for d in r.json()]
class SoundcloudSearchProvider(Provider):

View File

@ -170,7 +170,7 @@ class ContentRange(object):
return None
value = value.strip()
if not value.startswith('bytes '):
# Unparseable
# Unparsable
return None
value = value[len('bytes '):].strip()
if '/' not in value:
@ -539,7 +539,7 @@ class DownloadTask(object):
of downloading data, this can take a while when the Internet is
busy).
The "status_changed" attribute gets set to True everytime the
The "status_changed" attribute gets set to True every time the
"status" attribute changes its value. After you get the value of
the "status_changed" attribute, it is always reset to False:
@ -588,7 +588,7 @@ class DownloadTask(object):
_('Finished'), _('Failed'), _('Cancelling'), _('Cancelled'), _('Pausing'), _('Paused'))
(NEW, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLING, CANCELLED, PAUSING, PAUSED) = list(range(9))
# Wheter this task represents a file download or a device sync operation
# Whether this task represents a file download or a device sync operation
ACTIVITY_DOWNLOAD, ACTIVITY_SYNCHRONIZE = list(range(2))
# Minimum time between progress updates (in seconds)

View File

@ -74,7 +74,7 @@ class GtkBuilderWidget(object):
Convert widget names to attributes of this object.
It means a widget named vbox-dialog in GtkBuilder
is refered using self.vbox_dialog in the code.
is referred using self.vbox_dialog in the code.
"""
for widget in self.builder.get_objects():
# Just to be safe - every widget from the builder is buildable
@ -100,7 +100,7 @@ class GtkBuilderWidget(object):
def new(self):
"""
Method called when the user interface is loaded and ready to be used.
At this moment, the widgets are loaded and can be refered as self.widget_name
At this moment, the widgets are loaded and can be referred as self.widget_name
"""
def main(self):
@ -132,7 +132,7 @@ class GtkBuilderWidget(object):
"""
Starts the main loop of processing events checking for Control-C.
The default implementation checks wheter a Control-C is pressed,
The default implementation checks whether a Control-C is pressed,
then calls on_keyboard_interrupt().
Use this method for starting programs.

View File

@ -192,8 +192,10 @@ class UIConfig(config.Config):
# positions (instead using a user-defined placement algorithm) and honor
# requests after the window has already been shown.
# Move it a second time after the window has been shown.
# The first move reduces chance of window jumping.
util.idle_add(window.move, cfg.x, cfg.y)
# The first move reduces chance of window jumping,
# and gives the window manager a position to unmaximize to.
if not cfg.maximized:
util.idle_add(window.move, cfg.x, cfg.y)
# Ignore events while we're connecting to the window
self.__ignore_window_events = True
@ -201,14 +203,18 @@ class UIConfig(config.Config):
# Get window state, correct size comes from window.get_size(),
# see https://developer.gnome.org/SaveWindowState/
def _receive_configure_event(widget, event):
x_pos, y_pos = widget.get_position()
width_size, height_size = widget.get_size()
maximized = bool(event.window.get_state() & Gdk.WindowState.MAXIMIZED)
if not self.__ignore_window_events and not maximized:
cfg.x = x_pos
cfg.y = y_pos
cfg.width = width_size
cfg.height = height_size
if not self.__ignore_window_events:
# TODO: The maximize event might arrive after the configure event.
# This causes the maximized size to be saved, and restoring the
# window will not save its smaller size. Delaying the save with
# idle_add() is not enough time for the state event to arrive.
if not bool(event.window.get_state() & Gdk.WindowState.MAXIMIZED):
x_pos, y_pos = widget.get_position()
width_size, height_size = widget.get_size()
cfg.x = x_pos
cfg.y = y_pos
cfg.width = width_size
cfg.height = height_size
window.connect('configure-event', _receive_configure_event)

View File

@ -291,12 +291,15 @@ class gPodderPodcastDirectory(BuilderWidget):
@self.obtain_podcasts_with
def load_data():
if self.current_provider.kind == directory.Provider.PROVIDER_SEARCH:
return self.current_provider.on_search(query)
elif self.current_provider.kind == directory.Provider.PROVIDER_URL:
return self.current_provider.on_url(query)
elif self.current_provider.kind == directory.Provider.PROVIDER_FILE:
return self.current_provider.on_file(query)
try:
if self.current_provider.kind == directory.Provider.PROVIDER_SEARCH:
return self.current_provider.on_search(query)
elif self.current_provider.kind == directory.Provider.PROVIDER_URL:
return self.current_provider.on_url(query)
elif self.current_provider.kind == directory.Provider.PROVIDER_FILE:
return self.current_provider.on_file(query)
except Exception as e:
logger.warning('Got exception while loading podcasts: %s', e)
def on_can_subscribe_changed(self, can_subscribe):
self.btnOK.set_sensitive(can_subscribe)

View File

@ -77,7 +77,7 @@ def draw_rounded_rectangle(ctx, x, y, w, h, r=10, left_side_width=None,
def rounded_rectangle(ctx, x, y, width, height, radius=4.):
"""Simple rounded rectangle algorithmn
"""Simple rounded rectangle algorithm
http://www.cairographics.org/samples/rounded_rectangle/
"""
@ -126,7 +126,7 @@ def draw_text_box_centered(ctx, widget, w_width, w_height, text, font_desc=None,
def draw_cake(percentage, text=None, emblem=None, size=None):
# Download percentage bar icon - it turns out the cake is a lie (d'oh!)
# ..but the inital idea was to have a cake-style indicator, but that
# ..but the initial idea was to have a cake-style indicator, but that
# didn't work as well as the progress bar, but the name stuck..
if size is None:

View File

@ -154,7 +154,7 @@ class ProgressIndicator(object):
self.tick_counter += 1
if time.time() >= self.next_update or (final and self.dialog):
if type(final) == str:
if isinstance(final, str):
self.on_message(final)
self.on_progress(1.0)
elif self.max_ticks is not None:

View File

@ -242,6 +242,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.create_actions()
self.releasecell = None
# Init the treeviews that we use
self.init_podcast_list_treeview()
self.init_episode_list_treeview()
@ -431,6 +433,17 @@ class gPodder(BuilderWidget, dbus.service.Object):
action.connect('activate', self.on_item_view_show_episode_description_toggled)
g.add_action(action)
action = Gio.SimpleAction.new_stateful(
'viewShowEpisodeReleasedTime', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.show_released_time))
action.connect('activate', self.on_item_view_show_episode_released_time_toggled)
g.add_action(action)
action = Gio.SimpleAction.new_stateful(
'viewRightAlignEpisodeReleasedColumn', None,
GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.right_align_released_column))
action.connect('activate', self.on_item_view_right_align_episode_released_column_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)
@ -541,7 +554,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# NOTE: Not used with popover menus in adaptive version
"""
Update Extras/Extensions menu.
Called at startup and when en/dis-abling extenstions.
Called at startup and when en/dis-abling extensions.
"""
def gen_callback(label, callback):
return lambda action, param: callback()
@ -1141,6 +1154,14 @@ class gPodder(BuilderWidget, dbus.service.Object):
return False
def align_releasecell(self):
if self.config.ui.gtk.episode_list.right_align_released_column:
self.releasecell.set_property('xalign', 1)
self.releasecell.set_property('alignment', Pango.Alignment.RIGHT)
else:
self.releasecell.set_property('xalign', 0)
self.releasecell.set_property('alignment', Pango.Alignment.LEFT)
def init_episode_list_treeview(self):
self.episode_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
@ -1224,23 +1245,43 @@ class gPodder(BuilderWidget, dbus.service.Object):
# timecolumn = Gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
# timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
releasecell = Gtk.CellRendererText()
# releasecolumn = Gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
self.releasecell = Gtk.CellRendererText()
self.align_releasecell()
# releasecolumn = Gtk.TreeViewColumn(_('Released'))
# releasecolumn.pack_start(self.releasecell, True)
# releasecolumn.add_attribute(self.releasecell, 'markup', EpisodeListModel.C_PUBLISHED_TEXT)
# releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
namecolumn.pack_end(releasecell, True)
namecolumn.add_attribute(releasecell, 'text', EpisodeListModel.C_TIME_AND_PUBLISHED_TEXT)
namecolumn.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
namecolumn.set_resizable(True)
namecolumn.set_expand(True)
# sizetimecell = Gtk.CellRendererText()
# sizetimecell.set_property('xalign', 1)
# sizetimecell.set_property('alignment', Pango.Alignment.RIGHT)
# sizetimecolumn = Gtk.TreeViewColumn(_('Size+'))
# sizetimecolumn.pack_start(sizetimecell, True)
# sizetimecolumn.add_attribute(sizetimecell, 'markup', EpisodeListModel.C_FILESIZE_AND_TIME_TEXT)
# sizetimecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE_AND_TIME)
# timesizecell = Gtk.CellRendererText()
# timesizecell.set_property('xalign', 1)
# timesizecell.set_property('alignment', Pango.Alignment.RIGHT)
# timesizecolumn = Gtk.TreeViewColumn(_('Duration+'))
# timesizecolumn.pack_start(timesizecell, True)
# timesizecolumn.add_attribute(timesizecell, 'markup', EpisodeListModel.C_TIME_AND_SIZE)
# timesizecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME_AND_SIZE)
namecolumn.pack_end(self.releasecell, True)
namecolumn.add_attribute(self.releasecell, 'text', EpisodeListModel.C_TIME_AND_PUBLISHED_TEXT)
namecolumn.set_reorderable(True)
self.treeAvailable.append_column(namecolumn)
if not self.config.ui.gtk.state.main_window.episode_column_sort_id:
self.config.ui.gtk.state.main_window.episode_column_sort_id = EpisodeListModel.C_PUBLISHED
# EpisodeListModel.C_PUBLISHED is not available in config.py, set it here on first run
if not self.config.ui.gtk.state.main_window.episode_column_sort_id:
self.config.ui.gtk.state.main_window.episode_column_sort_id = EpisodeListModel.C_PUBLISHED
# for itemcolumn in (sizecolumn, timecolumn, releasecolumn):
# for itemcolumn in (sizecolumn, timecolumn, releasecolumn, sizetimecolumn, timesizecolumn):
# itemcolumn.set_reorderable(True)
# self.treeAvailable.append_column(itemcolumn)
# TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
@ -1256,7 +1297,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# w = w.get_parent()
#
# w.connect('button-release-event', self.on_episode_list_header_clicked)
#
# # Restore column sorting
# if column.get_sort_column_id() == self.config.ui.gtk.state.main_window.episode_column_sort_id:
# self.episode_list_model._sorter.set_sort_column_id(Gtk.TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID,
@ -1751,7 +1792,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
if name == 'ui.gtk.toolbar':
# self.toolbar.set_property('visible', new_value)
pass
elif name in ('ui.gtk.episode_list.descriptions',
elif name in ('ui.gtk.episode_list.show_released_time',
'ui.gtk.episode_list.descriptions',
'ui.gtk.episode_list.trim_title_prefix',
'ui.gtk.episode_list.always_show_new'):
self.update_episode_list_model()
@ -2647,7 +2689,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if episode is None:
logger.info('Invalid episode at path %s', str(path))
continue
except TypeError as te:
except TypeError as e:
logger.error('Invalid episode at path %s', str(path))
continue
@ -2680,7 +2722,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if task is None:
logger.info('Invalid task at path %s', str(path))
continue
except TypeError as te:
except TypeError as e:
logger.error('Invalid task at path %s', str(path))
continue
@ -3160,7 +3202,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
else:
channel._update_error = '?'
nr_update_errors += 1
logger.error('Error: %s', message, exc_info=(e.__class__ not in [
logger.error('Error updating feed: %s: %s', channel.title, message, exc_info=(e.__class__ not in [
gpodder.feedcore.BadRequest,
gpodder.feedcore.AuthenticationRequired,
gpodder.feedcore.Unsubscribe,
@ -3821,6 +3863,18 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.config.ui.gtk.episode_list.descriptions = not state
action.set_state(GLib.Variant.new_boolean(not state))
def on_item_view_show_episode_released_time_toggled(self, action, param):
state = action.get_state()
self.config.ui.gtk.episode_list.show_released_time = not state
action.set_state(GLib.Variant.new_boolean(not state))
def on_item_view_right_align_episode_released_column_toggled(self, action, param):
state = action.get_state()
self.config.ui.gtk.episode_list.right_align_released_column = not state
action.set_state(GLib.Variant.new_boolean(not state))
self.align_releasecell()
self.treeAvailable.queue_draw()
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

View File

@ -128,7 +128,7 @@ class BackgroundUpdate(object):
model.C_URL, episode.url,
model.C_TITLE, episode.title,
model.C_EPISODE, episode,
model.C_PUBLISHED_TEXT, episode.cute_pubdate(),
model.C_PUBLISHED_TEXT, episode.cute_pubdate(show_time=self.model._config_ui_gtk_episode_list_show_released_time),
model.C_PUBLISHED, episode.published,
)
update_fields = model.get_update_fields(episode)
@ -217,11 +217,13 @@ class EpisodeListModel(Gtk.ListStore):
self._config_ui_gtk_episode_list_always_show_new = False
self._config_ui_gtk_episode_list_trim_title_prefix = False
self._config_ui_gtk_episode_list_descriptions = False
self._config_ui_gtk_episode_list_show_released_time = False
def cache_config(self, config):
self._config_ui_gtk_episode_list_always_show_new = config.ui.gtk.episode_list.always_show_new
self._config_ui_gtk_episode_list_trim_title_prefix = config.ui.gtk.episode_list.trim_title_prefix
self._config_ui_gtk_episode_list_descriptions = config.ui.gtk.episode_list.descriptions
self._config_ui_gtk_episode_list_show_released_time = config.ui.gtk.episode_list.show_released_time
def _format_filesize(self, episode):
if episode.file_size > 0:

View File

@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import datetime
import html
import logging
from urllib.parse import urlparse
@ -226,7 +227,8 @@ class gPodderShownotesText(gPodderShownotes):
heading = episode.title
subheading = _('from %s') % (episode.channel.title)
details = self.details_fmt % {
'date': util.format_date(episode.published),
'date': '{} {}'.format(datetime.datetime.fromtimestamp(episode.published).strftime('%H:%M'),
util.format_date(episode.published)),
'size': util.format_filesize(episode.file_size, digits=1)
if episode.file_size > 0 else "-",
'duration': episode.get_play_info_string()}
@ -371,7 +373,8 @@ class gPodderShownotesHTML(gPodderShownotes):
heading = '<h3>%s</h3>' % html.escape(episode.title)
subheading = _('from %s') % html.escape(episode.channel.title)
details = '<small>%s</small>' % html.escape(self.details_fmt % {
'date': util.format_date(episode.published),
'date': '{} {}'.format(datetime.datetime.fromtimestamp(episode.published).strftime('%H:%M'),
util.format_date(episode.published)),
'size': util.format_filesize(episode.file_size, digits=1)
if episode.file_size > 0 else "-",
'duration': episode.get_play_info_string()})

View File

@ -161,7 +161,7 @@ class JsonConfig(object):
work_queue.append((data[key], value))
elif type(value) != type(data[key]): # noqa
# Type mismatch of current value and default
if type(value) == int and type(data[key]) == float:
if isinstance(value, int) and isinstance(data[key], float):
# Convert float to int if default value is int
data[key] = int(data[key])

View File

@ -842,11 +842,18 @@ class PodcastEpisode(PodcastModelObject):
self.title,
self.cute_pubdate())
def cute_pubdate(self):
def cute_pubdate(self, show_time=False):
result = util.format_date(self.published)
if result is None:
return '(%s)' % _('unknown')
else:
try:
if show_time:
timestamp = datetime.datetime.fromtimestamp(self.published)
return '<small>{}</small>\n{}'.format(timestamp.strftime('%H:%M'), result)
else:
return result
except:
return result
pubdate_prop = property(fget=cute_pubdate)
@ -1276,7 +1283,7 @@ class PodcastChannel(PodcastModelObject):
# This *might* cause episodes to be skipped if there were more than
# limit.episodes items added to the feed between updates.
# The benefit is that it prevents old episodes from apearing as new
# The benefit is that it prevents old episodes from appearing as new
# in certain situations (see bug #340).
self.db.purge(max_episodes, self.id) # TODO: Remove from self.children!

View File

@ -101,6 +101,11 @@ class Matcher(object):
return episode.file_type() == k
elif k == 'torrent':
return episode.url.endswith('.torrent') or 'torrent' in episode.mime_type
elif k == 'paused':
return (episode.download_task is not None
and episode.download_task.status in (episode.download_task.PAUSED, episode.download_task.PAUSING))
elif k == 'failed':
return (episode.download_task is not None and episode.download_task.status == episode.download_task.FAILED)
# Nouns (for comparisons)
if k in ('megabytes', 'mb'):

View File

@ -66,7 +66,7 @@ class Resolver(object):
def unregister_instance(self, klass):
logger.debug('Unregistering {} resolver instance: {}'.format(self._name, klass))
self._resolvers = [r for r in self._resolvers if type(r) != klass]
self._resolvers = [r for r in self._resolvers if not isinstance(r, klass)]
def _info(self, resolver):
return '%s from %s' % (resolver.__name__ if hasattr(resolver, '__name__')

View File

@ -113,7 +113,7 @@ def episode_filename_on_device(config, episode):
"""
# get the local file
from_file = episode.local_filename(create=False)
# get the formated base name
# get the formatted base name
filename_base = util.sanitize_filename(episode.sync_filename(
config.device_sync.custom_sync_name_enabled,
config.device_sync.custom_sync_name),
@ -536,9 +536,9 @@ class MP3PlayerDevice(Device):
# Assume same size and don't sync again
pass
if not to_file_exists or from_size != to_size:
logger.info('Copying %s => %s',
os.path.basename(from_file),
to_file.get_uri())
logger.info('Copying %s (%d bytes) => %s (%d bytes)',
os.path.basename(from_file), from_size,
to_file.get_uri(), to_size)
from_file = Gio.File.new_for_path(from_file)
try:
def hookconvert(current_bytes, total_bytes, user_data):
@ -745,6 +745,7 @@ class SyncTask(download.DownloadTask):
self.speed = 0.0
self.progress = 0.0
self.error_message = None
self.custom_downloader = None
# Have we already shown this task in a notification?
self._notification_shown = False
@ -828,7 +829,7 @@ class SyncTask(download.DownloadTask):
if self.status != SyncTask.DOWNLOADING:
return False
# We are synching this file right now
# We are syncing this file right now
self._notification_shown = False
sync_result = SyncTask.DOWNLOADING

View File

@ -294,7 +294,7 @@ def normalize_feed_url(url):
if scheme not in ('http', 'https', 'ftp', 'file'):
return None
# urlunsplit might return "a slighty different, but equivalent URL"
# urlunsplit might return "a slightly different, but equivalent URL"
return urllib.parse.urlunsplit((scheme, netloc, path, query, fragment))
@ -543,10 +543,10 @@ def format_date(timestamp):
yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
try:
timestamp_date = time.localtime(timestamp)[:3]
except ValueError as ve:
except ValueError as e:
logger.warning('Cannot convert timestamp', exc_info=True)
return None
except TypeError as te:
except TypeError as e:
logger.warning('Cannot convert timestamp', exc_info=True)
return None
@ -679,7 +679,7 @@ def remove_html_tags(html):
return result.strip()
class HyperlinkExtracter(object):
class HyperlinkExtractor(object):
def __init__(self):
self.parts = []
self.target_stack = [None]
@ -773,9 +773,9 @@ class HyperlinkExtracter(object):
class ExtractHyperlinkedText(object):
def __call__(self, document):
self.extracter = HyperlinkExtracter()
self.extractor = HyperlinkExtractor()
self.visit(document)
return self.extracter.get_result()
return self.extractor.get_result()
def visit(self, element):
# skip functions generated by html5lib for comments in the HTML
@ -784,42 +784,42 @@ class ExtractHyperlinkedText(object):
NS = '{http://www.w3.org/1999/xhtml}'
tag_name = (element.tag[len(NS):] if element.tag.startswith(NS) else element.tag).lower()
self.extracter.handle_starttag(tag_name, list(element.items()))
self.extractor.handle_starttag(tag_name, list(element.items()))
if element.text is not None:
self.extracter.handle_data(element.text)
self.extractor.handle_data(element.text)
for child in element:
self.visit(child)
if child.tail is not None:
self.extracter.handle_data(child.tail)
self.extractor.handle_data(child.tail)
self.extracter.handle_endtag(tag_name)
self.extractor.handle_endtag(tag_name)
class ExtractHyperlinkedTextHTMLParser(HTMLParser):
def __call__(self, html):
self.extracter = HyperlinkExtracter()
self.extractor = HyperlinkExtractor()
self.target_stack = [None]
self.feed(html)
self.close()
return self.extracter.get_result()
return self.extractor.get_result()
def handle_starttag(self, tag, attrs):
self.extracter.handle_starttag(tag, attrs)
self.extractor.handle_starttag(tag, attrs)
def handle_endtag(self, tag):
self.extracter.handle_endtag(tag)
self.extractor.handle_endtag(tag)
def handle_data(self, data):
self.extracter.handle_data(data)
self.extractor.handle_data(data)
def handle_entityref(self, name):
self.extracter.handle_entityref(name)
self.extractor.handle_entityref(name)
def handle_charref(self, name):
self.extracter.handle_charref(name)
self.extractor.handle_charref(name)
def extract_hyperlinked_text(html):
@ -842,7 +842,7 @@ def extract_hyperlinked_text(html):
def nice_html_description(img, description):
"""
basic html formating + hyperlink highlighting + video thumbnail
basic html formatting + hyperlink highlighting + video thumbnail
"""
description = re.sub(r'''https?://[^\s]+''',
r'''<a href="\g<0>">\g<0></a>''',
@ -1002,7 +1002,7 @@ def filename_from_url(url):
http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
"""
(scheme, netloc, path, para, query, fragid) = urllib.parse.urlparse(url)
(scheme, netloc, path, params, query, fragment) = urllib.parse.urlparse(url)
(filename, extension) = os.path.splitext(
os.path.basename(urllib.parse.unquote(path)))
@ -1278,7 +1278,7 @@ def get_real_url(url):
def find_command(command):
"""
Searches the system's PATH for a specific command that is
executable by the user. Returns the first occurence of an
executable by the user. Returns the first occurrence of an
executable binary in the PATH, or None if the command is
not available.
@ -1536,7 +1536,7 @@ def format_seconds_to_hour_min_sec(seconds):
def http_request(url, method='HEAD'):
(scheme, netloc, path, parms, qry, fragid) = urllib.parse.urlparse(url)
(scheme, netloc, path, params, query, fragment) = urllib.parse.urlparse(url)
if scheme == 'https':
conn = http.client.HTTPSConnection(netloc)
else:
@ -2229,7 +2229,7 @@ class Popen(subprocess.Popen):
def _parse_mimetype_sorted_dictitems(mimetype):
""" python 3.5 unorderd dict compat for doctest. don't use! """
""" python 3.5 unordered dict compat for doctest. don't use! """
r = parse_mimetype(mimetype)
return r[0], r[1], sorted(r[2].items())

View File

@ -347,13 +347,11 @@ def install(domain, localedir):
# initialize Python's gettext interface
gettext.bindtextdomain(domain, localedir)
gettext.bind_textdomain_codeset(domain, 'UTF-8')
# on windows systems, initialize libintl
if sys.platform == 'win32' or sys.platform == 'nt':
from ctypes import cdll
libintl = cdll.LoadLibrary('libintl-8.dll')
libintl.bindtextdomain(domain.encode('mbcs'), localedir.encode('mbcs'))
libintl.bind_textdomain_codeset(domain.encode('mbcs'), 'UTF-8'.encode('mbcs'))
del libintl

View File

@ -440,7 +440,7 @@ def get_channel_id_url(url, feed_data=None):
else:
r = feed_data
# video page may contain corrupt HTML/XML, search for tag to avoid exception
m = re.search(r'<meta itemprop="channelId" content="([^"]+)">', r.text)
m = re.search(r'channel_id=([^"]+)">', r.text)
if m:
channel_id = m.group(1)
else:

View File

@ -83,11 +83,11 @@ def checksums():
m = hashlib.md5()
s = hashlib.sha256()
with open(archive, "rb") as f:
bloc = f.read(4096)
while bloc:
m.update(bloc)
s.update(bloc)
bloc = f.read(4096)
block = f.read(4096)
while block:
m.update(block)
s.update(block)
block = f.read(4096)
ret[os.path.basename(archive)] = dict(md5=m.hexdigest(), sha256=s.hexdigest())
return ret

View File

@ -1,5 +1,5 @@
<?xml version="1.0" ?>
<!-- this stylesheet ajusts menu item accelerators:
<!-- this stylesheet adjusts menu item accelerators:
- Command-, for preferences
- Command-? for user manual

View File

@ -110,7 +110,7 @@ print('System Path:\n', '\n'.join(sys.path))
# see https://gpodder.github.io/docs/user-manual.html#gpodder-home-folder-and-download-location
# To override gPodder home and/or download directory:
# 1. uncomment (remove the pound sign and space) at the begining of the relevant line
# 1. uncomment (remove the pound sign and space) at the beginning of the relevant line
# 2. replace ~/gPodderData or ~/gPodderDownloads with the path you want for your gPodder home
# (you can move the original folder in the Finder first,
# then drag and drop to the launcher.py in TextEdit to ensure the correct path is set)
@ -125,7 +125,7 @@ for k, v in os.environ.items():
def gpodder_home():
# don't inadvertently create the new gPodder home,
# it would be prefered to the old one
# it would be preferred to the old one
default_path = join(os.environ['HOME'], 'Library', 'Application Support', 'gPodder')
cands = [
os.environ.get('GPODDER_HOME'),

View File

@ -67,7 +67,7 @@ cp -a "$checkout"/tools/mac-osx/make_cert_pem.py "$resources"/bin
# install gPodder hard dependencies
$run_pip install setuptools==64.0.3 wheel || exit 1
$run_pip install mygpoclient==1.9 podcastparser==0.6.9 requests[socks]==2.28.1 || exit 1
$run_pip install mygpoclient==1.9 podcastparser==0.6.10 requests[socks]==2.31.0 || exit 1
# install brotli and pycryptodomex (build from source)
$run_pip debug -v
$run_pip install -v brotli || exit 1

View File

@ -5,9 +5,9 @@ dbus-python
html5lib==1.1
mutagen==1.46.0
mygpoclient==1.9
podcastparser==0.6.9
requests[socks]==2.28.1
urllib3==1.26.13
podcastparser==0.6.10
requests[socks]==2.31.0
urllib3==2.0.4
yt-dlp
# eyed3 is optional and pulls in a lot of dependencies, so disable by default
# eyed3

View File

@ -84,18 +84,18 @@ function extract_installer {
}
PIP_REQUIREMENTS="\
certifi==2022.12.7
certifi==2023.7.22
chardet==5.1.0
comtypes==1.1.14
comtypes==1.2.0
git+https://github.com/jaraco/pywin32-ctypes.git@f27d6a0
html5lib==1.1
idna==3.4
mutagen==1.46.0
mygpoclient==1.9
podcastparser==0.6.9
podcastparser==0.6.10
PySocks==1.7.1
requests==2.28.1
urllib3==1.26.13
requests==2.31.0
urllib3==2.0.4
webencodings==0.5.1
yt-dlp
"

View File

@ -199,7 +199,7 @@ Function custom_gui_init
; uninstall failed
Abort
rm_instdir:
; either the uninstaller was successfull or
; either the uninstaller was successful or
; the uninstaller.exe wasn't found
RMDir /r "$INSTDIR"
do_continue: