Merge tag '3.11.1' into dev-adaptive

gPodder 3.11.1 release
This commit is contained in:
Teemu Ikonen 2023-02-27 12:46:18 +02:00
commit 4539d8c5e4
62 changed files with 18035 additions and 15758 deletions

View File

@ -15,9 +15,9 @@ jobs:
python-version: ['3.10']
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies

View File

@ -628,6 +628,7 @@ class gPodderCli(object):
show_guid = '--guid' in args
common.find_partial_downloads(self._model.get_podcasts(),
noop,
noop,
noop,
on_finish)
@ -687,6 +688,7 @@ class gPodderCli(object):
self._download_episodes(episodes)
common.find_partial_downloads(self._model.get_podcasts(),
noop,
noop,
noop,
on_finish)

961
po/ca.po

File diff suppressed because it is too large Load Diff

988
po/cs.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

979
po/da.po

File diff suppressed because it is too large Load Diff

989
po/de.po

File diff suppressed because it is too large Load Diff

979
po/el.po

File diff suppressed because it is too large Load Diff

979
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

979
po/eu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

983
po/fi.po

File diff suppressed because it is too large Load Diff

981
po/fr.po

File diff suppressed because it is too large Load Diff

979
po/gl.po

File diff suppressed because it is too large Load Diff

979
po/he.po

File diff suppressed because it is too large Load Diff

979
po/hu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

989
po/it.po

File diff suppressed because it is too large Load Diff

979
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

979
po/nb.po

File diff suppressed because it is too large Load Diff

988
po/nl.po

File diff suppressed because it is too large Load Diff

988
po/nn.po

File diff suppressed because it is too large Load Diff

979
po/pl.po

File diff suppressed because it is too large Load Diff

979
po/pt.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

981
po/ro.po

File diff suppressed because it is too large Load Diff

979
po/ru.po

File diff suppressed because it is too large Load Diff

979
po/sk.po

File diff suppressed because it is too large Load Diff

979
po/sv.po

File diff suppressed because it is too large Load Diff

979
po/tr.po

File diff suppressed because it is too large Load Diff

987
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

@ -9,7 +9,6 @@ import time
import gpodder
from gpodder import util
from gpodder.gtkui.interface.progress import ProgressIndicator
from gpodder.model import PodcastEpisode
logger = logging.getLogger(__name__)
@ -55,32 +54,30 @@ class gPodderExtension:
return [(_("Rename all downloaded episodes"), self.rename_all_downloaded_episodes)]
def rename_all_downloaded_episodes(self):
model = self.gpodder.episode_list_model
episodes = [row[model.C_EPISODE] for row in model if row[model.C_EPISODE].state == gpodder.STATE_DOWNLOADED]
episodes = [e for c in self.gpodder.channels for e in [e for e in c.children if e.state == gpodder.STATE_DOWNLOADED]]
number_of_episodes = len(episodes)
if number_of_episodes == 0:
self.gpodder.show_message(_('No downloaded episodes to rename'),
_('Rename all downloaded episodes'), important=True)
from gpodder.gtkui.interface.progress import ProgressIndicator
progress_indicator = ProgressIndicator(
_('Renaming all downloaded episodes'),
'', True, self.gpodder.get_dialog_parent())
progress_indicator.on_message('0 / %d' % number_of_episodes)
'', True, self.gpodder.get_dialog_parent(), number_of_episodes)
renamed_count = 0
for episode in episodes:
self.on_episode_downloaded(episode)
renamed_count += 1
progress_indicator.on_message('%d / %d' % (renamed_count, number_of_episodes))
progress_indicator.on_progress(renamed_count / number_of_episodes)
if time.time() >= progress_indicator.next_update:
progress_indicator.update_gui()
self.gpodder.force_ui_update()
if not progress_indicator.cancellable:
break
if not progress_indicator.on_tick():
break
renamed_count = progress_indicator.tick_counter
progress_indicator.on_finished()
self.gpodder.show_message(_('Renamed %(count)d downloaded episodes') % {'count': number_of_episodes},
_('Renaming finished'), important=True)
if renamed_count > 0:
self.gpodder.show_message(_('Renamed %(count)d downloaded episodes') % {'count': renamed_count},
_('Rename all downloaded episodes'), important=True)
def make_filename(self, current_filename, title, sortdate, podcast_title):
dirname = os.path.dirname(current_filename)

View File

@ -13,11 +13,11 @@ import time
try:
import yt_dlp as youtube_dl
program_name = 'yt-dlp'
want_ytdl_version = '2021.02.04'
want_ytdl_version = '2023.02.17'
except:
import youtube_dl
program_name = 'youtube-dl'
want_ytdl_version = '2021.02.04'
want_ytdl_version = '2023.02.17' # youtube-dl has been patched, but not yet released
import gpodder
from gpodder import download, feedcore, model, registry, util, youtube

View File

