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: jobs:
release-from-macos: release-from-macos:
macos: macos:
xcode: "13.2.1" xcode: "13.4.1"
shell: /bin/bash --login -o pipefail shell: /bin/bash --login -o pipefail
environment: environment:
- BUNDLE_TAG: 22.8.27 - BUNDLE_TAG: 22.8.27

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ['3.10'] python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -24,7 +24,7 @@ jobs:
run: | run: |
sudo apt-get update -q sudo apt-get update -q
sudo apt-get install intltool desktop-file-utils 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 pip3 install podcastparser mygpoclient
- name: Lint - name: Lint
run: make lint run: make lint

View File

@ -3,7 +3,7 @@
## Getting started <!-- omit in toc --> ## Getting started <!-- omit in toc -->
Before you begin: 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) - 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. 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 ## 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 - [Podcastparser](http://gpodder.org/podcastparser/) 0.6.0 or newer
- [mygpoclient](http://gpodder.org/mygpoclient/) 1.7 or newer - [mygpoclient](http://gpodder.org/mygpoclient/) 1.7 or newer
- [requests](https://requests.readthedocs.io) 2.24.0 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` [*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: 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. 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. It depends on your version of python.

View File

@ -69,8 +69,12 @@ unittest:
ISORTOPTS := -c share src/gpodder tools bin/* *.py ISORTOPTS := -c share src/gpodder tools bin/* *.py
lint: lint:
pycodestyle --version
pycodestyle share src/gpodder tools bin/* *.py pycodestyle share src/gpodder tools bin/* *.py
isort --version
isort -q $(ISORTOPTS) || isort --df $(ISORTOPTS) isort -q $(ISORTOPTS) || isort --df $(ISORTOPTS)
codespell --quiet-level 3 --skip "./.git,*.po,./share/applications/gpodder.desktop"
release: distclean release: distclean
$(PYTHON) setup.py sdist $(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) 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 # Most of our custom commands work either way, but this makes
# it work in all cases. # it work in all cases.
os.chdir(os.path.dirname(os.path.realpath(__file__))) 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 # 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. # on_episode_downloaded(), which runs whenever a download finishes.
# #
# Otherwise that process will inherit ALL file descriptors gPodder # Otherwise that process will inherit ALL file descriptors gPodder

View File

@ -242,7 +242,7 @@ RESUMERS = [
# with https://github.com/Serranya/deadbeef-mpris2-plugin # with https://github.com/Serranya/deadbeef-mpris2-plugin
MPRISResumer('resume in deadbeef', 'DeaDBeeF', ['deadbeef'], 'org.mpris.MediaPlayer2.DeaDBeeF'), 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'), MPRISResumer('resume in gmusicbrowser', 'gmusicbrowser', ['gmusicbrowser'], 'org.mpris.MediaPlayer2.gmusicbrowser'),
# Audacious doesn't implement MPRIS2.OpenUri # Audacious doesn't implement MPRIS2.OpenUri

View File

@ -100,7 +100,7 @@ try {{
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast) [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force # Delete this script temp file. Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force # Delete this script temp file.
}} else {{ }} 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") [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
$o = New-Object System.Windows.Forms.NotifyIcon $o = New-Object System.Windows.Forms.NotifyIcon

View File

@ -39,7 +39,7 @@ class gPodderExtension:
device_folder = device.get_episode_folder_on_device(episode) device_folder = device.get_episode_folder_on_device(episode)
episode_art = os.path.join(episode_folder, "folder.jpg") episode_art = os.path.join(episode_folder, "folder.jpg")
device_art = os.path.join(device_folder, self.config.art_name_on_device) 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): if os.path.isfile(episode_art) and not os.path.isfile(device_art):
logger.info('Syncing cover art for %s', episode.channel.title) logger.info('Syncing cover art for %s', episode.channel.title)
# copy and rename art # copy and rename art

View File

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

View File

@ -9,11 +9,12 @@ import os
import re import re
import sys import sys
import time import time
from collections.abc import Iterable
try: try:
import yt_dlp as youtube_dl import yt_dlp as youtube_dl
program_name = 'yt-dlp' program_name = 'yt-dlp'
want_ytdl_version = '2023.02.17' want_ytdl_version = '2023.06.22'
except: except:
import youtube_dl import youtube_dl
program_name = '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.') 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 = { 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 # Set to False to fall back to the fast but limited (only 15 episodes) gpodder code
'manage_channel': True, 'manage_channel': True,
# If for some reason youtube-dl download doesn't work for you, you can fallback to gpodder code. # 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('%', '%%') outtmpl = tempname.replace('%', '%%')
info, opts = self._ytdl.fetch_info(self._url, outtmpl, self._my_hook) info, opts = self._ytdl.fetch_info(self._url, outtmpl, self._my_hook)
if program_name == 'yt-dlp': 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 self.partial_filename = os.path.join(opts['paths']['home'], default) % info
elif program_name == 'youtube-dl': elif program_name == 'youtube-dl':
self.partial_filename = opts['outtmpl'] % info self.partial_filename = opts['outtmpl'] % info
@ -296,9 +297,14 @@ class gPodderYoutubeDL(download.CustomDownloader):
os.makedirs(cachedir, exist_ok=True) os.makedirs(cachedir, exist_ok=True)
self._ydl_opts = { self._ydl_opts = {
'cachedir': cachedir, 'cachedir': cachedir,
'no_color': True, # prevent escape codes in desktop notifications on errors
'noprogress': True, # prevent progress bar from appearing in console '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: if gpodder.verbose:
self._ydl_opts['verbose'] = True self._ydl_opts['verbose'] = True
else: else:
@ -306,7 +312,7 @@ class gPodderYoutubeDL(download.CustomDownloader):
# Don't create downloaders for URLs supported by these youtube-dl extractors # Don't create downloaders for URLs supported by these youtube-dl extractors
self.ie_blacklist = ["Generic"] self.ie_blacklist = ["Generic"]
# Cache URL regexes from youtube-dl matches here, seed with youtube regex # 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 # #686 on windows without a console, sys.stdout is None, causing exceptions
# when adding podcasts. # when adding podcasts.
# See https://docs.python.org/3/library/sys.html#sys.__stderr__ Note # 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. 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 # Duplicate a bit of the YoutubeDL machinery here because we only
# want to parse the channel/playlist first, not to fetch video entries. # 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): def is_supported_url(self, url):
if url is None: if url is None:
return False return False
if self.regex_cache[0].match(url) is not None: for i, res in enumerate(self.regex_cache):
return True if next(filter(None, (r.match(url) for r in res)), None) is not None:
for r in self.regex_cache[1:]: if i > 0:
if r.match(url) is not None: self.regex_cache.remove(res)
self.regex_cache.remove(r) self.regex_cache.insert(0, res)
self.regex_cache.insert(0, r)
return True return True
with youtube_dl.YoutubeDL(self._ydl_opts) as ydl: with youtube_dl.YoutubeDL(self._ydl_opts) as ydl:
# youtube-dl returns a list, yt-dlp returns a dict # youtube-dl returns a list, yt-dlp returns a dict
ies = ydl._ies ies = ydl._ies
if type(ydl._ies) == dict: if isinstance(ydl._ies, dict):
ies = ydl._ies.values() ies = ydl._ies.values()
for ie in ies: for ie in ies:
if ie.suitable(url) and ie.ie_key() not in self.ie_blacklist: 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 True
return False return False
@ -583,7 +590,7 @@ class gPodderExtension:
box.pack_start(Gtk.HSeparator(), False, False, 0) 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.set_active(self.container.config.embed_subtitles)
checkbox.connect('toggled', self.toggle_embed_subtitles) checkbox.connect('toggled', self.toggle_embed_subtitles)
box.pack_start(checkbox, False, False, 0) box.pack_start(checkbox, False, False, 0)

View File

@ -241,6 +241,14 @@
<attribute name="label" translatable="yes">Episode descriptions</attribute> <attribute name="label" translatable="yes">Episode descriptions</attribute>
<attribute name="accel">&lt;Primary&gt;d</attribute> <attribute name="accel">&lt;Primary&gt;d</attribute>
</item> </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> <item>
<attribute name="action">win.viewCtrlClickToSortEpisodes</attribute> <attribute name="action">win.viewCtrlClickToSortEpisodes</attribute>
<attribute name="label" translatable="yes">Require control click to sort episodes</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 .SH NAME
gpo \- Text mode interface of gPodder gpo \- Text mode interface of gPodder
.SH SYNOPSIS .SH SYNOPSIS

View File

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

View File

@ -44,13 +44,34 @@
<content_attribute id="money-purchasing">none</content_attribute> <content_attribute id="money-purchasing">none</content_attribute>
<content_attribute id="money-gambling">none</content_attribute> <content_attribute id="money-gambling">none</content_attribute>
</content_rating> </content_rating>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<caption>The main window</caption> <caption>The main window</caption>
<image>https://raw.githubusercontent.com/flathub/org.gpodder.gpodder/master/screenshot.png</image> <image>https://raw.githubusercontent.com/flathub/org.gpodder.gpodder/master/screenshot.png</image>
</screenshot> </screenshot>
</screenshots> </screenshots>
<releases> <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"> <release version="3.11.0" date="2022-07-30">
<description> <description>
<p>This release contains a year's worth of improvements. Major changes:</p> <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.9" date="2019-06-09"/>
<release version="3.10.8" date="2019-04-08"/> <release version="3.10.8" date="2019-04-08"/>
<release version="3.10.7" date="2019-02-02"/> <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"> <release version="3.10.5" date="2018-09-15">
<description> <description>
<p>This is a bugfix release, shortly after 3.10.4, for the Rename after Download extension.</p> <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.2" date="2018-06-10"/>
<release version="3.10.1" date="2018-02-19"/> <release version="3.10.1" date="2018-02-19"/>
<release version="3.10.0" date="2017-12-29"/> <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> </releases>
</component> </component>

View File

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

View File

@ -171,6 +171,8 @@ defaults = {
'always_show_new': True, 'always_show_new': True,
'trim_title_prefix': True, 'trim_title_prefix': True,
'descriptions': True, 'descriptions': True,
'show_released_time': False,
'right_align_released_column': False,
'ctrl_click_to_sort': False, 'ctrl_click_to_sort': False,
'columns': int('110', 2), # bitfield of visible columns '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): 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']) 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): 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)) return directory_entry_from_mygpo_json('http://gpodder.net/api/2/tag/%s/50.json' % urllib.parse.quote(tag))
def get_tags(self): 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): class SoundcloudSearchProvider(Provider):

View File

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

View File

@ -74,7 +74,7 @@ class GtkBuilderWidget(object):
Convert widget names to attributes of this object. Convert widget names to attributes of this object.
It means a widget named vbox-dialog in GtkBuilder 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(): for widget in self.builder.get_objects():
# Just to be safe - every widget from the builder is buildable # Just to be safe - every widget from the builder is buildable
@ -100,7 +100,7 @@ class GtkBuilderWidget(object):
def new(self): def new(self):
""" """
Method called when the user interface is loaded and ready to be used. 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): def main(self):
@ -132,7 +132,7 @@ class GtkBuilderWidget(object):
""" """
Starts the main loop of processing events checking for Control-C. 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(). then calls on_keyboard_interrupt().
Use this method for starting programs. 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 # positions (instead using a user-defined placement algorithm) and honor
# requests after the window has already been shown. # requests after the window has already been shown.
# Move it a second time after the window has been shown. # Move it a second time after the window has been shown.
# The first move reduces chance of window jumping. # The first move reduces chance of window jumping,
util.idle_add(window.move, cfg.x, cfg.y) # 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 # Ignore events while we're connecting to the window
self.__ignore_window_events = True self.__ignore_window_events = True
@ -201,14 +203,18 @@ class UIConfig(config.Config):
# Get window state, correct size comes from window.get_size(), # Get window state, correct size comes from window.get_size(),
# see https://developer.gnome.org/SaveWindowState/ # see https://developer.gnome.org/SaveWindowState/
def _receive_configure_event(widget, event): def _receive_configure_event(widget, event):
x_pos, y_pos = widget.get_position() if not self.__ignore_window_events:
width_size, height_size = widget.get_size() # TODO: The maximize event might arrive after the configure event.
maximized = bool(event.window.get_state() & Gdk.WindowState.MAXIMIZED) # This causes the maximized size to be saved, and restoring the
if not self.__ignore_window_events and not maximized: # window will not save its smaller size. Delaying the save with
cfg.x = x_pos # idle_add() is not enough time for the state event to arrive.
cfg.y = y_pos if not bool(event.window.get_state() & Gdk.WindowState.MAXIMIZED):
cfg.width = width_size x_pos, y_pos = widget.get_position()
cfg.height = height_size 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) window.connect('configure-event', _receive_configure_event)

View File

@ -291,12 +291,15 @@ class gPodderPodcastDirectory(BuilderWidget):
@self.obtain_podcasts_with @self.obtain_podcasts_with
def load_data(): def load_data():
if self.current_provider.kind == directory.Provider.PROVIDER_SEARCH: try:
return self.current_provider.on_search(query) if self.current_provider.kind == directory.Provider.PROVIDER_SEARCH:
elif self.current_provider.kind == directory.Provider.PROVIDER_URL: return self.current_provider.on_search(query)
return self.current_provider.on_url(query) elif self.current_provider.kind == directory.Provider.PROVIDER_URL:
elif self.current_provider.kind == directory.Provider.PROVIDER_FILE: return self.current_provider.on_url(query)
return self.current_provider.on_file(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): def on_can_subscribe_changed(self, can_subscribe):
self.btnOK.set_sensitive(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.): 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/ 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): def draw_cake(percentage, text=None, emblem=None, size=None):
# Download percentage bar icon - it turns out the cake is a lie (d'oh!) # 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.. # didn't work as well as the progress bar, but the name stuck..
if size is None: if size is None:

View File

@ -154,7 +154,7 @@ class ProgressIndicator(object):
self.tick_counter += 1 self.tick_counter += 1
if time.time() >= self.next_update or (final and self.dialog): 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_message(final)
self.on_progress(1.0) self.on_progress(1.0)
elif self.max_ticks is not None: elif self.max_ticks is not None:

View File

@ -242,6 +242,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.create_actions() self.create_actions()
self.releasecell = None
# Init the treeviews that we use # Init the treeviews that we use
self.init_podcast_list_treeview() self.init_podcast_list_treeview()
self.init_episode_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) action.connect('activate', self.on_item_view_show_episode_description_toggled)
g.add_action(action) 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( action = Gio.SimpleAction.new_stateful(
'viewCtrlClickToSortEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.ctrl_click_to_sort)) '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) 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 # NOTE: Not used with popover menus in adaptive version
""" """
Update Extras/Extensions menu. 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): def gen_callback(label, callback):
return lambda action, param: callback() return lambda action, param: callback()
@ -1141,6 +1154,14 @@ class gPodder(BuilderWidget, dbus.service.Object):
return False 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): def init_episode_list_treeview(self):
self.episode_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode) 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 = Gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
# timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME) # timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
releasecell = Gtk.CellRendererText() self.releasecell = Gtk.CellRendererText()
# releasecolumn = Gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT) 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) # 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) # sizetimecell = Gtk.CellRendererText()
namecolumn.set_resizable(True) # sizetimecell.set_property('xalign', 1)
namecolumn.set_expand(True) # 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) namecolumn.set_reorderable(True)
self.treeAvailable.append_column(namecolumn) 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 # 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: 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 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) # itemcolumn.set_reorderable(True)
# self.treeAvailable.append_column(itemcolumn) # self.treeAvailable.append_column(itemcolumn)
# TreeViewHelper.register_column(self.treeAvailable, itemcolumn) # TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
@ -1256,7 +1297,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# w = w.get_parent() # w = w.get_parent()
# #
# w.connect('button-release-event', self.on_episode_list_header_clicked) # w.connect('button-release-event', self.on_episode_list_header_clicked)
#
# # Restore column sorting # # Restore column sorting
# if column.get_sort_column_id() == self.config.ui.gtk.state.main_window.episode_column_sort_id: # 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, # 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': if name == 'ui.gtk.toolbar':
# self.toolbar.set_property('visible', new_value) # self.toolbar.set_property('visible', new_value)
pass 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.trim_title_prefix',
'ui.gtk.episode_list.always_show_new'): 'ui.gtk.episode_list.always_show_new'):
self.update_episode_list_model() self.update_episode_list_model()
@ -2647,7 +2689,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if episode is None: if episode is None:
logger.info('Invalid episode at path %s', str(path)) logger.info('Invalid episode at path %s', str(path))
continue continue
except TypeError as te: except TypeError as e:
logger.error('Invalid episode at path %s', str(path)) logger.error('Invalid episode at path %s', str(path))
continue continue
@ -2680,7 +2722,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if task is None: if task is None:
logger.info('Invalid task at path %s', str(path)) logger.info('Invalid task at path %s', str(path))
continue continue
except TypeError as te: except TypeError as e:
logger.error('Invalid task at path %s', str(path)) logger.error('Invalid task at path %s', str(path))
continue continue
@ -3160,7 +3202,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
else: else:
channel._update_error = '?' channel._update_error = '?'
nr_update_errors += 1 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.BadRequest,
gpodder.feedcore.AuthenticationRequired, gpodder.feedcore.AuthenticationRequired,
gpodder.feedcore.Unsubscribe, gpodder.feedcore.Unsubscribe,
@ -3821,6 +3863,18 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.config.ui.gtk.episode_list.descriptions = not state self.config.ui.gtk.episode_list.descriptions = not state
action.set_state(GLib.Variant.new_boolean(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): def on_item_view_ctrl_click_to_sort_episodes_toggled(self, action, param):
state = action.get_state() state = action.get_state()
self.config.ui.gtk.episode_list.ctrl_click_to_sort = not 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_URL, episode.url,
model.C_TITLE, episode.title, model.C_TITLE, episode.title,
model.C_EPISODE, episode, 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, model.C_PUBLISHED, episode.published,
) )
update_fields = model.get_update_fields(episode) 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_always_show_new = False
self._config_ui_gtk_episode_list_trim_title_prefix = 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_descriptions = False
self._config_ui_gtk_episode_list_show_released_time = False
def cache_config(self, config): 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_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_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_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): def _format_filesize(self, episode):
if episode.file_size > 0: if episode.file_size > 0:

View File

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

View File

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

View File

@ -842,11 +842,18 @@ class PodcastEpisode(PodcastModelObject):
self.title, self.title,
self.cute_pubdate()) self.cute_pubdate())
def cute_pubdate(self): def cute_pubdate(self, show_time=False):
result = util.format_date(self.published) result = util.format_date(self.published)
if result is None: if result is None:
return '(%s)' % _('unknown') 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 return result
pubdate_prop = property(fget=cute_pubdate) 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 # This *might* cause episodes to be skipped if there were more than
# limit.episodes items added to the feed between updates. # 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). # in certain situations (see bug #340).
self.db.purge(max_episodes, self.id) # TODO: Remove from self.children! 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 return episode.file_type() == k
elif k == 'torrent': elif k == 'torrent':
return episode.url.endswith('.torrent') or 'torrent' in episode.mime_type 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) # Nouns (for comparisons)
if k in ('megabytes', 'mb'): if k in ('megabytes', 'mb'):

View File

@ -66,7 +66,7 @@ class Resolver(object):
def unregister_instance(self, klass): def unregister_instance(self, klass):
logger.debug('Unregistering {} resolver instance: {}'.format(self._name, 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): def _info(self, resolver):
return '%s from %s' % (resolver.__name__ if hasattr(resolver, '__name__') 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 # get the local file
from_file = episode.local_filename(create=False) 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( filename_base = util.sanitize_filename(episode.sync_filename(
config.device_sync.custom_sync_name_enabled, config.device_sync.custom_sync_name_enabled,
config.device_sync.custom_sync_name), config.device_sync.custom_sync_name),
@ -536,9 +536,9 @@ class MP3PlayerDevice(Device):
# Assume same size and don't sync again # Assume same size and don't sync again
pass pass
if not to_file_exists or from_size != to_size: if not to_file_exists or from_size != to_size:
logger.info('Copying %s => %s', logger.info('Copying %s (%d bytes) => %s (%d bytes)',
os.path.basename(from_file), os.path.basename(from_file), from_size,
to_file.get_uri()) to_file.get_uri(), to_size)
from_file = Gio.File.new_for_path(from_file) from_file = Gio.File.new_for_path(from_file)
try: try:
def hookconvert(current_bytes, total_bytes, user_data): def hookconvert(current_bytes, total_bytes, user_data):
@ -745,6 +745,7 @@ class SyncTask(download.DownloadTask):
self.speed = 0.0 self.speed = 0.0
self.progress = 0.0 self.progress = 0.0
self.error_message = None self.error_message = None
self.custom_downloader = None
# Have we already shown this task in a notification? # Have we already shown this task in a notification?
self._notification_shown = False self._notification_shown = False
@ -828,7 +829,7 @@ class SyncTask(download.DownloadTask):
if self.status != SyncTask.DOWNLOADING: if self.status != SyncTask.DOWNLOADING:
return False return False
# We are synching this file right now # We are syncing this file right now
self._notification_shown = False self._notification_shown = False
sync_result = SyncTask.DOWNLOADING sync_result = SyncTask.DOWNLOADING

View File

@ -294,7 +294,7 @@ def normalize_feed_url(url):
if scheme not in ('http', 'https', 'ftp', 'file'): if scheme not in ('http', 'https', 'ftp', 'file'):
return None 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)) 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] yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
try: try:
timestamp_date = time.localtime(timestamp)[:3] timestamp_date = time.localtime(timestamp)[:3]
except ValueError as ve: except ValueError as e:
logger.warning('Cannot convert timestamp', exc_info=True) logger.warning('Cannot convert timestamp', exc_info=True)
return None return None
except TypeError as te: except TypeError as e:
logger.warning('Cannot convert timestamp', exc_info=True) logger.warning('Cannot convert timestamp', exc_info=True)
return None return None
@ -679,7 +679,7 @@ def remove_html_tags(html):
return result.strip() return result.strip()
class HyperlinkExtracter(object): class HyperlinkExtractor(object):
def __init__(self): def __init__(self):
self.parts = [] self.parts = []
self.target_stack = [None] self.target_stack = [None]
@ -773,9 +773,9 @@ class HyperlinkExtracter(object):
class ExtractHyperlinkedText(object): class ExtractHyperlinkedText(object):
def __call__(self, document): def __call__(self, document):
self.extracter = HyperlinkExtracter() self.extractor = HyperlinkExtractor()
self.visit(document) self.visit(document)
return self.extracter.get_result() return self.extractor.get_result()
def visit(self, element): def visit(self, element):
# skip functions generated by html5lib for comments in the HTML # skip functions generated by html5lib for comments in the HTML
@ -784,42 +784,42 @@ class ExtractHyperlinkedText(object):
NS = '{http://www.w3.org/1999/xhtml}' NS = '{http://www.w3.org/1999/xhtml}'
tag_name = (element.tag[len(NS):] if element.tag.startswith(NS) else element.tag).lower() 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: if element.text is not None:
self.extracter.handle_data(element.text) self.extractor.handle_data(element.text)
for child in element: for child in element:
self.visit(child) self.visit(child)
if child.tail is not None: 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): class ExtractHyperlinkedTextHTMLParser(HTMLParser):
def __call__(self, html): def __call__(self, html):
self.extracter = HyperlinkExtracter() self.extractor = HyperlinkExtractor()
self.target_stack = [None] self.target_stack = [None]
self.feed(html) self.feed(html)
self.close() self.close()
return self.extracter.get_result() return self.extractor.get_result()
def handle_starttag(self, tag, attrs): def handle_starttag(self, tag, attrs):
self.extracter.handle_starttag(tag, attrs) self.extractor.handle_starttag(tag, attrs)
def handle_endtag(self, tag): def handle_endtag(self, tag):
self.extracter.handle_endtag(tag) self.extractor.handle_endtag(tag)
def handle_data(self, data): def handle_data(self, data):
self.extracter.handle_data(data) self.extractor.handle_data(data)
def handle_entityref(self, name): def handle_entityref(self, name):
self.extracter.handle_entityref(name) self.extractor.handle_entityref(name)
def handle_charref(self, name): def handle_charref(self, name):
self.extracter.handle_charref(name) self.extractor.handle_charref(name)
def extract_hyperlinked_text(html): def extract_hyperlinked_text(html):
@ -842,7 +842,7 @@ def extract_hyperlinked_text(html):
def nice_html_description(img, description): 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]+''', description = re.sub(r'''https?://[^\s]+''',
r'''<a href="\g<0>">\g<0></a>''', 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://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4") 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( (filename, extension) = os.path.splitext(
os.path.basename(urllib.parse.unquote(path))) os.path.basename(urllib.parse.unquote(path)))
@ -1278,7 +1278,7 @@ def get_real_url(url):
def find_command(command): def find_command(command):
""" """
Searches the system's PATH for a specific command that is 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 executable binary in the PATH, or None if the command is
not available. not available.
@ -1536,7 +1536,7 @@ def format_seconds_to_hour_min_sec(seconds):
def http_request(url, method='HEAD'): 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': if scheme == 'https':
conn = http.client.HTTPSConnection(netloc) conn = http.client.HTTPSConnection(netloc)
else: else:
@ -2229,7 +2229,7 @@ class Popen(subprocess.Popen):
def _parse_mimetype_sorted_dictitems(mimetype): 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) r = parse_mimetype(mimetype)
return r[0], r[1], sorted(r[2].items()) return r[0], r[1], sorted(r[2].items())

View File

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

View File

@ -440,7 +440,7 @@ def get_channel_id_url(url, feed_data=None):
else: else:
r = feed_data r = feed_data
# video page may contain corrupt HTML/XML, search for tag to avoid exception # 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: if m:
channel_id = m.group(1) channel_id = m.group(1)
else: else:

View File

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

View File

@ -1,5 +1,5 @@
<?xml version="1.0" ?> <?xml version="1.0" ?>
<!-- this stylesheet ajusts menu item accelerators: <!-- this stylesheet adjusts menu item accelerators:
- Command-, for preferences - Command-, for preferences
- Command-? for user manual - 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 # see https://gpodder.github.io/docs/user-manual.html#gpodder-home-folder-and-download-location
# To override gPodder home and/or download directory: # 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 # 2. replace ~/gPodderData or ~/gPodderDownloads with the path you want for your gPodder home
# (you can move the original folder in the Finder first, # (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) # 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(): def gpodder_home():
# don't inadvertently create the new 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') default_path = join(os.environ['HOME'], 'Library', 'Application Support', 'gPodder')
cands = [ cands = [
os.environ.get('GPODDER_HOME'), 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 # install gPodder hard dependencies
$run_pip install setuptools==64.0.3 wheel || exit 1 $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) # install brotli and pycryptodomex (build from source)
$run_pip debug -v $run_pip debug -v
$run_pip install -v brotli || exit 1 $run_pip install -v brotli || exit 1

View File

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

View File

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

View File

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