Merge commit 'cd35e29fa1d627d206b556421d92907a64ed028c' into dev-adaptive
This commit is contained in:
commit
d3fc65377f
|
@ -3,10 +3,10 @@ version: 2
|
|||
jobs:
|
||||
release-from-macos:
|
||||
macos:
|
||||
xcode: "11.4.1"
|
||||
xcode: "13.2.1"
|
||||
shell: /bin/bash --login -o pipefail
|
||||
environment:
|
||||
- BUNDLE_TAG: 21.4.27
|
||||
- BUNDLE_TAG: 22.3.7
|
||||
steps:
|
||||
- checkout
|
||||
- run: >
|
||||
|
|
|
@ -12,3 +12,4 @@ share/applications/gpodder-url-handler.desktop
|
|||
share/applications/gpodder.desktop
|
||||
share/dbus-1/services/org.gpodder.service
|
||||
share/locale/
|
||||
venv/*
|
||||
|
|
|
@ -56,7 +56,7 @@ PyPI. With this, you get a self-contained gPodder CLI codebase.
|
|||
- Clickable links in GTK UI show notes: html5lib
|
||||
- HTML show notes: WebKit2 gobject bindings
|
||||
(webkit2gtk, webkitgtk4 or gir1.2-webkit2-4.0 packages).
|
||||
- Better Youtube support (> 15 entries in feeds, download audio-only): youtube_dl
|
||||
- Better Youtube support (> 15 entries in feeds, download audio-only): youtube_dl or yt-dlp
|
||||
|
||||
|
||||
### Build Dependencies
|
||||
|
|
10
bin/gpo
10
bin/gpo
|
@ -955,6 +955,16 @@ class gPodderCli(object):
|
|||
task.status = sync.SyncTask.DOWNLOADING
|
||||
task.add_progress_callback(progress_updated)
|
||||
task.run()
|
||||
|
||||
if task.notify_as_finished():
|
||||
if self._config.device_sync.after_sync.mark_episodes_played:
|
||||
logger.info('Marking as played on transfer: %s', task.episode.url)
|
||||
task.episode.mark(is_played=True)
|
||||
|
||||
if self._config.device_sync.after_sync.delete_episodes:
|
||||
logger.info('Removing episode after transfer: %s', task.episode.url)
|
||||
task.episode.delete_from_disk()
|
||||
|
||||
task.recycle()
|
||||
|
||||
done_lock = threading.Lock()
|
||||
|
|
488
po/cs_CZ.po
488
po/cs_CZ.po
File diff suppressed because it is too large
Load Diff
498
po/es_ES.po
498
po/es_ES.po
File diff suppressed because it is too large
Load Diff
488
po/es_MX.po
488
po/es_MX.po
File diff suppressed because it is too large
Load Diff
484
po/fa_IR.po
484
po/fa_IR.po
File diff suppressed because it is too large
Load Diff
484
po/id_ID.po
484
po/id_ID.po
File diff suppressed because it is too large
Load Diff
488
po/ko_KR.po
488
po/ko_KR.po
File diff suppressed because it is too large
Load Diff
484
po/messages.pot
484
po/messages.pot
File diff suppressed because it is too large
Load Diff
491
po/pt_BR.po
491
po/pt_BR.po
File diff suppressed because it is too large
Load Diff
497
po/zh_CN.po
497
po/zh_CN.po
File diff suppressed because it is too large
Load Diff
|
@ -10,7 +10,10 @@ import re
|
|||
import sys
|
||||
import time
|
||||
|
||||
import youtube_dl
|
||||
try:
|
||||
import yt_dlp as youtube_dl
|
||||
except:
|
||||
import youtube_dl
|
||||
from youtube_dl.utils import DownloadError, ExtractorError, sanitize_url
|
||||
|
||||
import gpodder
|
||||
|
@ -25,13 +28,13 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
__title__ = 'Youtube-dl'
|
||||
__description__ = _('Manage Youtube subscriptions using youtube-dl (pip install youtube_dl)')
|
||||
__description__ = _('Manage Youtube subscriptions using youtube-dl (pip install youtube_dl) or yt-dlp (pip install yt-dlp)')
|
||||
__only_for__ = 'gtk, cli'
|
||||
__authors__ = 'Eric Le Lay <elelay.fr:contact>'
|
||||
__doc__ = 'https://gpodder.github.io/docs/extensions/youtubedl.html'
|
||||
|
||||
want_ytdl_version = '2021.02.04'
|
||||
want_ytdl_version_msg = _('Your version of youtube-dl %(have_version)s has known issues, please upgrade to %(want_version)s or newer.')
|
||||
want_ytdl_version_msg = _('Your version of youtube-dl/yt-dlp %(have_version)s has known issues, please upgrade to %(want_version)s or newer.')
|
||||
|
||||
DefaultConfig = {
|
||||
# youtube-dl downloads and parses each video page to get informations about it, which is very slow.
|
||||
|
@ -99,18 +102,16 @@ class YoutubeCustomDownload(download.CustomDownload):
|
|||
# See #673 when merging multiple formats, the extension is appended to the tempname
|
||||
# by YoutubeDL resulting in empty .partial file + .partial.mp4 exists
|
||||
# and #796 .mkv is chosen by ytdl sometimes
|
||||
tempstat = os.stat(tempname)
|
||||
if not tempstat.st_size:
|
||||
for try_ext in (dot_ext, ".mp4", ".m4a", ".webm", ".mkv"):
|
||||
tempname_with_ext = tempname + try_ext
|
||||
if os.path.isfile(tempname_with_ext):
|
||||
logger.debug('Youtubedl downloaded to "%s" instead of "%s", moving',
|
||||
os.path.basename(tempname_with_ext),
|
||||
os.path.basename(tempname))
|
||||
os.remove(tempname)
|
||||
os.rename(tempname_with_ext, tempname)
|
||||
dot_ext = try_ext
|
||||
break
|
||||
for try_ext in (dot_ext, ".mp4", ".m4a", ".webm", ".mkv"):
|
||||
tempname_with_ext = tempname + try_ext
|
||||
if os.path.isfile(tempname_with_ext):
|
||||
logger.debug('Youtubedl downloaded to "%s" instead of "%s", moving',
|
||||
os.path.basename(tempname_with_ext),
|
||||
os.path.basename(tempname))
|
||||
os.remove(tempname)
|
||||
os.rename(tempname_with_ext, tempname)
|
||||
dot_ext = try_ext
|
||||
break
|
||||
ext_filetype = mimetype_from_extension(dot_ext)
|
||||
if ext_filetype:
|
||||
# Youtube weba formats have a webm extension and get a video/webm mime-type
|
||||
|
@ -262,6 +263,7 @@ class gPodderYoutubeDL(download.CustomDownloader):
|
|||
self._ydl_opts = {
|
||||
'cachedir': cachedir,
|
||||
'no_color': True, # prevent escape codes in desktop notifications on errors
|
||||
'noprogress': True, # prevent progress bar from appearing in console
|
||||
}
|
||||
if gpodder.verbose:
|
||||
self._ydl_opts['verbose'] = True
|
||||
|
@ -406,7 +408,11 @@ class gPodderYoutubeDL(download.CustomDownloader):
|
|||
self.regex_cache.insert(0, r)
|
||||
return True
|
||||
with youtube_dl.YoutubeDL(self._ydl_opts) as ydl:
|
||||
for ie in ydl._ies:
|
||||
# youtube-dl returns a list, yt-dlp returns a dict
|
||||
ies = ydl._ies
|
||||
if type(ydl._ies) == dict:
|
||||
ies = ydl._ies.values()
|
||||
for ie in ies:
|
||||
if ie.suitable(url) and ie.ie_key() not in self.ie_blacklist:
|
||||
self.regex_cache.insert(0, ie._VALID_URL_RE)
|
||||
return True
|
||||
|
|
|
@ -27,13 +27,14 @@
|
|||
<property name="layout-style">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnCancel">
|
||||
<property name="label" translatable="yes">Cancel</property>
|
||||
<property name="label" translatable="yes">_Cancel</property>
|
||||
<property name="use-action-appearance">False</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="can-default">True</property>
|
||||
<property name="has-default">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnCancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -44,7 +45,7 @@
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnOK">
|
||||
<property name="label" translatable="yes">OK</property>
|
||||
<property name="label" translatable="yes">_OK</property>
|
||||
<property name="use-action-appearance">False</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
|
@ -52,6 +53,7 @@
|
|||
<property name="can-default">True</property>
|
||||
<property name="has-default">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnOK_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -208,10 +210,11 @@
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="title_save_button">
|
||||
<property name="label" translatable="yes">Save</property>
|
||||
<property name="label" translatable="yes">_Save</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="valign">center</property>
|
||||
<signal name="clicked" handler="on_title_save_button_clicked" swapped="no"/>
|
||||
</object>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<property name="window-position">center-on-parent</property>
|
||||
<property name="type-hint">dialog</property>
|
||||
<property name="action">save</property>
|
||||
<property name="do-overwrite-confirmation">True</property>
|
||||
<property name="do-overwrite-confirmation">False</property>
|
||||
<property name="extra-widget">allsamefolder</property>
|
||||
<property name="preview-widget-active">False</property>
|
||||
<property name="use-preview-label">False</property>
|
||||
|
@ -29,36 +29,10 @@
|
|||
<property name="can-focus">False</property>
|
||||
<property name="layout-style">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnCancel">
|
||||
<property name="label" translatable="yes">_Cancel</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnCancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnOK">
|
||||
<property name="label" translatable="yes">_Save</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="can-default">True</property>
|
||||
<property name="has-default">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnOK_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -69,9 +43,5 @@
|
|||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<action-widgets>
|
||||
<action-widget response="-6">btnCancel</action-widget>
|
||||
<action-widget response="-3">btnOK</action-widget>
|
||||
</action-widgets>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
|
@ -172,7 +172,7 @@
|
|||
<property name="layout-style">start</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnSelectAll">
|
||||
<property name="label" translatable="yes">Select All</property>
|
||||
<property name="label" translatable="yes">Select _all</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
|
@ -187,7 +187,7 @@
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnSelectNone">
|
||||
<property name="label" translatable="yes">Select None</property>
|
||||
<property name="label" translatable="yes">Select _none</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
|
@ -215,12 +215,13 @@
|
|||
<property name="layout-style">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnCancel">
|
||||
<property name="label" translatable="yes">Cancel</property>
|
||||
<property name="label" translatable="yes">_Cancel</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="can-default">True</property>
|
||||
<property name="has-default">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnCancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
|
|
|
@ -40,6 +40,8 @@ def clean_up_downloads(delete_partial=False):
|
|||
|
||||
if delete_partial:
|
||||
temporary_files += glob.glob('%s/*/*.partial' % gpodder.downloads)
|
||||
# YoutubeDL creates .partial.* files for adaptive formats
|
||||
temporary_files += glob.glob('%s/*/*.partial.*' % gpodder.downloads)
|
||||
|
||||
for tempfile in temporary_files:
|
||||
util.delete_file(tempfile)
|
||||
|
@ -53,7 +55,7 @@ def find_partial_downloads(channels, start_progress_callback, progress_callback,
|
|||
progress_callback - A callback(title, progress) when an episode was found
|
||||
finish_progress_callback - A callback(resumable_episodes) when finished
|
||||
"""
|
||||
# Look for partial file downloads
|
||||
# Look for partial file downloads, ignoring .partial.* files created by YoutubeDL
|
||||
partial_files = glob.glob(os.path.join(gpodder.downloads, '*', '*.partial'))
|
||||
count = len(partial_files)
|
||||
resumable_episodes = []
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
|
||||
import collections
|
||||
import email
|
||||
import glob
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
|
@ -631,9 +632,17 @@ class DownloadTask(object):
|
|||
elif self.status == self.DOWNLOADING:
|
||||
self.status = self.CANCELLING
|
||||
|
||||
def delete_partial_files(self):
|
||||
temporary_files = [self.tempname]
|
||||
# YoutubeDL creates .partial.* files for adaptive formats
|
||||
temporary_files += glob.glob('%s.*' % self.tempname)
|
||||
|
||||
for tempfile in temporary_files:
|
||||
util.delete_file(tempfile)
|
||||
|
||||
def removed_from_list(self):
|
||||
if self.status != self.DONE:
|
||||
util.delete_file(self.tempname)
|
||||
self.delete_partial_files()
|
||||
|
||||
def __init__(self, episode, config, downloader=None):
|
||||
assert episode.download_task is None
|
||||
|
@ -684,6 +693,11 @@ class DownloadTask(object):
|
|||
# Store a reference to this task in the episode
|
||||
episode.download_task = self
|
||||
|
||||
def reuse(self):
|
||||
if not os.path.exists(self.tempname):
|
||||
# partial file was deleted when cancelled, recreate it
|
||||
open(self.tempname, 'w').close()
|
||||
|
||||
def notify_as_finished(self):
|
||||
if self.status == DownloadTask.DONE:
|
||||
if self._notification_shown:
|
||||
|
@ -791,7 +805,7 @@ class DownloadTask(object):
|
|||
with self:
|
||||
if self.status == DownloadTask.CANCELLING:
|
||||
self.status = DownloadTask.CANCELLED
|
||||
util.delete_file(self.tempname)
|
||||
self.delete_partial_files()
|
||||
self.progress = 0.0
|
||||
self.speed = 0.0
|
||||
self.recycle()
|
||||
|
@ -892,7 +906,7 @@ class DownloadTask(object):
|
|||
except DownloadCancelledException:
|
||||
logger.info('Download has been cancelled/paused: %s', self)
|
||||
if self.status == DownloadTask.CANCELLING:
|
||||
util.delete_file(self.tempname)
|
||||
self.delete_partial_files()
|
||||
self.progress = 0.0
|
||||
self.speed = 0.0
|
||||
result = DownloadTask.CANCELLED
|
||||
|
|
|
@ -110,6 +110,12 @@ class FeedAutodiscovery(HTMLParser):
|
|||
self._resolved_url = url
|
||||
|
||||
|
||||
class FetcherFeedData:
|
||||
def __init__(self, text, content):
|
||||
self.text = text
|
||||
self.content = content
|
||||
|
||||
|
||||
class Fetcher(object):
|
||||
# Supported types, see http://feedvalidator.org/docs/warning/EncodingMismatch.html
|
||||
FEED_TYPES = ('application/rss+xml',
|
||||
|
@ -152,7 +158,7 @@ class Fetcher(object):
|
|||
else:
|
||||
raise UnknownStatusCode(status)
|
||||
|
||||
def parse_feed(self, url, data_stream, headers, status, **kwargs):
|
||||
def parse_feed(self, url, feed_data, data_stream, headers, status, **kwargs):
|
||||
"""
|
||||
kwargs are passed from Fetcher.fetch
|
||||
:param str url: real url
|
||||
|
@ -169,7 +175,7 @@ class Fetcher(object):
|
|||
if url.startswith('file://'):
|
||||
url = url[len('file://'):]
|
||||
stream = open(url)
|
||||
return self.parse_feed(url, stream, {}, UPDATED_FEED, **kwargs)
|
||||
return self.parse_feed(url, None, stream, {}, UPDATED_FEED, **kwargs)
|
||||
|
||||
# remote feed
|
||||
headers = {}
|
||||
|
@ -210,4 +216,5 @@ class Fetcher(object):
|
|||
# xml documents specify the encoding inline so better pass encoded body.
|
||||
# Especially since requests will use ISO-8859-1 for content-type 'text/xml'
|
||||
# if the server doesn't specify a charset.
|
||||
return self.parse_feed(url, BytesIO(stream.content), stream.headers, UPDATED_FEED, **kwargs)
|
||||
return self.parse_feed(url, FetcherFeedData(stream.text, stream.content), BytesIO(stream.content), stream.headers,
|
||||
UPDATED_FEED, **kwargs)
|
||||
|
|
|
@ -196,7 +196,7 @@ class gPodderChannel(BuilderWidget):
|
|||
# Title editing callbacks
|
||||
def on_title_edit_button_clicked(self, button):
|
||||
self.title_save_button_saves = True
|
||||
self.title_save_button.set_label(_("Save"))
|
||||
self.title_save_button.set_label(_("_Save"))
|
||||
self.title_stack.set_visible_child(self.title_edit_box)
|
||||
self.title_entry.set_text(self.title_label.get_text())
|
||||
self.title_entry.grab_focus()
|
||||
|
@ -204,7 +204,7 @@ class gPodderChannel(BuilderWidget):
|
|||
def on_title_entry_changed(self, entry):
|
||||
if len(entry.get_text()) > 0:
|
||||
self.title_save_button_saves = True
|
||||
self.title_save_button.set_label(_("Save"))
|
||||
self.title_save_button.set_label(_("_Save"))
|
||||
else:
|
||||
self.title_save_button_saves = False
|
||||
self.title_save_button.set_label(_("Cancel"))
|
||||
|
|
|
@ -279,11 +279,11 @@ class gPodderEpisodeSelector(BuilderWidget):
|
|||
menu.append(item)
|
||||
menu.append(Gtk.SeparatorMenuItem())
|
||||
|
||||
item = Gtk.MenuItem(_('Select all'))
|
||||
item = Gtk.MenuItem(_('Select _all'))
|
||||
item.connect('activate', self.on_btnCheckAll_clicked)
|
||||
menu.append(item)
|
||||
|
||||
item = Gtk.MenuItem(_('Select none'))
|
||||
item = Gtk.MenuItem(_('Select _none'))
|
||||
item.connect('activate', self.on_btnCheckNone_clicked)
|
||||
menu.append(item)
|
||||
|
||||
|
|
|
@ -32,17 +32,12 @@ class gPodderExportToLocalFolder(BuilderWidget):
|
|||
""" Export to Local Folder UI: file dialog + checkbox to save all to same folder """
|
||||
def new(self):
|
||||
self.gPodderExportToLocalFolder.set_transient_for(self.parent_widget)
|
||||
self.RES_CANCEL = -6
|
||||
self.RES_SAVE = -3
|
||||
self.gPodderExportToLocalFolder.add_buttons("_Cancel", self.RES_CANCEL,
|
||||
"_Save", self.RES_SAVE)
|
||||
self._config.connect_gtk_window(self.gPodderExportToLocalFolder,
|
||||
'export_to_local_folder', True)
|
||||
self._ok = False
|
||||
self.gPodderExportToLocalFolder.hide()
|
||||
|
||||
def on_btnOK_clicked(self, widget):
|
||||
self._ok = True
|
||||
self.gPodderExportToLocalFolder.hide()
|
||||
|
||||
def on_btnCancel_clicked(self, widget):
|
||||
self.gPodderExportToLocalFolder.hide()
|
||||
|
||||
def save_as(self, initial_directory, filename, remaining=0):
|
||||
"""
|
||||
|
@ -64,9 +59,9 @@ class gPodderExportToLocalFolder(BuilderWidget):
|
|||
initial_directory = os.path.expanduser('~')
|
||||
self.gPodderExportToLocalFolder.set_current_folder(initial_directory)
|
||||
self.gPodderExportToLocalFolder.set_current_name(filename)
|
||||
self._ok = False
|
||||
self.gPodderExportToLocalFolder.run()
|
||||
notCancelled = self._ok
|
||||
res = self.gPodderExportToLocalFolder.run()
|
||||
self.gPodderExportToLocalFolder.hide()
|
||||
notCancelled = (res == self.RES_SAVE)
|
||||
allRemainingDefault = self.allsamefolder.get_active()
|
||||
if notCancelled:
|
||||
folder = self.gPodderExportToLocalFolder.get_current_folder()
|
||||
|
|
|
@ -708,6 +708,13 @@ class gPodder(BuilderWidget, dbus.service.Object):
|
|||
message = str(e)
|
||||
if not message:
|
||||
message = e.__class__.__name__
|
||||
if message == 'NotFound':
|
||||
message = _(
|
||||
'Could not find your device.\n'
|
||||
'\n'
|
||||
'Check login is a username (not an email)\n'
|
||||
'and that the device name matches one in your account.'
|
||||
)
|
||||
self.show_message(html.escape(message),
|
||||
_('Error while uploading'),
|
||||
important=True)
|
||||
|
@ -2077,9 +2084,9 @@ class gPodder(BuilderWidget, dbus.service.Object):
|
|||
if os.path.exists(copy_to):
|
||||
logger.warn(copy_from)
|
||||
logger.warn(copy_to)
|
||||
title = _('File already exist')
|
||||
title = _('File already exists')
|
||||
d = {'filename': os.path.basename(copy_to)}
|
||||
message = _('A file named "%(filename)s" already exist. Do you want to replace it?') % d
|
||||
message = _('A file named "%(filename)s" already exists. Do you want to replace it?') % d
|
||||
if not self.show_confirmation(message, title):
|
||||
return
|
||||
try:
|
||||
|
@ -3323,6 +3330,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
|
|||
for task in self.download_tasks_seen:
|
||||
if episode.url == task.url:
|
||||
task_exists = True
|
||||
task.reuse()
|
||||
if task.status not in (task.DOWNLOADING, task.QUEUED):
|
||||
if downloader:
|
||||
# replace existing task's download with forced one
|
||||
|
|
|
@ -110,13 +110,13 @@ class PodcastParserFeed(Feed):
|
|||
def get_link(self):
|
||||
vid = youtube.get_youtube_id(self.feed['url'])
|
||||
if vid is not None:
|
||||
self.feed['link'] = youtube.get_channel_id_url(self.feed['url'])
|
||||
self.feed['link'] = youtube.get_channel_id_url(self.feed['url'], self.fetcher.feed_data)
|
||||
return self.feed.get('link')
|
||||
|
||||
def get_description(self):
|
||||
vid = youtube.get_youtube_id(self.feed['url'])
|
||||
if vid is not None:
|
||||
self.feed['description'] = youtube.get_channel_desc(self.feed['url'])
|
||||
self.feed['description'] = youtube.get_channel_desc(self.feed['url'], self.fetcher.feed_data)
|
||||
return self.feed.get('description')
|
||||
|
||||
def get_cover_url(self):
|
||||
|
@ -215,7 +215,8 @@ class gPodderFetcher(feedcore.Fetcher):
|
|||
url = vimeo.get_real_channel_url(url)
|
||||
return url
|
||||
|
||||
def parse_feed(self, url, data_stream, headers, status, max_episodes=0, **kwargs):
|
||||
def parse_feed(self, url, feed_data, data_stream, headers, status, max_episodes=0, **kwargs):
|
||||
self.feed_data = feed_data
|
||||
try:
|
||||
feed = podcastparser.parse(url, data_stream)
|
||||
feed['url'] = url
|
||||
|
|
|
@ -26,6 +26,7 @@ import logging
|
|||
import re
|
||||
import urllib
|
||||
import xml.etree.ElementTree
|
||||
from functools import lru_cache
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
|
@ -366,6 +367,7 @@ def get_real_download_url(url, allow_partial, preferred_fmt_ids=None):
|
|||
return url, duration
|
||||
|
||||
|
||||
@lru_cache(1)
|
||||
def get_youtube_id(url):
|
||||
r = re.compile(r'http[s]?://(?:[a-z]+\.)?youtube\.com/watch\?v=([^&]*)', re.IGNORECASE).match(url)
|
||||
if r is not None:
|
||||
|
@ -427,16 +429,22 @@ def get_real_channel_url(url):
|
|||
return for_each_feed_pattern(return_user_feed, url, url)
|
||||
|
||||
|
||||
def get_channel_id_url(url):
|
||||
@lru_cache(1)
|
||||
def get_channel_id_url(url, feed_data=None):
|
||||
if 'youtube.com' in url:
|
||||
try:
|
||||
req = util.urlopen(url)
|
||||
if feed_data is None:
|
||||
r = util.urlopen(url)
|
||||
if not r.ok:
|
||||
raise YouTubeError('Youtube "%s": %d %s' % (url, r.status_code, r.reason))
|
||||
else:
|
||||
r = feed_data
|
||||
# video page may contain corrupt HTML/XML, search for tag to avoid exception
|
||||
m = re.search(r'<meta itemprop="channelId" content="([^"]+)">', req.text)
|
||||
m = re.search(r'<meta itemprop="channelId" content="([^"]+)">', r.text)
|
||||
if m:
|
||||
channel_id = m.group(1)
|
||||
else:
|
||||
raw_xml_data = io.BytesIO(req.content)
|
||||
raw_xml_data = io.BytesIO(r.content)
|
||||
xml_data = xml.etree.ElementTree.parse(raw_xml_data)
|
||||
channel_id = xml_data.find("{http://www.youtube.com/xml/schemas/2015}channelId").text
|
||||
channel_url = 'https://www.youtube.com/channel/{}'.format(channel_id)
|
||||
|
@ -445,8 +453,10 @@ def get_channel_id_url(url):
|
|||
except Exception:
|
||||
logger.warning('Could not retrieve youtube channel id.', exc_info=True)
|
||||
|
||||
raise Exception('Could not retrieve youtube channel id.')
|
||||
|
||||
def get_cover(url):
|
||||
|
||||
def get_cover(url, feed_data=None):
|
||||
if 'youtube.com' in url:
|
||||
|
||||
class YouTubeHTMLCoverParser(HTMLParser):
|
||||
|
@ -471,8 +481,11 @@ def get_cover(url):
|
|||
self.url.append(attribute_dict['src'])
|
||||
|
||||
try:
|
||||
channel_url = get_channel_id_url(url)
|
||||
html_data = util.response_text(util.urlopen(channel_url))
|
||||
channel_url = get_channel_id_url(url, feed_data)
|
||||
r = util.urlopen(channel_url)
|
||||
if not r.ok:
|
||||
raise YouTubeError('Youtube "%s": %d %s' % (url, r.status_code, r.reason))
|
||||
html_data = util.response_text(r)
|
||||
parser = YouTubeHTMLCoverParser()
|
||||
parser.feed(html_data)
|
||||
if parser.url:
|
||||
|
@ -523,7 +536,7 @@ def get_gdpr_consent_url(html_data):
|
|||
raise YouTubeError('No acceptable GDPR consent URL')
|
||||
|
||||
|
||||
def get_channel_desc(url):
|
||||
def get_channel_desc(url, feed_data=None):
|
||||
if 'youtube.com' in url:
|
||||
|
||||
class YouTubeHTMLDesc(HTMLParser):
|
||||
|
@ -542,8 +555,11 @@ def get_channel_desc(url):
|
|||
self.description = attribute_dict['content']
|
||||
|
||||
try:
|
||||
channel_url = get_channel_id_url(url)
|
||||
html_data = util.response_text(util.urlopen(channel_url))
|
||||
channel_url = get_channel_id_url(url, feed_data)
|
||||
r = util.urlopen(channel_url)
|
||||
if not r.ok:
|
||||
raise YouTubeError('Youtube "%s": %d %s' % (url, r.status_code, r.reason))
|
||||
html_data = util.response_text(r)
|
||||
parser = YouTubeHTMLDesc()
|
||||
parser.feed(html_data)
|
||||
if parser.description:
|
||||
|
|
|
@ -25,10 +25,11 @@ from gpodder.feedcore import Fetcher, Result, NEW_LOCATION, NOT_MODIFIED, UPDATE
|
|||
|
||||
|
||||
class MyFetcher(Fetcher):
|
||||
def parse_feed(self, url, data_stream, headers, status, **kwargs):
|
||||
def parse_feed(self, url, feed_data, data_stream, headers, status, **kwargs):
|
||||
return Result(status, {
|
||||
'parse_feed': {
|
||||
'url': url,
|
||||
'feed_data': feed_data,
|
||||
'data_stream': data_stream,
|
||||
'headers': headers,
|
||||
'extra_args': dict(**kwargs),
|
||||
|
@ -112,4 +113,4 @@ def test_temporary_error_retry(httpserver):
|
|||
assert res.status == UPDATED_FEED
|
||||
args = res.feed['parse_feed']
|
||||
assert args['headers']['content-type'] == 'text/xml'
|
||||
assert args['url'] == httpserver.url_for('/feed')
|
||||
assert args['url'] == httpserver.url_for('/feed')
|
||||
|
|
|
@ -104,7 +104,7 @@ os.environ['GI_TYPELIB_PATH'] = join(bundle_lib, 'girepository-1.0')
|
|||
# for forked python
|
||||
os.environ['PYTHONHOME'] = bundle_res
|
||||
# Set $PYTHON to point inside the bundle
|
||||
PYVER = 'python3.8'
|
||||
PYVER = 'python3.9'
|
||||
sys.path.append(bundle_res)
|
||||
print('System Path:\n', '\n'.join(sys.path))
|
||||
|
||||
|
|
|
@ -69,8 +69,8 @@ cp -a "$checkout"/tools/mac-osx/make_cert_pem.py "$resources"/bin
|
|||
$run_pip install setuptools wheel
|
||||
$run_pip install podcastparser==0.6.7 mygpoclient==1.8 requests[socks]==2.25.1
|
||||
|
||||
# install extension dependencies; no explicit version for youtube_dl
|
||||
$run_pip install mutagen==1.45.1 html5lib==1.1 youtube_dl
|
||||
# install extension dependencies; no explicit version for yt-dlp
|
||||
$run_pip install mutagen==1.45.1 html5lib==1.1 yt-dlp
|
||||
|
||||
cd "$checkout"
|
||||
touch share/applications/gpodder{,-url-handler}.desktop
|
||||
|
@ -86,7 +86,7 @@ for po in po/*; do
|
|||
done
|
||||
|
||||
# copy fake dbus
|
||||
cp -r tools/fake-dbus-module/dbus $resources/lib/python3.8/site-packages/dbus
|
||||
cp -r tools/fake-dbus-module/dbus $resources/lib/python3.9/site-packages/dbus
|
||||
|
||||
# install
|
||||
"$run_python" setup.py install --root="$resources/" --prefix=. --optimize=0
|
||||
|
|
|
@ -8,6 +8,6 @@ urllib3==1.26.5
|
|||
html5lib==1.1
|
||||
mutagen==1.45.1
|
||||
dbus-python
|
||||
youtube_dl
|
||||
yt-dlp
|
||||
# eyed3 is optional and pulls in a lot of dependencies, so disable by default
|
||||
# eyed3
|
||||
|
|
|
@ -91,7 +91,7 @@ html5lib==1.1
|
|||
webencodings==0.5.1
|
||||
certifi==2021.5.30
|
||||
mutagen==1.45.1
|
||||
youtube_dl
|
||||
yt-dlp
|
||||
requests==2.25.1
|
||||
urllib3==1.26.5
|
||||
chardet==4.0.0
|
||||
|
|
Loading…
Reference in New Issue