@ -512,7 +512,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="label_episode_limit">
<property name="visible">True</property>
@ -527,7 +527,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkSpinButton" id="spinbutton_episode_limit">
<property name="visible">True</property>
@ -558,6 +558,21 @@
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="checkbutton_only_added_are_new">
<property name="label" translatable="yes">Consider only episodes added in the update as new</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="margin-bottom">5</property>
<property name="draw-indicator">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkFlowBox">
<property name="visible">True</property>
@ -568,7 +583,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="label_auto_download">
<property name="visible">True</property>
@ -583,7 +598,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkComboBox" id="combo_auto_download">
<property name="visible">True</property>
@ -597,7 +612,7 @@
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
<property name="position">5</property>
</packing>
</child>
<child>
@ -739,7 +754,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="label_device_type">
<property name="visible">True</property>
@ -753,7 +768,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkComboBox" id="combobox_device_type">
<property name="visible">True</property>
@ -781,7 +796,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="label_device_mount">
<property name="visible">True</property>
@ -795,7 +810,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkButton" id="btn_filesystemMountpoint">
<property name="visible">True</property>
@ -851,7 +866,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="label_device_playlists">
<property name="visible">True</property>
@ -865,7 +880,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkButton" id="btn_playlistfolder">
<property name="visible">True</property>
@ -920,7 +935,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="label_on_sync">
<property name="visible">True</property>
@ -934,7 +949,7 @@
<child>
<object class="GtkFlowBoxChild">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkComboBox" id="combobox_on_sync">
<property name="visible">True</property>

View File

@ -149,6 +149,10 @@
<attribute name="action">win.openEpisodeDownloadFolder</attribute>
<attribute name="label" translatable="yes">Open download folder</attribute>
</item>
<item>
<attribute name="action">win.selectChannel</attribute>
<attribute name="label" translatable="yes">Select channel</attribute>
</item>
</section>
<section>
<item>
@ -228,6 +232,10 @@
<attribute name="action">win.viewAlwaysShowNewEpisodes</attribute>
<attribute name="label" translatable="yes">Always show New Episodes</attribute>
</item>
<item>
<attribute name="action">win.viewTrimEpisodeTitlePrefix</attribute>
<attribute name="label" translatable="yes">Trim episode title prefix</attribute>
</item>
<item>
<attribute name="action">win.viewShowEpisodeDescription</attribute>
<attribute name="label" translatable="yes">Episode descriptions</attribute>

View File

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

View File

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

View File

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

View File

