Merge branch 'master' into adaptive
This commit is contained in:
commit
63e24bd8c1
|
@ -219,4 +219,4 @@ check if the bug still appears to see if an extension causes the bug.
|
|||
- Homepage: http://gpodder.org/
|
||||
- Bug tracker: https://github.com/gpodder/gpodder/issues
|
||||
- Mailing list: http://freelists.org/list/gpodder
|
||||
- IRC channel: #gpodder on irc.freenode.net
|
||||
- IRC channel: #gpodder on irc.libera.chat
|
||||
|
|
504
po/cs_CZ.po
504
po/cs_CZ.po
File diff suppressed because it is too large
Load Diff
504
po/es_ES.po
504
po/es_ES.po
File diff suppressed because it is too large
Load Diff
504
po/es_MX.po
504
po/es_MX.po
File diff suppressed because it is too large
Load Diff
506
po/fa_IR.po
506
po/fa_IR.po
File diff suppressed because it is too large
Load Diff
504
po/id_ID.po
504
po/id_ID.po
File diff suppressed because it is too large
Load Diff
504
po/ko_KR.po
504
po/ko_KR.po
File diff suppressed because it is too large
Load Diff
504
po/messages.pot
504
po/messages.pot
File diff suppressed because it is too large
Load Diff
504
po/pt_BR.po
504
po/pt_BR.po
File diff suppressed because it is too large
Load Diff
506
po/zh_CN.po
506
po/zh_CN.po
File diff suppressed because it is too large
Load Diff
|
@ -29,7 +29,7 @@ __only_for__ = 'gtk, cli'
|
|||
__authors__ = 'Eric Le Lay <elelay.fr:contact>'
|
||||
__doc__ = 'https://gpodder.github.io/docs/extensions/youtubedl.html'
|
||||
|
||||
want_ytdl_version = '2020.11.12'
|
||||
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.')
|
||||
|
||||
DefaultConfig = {
|
||||
|
@ -116,6 +116,10 @@ class YoutubeCustomDownload(download.CustomDownload):
|
|||
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
|
||||
# but audio content has no width or height, so change it to audio/webm for correct icon and player
|
||||
if ext_filetype.startswith('video/') and ('height' not in res or res['height'] is None):
|
||||
ext_filetype = ext_filetype.replace('video/', 'audio/')
|
||||
headers['content-type'] = ext_filetype
|
||||
return headers, res.get('url', self._url)
|
||||
|
||||
|
|
|
@ -1,120 +1,43 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.38.2 -->
|
||||
<interface>
|
||||
<!-- interface-requires gtk+ 2.16 -->
|
||||
<!-- interface-naming-policy toplevel-contextual -->
|
||||
<object class="GtkDialog" id="gPodderPodcastDirectory">
|
||||
<property name="visible">False</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="border_width">6</property>
|
||||
<requires lib="gtk+" version="3.12"/>
|
||||
<object class="GtkWindow" id="gPodderPodcastDirectory">
|
||||
<property name="can-focus">False</property>
|
||||
<property name="border-width">6</property>
|
||||
<property name="title" translatable="yes">Find new podcasts</property>
|
||||
<property name="modal">True</property>
|
||||
<property name="transient-for">parent_widget</property>
|
||||
<property name="window_position">center-on-parent</property>
|
||||
<property name="default_width">600</property>
|
||||
<property name="default_height">400</property>
|
||||
<property name="type_hint">dialog</property>
|
||||
<child internal-child="vbox">
|
||||
<property name="window-position">center-on-parent</property>
|
||||
<property name="default-width">600</property>
|
||||
<property name="default-height">400</property>
|
||||
<property name="type-hint">dialog</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="vb_directory">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child internal-child="action_area">
|
||||
<object class="GtkHButtonBox" id="hboxBottomButtons">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="layout_style">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnSelectAll">
|
||||
<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>
|
||||
<property name="use_underline">True</property>
|
||||
<signal name="clicked" handler="on_btnSelectAll_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnSelectNone">
|
||||
<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>
|
||||
<property name="use_underline">True</property>
|
||||
<signal name="clicked" handler="on_btnSelectNone_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnCancel">
|
||||
<property name="label">gtk-cancel</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="has_focus">True</property>
|
||||
<property name="can_default">True</property>
|
||||
<property name="has_default">True</property>
|
||||
<property name="receives_default">False</property>
|
||||
<property name="use_stock">True</property>
|
||||
<signal name="clicked" handler="on_btnCancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnOK">
|
||||
<property name="label">gtk-add</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">False</property>
|
||||
<property name="use_stock">True</property>
|
||||
<signal name="clicked" handler="on_btnOK_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkPaned" id="hpaned">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="can-focus">True</property>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="sw_providers">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="hscrollbar_policy">never</property>
|
||||
<property name="vscrollbar_policy">automatic</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="shadow-type">in</property>
|
||||
<child>
|
||||
<object class="GtkTreeView" id="tv_providers">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="headers_visible">False</property>
|
||||
<property name="enable_search">False</property>
|
||||
<signal name="row-activated" handler="on_tv_providers_row_activated" swapped="no"/>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="headers-visible">False</property>
|
||||
<property name="enable-search">False</property>
|
||||
<signal name="cursor-changed" handler="on_tv_providers_cursor_changed" swapped="no"/>
|
||||
<signal name="row-activated" handler="on_tv_providers_row_activated" swapped="no"/>
|
||||
<child internal-child="selection">
|
||||
<object class="GtkTreeSelection"/>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
@ -126,19 +49,18 @@
|
|||
<child>
|
||||
<object class="GtkBox" id="vb_podcasts">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="spacing">5</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">5</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="hb_text_entry">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="spacing">5</property>
|
||||
<property name="orientation">horizontal</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="lb_search">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="label" translatable="yes">label</property>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -150,12 +72,10 @@
|
|||
<child>
|
||||
<object class="GtkEntry" id="en_query">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="invisible_char">•</property>
|
||||
<property name="primary_icon_activatable">False</property>
|
||||
<property name="secondary_icon_activatable">False</property>
|
||||
<property name="primary_icon_sensitive">True</property>
|
||||
<property name="secondary_icon_sensitive">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="invisible-char">•</property>
|
||||
<property name="primary-icon-activatable">False</property>
|
||||
<property name="secondary-icon-activatable">False</property>
|
||||
<signal name="activate" handler="on_bt_search_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -168,8 +88,8 @@
|
|||
<object class="GtkButton" id="bt_search">
|
||||
<property name="label" translatable="yes">...</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_bt_search_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -188,9 +108,7 @@
|
|||
<child>
|
||||
<object class="GtkScrolledWindow" id="sw_tagcloud">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="hscrollbar_policy">automatic</property>
|
||||
<property name="vscrollbar_policy">automatic</property>
|
||||
<property name="can-focus">True</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
|
@ -204,15 +122,17 @@
|
|||
<child>
|
||||
<object class="GtkScrolledWindow" id="sw_podcasts">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="hscrollbar_policy">never</property>
|
||||
<property name="vscrollbar_policy">automatic</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="shadow-type">in</property>
|
||||
<child>
|
||||
<object class="GtkTreeView" id="tv_podcasts">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="headers_visible">False</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="headers-visible">False</property>
|
||||
<child internal-child="selection">
|
||||
<object class="GtkTreeSelection"/>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
@ -232,16 +152,111 @@
|
|||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFlowBox" id="flowbox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="min-children-per-line">1</property>
|
||||
<property name="max-children-per-line">2</property>
|
||||
<child>
|
||||
<object class="GtkFlowBoxChild">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<child>
|
||||
<object class="GtkButtonBox" id="selectbox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="layout-style">start</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnSelectAll">
|
||||
<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>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnSelectAll_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnSelectNone">
|
||||
<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>
|
||||
<property name="use-underline">True</property>
|
||||
<signal name="clicked" handler="on_btnSelectNone_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFlowBoxChild">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<child>
|
||||
<object class="GtkButtonBox" id="addbox">
|
||||
<property name="visible">True</property>
|
||||
<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="can-default">True</property>
|
||||
<property name="has-default">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<signal name="clicked" handler="on_btnCancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="pack-type">end</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnOK">
|
||||
<property name="label" translatable="yes">Add</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<signal name="clicked" handler="on_btnOK_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="pack-type">end</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<action-widgets>
|
||||
<action-widget response="0">btnSelectAll</action-widget>
|
||||
<action-widget response="0">btnSelectNone</action-widget>
|
||||
<action-widget response="0">btnCancel</action-widget>
|
||||
<action-widget response="0">btnOK</action-widget>
|
||||
</action-widgets>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.TH GPO "1" "April 2021" "gpodder 3.10.19" "User Commands"
|
||||
.TH GPO "1" "June 2021" "gpodder 3.10.20" "User Commands"
|
||||
.SH NAME
|
||||
gpo \- Text mode interface of gPodder
|
||||
.SH SYNOPSIS
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.17.
|
||||
.TH GPODDER "1" "April 2021" "gpodder 3.10.19" "User Commands"
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.48.3.
|
||||
.TH GPODDER "1" "June 2021" "gpodder 3.10.20" "User Commands"
|
||||
.SH NAME
|
||||
gpodder \- Media aggregator and podcast client
|
||||
.SH SYNOPSIS
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
# This metadata block gets parsed by setup.py - use single quotes only
|
||||
__tagline__ = 'Media aggregator and podcast client'
|
||||
__author__ = 'Thomas Perl <thp@gpodder.org>'
|
||||
__version__ = '3.10.19'
|
||||
__date__ = '2021-04-15'
|
||||
__version__ = '3.10.20'
|
||||
__date__ = '2021-06-06'
|
||||
__copyright__ = '© 2005-2021 The gPodder Team'
|
||||
__license__ = 'GNU General Public License, version 3 or later'
|
||||
__url__ = 'http://gpodder.org/'
|
||||
|
|
|
@ -901,6 +901,10 @@ class DownloadTask(object):
|
|||
if self.status == DownloadTask.FAILED:
|
||||
self.__episode._download_error = self.error_message
|
||||
|
||||
# Delete empty partial files, they prevent streaming after a download failure (live stream)
|
||||
if util.calculate_size(self.filename) == 0:
|
||||
util.delete_file(self.tempname)
|
||||
|
||||
if self.status == DownloadTask.DOWNLOADING:
|
||||
# Everything went well - we're done
|
||||
self.status = DownloadTask.DONE
|
||||
|
|
|
@ -80,7 +80,7 @@ class gPodderChannel(BuilderWidget):
|
|||
|
||||
b = Gtk.TextBuffer()
|
||||
if self.channel._update_error:
|
||||
err = '\n\nERROR: {}'.format(self.channel._update_error)
|
||||
err = '\n\n' + (_('ERROR: %s') % self.channel._update_error)
|
||||
else:
|
||||
err = ''
|
||||
b.set_text(util.remove_html_tags(self.channel.description) + err)
|
||||
|
|
|
@ -127,6 +127,7 @@ class gPodderPodcastDirectory(BuilderWidget):
|
|||
def setup_podcasts_treeview(self):
|
||||
column = Gtk.TreeViewColumn('')
|
||||
cell = Gtk.CellRendererToggle()
|
||||
cell.set_fixed_size(48, -1)
|
||||
column.pack_start(cell, False)
|
||||
column.add_attribute(cell, 'active', DirectoryPodcastsModel.C_SELECTED)
|
||||
cell.connect('toggled', lambda cell, path: self.podcasts_model.toggle(path))
|
||||
|
|
|
@ -2340,15 +2340,21 @@ class gPodder(BuilderWidget, dbus.service.Object):
|
|||
def playback_episodes_for_real(self, episodes):
|
||||
groups = collections.defaultdict(list)
|
||||
for episode in episodes:
|
||||
episode._download_error = None
|
||||
|
||||
player = self.episode_player(episode)
|
||||
|
||||
try:
|
||||
allow_partial = (player != 'default')
|
||||
filename = episode.get_playback_url(self.config, allow_partial)
|
||||
except Exception as e:
|
||||
episode._download_error = str(e)
|
||||
continue
|
||||
|
||||
# Mark episode as played in the database
|
||||
episode.playback_mark()
|
||||
self.mygpo_client.on_playback([episode])
|
||||
|
||||
allow_partial = (player != 'default')
|
||||
filename = episode.get_playback_url(self.config, allow_partial)
|
||||
|
||||
# Determine the playback resume position - if the file
|
||||
# was played 100%, we simply start from the beginning
|
||||
resume_position = episode.current_position
|
||||
|
@ -3649,6 +3655,12 @@ class gPodder(BuilderWidget, dbus.service.Object):
|
|||
# The remaining stuff is to be done in the GTK main thread
|
||||
util.idle_add(finish_deletion, select_url)
|
||||
|
||||
def on_itemRefreshCover_activate(self, widget, *args):
|
||||
assert self.active_channel is not None
|
||||
|
||||
self.podcast_list_model.clear_cover_cache(self.active_channel.url)
|
||||
self.cover_downloader.replace_cover(self.active_channel, custom_url=False)
|
||||
|
||||
def on_itemRemoveChannel_activate(self, widget, *args):
|
||||
if self.active_channel is None:
|
||||
title = _('No podcast selected')
|
||||
|
|
|
@ -29,6 +29,8 @@ import xml.etree.ElementTree
|
|||
from html.parser import HTMLParser
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import requests
|
||||
|
||||
import gpodder
|
||||
from gpodder import registry, util
|
||||
|
||||
|
@ -195,31 +197,41 @@ def get_real_download_url(url, allow_partial, preferred_fmt_ids=None):
|
|||
|
||||
vid = get_youtube_id(url)
|
||||
if vid is not None:
|
||||
page = None
|
||||
url = 'https://www.youtube.com/get_video_info?&el=detailpage&video_id=' + vid
|
||||
# TODO: changing 'detailpage' to 'embedded' allows age-restricted content
|
||||
url = 'https://www.youtube.com/get_video_info?html5=1&el=detailpage&video_id=' + vid
|
||||
r = requests.get(url)
|
||||
if not r.ok:
|
||||
logger.warning('Youtube get_video_info: %d %s' % (r.status_code, r.reason))
|
||||
|
||||
while page is None:
|
||||
req = util.http_request(url, method='GET')
|
||||
if 'location' in req.msg:
|
||||
url = req.msg['location']
|
||||
else:
|
||||
page = req.read()
|
||||
# TODO: watch URL does not work in europe due to GDPR cookie consent
|
||||
url = 'https://www.youtube.com/watch?bpctr=9999999999&has_verified=1&v=' + vid
|
||||
r = requests.get(url)
|
||||
if not r.ok:
|
||||
raise YouTubeError('%d %s' % (r.status_code, r.reason))
|
||||
|
||||
page = page.decode()
|
||||
# Try to find the best video format available for this video
|
||||
# (http://forum.videohelp.com/topic336882-1800.html#1912972)
|
||||
ipr = re.search(r'ytInitialPlayerResponse\s*=\s*({.+?})\s*;', r.text)
|
||||
if ipr is None:
|
||||
raise YouTubeError('No ytInitialPlayerResponse found')
|
||||
|
||||
def find_urls(page):
|
||||
old_page = None
|
||||
new_page = ipr.group(1)
|
||||
else:
|
||||
old_page = r.text
|
||||
new_page = None
|
||||
|
||||
def find_urls(old_page, new_page):
|
||||
# streamingData is preferable to url_encoded_fmt_stream_map
|
||||
# streamingData.formats are the same as url_encoded_fmt_stream_map
|
||||
# streamingData.adaptiveFormats are audio-only and video-only formats
|
||||
x = parse_qs(page)
|
||||
|
||||
x = parse_qs(old_page) if old_page else json.loads(new_page)
|
||||
player_response = json.loads(x['player_response'][0]) if old_page and 'player_response' in x else x
|
||||
error_message = None
|
||||
|
||||
if 'reason' in x:
|
||||
# TODO: unknown if this is valid for new_page
|
||||
error_message = util.remove_html_tags(x['reason'][0])
|
||||
elif 'player_response' in x:
|
||||
player_response = json.loads(x['player_response'][0])
|
||||
elif 'playabilityStatus' in player_response:
|
||||
playabilityStatus = player_response['playabilityStatus']
|
||||
|
||||
if 'reason' in playabilityStatus:
|
||||
|
@ -230,15 +242,10 @@ def get_real_download_url(url, allow_partial, preferred_fmt_ids=None):
|
|||
# playabilityStatus.liveStreamability.liveStreamabilityRenderer.displayEndscreen -- video has ended if present
|
||||
|
||||
if allow_partial and 'streamingData' in player_response and 'hlsManifestUrl' in player_response['streamingData']:
|
||||
manifest = None
|
||||
url = player_response['streamingData']['hlsManifestUrl']
|
||||
while manifest is None:
|
||||
req = util.http_request(url, method='GET')
|
||||
if 'location' in req.msg:
|
||||
url = req.msg['location']
|
||||
else:
|
||||
manifest = req.read()
|
||||
manifest = manifest.decode().splitlines()
|
||||
r = requests.get(player_response['streamingData']['hlsManifestUrl'])
|
||||
if not r.ok:
|
||||
raise YouTubeError('HLS Manifest: %d %s' % (r.status_code, r.reason))
|
||||
manifest = r.text.splitlines()
|
||||
|
||||
urls = [line for line in manifest if line[0] != '#']
|
||||
itag_re = re.compile(r'/itag/([0-9]+)/')
|
||||
|
@ -249,41 +256,41 @@ def get_real_download_url(url, allow_partial, preferred_fmt_ids=None):
|
|||
|
||||
error_message = 'live stream'
|
||||
elif 'streamingData' in player_response:
|
||||
# DRM videos store url inside a cipher key - not supported
|
||||
if 'formats' in player_response['streamingData']:
|
||||
for f in player_response['streamingData']['formats']:
|
||||
if 'url' in f:
|
||||
if 'url' in f: # DRM videos store url inside a signatureCipher key
|
||||
yield int(f['itag']), [f['url'], f.get('approxDurationMs')]
|
||||
if 'adaptiveFormats' in player_response['streamingData']:
|
||||
for f in player_response['streamingData']['adaptiveFormats']:
|
||||
if 'url' in f:
|
||||
if 'url' in f: # DRM videos store url inside a signatureCipher key
|
||||
yield int(f['itag']), [f['url'], f.get('approxDurationMs')]
|
||||
return
|
||||
|
||||
if error_message is not None:
|
||||
raise YouTubeError('Cannot download video: %s' % error_message)
|
||||
raise YouTubeError(('Cannot stream video: %s' if allow_partial else 'Cannot download video: %s') % error_message)
|
||||
|
||||
r4 = re.search(r'url_encoded_fmt_stream_map=([^&]+)', page)
|
||||
if r4 is not None:
|
||||
fmt_url_map = urllib.parse.unquote(r4.group(1))
|
||||
for fmt_url_encoded in fmt_url_map.split(','):
|
||||
video_info = parse_qs(fmt_url_encoded)
|
||||
yield int(video_info['itag'][0]), [video_info['url'][0], None]
|
||||
if old_page:
|
||||
r4 = re.search(r'url_encoded_fmt_stream_map=([^&]+)', old_page)
|
||||
if r4 is not None:
|
||||
fmt_url_map = urllib.parse.unquote(r4.group(1))
|
||||
for fmt_url_encoded in fmt_url_map.split(','):
|
||||
video_info = parse_qs(fmt_url_encoded)
|
||||
yield int(video_info['itag'][0]), [video_info['url'][0], None]
|
||||
|
||||
fmt_id_url_map = sorted(find_urls(page), reverse=True)
|
||||
fmt_id_url_map = sorted(find_urls(old_page, new_page), reverse=True)
|
||||
|
||||
if not fmt_id_url_map:
|
||||
drm = re.search(r'%22(cipher|signatureCipher)%22%3A', page)
|
||||
drm = re.search(r'(%22(cipher|signatureCipher)%22%3A|"signatureCipher"):', old_page or new_page)
|
||||
if drm is not None:
|
||||
raise YouTubeError('Unsupported DRM content found for video ID "%s"' % vid)
|
||||
raise YouTubeError('No formats found for video ID "%s"' % vid)
|
||||
raise YouTubeError('Unsupported DRM content')
|
||||
raise YouTubeError('No formats found')
|
||||
|
||||
formats_available = set(fmt_id for fmt_id, url in fmt_id_url_map)
|
||||
fmt_id_url_map = dict(fmt_id_url_map)
|
||||
|
||||
for id in preferred_fmt_ids:
|
||||
if re.search(r'\+', str(id)):
|
||||
# skip formats that contain a + (136+140)
|
||||
if re.search(r'(^best|\+)', str(id)):
|
||||
# skip formats that contain 'best.*' or a + (136+140)
|
||||
continue
|
||||
id = int(id)
|
||||
if id in formats_available:
|
||||
|
@ -298,7 +305,7 @@ def get_real_download_url(url, allow_partial, preferred_fmt_ids=None):
|
|||
url, duration = fmt_id_url_map[id]
|
||||
break
|
||||
else:
|
||||
raise YouTubeError('No preferred formats found for video ID "%s"' % vid)
|
||||
raise YouTubeError('No preferred formats found')
|
||||
|
||||
return url, duration
|
||||
|
||||
|
@ -366,10 +373,15 @@ def get_real_channel_url(url):
|
|||
def get_channel_id_url(url):
|
||||
if 'youtube.com' in url:
|
||||
try:
|
||||
channel_url = ''
|
||||
raw_xml_data = io.BytesIO(util.urlopen(url).content)
|
||||
xml_data = xml.etree.ElementTree.parse(raw_xml_data)
|
||||
channel_id = xml_data.find("{http://www.youtube.com/xml/schemas/2015}channelId").text
|
||||
req = util.urlopen(url)
|
||||
# video page may contain corrupt HTML/XML, search for tag to avoid exception
|
||||
m = re.search(r'<meta itemprop="channelId" content="([^"]+)">', req.text)
|
||||
if m:
|
||||
channel_id = m.group(1)
|
||||
else:
|
||||
raw_xml_data = io.BytesIO(req.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)
|
||||
return channel_url
|
||||
|
||||
|
@ -473,27 +485,34 @@ def parse_youtube_url(url):
|
|||
scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url)
|
||||
logger.debug("Analyzing URL: {}".format(" ".join([scheme, netloc, path, query, fragment])))
|
||||
|
||||
if 'youtube.com' in netloc and ('/user/' in path or '/channel/' in path or 'list=' in query):
|
||||
logger.debug("Valid Youtube URL detected. Parsing...")
|
||||
if 'youtube.com' in netloc:
|
||||
if '/user/' in path or '/channel/' in path or 'list=' in query:
|
||||
logger.debug("Valid Youtube URL detected. Parsing...")
|
||||
|
||||
if path.startswith('/user/'):
|
||||
user_id = path.split('/')[2]
|
||||
query = 'user={user_id}'.format(user_id=user_id)
|
||||
if path.startswith('/user/'):
|
||||
user_id = path.split('/')[2]
|
||||
query = 'user={user_id}'.format(user_id=user_id)
|
||||
|
||||
if path.startswith('/channel/'):
|
||||
channel_id = path.split('/')[2]
|
||||
query = 'channel_id={channel_id}'.format(channel_id=channel_id)
|
||||
if path.startswith('/channel/'):
|
||||
channel_id = path.split('/')[2]
|
||||
query = 'channel_id={channel_id}'.format(channel_id=channel_id)
|
||||
|
||||
if 'list=' in query:
|
||||
playlist_query = [query_value for query_value in query.split("&") if 'list=' in query_value][0]
|
||||
playlist_id = playlist_query[5:]
|
||||
query = 'playlist_id={playlist_id}'.format(playlist_id=playlist_id)
|
||||
if 'list=' in query:
|
||||
playlist_query = [query_value for query_value in query.split("&") if 'list=' in query_value][0]
|
||||
playlist_id = playlist_query[5:]
|
||||
query = 'playlist_id={playlist_id}'.format(playlist_id=playlist_id)
|
||||
|
||||
path = '/feeds/videos.xml'
|
||||
path = '/feeds/videos.xml'
|
||||
|
||||
new_url = urllib.parse.urlunsplit((scheme, netloc, path, query, fragment))
|
||||
logger.debug("New Youtube URL: {}".format(new_url))
|
||||
return new_url
|
||||
else:
|
||||
logger.debug("Not a valid Youtube URL: {}".format(url))
|
||||
return url
|
||||
new_url = urllib.parse.urlunsplit((scheme, netloc, path, query, fragment))
|
||||
logger.debug("New Youtube URL: {}".format(new_url))
|
||||
return new_url
|
||||
|
||||
# look for channel URL in page
|
||||
new_url = get_channel_id_url(url)
|
||||
if new_url:
|
||||
logger.debug("New Youtube URL: {}".format(new_url))
|
||||
return new_url
|
||||
|
||||
logger.debug("Not a valid Youtube URL: {}".format(url))
|
||||
return url
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# PyPI / pip requirements for Linux
|
||||
# For the benefit of e.g. flatpak-pip-generator.
|
||||
#
|
||||
mygpoclient==1.8
|
||||
podcastparser==0.6.6
|
||||
requests[socks]==2.25.1
|
||||
urllib3==1.26.5
|
||||
html5lib==1.1
|
||||
mutagen==1.45.1
|
||||
dbus-python
|
||||
youtube_dl
|
||||
# eyed3 is optional and pulls in a lot of dependencies, so disable by default
|
||||
# eyed3
|
|
@ -91,9 +91,9 @@ html5lib==1.1
|
|||
webencodings==0.5.1
|
||||
certifi==2020.11.8
|
||||
mutagen==1.45.1
|
||||
youtube_dl==2021.03.25
|
||||
requests==2.25.0
|
||||
urllib3==1.26.4
|
||||
youtube_dl
|
||||
requests==2.25.1
|
||||
urllib3==1.26.5
|
||||
chardet==4.0.0
|
||||
idna==3.1
|
||||
PySocks==1.7.1
|
||||
|
|
Loading…
Reference in New Issue