Merge branch 'master' into dev-adaptive

This commit is contained in:
Teemu Ikonen 2022-10-28 21:59:50 +03:00
commit 46dbc80d8b
70 changed files with 12562 additions and 11705 deletions

View File

@ -6,7 +6,7 @@ jobs:
xcode: "13.2.1" xcode: "13.2.1"
shell: /bin/bash --login -o pipefail shell: /bin/bash --login -o pipefail
environment: environment:
- BUNDLE_TAG: 22.7.28 - BUNDLE_TAG: 22.8.27
steps: steps:
- checkout - checkout
- run: > - run: >
@ -15,7 +15,7 @@ jobs:
saved_hash=$(awk '{print $1;}' < "pythonbase-$BUNDLE_TAG.zip.sha256"); saved_hash=$(awk '{print $1;}' < "pythonbase-$BUNDLE_TAG.zip.sha256");
comp_hash=$(openssl sha256 "pythonbase-$BUNDLE_TAG.zip" | awk '{print $2;}'); comp_hash=$(openssl sha256 "pythonbase-$BUNDLE_TAG.zip" | awk '{print $2;}');
if [ "$saved_hash" != "$comp_hash" ]; then echo "E: $saved_hash != $comp_hash"; exit 1; else echo "valid hash"; fi; if [ "$saved_hash" != "$comp_hash" ]; then echo "E: $saved_hash != $comp_hash"; exit 1; else echo "valid hash"; fi;
LC_CTYPE=C.UTF-8 LANG=C.UTF-8 tools/mac-osx/release_on_mac.sh "$(pwd)/pythonbase-$BUNDLE_TAG.zip"; LC_CTYPE=C.UTF-8 LANG=C.UTF-8 tools/mac-osx/release_on_mac.sh "$(pwd)/pythonbase-$BUNDLE_TAG.zip" || exit 1;
rm -Rf tools/mac-osx/_build/{gPodder.app,*.deps.zip*,gPodder.contents,run-*,gpo,gpodder-migrate2tres} rm -Rf tools/mac-osx/_build/{gPodder.app,*.deps.zip*,gPodder.contents,run-*,gpo,gpodder-migrate2tres}
- store_artifacts: - store_artifacts:
path: tools/mac-osx/_build/ path: tools/mac-osx/_build/

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ['3.10']
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

20
bin/gpo
View File