@ -47,7 +47,7 @@ def clean_up_downloads(delete_partial=False):
util.delete_file(tempfile)
def find_partial_downloads(channels, start_progress_callback, progress_callback, finish_progress_callback):
def find_partial_downloads(channels, start_progress_callback, progress_callback, final_progress_callback, finish_progress_callback):
"""Find partial downloads and match them with episodes
channels - A list of all model.PodcastChannel objects
@ -86,6 +86,8 @@ def find_partial_downloads(channels, start_progress_callback, progress_callback,
if not candidates:
break
final_progress_callback()
for f in partial_files:
logger.warning('Partial file without episode: %s', f)
util.delete_file(f)

View File

@ -154,6 +154,7 @@ defaults = {
'toolbar': False,
'new_episodes': 'show', # ignore, show, queue, download
'only_added_are_new': False, # Only just added episodes are considered new after an update
'live_search_delay': 200,
'search_always_visible': False,
'find_as_you_type': True,
@ -168,6 +169,7 @@ defaults = {
'episode_list': {
'view_mode': 1,
'always_show_new': True,
'trim_title_prefix': True,
'descriptions': True,
'ctrl_click_to_sort': False,
'columns': int('110', 2), # bitfield of visible columns
@ -206,6 +208,7 @@ defaults = {
'two_way_sync': False,
'use_absolute_path': True,
'folder': 'Playlists',
'extension': 'm3u',
}
},

View File

@ -44,6 +44,7 @@ class CoverDownloader(object):
'.jpg': lambda d: d.startswith(b'\xff\xd8'),
'.gif': lambda d: d.startswith(b'GIF89a') or d.startswith(b'GIF87a'),
'.ico': lambda d: d.startswith(b'\0\0\1\0'),
'.svg': lambda d: d.startswith(b'<svg '),
}
EXTENSIONS = list(SUPPORTED_EXTENSIONS.keys())

View File

@ -39,7 +39,8 @@ class gPodderDevicePlaylist(object):
def __init__(self, config, playlist_name):
self._config = config
self.linebreak = '\r\n'
self.playlist_file = util.sanitize_filename(playlist_name, self._config.device_sync.max_filename_length) + '.m3u'
self.playlist_file = (util.sanitize_filename(playlist_name, self._config.device_sync.max_filename_length) +
'.' + self._config.device_sync.playlists.extension)
device_folder = util.new_gio_file(self._config.device_sync.device_folder)
self.playlist_folder = device_folder.resolve_relative_path(self._config.device_sync.playlists.folder)
@ -101,7 +102,7 @@ class gPodderDevicePlaylist(object):
foldername = episode_foldername_on_device(self._config, episode)
if foldername:
filename = os.path.join(foldername, filename)
if self._config.device_sync.playlist.absolute_path:
if self._config.device_sync.playlists.use_absolute_path:
filename = os.path.join(util.relpath(self._config.device_sync.device_folder, self.mountpoint.get_uri()), filename)
return filename

View File

@ -412,7 +412,7 @@ class DownloadQueueWorker(object):
if not self.continue_check_callback(self):
return
task = self.queue.get_next()
task = self.queue.get_next() if self.queue.enabled else None
if not task:
logger.info('No more tasks for %s to carry out.', self)
break
@ -445,6 +445,13 @@ class DownloadQueueManager(object):
self.worker_threads_access = threading.RLock()
self.worker_threads = []
def disable(self):
self.tasks.enabled = False
def enable(self):
self.tasks.enabled = True
self.__spawn_threads()
def __exit_callback(self, worker_thread):
with self.worker_threads_access:
self.worker_threads.remove(worker_thread)
@ -461,6 +468,9 @@ class DownloadQueueManager(object):
def __spawn_threads(self):
"""Spawn new worker threads if necessary
"""
if not self.tasks.enabled:
return
with self.worker_threads_access:
work_count = self.tasks.available_work_count()
if self._config.limit.downloads.enabled:
@ -974,7 +984,7 @@ class DownloadTask(object):
except ConnectionError as ce:
# special case request exception
result = DownloadTask.FAILED
logger.error('Download failed: %s', str(ce), exc_info=True)
logger.error('Download failed: %s', str(ce))
d = {'host': ce.args[0].pool.host, 'port': ce.args[0].pool.port}
self.error_message = _("Couldn't connect to server %(host)s:%(port)s" % d)
except RequestException as re:
@ -982,20 +992,19 @@ class DownloadTask(object):
if isinstance(re.args[0], MaxRetryError):
re = re.args[0]
logger.error('%s while downloading "%s"', str(re),
self.__episode.title, exc_info=True)
self.__episode.title)
result = DownloadTask.FAILED
d = {'error': str(re)}
self.error_message = _('Request Error: %(error)s') % d
except IOError as ioe:
logger.error('%s while downloading "%s": %s', ioe.strerror,
self.__episode.title, ioe.filename, exc_info=True)
self.__episode.title, ioe.filename)
result = DownloadTask.FAILED
d = {'error': ioe.strerror, 'filename': ioe.filename}
self.error_message = _('I/O Error: %(error)s: %(filename)s') % d
except gPodderDownloadHTTPError as gdhe:
logger.error('HTTP %s while downloading "%s": %s',
gdhe.error_code, self.__episode.title, gdhe.error_message,
exc_info=True)
gdhe.error_code, self.__episode.title, gdhe.error_message)
result = DownloadTask.FAILED
d = {'code': gdhe.error_code, 'message': gdhe.error_message}
self.error_message = _('HTTP Error %(code)s: %(message)s') % d

View File

@ -257,6 +257,9 @@ class gPodderPreferences(BuilderWidget):
self._config.connect_gtk_spinbutton('limit.episodes', self.spinbutton_episode_limit)
self._config.connect_gtk_togglebutton('ui.gtk.only_added_are_new',
self.checkbutton_only_added_are_new)
self.auto_download_model = NewEpisodeActionList(self._config)
self.combo_auto_download.set_model(self.auto_download_model)
cellrenderer = Gtk.CellRendererText()

View File

@ -72,6 +72,8 @@ class DownloadStatusModel(Gtk.ListStore):
self._status_ids[download.DownloadTask.PAUSING] = 'media-playback-pause'
self._status_ids[download.DownloadTask.PAUSED] = 'media-playback-pause'
self.enabled = True
def _format_message(self, episode, message, podcast):
episode = html.escape(episode)
podcast = html.escape(podcast)
@ -193,10 +195,7 @@ class DownloadStatusModel(Gtk.ListStore):
# as only the main thread is allowed to manipulate the list store.
def get_next(self):
dqr = DequeueRequest()
# 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)
util.idle_add(self.__get_next, dqr)
return dqr.dequeue()
def _work_gen(self):

View File

@ -22,6 +22,7 @@ import time
from gi.repository import GLib, Gtk, Pango
import gpodder
from gpodder import util
from gpodder.gtkui.widgets import SpinningProgressIndicator
_ = gpodder.gettext
@ -32,14 +33,15 @@ class ProgressIndicator(object):
DELAY = 500
# Time between GUI updates after window creation
INTERVAL = 100
INTERVAL = 250
def __init__(self, title, subtitle=None, cancellable=False, parent=None):
def __init__(self, title, subtitle=None, cancellable=False, parent=None, max_ticks=None):
self.title = title
self.subtitle = subtitle
self.cancellable = True if cancellable else False
self.cancel_callback = cancellable
self.cancel_id = 0
self.cancelled = False
self.next_update = time.time() + (self.DELAY / 1000)
self.parent = parent
self.dialog = None
@ -48,12 +50,22 @@ class ProgressIndicator(object):
self._initial_message = None
self._initial_progress = None
self._progress_set = False
# use timeout_add, not util.idle_timeout_add, so it updates before Gtk+ redraws the dialog
self.source_id = GLib.timeout_add(self.DELAY, self._create_progress)
self.set_max_ticks(max_ticks)
def set_max_ticks(self, max_ticks):
self.max_ticks = max_ticks
self.tick_counter = 0
if max_ticks is not None:
self.on_message('0 / %d' % max_ticks)
def _on_delete_event(self, window, event):
if self.cancellable:
self.dialog.response(Gtk.ResponseType.CANCEL)
self.cancellable = False
self.cancelled = True
return True
def _create_progress(self):
@ -64,6 +76,7 @@ class ProgressIndicator(object):
if self.cancellable:
def cancel_callback(dialog, response):
self.cancellable = False
self.cancelled = True
self.dialog.set_deletable(False)
self.dialog.set_response_sensitive(Gtk.ResponseType.CANCEL, False)
if callable(self.cancel_callback):
@ -78,8 +91,7 @@ class ProgressIndicator(object):
if isinstance(label, Gtk.Label):
label.set_selectable(False)
self.dialog.set_response_sensitive(Gtk.ResponseType.CANCEL,
self.cancellable)
self.dialog.set_response_sensitive(Gtk.ResponseType.CANCEL, self.cancellable)
self.progressbar = Gtk.ProgressBar()
self.progressbar.set_show_text(True)
@ -100,6 +112,7 @@ class ProgressIndicator(object):
self._update_gui()
# previous self.source_id timeout is removed when this returns False
# use timeout_add, not util.idle_timeout_add, so it updates before Gtk+ redraws the dialog
self.source_id = GLib.timeout_add(self.INTERVAL, self._update_gui)
return False
@ -111,13 +124,6 @@ class ProgressIndicator(object):
self.next_update = time.time() + (self.INTERVAL / 1000)
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):
if self.progressbar:
self.progressbar.set_text(message)
@ -131,6 +137,39 @@ class ProgressIndicator(object):
else:
self._initial_progress = progress
def on_tick(self, final=False):
if final:
# Dialog is no longer cancellable
self.cancellable = False
if self.dialog is not None:
self.dialog.set_response_sensitive(Gtk.ResponseType.CANCEL, False)
self.dialog.set_deletable(False)
elif 2 * (time.time() - (self.next_update - (self.DELAY / 1000))) > (self.DELAY / 1000):
# Assume final operation will take as long as all ticks and open dialog
if self.source_id:
GLib.source_remove(self.source_id)
self._create_progress()
if self.max_ticks is not None and not final:
self.tick_counter += 1
if time.time() >= self.next_update or (final and self.dialog):
if type(final) == str:
self.on_message(final)
self.on_progress(1.0)
elif self.max_ticks is not None:
self.on_message('%d / %d' % (self.tick_counter, self.max_ticks))
self.on_progress(self.tick_counter / self.max_ticks)
# Allow UI to redraw.
util.idle_add(Gtk.main_quit)
# self._create_progress() or self._update_gui() is called by a timer to update the dialog
Gtk.main()
if self.cancelled:
return False
return True
def on_finished(self):
if self.dialog is not None:
if self.cancel_id:

View File

@ -51,6 +51,7 @@ class SearchTree:
if self.search_box.get_property('visible'):
if self._search_timeout is not None:
GLib.source_remove(self._search_timeout)
# use timeout_add, not util.idle_timeout_add, so it updates the TreeView before background tasks
self._search_timeout = GLib.timeout_add(
self.config.ui.gtk.live_search_delay,
self.set_search_term, editable.get_chars(0, -1))

View File

@ -104,6 +104,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.last_episode_date_refresh = None
self.refresh_episode_dates()
self.on_episode_list_selection_changed_id = None
def new(self):
if self.application.want_headerbar:
# Plus menu button
@ -252,7 +254,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.leaflet.set_visible_child(self.contentbox)
self.download_tasks_seen = set()
self.download_list_update_enabled = False
self.download_list_update_timer = None
self.things_adding_tasks = 0
self.download_task_monitors = set()
@ -419,6 +421,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
action.connect('activate', self.on_item_view_always_show_new_episodes_toggled)
g.add_action(action)
action = Gio.SimpleAction.new_stateful(
'viewTrimEpisodeTitlePrefix', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.trim_title_prefix))
action.connect('activate', self.on_item_view_trim_episode_title_prefix_toggled)
g.add_action(action)
action = Gio.SimpleAction.new_stateful(
'viewShowEpisodeDescription', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.descriptions))
action.connect('activate', self.on_item_view_show_episode_description_toggled)
@ -458,6 +465,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# ('toggleEpisodeLock', self.on_item_toggle_lock_activate),
('openEpisodeDownloadFolder', self.on_open_episode_download_folder),
('openChannelDownloadFolder', self.on_open_download_folder),
('selectChannel', self.on_select_channel_of_episode),
('findEpisode', self.on_find_episode_activate),
('toggleShownotes', self.on_shownotes_selected_episodes),
# Extras
@ -489,6 +497,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# self.toggle_episode_lock_action = g.lookup_action('toggleEpisodeLock')
self.episode_new_action = g.lookup_action('episodeNew')
self.open_episode_download_folder_action = g.lookup_action('openEpisodeDownloadFolder')
self.select_channel_of_episode_action = g.lookup_action('selectChannel')
# Extras
self.auto_archive_action = g.lookup_action('channelAutoArchive')
self.bluetooth_episodes_action = g.lookup_action('bluetoothEpisodes')
@ -583,9 +592,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
def progress_callback(title, progress):
self.partial_downloads_indicator.on_message(title)
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()
self.partial_downloads_indicator.on_tick() # not cancellable
def final_progress_callback():
self.partial_downloads_indicator.on_tick(final=_('Cleaning up...'))
def finish_progress_callback(resumable_episodes):
def offer_resuming():
@ -605,6 +615,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
common.find_partial_downloads(self.channels,
start_progress_callback,
progress_callback,
final_progress_callback,
finish_progress_callback)
def episode_object_by_uri(self, uri):
@ -1397,6 +1408,13 @@ class gPodder(BuilderWidget, dbus.service.Object):
self._search_episodes.hide_search()
def on_episode_list_selection_changed(self, selection):
# Only update the UI every 250ms to prevent lag when rapidly changing selected episode or shift-selecting episodes
if self.on_episode_list_selection_changed_id is None:
self.on_episode_list_selection_changed_id = util.idle_timeout_add(250, self._on_episode_list_selection_changed)
def _on_episode_list_selection_changed(self):
self.on_episode_list_selection_changed_id = None
# Update the toolbar buttons
self.play_or_download()
# and the shownotes
@ -1533,10 +1551,17 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.things_adding_tasks += 1
elif state == gPodderSyncUI.DL_ADDED_TASKS:
self.things_adding_tasks -= 1
if not self.download_list_update_enabled:
if self.download_list_update_timer is None:
self.update_downloads_list()
util.IdleTimeout(1500, self.update_downloads_list)
self.download_list_update_enabled = True
self.download_list_update_timer = util.IdleTimeout(1500, self.update_downloads_list).set_max_milliseconds(5000)
def stop_download_list_update_timer(self):
if self.download_list_update_timer is None:
return False
self.download_list_update_timer.cancel()
self.download_list_update_timer = None
return True
def cleanup_downloads(self):
model = self.download_status_model
@ -1700,7 +1725,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.cleanup_downloads()
# Stop updating the download list here
self.download_list_update_enabled = False
self.stop_download_list_update_timer()
self.gPodder.set_title(' - '.join(title))
@ -1709,7 +1734,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if channel_urls:
self.update_podcast_list_model(channel_urls)
return self.download_list_update_enabled
return (self.download_list_update_timer is not None)
except Exception as e:
logger.error('Exception happened while updating download list.', exc_info=True)
self.show_message(
@ -1727,6 +1752,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# self.toolbar.set_property('visible', new_value)
pass
elif name in ('ui.gtk.episode_list.descriptions',
'ui.gtk.episode_list.trim_title_prefix',
'ui.gtk.episode_list.always_show_new'):
self.update_episode_list_model()
elif name in ('auto.update.enabled', 'auto.update.frequency'):
@ -2012,40 +2038,25 @@ class gPodder(BuilderWidget, dbus.service.Object):
else:
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):
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)
'', True, self.get_dialog_parent(), 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)
restart_timer = self.stop_download_list_update_timer()
self.download_queue_manager.disable()
self.__for_each_task_set_status(tasks, status, force_start, progress_indicator, restart_timer)
self.download_queue_manager.enable()
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
def __for_each_task_set_status(self, tasks, status, force_start=False, progress_indicator=None, restart_timer=False):
episode_urls = set()
model = self.treeDownloads.get_model()
has_queued_tasks = False
@ -2095,13 +2106,14 @@ class gPodder(BuilderWidget, dbus.service.Object):
else:
# We can (hopefully) simply set the task status here
task.status = status
if progress_callback:
n += 1
if not progress_callback('%d / %d' % (n, count), n / count):
if progress_indicator:
if not progress_indicator.on_tick():
break
if progress_indicator:
progress_indicator.on_tick(final=_('Updating...'))
# Update the tab title and downloads list
if has_queued_tasks:
if has_queued_tasks or restart_timer:
self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
else:
self.update_downloads_list()
@ -2156,9 +2168,18 @@ class gPodder(BuilderWidget, dbus.service.Object):
assert len(episodes) == 1
util.gui_open(episodes[0].parent.save_dir, gui=self)
def on_select_channel_of_episode(self, unused1=None, unused2=None):
episodes = self.get_selected_episodes()
assert len(episodes) == 1
channel = episodes[0].parent
# Focus channel list
self.treeChannels.grab_focus()
# Select channel in list
path = self.podcast_list_model.get_filter_path_from_url(channel.url)
self.treeChannels.set_cursor(path)
def treeview_channels_show_context_menu(self, event=None):
treeview = self.treeChannels
model, paths = self.treeview_handle_context_menu_click(treeview, event)
if not paths:
return True
@ -2465,6 +2486,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# self.toggle_episode_lock_action.set_enabled(can_lock)
self.episode_lock_action.set_enabled(can_play)
self.open_episode_download_folder_action.set_enabled(len(episodes) == 1)
self.select_channel_of_episode_action.set_enabled(len(episodes) == 1)
# Episodes context menu
self.episode_new_action.set_enabled(is_episode_selected)
@ -2698,7 +2720,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if remaining_seconds > 3600:
# timeout an hour early in the event daylight savings changes the clock forward
remaining_seconds = remaining_seconds - 3600
GLib.timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
util.idle_timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
sections_changed=False):
@ -3115,6 +3137,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
def update_feed_cache_proc():
updated_channels = []
nr_update_errors = 0
new_episodes = []
for updated, channel in enumerate(channels):
if self.feed_cache_update_cancelled:
break
@ -3128,7 +3151,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
try:
channel._update_error = None
util.idle_add(indicate_updating_podcast, channel)
channel.update(max_episodes=self.config.limit.episodes)
new_episodes.extend(channel.update(max_episodes=self.config.limit.episodes))
self._update_cover(channel)
except Exception as e:
message = str(e)
@ -3172,7 +3195,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
nr_update_errors) % {'count': nr_update_errors},
_('Error while updating feeds'), widget=self.treeChannels)
def update_feed_cache_finish_callback():
def update_feed_cache_finish_callback(new_episodes):
# Process received episode actions for all updated URLs
self.process_received_episode_actions()
@ -3185,9 +3208,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
# The user decided to abort the feed update
self.show_update_feeds_buttons()
# Only search for new episodes in podcasts that have been
# updated, not in other podcasts (for single-feed updates)
episodes = self.get_new_episodes([c for c in updated_channels])
# The filter extension can mark newly added episodes as old,
# so take only episodes marked as new.
episodes = ((e for e in new_episodes if e.check_is_new())
if self.config.ui.gtk.only_added_are_new
else self.get_new_episodes([c for c in updated_channels]))
if self.config.downloads.chronological_order:
# download older episodes first
@ -3246,7 +3271,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
GLib.timeout_add(2000, hide_update)
util.idle_add(update_feed_cache_finish_callback)
util.idle_add(update_feed_cache_finish_callback, new_episodes)
def on_gPodder_delete_event(self, *args):
"""Called when the GUI wants to close the window
@ -3550,34 +3575,44 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.download_episode_list(episodes, True, hide_progress=hide_progress)
def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None, hide_progress=False):
def queue_tasks(tasks, queued_existing_task):
n = 0
count = len(tasks)
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
# Start progress indicator to queue existing tasks
count = len(episodes)
if count and not hide_progress:
progress_indicator = ProgressIndicator(
_('Queueing'),
'', True, self.get_dialog_parent(), count)
else:
progress_indicator = None
for task in tasks:
with task:
if add_paused:
task.status = task.PAUSED
else:
self.mygpo_client.on_download([task.episode])
self.queue_task(task, force_start)
restart_timer = self.stop_download_list_update_timer()
self.download_queue_manager.disable()
def queue_tasks(tasks, queued_existing_task):
if progress_indicator is None or not progress_indicator.cancelled:
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:
count = len(tasks)
if count:
# Restart progress indicator to queue new tasks
progress_indicator.set_max_ticks(count)
progress_indicator.on_progress(0.0)
for task in tasks:
with task:
if add_paused:
task.status = task.PAUSED
else:
self.mygpo_client.on_download([task.episode])
self.queue_task(task, force_start)
if progress_indicator:
if not progress_indicator.on_tick():
break
if tasks or queued_existing_task:
if progress_indicator:
progress_indicator.on_tick(final=_('Updating...'))
self.download_queue_manager.enable()
# Update the tab title and downloads list
if tasks or queued_existing_task or restart_timer:
self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
# Flush updated episode status
if self.mygpo_client.can_access_webservice():
@ -3594,6 +3629,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
episodes = list(Model.sort_episodes_by_pubdate(episodes))
for episode in episodes:
if progress_indicator:
# The continues require ticking before doing the work
if not progress_indicator.on_tick():
break
logger.debug('Downloading episode: %s', episode.title)
if not episode.was_downloaded(and_exists=True):
episode._download_error = None
@ -3638,14 +3678,30 @@ class gPodder(BuilderWidget, dbus.service.Object):
if not tasks:
return
progress_indicator = ProgressIndicator(
download.DownloadTask.STATUS_MESSAGE[download.DownloadTask.CANCELLING],
'', True, self.get_dialog_parent(), len(tasks))
restart_timer = self.stop_download_list_update_timer()
self.download_queue_manager.disable()
for task in tasks:
task.cancel()
if not progress_indicator.on_tick():
break
progress_indicator.on_tick(final=_('Updating...'))
self.download_queue_manager.enable()
self.update_episode_list_icons([task.url for task in tasks])
self.play_or_download()
# Update the tab title and downloads list
self.update_downloads_list()
if restart_timer:
self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
else:
self.update_downloads_list()
progress_indicator.on_finished()
def new_episodes_show(self, episodes, notification=False, selected=None):
columns = (
@ -3755,6 +3811,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.config.ui.gtk.episode_list.always_show_new = not state
action.set_state(GLib.Variant.new_boolean(not state))
def on_item_view_trim_episode_title_prefix_toggled(self, action, param):
state = action.get_state()
self.config.ui.gtk.episode_list.trim_title_prefix = not state
action.set_state(GLib.Variant.new_boolean(not state))
def on_item_view_show_episode_description_toggled(self, action, param):
state = action.get_state()
self.config.ui.gtk.episode_list.descriptions = not state
@ -4300,8 +4361,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
interval = 60 * 1000 * self.config.auto.update.frequency
logger.debug('Setting up auto update timer with interval %d.',
self.config.auto.update.frequency)
self._auto_update_timer_source_id = GLib.timeout_add(
interval, self._on_auto_update_timer)
self._auto_update_timer_source_id = util.idle_timeout_add(interval, self._on_auto_update_timer)
def _on_auto_update_timer(self):
if self.config.check_connection and not util.connection_available():

View File

@ -215,10 +215,12 @@ class EpisodeListModel(Gtk.ListStore):
# Caching config values is faster than accessing them directly from config.ui.gtk.episode_list.*
# and is easier to maintain then threading them through every method call.
self._config_ui_gtk_episode_list_always_show_new = False
self._config_ui_gtk_episode_list_trim_title_prefix = False
self._config_ui_gtk_episode_list_descriptions = False
def cache_config(self, config):
self._config_ui_gtk_episode_list_always_show_new = config.ui.gtk.episode_list.always_show_new
self._config_ui_gtk_episode_list_trim_title_prefix = config.ui.gtk.episode_list.trim_title_prefix
self._config_ui_gtk_episode_list_descriptions = config.ui.gtk.episode_list.descriptions
def _format_filesize(self, episode):
@ -294,7 +296,7 @@ class EpisodeListModel(Gtk.ListStore):
def _format_description(self, episode):
d = []
title = episode.trimmed_title
title = episode.trimmed_title if self._config_ui_gtk_episode_list_trim_title_prefix else episode.title
if episode.state != gpodder.STATE_DELETED and episode.is_new:
d.append('<b>')
d.append(html.escape(title))

View File

@ -292,6 +292,11 @@ class PodcastEpisode(PodcastModelObject):
episode.title = entry['title']
episode.link = entry['link']
episode.episode_art_url = entry.get('episode_art_url')
# Only one of the two description fields should be set at a time.
# This keeps the database from doubling in size and reduces load time from slow storage.
# episode._text_description is initialized by episode.cache_text_description() from the set field.
# episode.html_description() returns episode.description_html or generates from episode.description.
if entry.get('description_html'):
episode.description = ''
episode.description_html = entry['description_html']
@ -1018,6 +1023,9 @@ class PodcastChannel(PodcastModelObject):
known_files.add(filename)
# youtube-dl and yt-dlp create <name>.partial and <name>.partial.<ext> files while downloading.
# On startup, the latter is reported as an unknown external file.
# Both files are properly removed when the download completes.
existing_files = set(filename for filename in
glob.glob(os.path.join(self.save_dir, '*'))
if not filename.endswith('.partial'))
@ -1222,7 +1230,7 @@ class PodcastChannel(PodcastModelObject):
next_feed = None
# mark episodes not new
real_new_episode_count = 0
real_new_episodes = []
# Search all entries for new episodes
for episode in new_episodes:
# Workaround for bug 340: If the episode has been
@ -1234,17 +1242,18 @@ class PodcastChannel(PodcastModelObject):
episode.save()
if episode.is_new:
real_new_episode_count += 1
real_new_episodes.append(episode)
# Only allow a certain number of new episodes per update
if (self.download_strategy == PodcastChannel.STRATEGY_LATEST
and real_new_episode_count > 1):
and len(real_new_episodes) > 1):
episode.is_new = False
episode.save()
self.children.extend(new_episodes)
self.remove_unreachable_episodes(existing, seen_guids, max_episodes)
return real_new_episodes
def remove_unreachable_episodes(self, existing, seen_guids, max_episodes):
# Remove "unreachable" episodes - episodes that have not been
@ -1276,11 +1285,12 @@ class PodcastChannel(PodcastModelObject):
def update(self, max_episodes=0):
max_episodes = int(max_episodes)
new_episodes = []
try:
result = self.feed_fetcher.fetch_channel(self, max_episodes)
if result.status == feedcore.UPDATED_FEED:
self._consume_updated_feed(result.feed, max_episodes)
new_episodes = self._consume_updated_feed(result.feed, max_episodes)
elif result.status == feedcore.NEW_LOCATION:
# FIXME: could return the feed because in autodiscovery it is parsed already
url = result.feed
@ -1290,7 +1300,7 @@ class PodcastChannel(PodcastModelObject):
self.url = url
# With the updated URL, fetch the feed again
self.update(max_episodes)
return
return new_episodes
elif result.status == feedcore.NOT_MODIFIED:
pass
@ -1317,6 +1327,7 @@ class PodcastChannel(PodcastModelObject):
self._determine_common_prefix()
self.db.commit()
return new_episodes
def delete(self):
self.db.delete_podcast(self)

View File

@ -121,6 +121,12 @@ class Matcher(object):
return episode.channel.title
elif k == 'section':
return episode.channel.section
elif k == 'url':
return episode.url
elif k == 'link':
return episode.link
elif k == 'filename':
return episode.download_filename
raise KeyError(k)

View File

@ -256,7 +256,10 @@ class gPodderSyncUI(object):
# if playlist doesn't exist (yet) episodes_in_playlist will be empty
if episodes_in_playlists:
for episode_filename in episodes_in_playlists:
if not playlist.mountpoint.resolve_relative_path(episode_filename).query_exists():
if ((not self._config.device_sync.playlists.use_absolute_path
and not playlist.playlist_folder.resolve_relative_path(episode_filename).query_exists()) or
(self._config.device_sync.playlists.use_absolute_path
and not playlist.mountpoint.resolve_relative_path(episode_filename).query_exists())):
# episode was synced but no longer on device
# i.e. must have been deleted by user, so delete from gpodder
try:

View File

@ -1320,8 +1320,25 @@ def idle_add(func, *args):
func(*args)
def idle_timeout_add(milliseconds, func, *args):
"""Run a function in the main GUI thread at regular intervals, at idle priority
PRIORITY_HIGH -100
PRIORITY_DEFAULT 0 timeout_add()
PRIORITY_HIGH_IDLE 100
resizing 110
redraw 120
PRIORITY_DEFAULT_IDLE 200 idle_add()
PRIORITY_LOW 300
"""
if not gpodder.ui.gtk:
raise Exception('util.idle_timeout_add() is only supported by Gtk+')
from gi.repository import GLib
return GLib.timeout_add(milliseconds, func, *args, priority=GLib.PRIORITY_DEFAULT_IDLE)
class IdleTimeout(object):
"""Run a function in the main GUI thread at regular intervals since the last run
"""Run a function in the main GUI thread at regular intervals since the last run, at idle priority
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
@ -1331,15 +1348,28 @@ class IdleTimeout(object):
if not gpodder.ui.gtk:
raise Exception('util.IdleTimeout() is only supported by Gtk+')
self.milliseconds = milliseconds
self.max_milliseconds = 0
self.func = func
from gi.repository import GLib
self.id = GLib.timeout_add(milliseconds, self._callback, *args)
self.id = GLib.timeout_add(milliseconds, self._callback, *args, priority=GLib.PRIORITY_DEFAULT_IDLE)
def set_max_milliseconds(self, max):
self.max_milliseconds = max
return self
def _callback(self, *args):
self.cancel()
start_time = time.time()
if self.func(*args):
if self.max_milliseconds > self.milliseconds:
duration = round((time.time() - start_time) * 1000)
if duration > self.max_milliseconds:
duration = self.max_milliseconds
milliseconds = round(lerp(self.milliseconds, self.max_milliseconds, duration / self.max_milliseconds))
else:
milliseconds = self.milliseconds
from gi.repository import GLib
self.id = GLib.timeout_add(self.milliseconds, self._callback, *args)
self.id = GLib.timeout_add(milliseconds, self._callback, *args, priority=GLib.PRIORITY_DEFAULT_IDLE)
def cancel(self):
if self.id:
@ -1348,6 +1378,12 @@ class IdleTimeout(object):
self.id = 0
def lerp(a, b, f):
"""Linear interpolation between 'a' and 'b', where 'f' is between 0.0 and 1.0
"""
return ((1.0 - f) * a) + (f * b)
def bluetooth_available():
"""
Returns True or False depending on the availability

View File

@ -453,14 +453,14 @@ def get_channel_id_url(url, feed_data=None):
if m:
channel_id = m.group(1)
if channel_id is None:
raise Exception('Could not retrieve youtube channel id.')
raise Exception('Could not retrieve YouTube channel ID for URL %s.' % url)
channel_url = 'https://www.youtube.com/channel/{}'.format(channel_id)
return channel_url
except Exception:
logger.warning('Could not retrieve youtube channel id.', exc_info=True)
logger.warning('Could not retrieve YouTube channel ID for URL %s.' % url, exc_info=True)
raise Exception('Could not retrieve youtube channel id.')
raise Exception('Could not retrieve YouTube channel ID for URL %s.' % url)
def get_cover(url, feed_data=None):

View File

@ -67,13 +67,13 @@ cp -a "$checkout"/tools/mac-osx/make_cert_pem.py "$resources"/bin
# install gPodder hard dependencies
$run_pip install setuptools==64.0.3 wheel || exit 1
$run_pip install mygpoclient==1.9 podcastparser==0.6.8 requests[socks]==2.28.1 || exit 1
$run_pip install mygpoclient==1.9 podcastparser==0.6.9 requests[socks]==2.28.1 || exit 1
# install brotli and pycryptodomex (build from source)
$run_pip debug -v
$run_pip install -v brotli || exit 1
$run_pip install -v pycryptodomex || exit 1
# install extension dependencies; no explicit version for yt-dlp
$run_pip install html5lib==1.1 mutagen==1.45.1 yt-dlp || exit 1
$run_pip install html5lib==1.1 mutagen==1.46.0 yt-dlp || exit 1
cd "$checkout"
touch share/applications/gpodder{,-url-handler}.desktop

View File

@ -3,11 +3,11 @@
#
dbus-python
html5lib==1.1
mutagen==1.45.1
mutagen==1.46.0
mygpoclient==1.9
podcastparser==0.6.8
podcastparser==0.6.9
requests[socks]==2.28.1
urllib3==1.26.10
urllib3==1.26.13
yt-dlp
# eyed3 is optional and pulls in a lot of dependencies, so disable by default
# eyed3

View File

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