@ -88,6 +88,7 @@ import pydoc
import re import re
import shlex import shlex
import sys import sys
import textwrap
import threading import threading
try: try:
@ -438,10 +439,20 @@ class gPodderCli(object):
if podcast is None: if podcast is None:
self._error(_('You are not subscribed to %s.') % url) self._error(_('You are not subscribed to %s.') % url)
else: else:
# Clean up downloads and download directories
common.clean_up_downloads()
podcast.delete() podcast.delete()
self._db.commit() self._db.commit()
self._error(_('Unsubscribed from %s.') % url) self._error(_('Unsubscribed from %s.') % url)
# Delete downloaded episodes
podcast.remove_downloaded()
# TODO: subscribe and unsubscribe need to sync with mygpo
# Upload subscription list changes to the web service
# self.mygpo_client.on_unsubscribe([podcast.url])
return True return True
def is_episode_new(self, episode): def is_episode_new(self, episode):
@ -481,13 +492,18 @@ class gPodderCli(object):
return "disabled" return "disabled"
return "enabled" return "enabled"
title, url, status = podcast.title, podcast.url, \ title, url, description, link, status = (
feed_update_status_msg(podcast) podcast.title, podcast.url, podcast.description, podcast.link,
feed_update_status_msg(podcast))
description = '\n'.join(textwrap.wrap(description, subsequent_indent=' ' * 8))
episodes = self._episodesList(podcast) episodes = self._episodesList(podcast)
episodes = '\n '.join(episodes) episodes = '\n '.join(episodes)
self._pager(""" self._pager("""
Title: %(title)s Title: %(title)s
URL: %(url)s URL: %(url)s
Description:
%(description)s
Link: %(link)s
Feed update is %(status)s Feed update is %(status)s
Episodes: Episodes:

View File

@ -38,8 +38,8 @@ UIFILES=$(wildcard share/gpodder/ui/gtk/*.ui \
share/gpodder/ui/adaptive/*.ui) share/gpodder/ui/adaptive/*.ui)
UIFILES_H=$(subst .ui,.ui.h,$(UIFILES)) UIFILES_H=$(subst .ui,.ui.h,$(UIFILES))
GETTEXT_SOURCE=$(wildcard src/gpodder/*.py \ GETTEXT_SOURCE=$(wildcard src/gpodder/*.py \
src/gpodder/gtkui/*.py \ src/gpodder/gtkui/*.py \
src/gpodder/gtkui/interface/*.py \ src/gpodder/gtkui/interface/*.py \
src/gpodder/gtkui/desktop/*.py \ src/gpodder/gtkui/desktop/*.py \
src/gpodder/plugins/*.py \ src/gpodder/plugins/*.py \
share/gpodder/extensions/*.py) share/gpodder/extensions/*.py)
@ -72,13 +72,12 @@ lint:
pycodestyle share src/gpodder tools bin/* *.py pycodestyle share src/gpodder tools bin/* *.py
isort -q $(ISORTOPTS) || isort --df $(ISORTOPTS) isort -q $(ISORTOPTS) || isort --df $(ISORTOPTS)
release: distclean release: distclean
$(PYTHON) setup.py sdist $(PYTHON) setup.py sdist
releasetest: unittest $(DESKTOP_FILES) $(POFILES) releasetest: unittest $(DESKTOP_FILES) $(POFILES)
for f in $(DESKTOP_FILES); do desktop-file-validate $$f; done for f in $(DESKTOP_FILES); do desktop-file-validate $$f || exit 1; done
for f in $(POFILES); do msgfmt --check $$f; done for f in $(POFILES); do msgfmt --check $$f || exit 1; done
$(GPODDER_SERVICE_FILE): $(GPODDER_SERVICE_FILE_IN) $(GPODDER_SERVICE_FILE): $(GPODDER_SERVICE_FILE_IN)
sed -e 's#__PREFIX__#$(PREFIX)#' $< >$@ sed -e 's#__PREFIX__#$(PREFIX)#' $< >$@
@ -140,7 +139,6 @@ $(MESSAGES): $(GETTEXT_SOURCE)
messages-force: messages-force:
xgettext --from-code=utf-8 -LPython -k_:1 -kN_:1 -kN_:1,2 -kn_:1,2 -o $(MESSAGES) $(GETTEXT_SOURCE) xgettext --from-code=utf-8 -LPython -k_:1 -kN_:1 -kN_:1,2 -kn_:1,2 -o $(MESSAGES) $(GETTEXT_SOURCE)
########################################################################## ##########################################################################
# This only works in a Git working commit, and assumes that the local Git # This only works in a Git working commit, and assumes that the local Git
@ -171,5 +169,3 @@ distclean: clean
.PHONY: help unittest release releasetest install manpages clean distclean messages headlink lint revbump .PHONY: help unittest release releasetest install manpages clean distclean messages headlink lint revbump
########################################################################## ##########################################################################

690
po/ca.po

File diff suppressed because it is too large Load Diff

680
po/cs.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

682
po/da.po

File diff suppressed because it is too large Load Diff

682
po/de.po

File diff suppressed because it is too large Load Diff

682
po/el.po

File diff suppressed because it is too large Load Diff

682
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

682
po/eu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

682
po/fi.po

File diff suppressed because it is too large Load Diff

1004
po/fr.po

File diff suppressed because it is too large Load Diff

682
po/gl.po

File diff suppressed because it is too large Load Diff

682
po/he.po

File diff suppressed because it is too large Load Diff

682
po/hu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

682
po/it.po

File diff suppressed because it is too large Load Diff

682
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

686
po/nb.po

File diff suppressed because it is too large Load Diff

680
po/nl.po

File diff suppressed because it is too large Load Diff

684
po/nn.po

File diff suppressed because it is too large Load Diff

682
po/pl.po

File diff suppressed because it is too large Load Diff

682
po/pt.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

696
po/ro.po

File diff suppressed because it is too large Load Diff

708
po/ru.po

File diff suppressed because it is too large Load Diff

680
po/sk.po

File diff suppressed because it is too large Load Diff

682
po/sv.po

File diff suppressed because it is too large Load Diff

682
po/tr.po

File diff suppressed because it is too large Load Diff

696
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

@ -1,4 +1,4 @@
#!/usr/bin/python3 #!/usr/bin/env python3
# Example script that can be used as post-play extension in media players # Example script that can be used as post-play extension in media players
# #
# Set the configuration options "audio_played_dbus" and "video_played_dbus" # Set the configuration options "audio_played_dbus" and "video_played_dbus"

View File

@ -75,7 +75,9 @@ class gPodderExtension:
close_fds=True) close_fds=True)
result = ffmpeg.wait() result = ffmpeg.wait()
util.delete_file(list_filename) util.delete_file(list_filename)
util.idle_add(lambda: indicator.on_finished())
indicator.on_finished()
util.idle_add(lambda: self.gpodder.show_message( util.idle_add(lambda: self.gpodder.show_message(
_('Videos successfully converted') if result == 0 else _('Videos successfully converted') if result == 0 else
_('Error converting videos'), _('Error converting videos'),

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#### ####
# 01/2011 Bernd Schlapsi <brot@gmx.info> # 01/2011 Bernd Schlapsi <brot@gmx.info>

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Requirements: apt-get install python-kaa-metadata ffmpeg python-dbus # Requirements: apt-get install python-kaa-metadata ffmpeg python-dbus
# To use, copy it as a Python script into ~/.config/gpodder/extensions/rockbox_mp4_convert.py # To use, copy it as a Python script into ~/.config/gpodder/extensions/rockbox_mp4_convert.py

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#### ####
# 01/2011 Bernd Schlapsi <brot@gmx.info> # 01/2011 Bernd Schlapsi <brot@gmx.info>

View File

@ -13,7 +13,7 @@ from gpodder import util
import gi # isort:skip import gi # isort:skip
gi.require_version('Unity', '7.0') # isort:skip gi.require_version('Unity', '7.0') # isort:skip
from gi.repository import GObject, Unity # isort:skip from gi.repository import GLib, Unity # isort:skip
_ = gpodder.gettext _ = gpodder.gettext
@ -59,4 +59,4 @@ class gPodderExtension:
self.launcher_entry = None self.launcher_entry = None
def on_download_progress(self, progress): def on_download_progress(self, progress):
GObject.idle_add(self.launcher_entry.set_progress, float(progress)) GLib.idle_add(self.launcher_entry.set_progress, float(progress))

View File

@ -12,8 +12,10 @@ import time
try: try:
import yt_dlp as youtube_dl import yt_dlp as youtube_dl
program_name = 'yt-dlp'
except: except:
import youtube_dl import youtube_dl
program_name = 'youtube-dl'
import gpodder import gpodder
from gpodder import download, feedcore, model, registry, util, youtube from gpodder import download, feedcore, model, registry, util, youtube
@ -426,6 +428,14 @@ class gPodderYoutubeDL(download.CustomDownloader):
""" """
if not self.force and not self.my_config.manage_downloads: if not self.force and not self.my_config.manage_downloads:
return None return None
try: # Reject URLs linking to known media files
(_, ext) = util.filename_from_url(episode.url)
if util.file_type_by_extension(ext) is not None:
return None
except Exception:
pass
if self.is_supported_url(episode.url): if self.is_supported_url(episode.url):
return YoutubeCustomDownload(self, episode.url, episode) return YoutubeCustomDownload(self, episode.url, episode)
@ -439,12 +449,10 @@ class gPodderExtension:
def on_load(self): def on_load(self):
self.ytdl = gPodderYoutubeDL(self.container.manager.core.config, self.container.config) self.ytdl = gPodderYoutubeDL(self.container.manager.core.config, self.container.config)
logger.info('Registering youtube-dl.') logger.info('Registering youtube-dl. (using %s %s)' % (program_name, youtube_dl.version.__version__))
registry.feed_handler.register(self.ytdl.fetch_channel) registry.feed_handler.register(self.ytdl.fetch_channel)
registry.custom_downloader.register(self.ytdl.custom_downloader) registry.custom_downloader.register(self.ytdl.custom_downloader)
logger.debug('youtube-dl %s' % youtube_dl.version.__version__)
if youtube_dl.utils.version_tuple(youtube_dl.version.__version__) < youtube_dl.utils.version_tuple(want_ytdl_version): if youtube_dl.utils.version_tuple(youtube_dl.version.__version__) < youtube_dl.utils.version_tuple(want_ytdl_version):
logger.error(want_ytdl_version_msg logger.error(want_ytdl_version_msg
% {'have_version': youtube_dl.version.__version__, 'want_version': want_ytdl_version}) % {'have_version': youtube_dl.version.__version__, 'want_version': want_ytdl_version})

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 --> <!-- Generated with glade 3.40.0 -->
<!--*- mode: xml -*--> <!--*- mode: xml -*-->
<interface> <interface>
<requires lib="gtk+" version="3.16"/> <requires lib="gtk+" version="3.16"/>
@ -36,7 +36,7 @@
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="is-important">True</property> <property name="is-important">True</property>
<property name="label" translatable="yes">Play</property> <property name="label" translatable="yes">Play</property>
<property name="icon-name">media-playback-start</property> <property name="icon-name">media-playback-start-symbolic</property>
<signal name="clicked" handler="on_playback_selected_episodes" swapped="no"/> <signal name="clicked" handler="on_playback_selected_episodes" swapped="no"/>
</object> </object>
<packing> <packing>
@ -51,7 +51,7 @@
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="is-important">True</property> <property name="is-important">True</property>
<property name="label" translatable="yes">Download</property> <property name="label" translatable="yes">Download</property>
<property name="icon-name">go-down</property> <property name="icon-name">document-save-symbolic</property>
<signal name="clicked" handler="on_download_selected_episodes" swapped="no"/> <signal name="clicked" handler="on_download_selected_episodes" swapped="no"/>
</object> </object>
<packing> <packing>
@ -66,7 +66,7 @@
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="is-important">True</property> <property name="is-important">True</property>
<property name="label" translatable="yes">Pause</property> <property name="label" translatable="yes">Pause</property>
<property name="icon-name">media-playback-pause</property> <property name="icon-name">media-playback-pause-symbolic</property>
<signal name="clicked" handler="on_pause_selected_episodes" swapped="no"/> <signal name="clicked" handler="on_pause_selected_episodes" swapped="no"/>
</object> </object>
<packing> <packing>
@ -81,7 +81,7 @@
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="is-important">True</property> <property name="is-important">True</property>
<property name="label" translatable="yes">Cancel</property> <property name="label" translatable="yes">Cancel</property>
<property name="icon-name">process-stop</property> <property name="icon-name">process-stop-symbolic</property>
<signal name="clicked" handler="on_item_cancel_download_activate" swapped="no"/> <signal name="clicked" handler="on_item_cancel_download_activate" swapped="no"/>
</object> </object>
<packing> <packing>
@ -105,7 +105,7 @@
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="action-name">app.preferences</property> <property name="action-name">app.preferences</property>
<property name="label" translatable="yes">Preferences</property> <property name="label" translatable="yes">Preferences</property>
<property name="icon-name">preferences-desktop</property> <property name="icon-name">preferences-other-symbolic</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
@ -127,7 +127,7 @@
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="label" translatable="yes">Quit</property> <property name="label" translatable="yes">Quit</property>
<property name="icon-name">application-exit</property> <property name="icon-name">application-exit-symbolic</property>
<signal name="clicked" handler="on_gPodder_delete_event" swapped="no"/> <signal name="clicked" handler="on_gPodder_delete_event" swapped="no"/>
</object> </object>
<packing> <packing>
@ -384,17 +384,85 @@
</packing> </packing>
</child> </child>
<child> <child>
<!-- n-columns=1 n-rows=2 --> <object class="GtkBox">
<object class="GtkGrid" id="vboxDownloadStatusWidgets">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="border-width">5</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="row-spacing">5</property>
<child> <child>
<object class="GtkScrolledWindow" id="scrolledwindow1"> <object class="GtkInfoBar" id="resume_all_infobar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="message-type">question</property>
<property name="show-close-button">True</property>
<property name="revealed">False</property>
<signal name="response" handler="on_resume_all_infobar_response" swapped="no"/>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout-style">end</property>
<child>
<object class="GtkButton" id="resume_all_button">
<property name="label" translatable="yes">Resume all</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child internal-child="content_area">
<object class="GtkBox">
<property name="can-focus">False</property>
<property name="spacing">16</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Incomplete downloads from a previous session were found.</property>
<property name="wrap">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<action-widgets>
<action-widget response="-5">resume_all_button</action-widget>
</action-widgets>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="vexpand">True</property> <property name="vexpand">True</property>
<property name="shadow-type">in</property> <property name="shadow-type">in</property>
<child> <child>
@ -415,23 +483,26 @@
</child> </child>
</object> </object>
<packing> <packing>
<property name="left-attach">0</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">1</property>
</packing> </packing>
</child> </child>
<child> <child>
<!-- n-columns=3 n-rows=1 --> <object class="GtkBox">
<object class="GtkGrid" id="hboxDownloadSettings">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="border-width">5</property> <property name="margin-start">5</property>
<property name="column-spacing">10</property> <property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="margin-bottom">10</property>
<property name="spacing">5</property>
<child> <child>
<!-- n-columns=3 n-rows=1 --> <object class="GtkBox">
<object class="GtkGrid" id="hboxDownloadLimit">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="column-spacing">5</property> <property name="margin-start">5</property>
<property name="spacing">5</property>
<child> <child>
<object class="GtkCheckButton" id="cbLimitDownloads"> <object class="GtkCheckButton" id="cbLimitDownloads">
<property name="label" translatable="yes">Limit rate to</property> <property name="label" translatable="yes">Limit rate to</property>
@ -442,22 +513,27 @@
<signal name="toggled" handler="on_cbLimitDownloads_toggled" swapped="no"/> <signal name="toggled" handler="on_cbLimitDownloads_toggled" swapped="no"/>
</object> </object>
<packing> <packing>
<property name="left-attach">0</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">0</property>
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkSpinButton" id="spinLimitDownloads"> <object class="GtkSpinButton" id="spinLimitDownloads">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="invisible-char">●</property> <property name="caps-lock-warning">False</property>
<property name="input-purpose">number</property>
<property name="adjustment">adjustment1</property> <property name="adjustment">adjustment1</property>
<property name="climb-rate">1</property> <property name="climb-rate">1</property>
<property name="digits">1</property> <property name="digits">1</property>
<property name="numeric">True</property>
<property name="update-policy">if-valid</property>
</object> </object>
<packing> <packing>
<property name="left-attach">1</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">1</property>
</packing> </packing>
</child> </child>
<child> <child>
@ -468,33 +544,23 @@
<property name="xalign">0</property> <property name="xalign">0</property>
</object> </object>
<packing> <packing>
<property name="left-attach">2</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">2</property>
</packing> </packing>
</child> </child>
</object> </object>
<packing> <packing>
<property name="left-attach">0</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">0</property>
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkLabel" id="DownloadSettingsSpacer"> <object class="GtkBox">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="hexpand">True</property> <property name="spacing">5</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<!-- n-columns=2 n-rows=1 -->
<object class="GtkGrid" id="hboxDownloadRate">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="column-spacing">5</property>
<child> <child>
<object class="GtkCheckButton" id="cbMaxDownloads"> <object class="GtkCheckButton" id="cbMaxDownloads">
<property name="label" translatable="yes">Limit downloads to</property> <property name="label" translatable="yes">Limit downloads to</property>
@ -505,33 +571,42 @@
<signal name="toggled" handler="on_cbMaxDownloads_toggled" swapped="no"/> <signal name="toggled" handler="on_cbMaxDownloads_toggled" swapped="no"/>
</object> </object>
<packing> <packing>
<property name="left-attach">0</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">0</property>
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkSpinButton" id="spinMaxDownloads"> <object class="GtkSpinButton" id="spinMaxDownloads">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="invisible-char">●</property> <property name="caps-lock-warning">False</property>
<property name="input-purpose">number</property>
<property name="adjustment">adjustment2</property> <property name="adjustment">adjustment2</property>
<property name="climb-rate">1</property> <property name="climb-rate">1</property>
<property name="snap-to-ticks">True</property>
<property name="numeric">True</property>
<property name="update-policy">if-valid</property>
</object> </object>
<packing> <packing>
<property name="left-attach">1</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="position">1</property>
</packing> </packing>
</child> </child>
</object> </object>
<packing> <packing>
<property name="left-attach">2</property> <property name="expand">False</property>
<property name="top-attach">0</property> <property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing> </packing>
</child> </child>
</object> </object>
<packing> <packing>
<property name="left-attach">0</property> <property name="expand">False</property>
<property name="top-attach">1</property> <property name="fill">True</property>
<property name="position">2</property>
</packing> </packing>
</child> </child>
</object> </object>

View File

@ -80,6 +80,7 @@
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="has-focus">True</property> <property name="has-focus">True</property>
<property name="activates-default">True</property> <property name="activates-default">True</property>
<property name="secondary-icon-name">edit-clear</property>
<signal name="changed" handler="on_entry_url_changed" swapped="no"/> <signal name="changed" handler="on_entry_url_changed" swapped="no"/>
</object> </object>
<packing> <packing>

View File

@ -19,6 +19,10 @@
</item> </item>
</section> </section>
<section> <section>
<item>
<attribute name="label" translatable="yes">Open Logs</attribute>
<attribute name="action">app.logs</attribute>
</item>
<item> <item>
<attribute name="label" translatable="yes">Help</attribute> <attribute name="label" translatable="yes">Help</attribute>
<attribute name="action">app.help</attribute> <attribute name="action">app.help</attribute>

View File

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- :indentSize=4:noTabs=true:tabSize=4: -->
<component type="desktop-application"> <component type="desktop-application">
<id>org.gpodder.gpodder</id> <id>org.gpodder.gpodder</id>
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
@ -10,24 +9,81 @@
<p>gPodder lets you manage your Podcast subscriptions, discover new content and download episodes to your devices.</p> <p>gPodder lets you manage your Podcast subscriptions, discover new content and download episodes to your devices.</p>
<p>You can also take advantage of the service gpodder.net, which lets you sync subscriptions, playback progress and starred episodes.</p> <p>You can also take advantage of the service gpodder.net, which lets you sync subscriptions, playback progress and starred episodes.</p>
</description> </description>
<launchable type="desktop-id">gpodder.desktop</launchable> <launchable type="desktop-id">org.gpodder.gpodder.desktop</launchable>
<url type="homepage">https://www.gpodder.org</url> <url type="homepage">https://www.gpodder.org</url>
<provides> <provides>
<binary>gpodder</binary> <binary>gpodder</binary>
<id>gpodder.desktop</id> <id>gpodder.desktop</id>
</provides> </provides>
<content_rating type="oars-1.0"/> <content_rating type="oars-1.1">
<screenshots> <content_attribute id="violence-cartoon">none</content_attribute>
<content_attribute id="violence-fantasy">none</content_attribute>
<content_attribute id="violence-realistic">none</content_attribute>
<content_attribute id="violence-bloodshed">none</content_attribute>
<content_attribute id="violence-sexual">none</content_attribute>
<content_attribute id="violence-desecration">none</content_attribute>
<content_attribute id="violence-slavery">none</content_attribute>
<content_attribute id="violence-worship">none</content_attribute>
<content_attribute id="drugs-alcohol">none</content_attribute>
<content_attribute id="drugs-narcotics">none</content_attribute>
<content_attribute id="drugs-tobacco">none</content_attribute>
<content_attribute id="sex-nudity">none</content_attribute>
<content_attribute id="sex-themes">none</content_attribute>
<content_attribute id="sex-homosexuality">none</content_attribute>
<content_attribute id="sex-prostitution">none</content_attribute>
<content_attribute id="sex-adultery">none</content_attribute>
<content_attribute id="sex-appearance">none</content_attribute>
<content_attribute id="language-profanity">none</content_attribute>
<content_attribute id="language-humor">none</content_attribute>
<content_attribute id="language-discrimination">none</content_attribute>
<content_attribute id="social-chat">none</content_attribute>
<content_attribute id="social-info">none</content_attribute>
<content_attribute id="social-audio">none</content_attribute>
<content_attribute id="social-location">none</content_attribute>
<content_attribute id="social-contacts">none</content_attribute>
<content_attribute id="money-purchasing">none</content_attribute>
<content_attribute id="money-gambling">none</content_attribute>
</content_rating>
<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.0" date="2022-07-30">
<description>
<p>This release contains a year's worth of improvements. Major changes:</p>
<ul>
<li>Warning: There is a database schema update (See "Moving to an older gPodder release" in the user manual at gpodder.github.io for how to rollback)</li>
<li>Numerous bug fixes</li>
<li>Performance improvements</li>
<li>A new preferences dialog</li>
<li>Support again syncing to mtp:// and iPod devices on Linux</li>
</ul>
</description>
</release>
<release version="3.10.21" date="2021-07-20"/>
<release version="3.10.20" date="2021-06-06"/>
<release version="3.10.19" date="2021-04-15"/>
<release version="3.10.17" date="2020-11-23"/>
<release version="3.10.13" date="2020-01-29"/>
<release version="3.10.12" date="2020-01-25"/>
<release version="3.10.11" date="2019-09-29"/>
<release version="3.10.10" date="2019-09-27"/>
<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.5" date="2018-09-15"> <release version="3.10.5" date="2018-09-15">
<description>This is a bugfix release, shortly after 3.10.4, for the Rename after Download extension.</description> <description>
<p>This is a bugfix release, shortly after 3.10.4, for the Rename after Download extension.</p>
</description>
</release> </release>
<release version="3.10.4" date="2018-09-09"/> <release version="3.10.4" date="2018-09-09"/>
<release version="3.10.3" date="2018-06-14"/> <release version="3.10.3" date="2018-06-14"/>
<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"/>
</releases> </releases>
</component> </component>

View File

@ -430,3 +430,13 @@ class Config(object):
logger.debug("setting config.device_sync.max_filename_length=120" logger.debug("setting config.device_sync.max_filename_length=120"
" (999 is bad for NTFS and ext{2-4})") " (999 is bad for NTFS and ext{2-4})")
self.device_sync.max_filename_length = 120 self.device_sync.max_filename_length = 120
def clamp_range(self, name, min, max):
value = getattr(self, name)
if value < min:
setattr(self, name, min)
return True
if value > max:
setattr(self, name, max)
return True
return False

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# gPodder - A media aggregator and podcast client # gPodder - A media aggregator and podcast client

View File

@ -95,6 +95,10 @@ class gPodderApplication(Gtk.Application):
action.connect('activate', self.on_help_activate) action.connect('activate', self.on_help_activate)
self.add_action(action) self.add_action(action)
action = Gio.SimpleAction.new('logs', None)
action.connect('activate', self.on_logs_activate)
self.add_action(action)
action = Gio.SimpleAction.new('preferences', None) action = Gio.SimpleAction.new('preferences', None)
action.connect('activate', self.on_itemPreferences_activate) action.connect('activate', self.on_itemPreferences_activate)
self.add_action(action) self.add_action(action)
@ -259,6 +263,9 @@ class gPodderApplication(Gtk.Application):
def on_help_activate(self, action, param): def on_help_activate(self, action, param):
util.open_website('https://gpodder.github.io/docs/') util.open_website('https://gpodder.github.io/docs/')
def on_logs_activate(self, action, param):
util.gui_open(os.path.join(gpodder.home, 'Logs'), gui=self.window)
def on_itemPreferences_activate(self, action, param=None): def on_itemPreferences_activate(self, action, param=None):
gPodderPreferences(self.window.gPodder, gPodderPreferences(self.window.gPodder,
_config=self.window.config, _config=self.window.config,

View File

@ -22,6 +22,8 @@
# gpodder.gtkui.config -- Config object with GTK+ support (2009-08-24) # gpodder.gtkui.config -- Config object with GTK+ support (2009-08-24)
# #
import logging
import gi # isort:skip import gi # isort:skip
gi.require_version('Gdk', '3.0') # isort:skip gi.require_version('Gdk', '3.0') # isort:skip
gi.require_version('Gtk', '3.0') # isort:skip gi.require_version('Gtk', '3.0') # isort:skip
@ -30,6 +32,8 @@ from gi.repository import Gdk, Gtk, Pango
import gpodder import gpodder
from gpodder import config, util from gpodder import config, util
logger = logging.getLogger(__name__)
_ = gpodder.gettext _ = gpodder.gettext
@ -156,6 +160,25 @@ class UIConfig(config.Config):
if gpodder.ui.win32: if gpodder.ui.win32:
window.set_gravity(Gdk.Gravity.STATIC) window.set_gravity(Gdk.Gravity.STATIC)
if -1 not in (cfg.x, cfg.y, cfg.width, cfg.height):
# get screen resolution
screen = Gdk.Screen.get_default()
screen_width = 0
screen_height = 0
for i in range(0, screen.get_n_monitors()):
monitor = screen.get_monitor_geometry(i)
screen_width += monitor.width
screen_height += monitor.height
logger.debug('Screen %d x %d' % (screen_width, screen_height))
# reset window position if more than 50% is off-screen
half_width = cfg.width / 2
half_height = cfg.height / 2
if (cfg.x + cfg.width - half_width) < 0 or (cfg.y + cfg.height - half_height) < 0 \
or cfg.x > (screen_width - half_width) or cfg.y > (screen_height - half_height):
logger.warning('"%s" window was off-screen at (%d, %d), resetting to default position' % (config_prefix, cfg.x, cfg.y))
cfg.x = -1
cfg.y = -1
if cfg.width != -1 and cfg.height != -1: if cfg.width != -1 and cfg.height != -1:
window.resize(cfg.width, cfg.height) window.resize(cfg.width, cfg.height)

View File

@ -111,7 +111,7 @@ class gPodderEpisodeSelector(BuilderWidget):
self.size_attribute = 'file_size' self.size_attribute = 'file_size'
if not hasattr(self, 'tooltip_attribute'): if not hasattr(self, 'tooltip_attribute'):
self.tooltip_attribute = 'description' self.tooltip_attribute = '_text_description'
if not hasattr(self, 'selection_buttons'): if not hasattr(self, 'selection_buttons'):
self.selection_buttons = {} self.selection_buttons = {}

View File

@ -193,7 +193,10 @@ class DownloadStatusModel(Gtk.ListStore):
# as only the main thread is allowed to manipulate the list store. # as only the main thread is allowed to manipulate the list store.
def get_next(self): def get_next(self):
dqr = DequeueRequest() dqr = DequeueRequest()
util.idle_add(self.__get_next, dqr) # this can not be idle_add because update_downloads_list() is called from a higher
# priority timeout_add and would spin forever, never calling this.
from gi.repository import GLib
GLib.timeout_add(0, self.__get_next, dqr)
return dqr.dequeue() return dqr.dequeue()
def _work_gen(self): def _work_gen(self):

View File

@ -41,6 +41,7 @@ class gPodderAddPodcast(BuilderWidget):
if hasattr(self, 'preset_url'): if hasattr(self, 'preset_url'):
self.entry_url.set_text(self.preset_url) self.entry_url.set_text(self.preset_url)
self.entry_url.connect('activate', self.on_entry_url_activate) self.entry_url.connect('activate', self.on_entry_url_activate)
self.entry_url.connect('icon-press', self.on_clear_url)
self.gPodderAddPodcast.show() self.gPodderAddPodcast.show()
if not hasattr(self, 'preset_url'): if not hasattr(self, 'preset_url'):
@ -67,6 +68,9 @@ class gPodderAddPodcast(BuilderWidget):
clipboard.request_text(receive_clipboard_text, True) clipboard.request_text(receive_clipboard_text, True)
clipboard.request_text(receive_clipboard_text, False) clipboard.request_text(receive_clipboard_text, False)
def on_clear_url(self, widget, icon_position, event):
self.entry_url.set_text('')
def on_btn_close_clicked(self, widget): def on_btn_close_clicked(self, widget):
self.gPodderAddPodcast.destroy() self.gPodderAddPodcast.destroy()
@ -76,7 +80,7 @@ class gPodderAddPodcast(BuilderWidget):
def receive_clipboard_text(self, clipboard, text, data=None): def receive_clipboard_text(self, clipboard, text, data=None):
if text is not None: if text is not None:
self.entry_url.set_text(text).strip() self.entry_url.set_text(text.strip())
else: else:
self.show_message(_('Nothing to paste.'), _('Clipboard is empty')) self.show_message(_('Nothing to paste.'), _('Clipboard is empty'))

View File

@ -17,7 +17,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from gi.repository import GObject, Gtk, Pango import time
from gi.repository import GLib, Gtk, Pango
import gpodder import gpodder
from gpodder.gtkui.widgets import SpinningProgressIndicator from gpodder.gtkui.widgets import SpinningProgressIndicator
@ -35,7 +37,10 @@ class ProgressIndicator(object):
def __init__(self, title, subtitle=None, cancellable=False, parent=None): def __init__(self, title, subtitle=None, cancellable=False, parent=None):
self.title = title self.title = title
self.subtitle = subtitle self.subtitle = subtitle
self.cancellable = cancellable self.cancellable = True if cancellable else False
self.cancel_callback = cancellable
self.cancel_id = 0
self.next_update = time.time() + (self.DELAY / 1000)
self.parent = parent self.parent = parent
self.dialog = None self.dialog = None
self.progressbar = None self.progressbar = None
@ -43,11 +48,12 @@ class ProgressIndicator(object):
self._initial_message = None self._initial_message = None
self._initial_progress = None self._initial_progress = None
self._progress_set = False self._progress_set = False
self.source_id = GObject.timeout_add(self.DELAY, self._create_progress) self.source_id = GLib.timeout_add(self.DELAY, self._create_progress)
def _on_delete_event(self, window, event): def _on_delete_event(self, window, event):
if self.cancellable: if self.cancellable:
self.dialog.response(Gtk.ResponseType.CANCEL) self.dialog.response(Gtk.ResponseType.CANCEL)
self.cancellable = False
return True return True
def _create_progress(self): def _create_progress(self):
@ -55,6 +61,14 @@ class ProgressIndicator(object):
0, 0, Gtk.ButtonsType.CANCEL, self.subtitle or self.title) 0, 0, Gtk.ButtonsType.CANCEL, self.subtitle or self.title)
self.dialog.set_modal(True) self.dialog.set_modal(True)
self.dialog.connect('delete-event', self._on_delete_event) self.dialog.connect('delete-event', self._on_delete_event)
if self.cancellable:
def cancel_callback(dialog, response):
self.cancellable = False
self.dialog.set_deletable(False)
self.dialog.set_response_sensitive(Gtk.ResponseType.CANCEL, False)
if callable(self.cancel_callback):
self.cancel_callback(dialog, response)
self.cancel_id = self.dialog.connect('response', cancel_callback)
self.dialog.set_title(self.title) self.dialog.set_title(self.title)
self.dialog.set_deletable(self.cancellable) self.dialog.set_deletable(self.cancellable)
@ -83,8 +97,10 @@ class ProgressIndicator(object):
self.dialog.set_image(self.indicator) self.dialog.set_image(self.indicator)
self.dialog.show_all() self.dialog.show_all()
GObject.source_remove(self.source_id) self._update_gui()
self.source_id = GObject.timeout_add(self.INTERVAL, self._update_gui)
# previous self.source_id timeout is removed when this returns False
self.source_id = GLib.timeout_add(self.INTERVAL, self._update_gui)
return False return False
def _update_gui(self): def _update_gui(self):
@ -92,8 +108,16 @@ class ProgressIndicator(object):
self.indicator.step_animation() self.indicator.step_animation()
if not self._progress_set and self.progressbar: if not self._progress_set and self.progressbar:
self.progressbar.pulse() self.progressbar.pulse()
self.next_update = time.time() + (self.INTERVAL / 1000)
return True return True
def update_gui(self):
if self.dialog:
if self.source_id:
GLib.source_remove(self.source_id)
self.source_id = 0
self._update_gui()
def on_message(self, message): def on_message(self, message):
if self.progressbar: if self.progressbar:
self.progressbar.set_text(message) self.progressbar.set_text(message)
@ -109,5 +133,8 @@ class ProgressIndicator(object):
def on_finished(self): def on_finished(self):
if self.dialog is not None: if self.dialog is not None:
if self.cancel_id:
self.dialog.disconnect(self.cancel_id)
self.dialog.destroy() self.dialog.destroy()
GObject.source_remove(self.source_id) if self.source_id:
GLib.source_remove(self.source_id)

View File

@ -20,7 +20,7 @@
import html import html
from gi.repository import GObject, Gtk from gi.repository import GLib, GObject, Gtk
class TagCloud(Gtk.Layout): class TagCloud(Gtk.Layout):
@ -100,7 +100,7 @@ class TagCloud(Gtk.Layout):
def unrelayout(): def unrelayout():
self._in_relayout = False self._in_relayout = False
return False return False
GObject.idle_add(unrelayout) GLib.idle_add(unrelayout)
GObject.type_register(TagCloud) GObject.type_register(TagCloud)

View File

@ -62,7 +62,7 @@ import gi # isort:skip
gi.require_version('Gtk', '3.0') # isort:skip gi.require_version('Gtk', '3.0') # isort:skip
gi.require_version('Gdk', '3.0') # isort:skip gi.require_version('Gdk', '3.0') # isort:skip
gi.require_version('Handy', '1') # isort:skip gi.require_version('Handy', '1') # isort:skip
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk, Pango # isort:skip from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango # isort:skip
from gi.repository import Handy # isort:skip from gi.repository import Handy # isort:skip
@ -204,6 +204,9 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads) self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads) self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
# When the amount of maximum downloads changes, notify the queue manager # When the amount of maximum downloads changes, notify the queue manager
def changed_cb(spinbutton): def changed_cb(spinbutton):
return self.download_queue_manager.update_max_downloads() return self.download_queue_manager.update_max_downloads()
@ -530,7 +533,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if response_id == Gtk.ResponseType.OK: if response_id == Gtk.ResponseType.OK:
selection = self.treeDownloads.get_selection() selection = self.treeDownloads.get_selection()
selection.select_all() selection.select_all()
selected_tasks, _, _, _, _, _ = self.downloads_list_get_selection() selected_tasks = self.downloads_list_get_selection()[0]
selection.unselect_all() selection.unselect_all()
self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED) self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
self.resume_all_infobar.set_revealed(False) self.resume_all_infobar.set_revealed(False)
@ -549,14 +552,16 @@ class gPodder(BuilderWidget, dbus.service.Object):
def progress_callback(title, progress): def progress_callback(title, progress):
self.partial_downloads_indicator.on_message(title) self.partial_downloads_indicator.on_message(title)
self.partial_downloads_indicator.on_progress(progress) self.partial_downloads_indicator.on_progress(progress)
if time.time() >= self.partial_downloads_indicator.next_update:
self.partial_downloads_indicator.update_gui()
self.force_ui_update()
def finish_progress_callback(resumable_episodes): def finish_progress_callback(resumable_episodes):
def offer_resuming(): def offer_resuming():
if resumable_episodes: if resumable_episodes:
self.download_episode_list_paused(resumable_episodes) self.download_episode_list_paused(resumable_episodes, hide_progress=True)
self.resume_all_infobar.set_revealed(True) self.resume_all_infobar.set_revealed(True)
self.on_show_progress_activate() self.on_show_progress_activate()
logger.debug("find_partial_downloads done, calling extensions") logger.debug("find_partial_downloads done, calling extensions")
gpodder.user_extensions.on_find_partial_downloads_done() gpodder.user_extensions.on_find_partial_downloads_done()
@ -778,7 +783,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
important=True) important=True)
util.idle_add(show_error, e) util.idle_add(show_error, e)
util.idle_add(indicator.on_finished) indicator.on_finished()
def on_button_subscribe_clicked(self, button): def on_button_subscribe_clicked(self, button):
self.on_itemImportChannels_activate(button) self.on_itemImportChannels_activate(button)
@ -1336,7 +1341,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# (('text/uri-list', 0, 0),), Gdk.DragAction.COPY) # (('text/uri-list', 0, 0),), Gdk.DragAction.COPY)
# #
# def drag_data_get(tree, context, selection_data, info, timestamp): # def drag_data_get(tree, context, selection_data, info, timestamp):
# uris = ['file://' + e.local_filename(create=False) # uris = ['file://' + urllib.parse.quote(e.local_filename(create=False))
# for e in self.get_selected_episodes() # for e in self.get_selected_episodes()
# if e.was_downloaded(and_exists=True)] # if e.was_downloaded(and_exists=True)]
# selection_data.set_uris(uris) # selection_data.set_uris(uris)
@ -1497,7 +1502,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.things_adding_tasks -= 1 self.things_adding_tasks -= 1
if not self.download_list_update_enabled: if not self.download_list_update_enabled:
self.update_downloads_list() self.update_downloads_list()
GObject.timeout_add(1500, self.update_downloads_list) util.IdleTimeout(1500, self.update_downloads_list)
self.download_list_update_enabled = True self.download_list_update_enabled = True
def cleanup_downloads(self): def cleanup_downloads(self):
@ -1544,7 +1549,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
try: try:
model = self.download_status_model model = self.download_status_model
downloading, synchronizing, pausing, cancelling, queued, paused, failed, finished, others = (0,) * 9 downloading, synchronizing, pausing, cancelling, queued, paused, failed, finished = (0,) * 8
total_speed, total_size, done_size = 0, 0, 0 total_speed, total_size, done_size = 0, 0, 0
files_downloading = 0 files_downloading = 0
@ -1577,8 +1582,6 @@ class gPodder(BuilderWidget, dbus.service.Object):
total_speed += speed total_speed += speed
elif activity == download.DownloadTask.ACTIVITY_SYNCHRONIZE: elif activity == download.DownloadTask.ACTIVITY_SYNCHRONIZE:
synchronizing += 1 synchronizing += 1
else:
others += 1
elif status == download.DownloadTask.PAUSING: elif status == download.DownloadTask.PAUSING:
pausing += 1 pausing += 1
if activity == download.DownloadTask.ACTIVITY_DOWNLOAD: if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
@ -1595,10 +1598,6 @@ class gPodder(BuilderWidget, dbus.service.Object):
failed += 1 failed += 1
elif status == download.DownloadTask.DONE: elif status == download.DownloadTask.DONE:
finished += 1 finished += 1
else:
others += 1
# TODO: 'others' is not used
# Remember which tasks we have seen after this run # Remember which tasks we have seen after this run
self.download_tasks_seen = download_tasks_seen self.download_tasks_seen = download_tasks_seen
@ -1706,6 +1705,24 @@ class gPodder(BuilderWidget, dbus.service.Object):
elif name == 'ui.gtk.episode_list.columns': elif name == 'ui.gtk.episode_list.columns':
# self.update_episode_list_columns_visibility() # self.update_episode_list_columns_visibility()
pass pass
elif name == 'limit.downloads.concurrent_max':
# Do not allow value to be set below 1
if new_value < 1:
self.config.limit.downloads.concurrent_max = 1
return
# Clamp current value to new maximum value
if self.config.limit.downloads.concurrent > new_value:
self.config.limit.downloads.concurrent = new_value
self.spinMaxDownloads.get_adjustment().set_upper(new_value)
elif name == 'limit.downloads.concurrent':
if self.config.clamp_range('limit.downloads.concurrent', 1, self.config.limit.downloads.concurrent_max):
return
self.spinMaxDownloads.set_value(new_value)
elif name == 'limit.bandwidth.kbps':
adjustment = self.spinLimitDownloads.get_adjustment()
if self.config.clamp_range('limit.bandwidth.kbps', adjustment.get_lower(), adjustment.get_upper()):
return
self.spinLimitDownloads.set_value(new_value)
def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip): def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
# With get_bin_window, we get the window that contains the rows without # With get_bin_window, we get the window that contains the rows without
@ -1962,7 +1979,40 @@ class gPodder(BuilderWidget, dbus.service.Object):
else: else:
self.download_queue_manager.queue_task(task) self.download_queue_manager.queue_task(task)
def force_ui_update(self):
def callback():
Gtk.main_quit()
GLib.timeout_add(1, callback)
Gtk.main()
def _for_each_task_set_status(self, tasks, status, force_start=False): def _for_each_task_set_status(self, tasks, status, force_start=False):
count = len(tasks)
if count:
progress_indicator = ProgressIndicator(
_('Queueing') if status == download.DownloadTask.QUEUED else
_('Removing') if status is None else download.DownloadTask.STATUS_MESSAGE[status],
'', True, self.get_dialog_parent())
progress_indicator.on_message('0 / %d' % count)
else:
progress_indicator = None
def progress_callback(title, progress):
progress_indicator.on_message(title)
progress_indicator.on_progress(progress)
if time.time() >= progress_indicator.next_update:
progress_indicator.update_gui()
self.force_ui_update()
if not progress_indicator.cancellable:
return False
return True
self.__for_each_task_set_status(tasks, status, force_start=force_start, progress_callback=progress_callback)
if progress_indicator:
progress_indicator.on_finished()
def __for_each_task_set_status(self, tasks, status, force_start=False, progress_callback=None):
count = len(tasks)
n = 0
episode_urls = set() episode_urls = set()
model = self.treeDownloads.get_model() model = self.treeDownloads.get_model()
for row_reference, task in tasks: for row_reference, task in tasks:
@ -2011,6 +2061,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
else: else:
# We can (hopefully) simply set the task status here # We can (hopefully) simply set the task status here
task.status = status task.status = status
if progress_callback:
n += 1
if not progress_callback('%d / %d' % (n, count), n / count):
break
# Tell the podcasts tab to update icons for our removed podcasts # Tell the podcasts tab to update icons for our removed podcasts
self.update_episode_list_icons(episode_urls) self.update_episode_list_icons(episode_urls)
# Update the tab title and downloads list # Update the tab title and downloads list
@ -2340,10 +2394,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
# play icon and label # play icon and label
# if open_instead_of_play or not is_episode_selected: # if open_instead_of_play or not is_episode_selected:
# self.toolPlay.set_icon_name('document-open') # self.toolPlay.set_icon_name('document-open-symbolic')
# self.toolPlay.set_label(_('Open')) # self.toolPlay.set_label(_('Open'))
# else: # else:
# self.toolPlay.set_icon_name('media-playback-start') # self.toolPlay.set_icon_name('media-playback-start-symbolic')
# #
# downloaded = all(e.was_downloaded(and_exists=True) for e in episodes) # downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
# downloading = any(e.downloading for e in episodes) # downloading = any(e.downloading for e in episodes)
@ -2601,7 +2655,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if remaining_seconds > 3600: if remaining_seconds > 3600:
# timeout an hour early in the event daylight savings changes the clock forward # timeout an hour early in the event daylight savings changes the clock forward
remaining_seconds = remaining_seconds - 3600 remaining_seconds = remaining_seconds - 3600
GObject.timeout_add(remaining_seconds * 1000, self.refresh_episode_dates) GLib.timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
def update_podcast_list_model(self, urls=None, selected=False, select_url=None, def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
sections_changed=False): sections_changed=False):
@ -2793,6 +2847,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
def on_after_update(): def on_after_update():
progress.on_finished() progress.on_finished()
# Report already-existing subscriptions to the user # Report already-existing subscriptions to the user
if existing: if existing:
title = _('Existing subscriptions skipped') title = _('Existing subscriptions skipped')
@ -2960,9 +3015,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.mygpo_client.process_episode_actions(self.find_episode) self.mygpo_client.process_episode_actions(self.find_episode)
indicator.on_finished()
self.db.commit() self.db.commit()
indicator.on_finished()
def _update_cover(self, channel): def _update_cover(self, channel):
if channel is not None: if channel is not None:
self.cover_downloader.request_cover(channel) self.cover_downloader.request_cover(channel)
@ -3217,7 +3273,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
selected_tasks = [(Gtk.TreeRowReference.new(model, path), selected_tasks = [(Gtk.TreeRowReference.new(model, path),
model.get_value(model.get_iter(path), model.get_value(model.get_iter(path),
DownloadStatusModel.C_TASK)) for path in paths] DownloadStatusModel.C_TASK)) for path in paths]
self._for_each_task_set_status(selected_tasks, status=None, force_start=False) self._for_each_task_set_status(selected_tasks, status=None)
return return
if not episodes: if not episodes:
@ -3251,8 +3307,6 @@ class gPodder(BuilderWidget, dbus.service.Object):
parent=self.get_dialog_parent()) parent=self.get_dialog_parent())
def finish_deletion(episode_urls, channel_urls): def finish_deletion(episode_urls, channel_urls):
progress.on_finished()
# Episodes have been deleted - persist the database # Episodes have been deleted - persist the database
self.db.commit() self.db.commit()
@ -3261,6 +3315,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.update_header_bar_subtitle() self.update_header_bar_subtitle()
self.play_or_download() self.play_or_download()
progress.on_finished()
@util.run_in_background @util.run_in_background
def thread_proc(): def thread_proc():
episode_urls = set() episode_urls = set()
@ -3445,11 +3501,21 @@ class gPodder(BuilderWidget, dbus.service.Object):
util.idle_add(show_welcome_window) util.idle_add(show_welcome_window)
def download_episode_list_paused(self, episodes): def download_episode_list_paused(self, episodes, hide_progress=False):
self.download_episode_list(episodes, True) self.download_episode_list(episodes, True, hide_progress=hide_progress)
def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None): def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None, hide_progress=False):
def queue_tasks(tasks, queued_existing_task): def queue_tasks(tasks, queued_existing_task):
n = 0
count = len(episodes)
if count and not hide_progress:
progress_indicator = ProgressIndicator(
_('Queueing'),
'', True, self.get_dialog_parent())
progress_indicator.on_message('0 / %d' % count)
else:
progress_indicator = None
for task in tasks: for task in tasks:
with task: with task:
if add_paused: if add_paused:
@ -3457,12 +3523,24 @@ class gPodder(BuilderWidget, dbus.service.Object):
else: else:
self.mygpo_client.on_download([task.episode]) self.mygpo_client.on_download([task.episode])
self.queue_task(task, force_start) self.queue_task(task, force_start)
if progress_indicator:
n += 1
progress_indicator.on_message('%d / %d' % (n, count))
progress_indicator.on_progress(n / count)
if time.time() >= progress_indicator.next_update:
progress_indicator.update_gui()
self.force_ui_update()
if not progress_indicator.cancellable:
break
if tasks or queued_existing_task: if tasks or queued_existing_task:
self.set_download_list_state(gPodderSyncUI.DL_ONEOFF) self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
# Flush updated episode status # Flush updated episode status
if self.mygpo_client.can_access_webservice(): if self.mygpo_client.can_access_webservice():
self.mygpo_client.flush() self.mygpo_client.flush()
if progress_indicator:
progress_indicator.on_finished()
queued_existing_task = False queued_existing_task = False
new_tasks = [] new_tasks = []
@ -3907,10 +3985,6 @@ class gPodder(BuilderWidget, dbus.service.Object):
def on_wNotebook_switch_page(self, notebook, page, page_num): def on_wNotebook_switch_page(self, notebook, page, page_num):
self.play_or_download(in_downloads=page_num > 0) self.play_or_download(in_downloads=page_num > 0)
if page_num == 0:
# The infobar in the downloads tab should be hidden
# when the user switches away from the downloads tab
self.resume_all_infobar.set_revealed(False)
def on_treeChannels_row_activated(self, widget, path, *args): def on_treeChannels_row_activated(self, widget, path, *args):
self.navigate_from_shownotes() self.navigate_from_shownotes()
@ -4081,7 +4155,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
selected_tasks = [(Gtk.TreeRowReference.new(model, path), selected_tasks = [(Gtk.TreeRowReference.new(model, path),
model.get_value(model.get_iter(path), model.get_value(model.get_iter(path),
DownloadStatusModel.C_TASK)) for path in paths] DownloadStatusModel.C_TASK)) for path in paths]
self._for_each_task_set_status(selected_tasks, status=download.DownloadTask.QUEUED, force_start=False) self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
else: else:
episodes = [e for e in self.get_selected_episodes() if e.can_download()] episodes = [e for e in self.get_selected_episodes() if e.can_download()]
self.download_episode_list(episodes) self.download_episode_list(episodes)
@ -4094,7 +4168,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
selected_tasks = [(Gtk.TreeRowReference.new(model, path), selected_tasks = [(Gtk.TreeRowReference.new(model, path),
model.get_value(model.get_iter(path), model.get_value(model.get_iter(path),
DownloadStatusModel.C_TASK)) for path in paths] DownloadStatusModel.C_TASK)) for path in paths]
self._for_each_task_set_status(selected_tasks, status=download.DownloadTask.PAUSING, force_start=False) self._for_each_task_set_status(selected_tasks, download.DownloadTask.PAUSING)
else: else:
for episode in self.get_selected_episodes(): for episode in self.get_selected_episodes():
if episode.can_pause(): if episode.can_pause():
@ -4158,7 +4232,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
def restart_auto_update_timer(self): def restart_auto_update_timer(self):
if self._auto_update_timer_source_id is not None: if self._auto_update_timer_source_id is not None:
logger.debug('Removing existing auto update timer.') logger.debug('Removing existing auto update timer.')
GObject.source_remove(self._auto_update_timer_source_id) GLib.source_remove(self._auto_update_timer_source_id)
self._auto_update_timer_source_id = None self._auto_update_timer_source_id = None
if (self.config.auto_update_feeds and if (self.config.auto_update_feeds and
@ -4166,7 +4240,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
interval = 60 * 1000 * self.config.auto_update_frequency interval = 60 * 1000 * self.config.auto_update_frequency
logger.debug('Setting up auto update timer with interval %d.', logger.debug('Setting up auto update timer with interval %d.',
self.config.auto_update_frequency) self.config.auto_update_frequency)
self._auto_update_timer_source_id = GObject.timeout_add( self._auto_update_timer_source_id = GLib.timeout_add(
interval, self._on_auto_update_timer) interval, self._on_auto_update_timer)
def _on_auto_update_timer(self): def _on_auto_update_timer(self):

View File

@ -30,7 +30,7 @@ import re
import time import time
from itertools import groupby from itertools import groupby
from gi.repository import GdkPixbuf, GObject, Gtk from gi.repository import GdkPixbuf, GLib, GObject, Gtk
import gpodder import gpodder
from gpodder import coverart, model, query, util from gpodder import coverart, model, query, util
@ -331,10 +331,10 @@ class EpisodeListModel(Gtk.ListStore):
def _update_from_episodes(self, episodes, include_description): def _update_from_episodes(self, episodes, include_description):
if self.background_update_tag is not None: if self.background_update_tag is not None:
GObject.source_remove(self.background_update_tag) GLib.source_remove(self.background_update_tag)
self.background_update = BackgroundUpdate(self, episodes, include_description) self.background_update = BackgroundUpdate(self, episodes, include_description)
self.background_update_tag = GObject.idle_add(self._update_background) self.background_update_tag = GLib.idle_add(self._update_background)
def _update_background(self): def _update_background(self):
if self.background_update is not None: if self.background_update is not None:

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# gPodder - A media aggregator and podcast client # gPodder - A media aggregator and podcast client

View File

@ -1,4 +1,4 @@
#!/usr/bin/python #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# gPodder - A media aggregator and podcast client # gPodder - A media aggregator and podcast client

View File

@ -497,7 +497,7 @@ class PodcastEpisode(PodcastModelObject):
PAUSING and PAUSED tasks can be resumed. PAUSING and PAUSED tasks can be resumed.
""" """
return not self.was_downloaded(and_exists=True) and ( return not self.was_downloaded(and_exists=True) and (
not self.download_task self.download_task is None
or self.download_task.can_queue() or self.download_task.can_queue()
or self.download_task.status == self.download_task.PAUSING) or self.download_task.status == self.download_task.PAUSING)
@ -505,20 +505,20 @@ class PodcastEpisode(PodcastModelObject):
""" """
gPodder.on_pause_selected_episodes() filters selection with this method. gPodder.on_pause_selected_episodes() filters selection with this method.
""" """
return self.download_task and self.download_task.can_pause() return self.download_task is not None and self.download_task.can_pause()
def can_cancel(self): def can_cancel(self):
""" """
DownloadTask.cancel() only cancels the following tasks. DownloadTask.cancel() only cancels the following tasks.
""" """
return self.download_task and self.download_task.can_cancel() return self.download_task is not None and self.download_task.can_cancel()
def can_delete(self): def can_delete(self):
""" """
gPodder.delete_episode_list() filters out locked episodes, and cancels all unlocked tasks in selection. gPodder.delete_episode_list() filters out locked episodes, and cancels all unlocked tasks in selection.
""" """
return self.state != gpodder.STATE_DELETED and not self.archive and ( return self.state != gpodder.STATE_DELETED and not self.archive and (
not self.download_task or self.download_task.status == self.download_task.FAILED) self.download_task is None or self.download_task.status == self.download_task.FAILED)
def can_lock(self): def can_lock(self):
""" """

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# gPodder - A media aggregator and podcast client # gPodder - A media aggregator and podcast client

View File

@ -1,4 +1,3 @@
#!/usr/bin/python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# gPodder - A media aggregator and podcast client # gPodder - A media aggregator and podcast client

View File

@ -458,16 +458,20 @@ class MP3PlayerDevice(Device):
self.destination.get_uri(), err.message) self.destination.get_uri(), err.message)
return False return False
if info.get_file_type() != Gio.FileType.DIRECTORY:
logger.error('destination %s is not a directory', self.destination.get_uri())
return False
# open is ok if the target is a directory, and it can be written to # open is ok if the target is a directory, and it can be written to
# for smb, query_info doesn't return FILE_ATTRIBUTE_ACCESS_CAN_WRITE, # for smb, query_info doesn't return FILE_ATTRIBUTE_ACCESS_CAN_WRITE,
# -- if that's the case, just assume that it's writable # -- if that's the case, just assume that it's writable
if (info.get_file_type() == Gio.FileType.DIRECTORY and ( if (not info.has_attribute(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE) or
not info.has_attribute(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE) or info.get_attribute_boolean(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE)):
info.get_attribute_boolean(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE))):
self.notify('status', _('MP3 player opened')) self.notify('status', _('MP3 player opened'))
self.tracks_list = self.get_all_tracks() self.tracks_list = self.get_all_tracks()
return True return True
logger.error('destination %s is not writable', self.destination.get_uri())
return False return False
def get_episode_folder_on_device(self, episode): def get_episode_folder_on_device(self, episode):

View File

@ -105,14 +105,22 @@ class gPodderSyncUI(object):
done_callback() done_callback()
return return
if not device.open(): try:
if not device.open():
self._show_message_cannot_open()
if done_callback:
done_callback()
return
else:
# Only set if device is configured and opened successfully
self.device = device
except Exception as err:
logger.error('opening destination %s failed with %s',
device.destination.get_uri(), err.message)
self._show_message_cannot_open() self._show_message_cannot_open()
if done_callback: if done_callback:
done_callback() done_callback()
return return
else:
# Only set if device is configured and opened successfully
self.device = device
if episodes is None: if episodes is None:
force_played = False force_played = False

View File

@ -1318,12 +1318,40 @@ def idle_add(func, *args):
as possible from the main UI thread. as possible from the main UI thread.
""" """
if gpodder.ui.gtk: if gpodder.ui.gtk:
from gi.repository import GObject from gi.repository import GLib
GObject.idle_add(func, *args) GLib.idle_add(func, *args)
else: else:
func(*args) func(*args)
class IdleTimeout(object):
"""Run a function in the main GUI thread at regular intervals since the last run
A simple timeout_add() continuously calls the function if it exceeds the interval,
which lags the UI and prevents idle_add() calls from happening. This class restarts
the timer after the function finishes, allowing other callbacks to run.
"""
def __init__(self, milliseconds, func, *args):
if not gpodder.ui.gtk:
raise Exception('util.IdleTimeout() is only supported by Gtk+')
self.milliseconds = milliseconds
self.func = func
from gi.repository import GLib
self.id = GLib.timeout_add(milliseconds, self._callback, *args)
def _callback(self, *args):
self.cancel()
if self.func(*args):
from gi.repository import GLib
self.id = GLib.timeout_add(self.milliseconds, self._callback, *args)
def cancel(self):
if self.id:
from gi.repository import GLib
GLib.source_remove(self.id)
self.id = 0
def bluetooth_available(): def bluetooth_available():
""" """
Returns True or False depending on the availability Returns True or False depending on the availability
@ -1358,7 +1386,7 @@ def bluetooth_send_file(filename):
return False return False
def format_time(value): def format_time(seconds):
"""Format a seconds value to a string """Format a seconds value to a string
>>> format_time(0) >>> format_time(0)
@ -1369,12 +1397,22 @@ def format_time(value):
'01:00:00' '01:00:00'
>>> format_time(10921) >>> format_time(10921)
'03:02:01' '03:02:01'
>>> format_time(86401)
'24:00:01'
""" """
dt = datetime.datetime.utcfromtimestamp(value) hours = 0
if dt.hour == 0: minutes = 0
return dt.strftime('%M:%S') if seconds >= 3600:
hours = seconds // 3600
seconds -= hours * 3600
if seconds >= 60:
minutes = seconds // 60
seconds -= minutes * 60
if hours == 0:
return '%02d:%02d' % (minutes, seconds)
else: else:
return dt.strftime('%H:%M:%S') return '%02d:%02d:%02d' % (hours, minutes, seconds)
def parse_time(value): def parse_time(value):

View File

@ -66,14 +66,14 @@ cp -a "$checkout"/tools/mac-osx/launcher.py "$resources"/
cp -a "$checkout"/tools/mac-osx/make_cert_pem.py "$resources"/bin cp -a "$checkout"/tools/mac-osx/make_cert_pem.py "$resources"/bin
# install gPodder hard dependencies # install gPodder hard dependencies
$run_pip install setuptools wheel $run_pip install setuptools==64.0.3 wheel || exit 1
$run_pip install mygpoclient==1.9 podcastparser==0.6.8 requests[socks]==2.28.1 $run_pip install mygpoclient==1.9 podcastparser==0.6.8 requests[socks]==2.28.1 || 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 $run_pip install -v brotli || exit 1
$run_pip install -v pycryptodomex $run_pip install -v pycryptodomex || exit 1
# install extension dependencies; no explicit version for yt-dlp # install extension dependencies; no explicit version for yt-dlp
$run_pip install html5lib==1.1 mutagen==1.45.1 yt-dlp $run_pip install html5lib==1.1 mutagen==1.45.1 yt-dlp || exit 1
cd "$checkout" cd "$checkout"
touch share/applications/gpodder{,-url-handler}.desktop touch share/applications/gpodder{,-url-handler}.desktop

View File

@ -473,10 +473,10 @@ function build_portable_installer {
mkdir "$PORTABLE"/config mkdir "$PORTABLE"/config
cp -RT "${MINGW_ROOT}" "$PORTABLE"/data cp -RT "${MINGW_ROOT}" "$PORTABLE"/data
rm -Rf 7zout 7z1604.exe rm -Rf 7zout 7z2201.exe
7z a payload.7z "$PORTABLE" 7z a payload.7z "$PORTABLE"
wget -P "$DIR" -c http://www.7-zip.org/a/7z1604.exe wget -P "$DIR" -c http://www.7-zip.org/a/7z2201.exe
7z x -o7zout 7z1604.exe 7z x -o7zout 7z2201.exe
cat 7zout/7z.sfx payload.7z > "$PORTABLE".exe cat 7zout/7z.sfx payload.7z > "$PORTABLE".exe
rm -Rf 7zout 7z1604.exe payload.7z "$PORTABLE" rm -Rf 7zout 7z2201.exe payload.7z "$PORTABLE"
} }