Verified Commit 45e037ce authored by Todd Weaver's avatar Todd Weaver
Browse files

Adding process for getting strongest instances into fallback array

Adding slider time/viewed/duration
Adding player UI opacity
parent 424e544b
# instances.py
#
# Copyright 2021 Todd Weaver
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gi
gi.require_version('Soup', '2.4')
from gi.repository import Soup
import json
class Instances:
def __init__(self, **kwargs):
self.app_window = kwargs.get('app_window', None)
def get_strong_instances(self):
# this processes a series of tests
# 1. check if instance api and search are valid
# 2. check if the instance returns a valid video page result
# 3. check if the instance returns valid video url
# then sets it as a strong instance
# get urls of instances from api.invidious.io
uri = f"https://api.invidious.io/instances.json?sort_by=health"
print("0: " + uri)
session = Soup.Session.new()
session.set_property("timeout", 10)
message = Soup.Message.new("GET", uri)
session.queue_message(message, self.get_strong_instances_cb, None)
def get_strong_instances_cb(self, session, results, user_data):
if results.status_code != 200:
self.app_window.show_error_box("Service Failure",
"No instances found, cannot complete search.")
return False
try:
instances_json = json.loads(results.response_body.data)
except:
self.app_window.show_error_box("Service Failure",
"Instances are malformed, cannot complete search.")
return False
for instance in instances_json:
instance_uri = instance[1]['uri'].rstrip('/')
# remove non http gettable urls
if (not instance_uri.endswith('.onion') and
not instance_uri.endswith('.i2p')):
self.check_query_api_valid(instance[1]['uri'])
# check the instance can run a query on the API
def check_query_api_valid(self, uri):
# /api/v1/search?q=query
# /api/v1/search?q=Librem%205;fields=type
search_uri = f"{uri}/api/v1/search?q=Librem%205;fields=type"
print("1: " + search_uri)
session = Soup.Session.new()
session.set_property("timeout", 2)
message = Soup.Message.new("GET", search_uri)
session.queue_message(message, self.check_query_valid_cb, uri)
def check_query_valid_cb(self, session, results, uri):
if results.status_code != 200:
return False
try:
instance_json = json.loads(results.response_body.data)
except:
return False
# only need to check if 'type' appears
for r in instance_json:
if 'type' in r:
self.check_video_api_valid(uri)
break
# check the instances return a valid API
def check_video_api_valid(self, uri):
# api urls to confirm are strong (some throw forbidden)
# /api/v1/videos/{videoId}
# /api/v1/videos/cAUNrY_qPCg?fields=type
fs_uri = f"{uri}/api/v1/videos/cAUNrY_qPCg?fields=formatStreams"
print("2: " + fs_uri)
session = Soup.Session.new()
session.set_property("timeout", 2)
message = Soup.Message.new("GET", fs_uri)
session.queue_message(message, self.check_video_api_valid_cb, uri)
def check_video_api_valid_cb(self, session, results, uri):
if results.status_code != 200:
return False
try:
instance_json = json.loads(results.response_body.data)
except:
return False
# verify json has key 'formatStreams' (not 'error')
if 'formatStreams' in instance_json:
fs = instance_json['formatStreams'][0]
if fs['type'].startswith('video/mp4'):
confirm_video = fs['url']
self.check_video_valid(uri, confirm_video)
def check_video_valid(self, uri, confirm_video):
session = Soup.Session.new()
session.set_property("timeout", 2)
message = Soup.Message.new("HEAD", confirm_video)
session.queue_message(message, self.check_video_valid_cb, uri)
def check_video_valid_cb(self, session, results, uri):
print(results.status_code)
if results.status_code != 200:
return False
self.app_window.strong_instances.append(uri)
......@@ -31,6 +31,7 @@ stream_sources = [
'window.py',
'results.py',
'search.py',
'instances.py',
]
install_data(stream_sources, install_dir: moduledir)
......@@ -41,6 +41,8 @@ class ResultsBox(Gtk.Box):
play = Gtk.Template.Child()
pause = Gtk.Template.Child()
slider = Gtk.Template.Child()
time_viewed = Gtk.Template.Child()
time_remaining = Gtk.Template.Child()
audio_dl = Gtk.Template.Child()
audio_dl_image = Gtk.Template.Child()
......@@ -74,6 +76,9 @@ class ResultsBox(Gtk.Box):
self.video_box_width = int(size.width - self.window_to_player_box_padding)
self.video_box_height = int(self.video_box_width / 1.77)
self.player_box.set_size_request(self.video_box_width, self.video_box_height)
self.poster_image.set_size_request(self.video_box_width, self.video_box_height)
# init gstreamer player
self.player = Gst.ElementFactory.make("playbin", "player")
self.sink = Gst.ElementFactory.make("gtksink")
......@@ -83,13 +88,14 @@ class ResultsBox(Gtk.Box):
self.player_box.add(self.video_widget)
def get_duration(self, seconds):
def get_readable_seconds(self, seconds):
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
if seconds >= 3600:
self.video_duration = f"{h:d}:{m:02d}:{s:02d}"
readable_seconds = f"{h:d}:{m:02d}:{s:02d}"
else:
self.video_duration = f"{m:d}:{s:02d}"
readable_seconds = f"{m:d}:{s:02d}"
return readable_seconds
def on_file_read(self, poster_file, async_res, user_data):
stream = poster_file.read_finish(async_res)
......@@ -105,6 +111,7 @@ class ResultsBox(Gtk.Box):
self.poster_image.clear()
self.poster_image.set_from_pixbuf(pixbuf)
self.poster_image.set_visible(True)
def stream_at_scale_async(self, poster_file):
stream = poster_file.read_async(GLib.PRIORITY_DEFAULT, None,
......@@ -112,13 +119,14 @@ class ResultsBox(Gtk.Box):
def parse_video_results(self, session, result, message):
if message.status_code != 200:
print("video json details did not respond")
# remove unplayable video urls from list
self.set_visible(False)
return False
try:
self.json = json.loads(message.response_body.data)
except:
print("video json did not parse.")
self.set_visible(False)
return False
self.video_uri = None
......@@ -133,7 +141,6 @@ class ResultsBox(Gtk.Box):
# self.video_uri = format_stream['url']
if not self.video_uri:
# remove unplayable video urls from list
self.set_visible(False)
return False
......@@ -159,11 +166,11 @@ class ResultsBox(Gtk.Box):
self.video_title = video_meta['title']
self.video_channel = video_meta['author']
self.poster_uri = video_meta['poster_uri']
self.get_duration(video_meta['lengthSeconds'])
video_duration = self.get_readable_seconds(video_meta['lengthSeconds'])
self.title.set_label(self.video_title)
self.channel.set_label(self.video_channel)
self.duration.set_label(self.video_duration)
self.duration.set_label(video_duration)
self.get_video_details()
......@@ -171,16 +178,27 @@ class ResultsBox(Gtk.Box):
if not self.is_playing:
return False
else:
success, self.duration = self.player.query_duration(Gst.Format.TIME)
success, duration = self.player.query_duration(Gst.Format.TIME)
# GtkScale is set to 100%, calculate duration and position steps
self.percent = 100 / (self.duration / Gst.SECOND)
self.percent = 100 / (duration / Gst.SECOND)
# get current position (nanoseconds)
success, position = self.player.query_position(Gst.Format.TIME)
position_value = float(position) / Gst.SECOND * self.percent
if int(duration) > 0:
viewed_seconds = int(position / Gst.SECOND)
remaining_seconds = int((duration - position) / Gst.SECOND)
viewed = self.get_readable_seconds(viewed_seconds)
remaining = self.get_readable_seconds(remaining_seconds)
print("Viewed: " + str(viewed))
print("Remaining: -" + str(remaining))
self.time_viewed.set_label(viewed)
self.time_remaining.set_label(f"-{remaining}")
# is negative number when not successful, so put it to 0
if not success:
position_value = 0
......@@ -227,7 +245,10 @@ class ResultsBox(Gtk.Box):
dest_title = self.strictify_name(self.video_title)
dest_path = f"{dest_dir}/{dest_title}.{dest_ext}"
dest = Gio.File.new_for_path(dest_path)
dl_stream.copy_async(dest, Gio.FileCopyFlags.OVERWRITE,
flags = Gio.FileCopyFlags
dl_stream.copy_async(dest,
# bitwise or (not tuple) for multiple flags
Gio.FileCopyFlags.OVERWRITE|Gio.FileCopyFlags.ALL_METADATA|Gio.FileCopyFlags.TARGET_DEFAULT_PERMS,
GLib.PRIORITY_DEFAULT, None,
self.progress_video_cb, (),
self.ready_video_cb, None)
......@@ -264,7 +285,7 @@ class ResultsBox(Gtk.Box):
src.copy_finish(async_res)
except GLib.Error as e:
self.show_error_icon('audio')
print("Failed to write file stream %s", e.message)
print("Failed to write file stream ", e.message)
return False
self.show_success_icon('audio')
......@@ -273,7 +294,7 @@ class ResultsBox(Gtk.Box):
src.copy_finish(async_res)
except GLib.Error as e:
self.show_error_icon('video')
print("Failed to write file stream %s", e.message)
print("Failed to write file stream ", e.message)
return False
self.show_success_icon('video')
......@@ -413,11 +434,8 @@ class ResultsBox(Gtk.Box):
# "container": "mp4",
# "qualityLabel": "720p"
video_quality = "720p"
last_bitrate = None
self.audio_dl_uri = None
self.video_dl_uri = None
for af in self.json['adaptiveFormats']:
if af['type'].startswith('audio/mp4'):
if not self.audio_dl_uri:
......@@ -428,18 +446,21 @@ class ResultsBox(Gtk.Box):
last_bitrate = af['bitrate']
self.audio_dl_uri = af['url']
elif af['type'].startswith('video/mp4'):
video_quality = "720p"
self.video_dl_uri = None
for fs in self.json['formatStreams']:
if fs['type'].startswith('video/mp4'):
# set it to something
if not self.video_dl_uri:
self.video_dl_uri = af['url']
if 'qualityLabel' in af:
if af['qualityLabel'] == "720p" and video_quality == "720p":
self.video_dl_uri = af['url']
elif af['qualityLabel'] == "480p" and video_quality == "480p":
self.video_dl_uri = af['url']
elif af['qualityLabel'] == "1080p" and video_quality == "1080p":
self.video_dl_uri = af['url']
self.video_dl_uri = fs['url']
if 'qualityLabel' in fs:
if fs['qualityLabel'] == "720p" and video_quality == "720p":
self.video_dl_uri = fs['url']
elif fs['qualityLabel'] == "480p" and video_quality == "480p":
self.video_dl_uri = fs['url']
elif fs['qualityLabel'] == "1080p" and video_quality == "1080p":
self.video_dl_uri = fs['url']
if self.audio_dl_uri:
self.audio_dl.set_sensitive(True)
......
......@@ -28,58 +28,37 @@ class Search:
def __init__(self, **kwargs):
self.app_window = kwargs.get('app_window', None)
self.query = kwargs.get('query', None)
self.do_search()
def get_strong_instance(self):
# lookup instances, get a strong one, to be done (stubbed for now)
# get urls from api.invidious.io
# https://api.invidious.io/instances.json?sort_by=health
# api urls to confirm are strong (some throw forbidden)
# api/v1/videos/{videoId}
# api/v1/search/query
#self.instance = "https://ytprivate.com"
#self.instance = "https://vid.puffyan.us"
#self.instance = "https://iteroni.com/"
self.instance = "https://invidious.xyz"
def clear_entries(self):
children = self.app_window.results_list.get_children()
for child in children:
child.destroy()
def do_search(self):
self.get_strong_instance()
def do_search(self, query):
esc_query = GLib.uri_escape_string(query, None, None)
uri = f"{self.app_window.strong_instances[0]}/api/v1/search?q={esc_query};fields=title,videoId,author,lengthSeconds,videoThumbnails"
query = GLib.uri_escape_string(self.query, None, None)
uri = f"{self.instance}/api/v1/search?q={query};fields=title,videoId,author,lengthSeconds,videoThumbnails"
print(uri)
self.session = Soup.Session.new()
self.session.set_property("timeout", 5)
message = Soup.Message.new("GET", uri)
self.session.queue_message(message, self.show_results, message)
def show_error_box(self, heading, text):
self.app_window.error_box.set_visible(True)
self.app_window.error_heading.set_label(heading)
self.app_window.error_text.set_label(text)
def show_results(self, session, result, message):
self.clear_entries()
self.app_window.spinner.set_visible(False)
if message.status_code != 200:
self.show_error_box("Service Failure",
self.app_window.show_error_box("Service Failure",
"There is no response from the streaming servers.")
return False
try:
self.json = json.loads(message.response_body.data)
except:
self.show_error_box("Service Failure",
self.app_window.show_error_box("Service Failure",
"The streaming server response failed to parse results.")
print("json did not load from url results")
return False
self.get_poster_url()
......@@ -96,7 +75,7 @@ class Search:
# tweak json with local poster url
for video_meta in self.json:
# append the strong instance for results to use
video_meta['strong_instance'] = self.instance
video_meta['strong_instance'] = self.app_window.strong_instances[0]
for poster in video_meta['videoThumbnails']:
if poster['quality'] == 'medium':
video_meta['poster_uri'] = poster['url']
......@@ -33,7 +33,6 @@
<object class="GtkImage" id="poster_image">
<property name="width-request">332</property>
<property name="height-request">186</property>
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
......@@ -41,7 +40,7 @@
<property name="resource">/sm/puri/Stream/ui/video-placeholder-332x186.png</property>
</object>
<packing>
<property name="expand">False</property>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
......@@ -94,6 +93,7 @@
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Save Audio</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="audio_dl_button" swapped="no"/>
......@@ -121,6 +121,7 @@
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Save Video</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="video_dl_button" swapped="no"/>
......@@ -164,6 +165,7 @@
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Play</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="play_button" swapped="no"/>
......@@ -190,6 +192,7 @@
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Pause</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="pause_button" swapped="no"/>
......@@ -237,6 +240,7 @@
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Adjust Speed</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="speed_button" swapped="no"/>
......@@ -272,6 +276,7 @@
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Fullscreen</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="fullscreen_button" swapped="no"/>
......@@ -292,10 +297,10 @@
</child>
<child>
<object class="GtkButton" id="unfullscreen">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Unfullscreen</property>
<property name="opacity">0.80</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="unfullscreen_button" swapped="no"/>
......@@ -337,19 +342,78 @@
</packing>
</child>
<child>
<object class="GtkScale" id="slider">
<object class="GtkBox">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<property name="adjustment">slider-duration</property>
<property name="show-fill-level">True</property>
<property name="round-digits">1</property>
<property name="draw-value">False</property>
<property name="has-origin">False</property>
<signal name="scroll-event" handler="swallow_slider_scroll_event" swapped="no"/>
<signal name="value-changed" handler="seek_slider" swapped="no"/>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="time_viewed">
<property name="width-request">24</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.80</property>
<property name="halign">end</property>
<property name="valign">center</property>
<property name="margin-start">6</property>
<property name="label" translatable="yes">...</property>
<attributes>
<attribute name="scale" value="0.80000000000000004"/>
</attributes>
<style>
<class name="overlay-text"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScale" id="slider">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="opacity">0.80</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<property name="hexpand">True</property>
<property name="adjustment">slider-duration</property>
<property name="show-fill-level">True</property>
<property name="round-digits">1</property>
<property name="draw-value">False</property>
<property name="has-origin">False</property>
<signal name="scroll-event" handler="swallow_slider_scroll_event" swapped="no"/>
<signal name="value-changed" handler="seek_slider" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="time_remaining">
<property name="width-request">24</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.80</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="margin-end">6</property>
<property name="label" translatable="yes">...</property>
<attributes>
<attribute name="scale" value="0.80000000000000004"/>
</attributes>
<style>
<class name="overlay-text"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
......
......@@ -17,12 +17,22 @@
background-color: black;
}
button.rounded-button {
padding: 4px;
.overlay-text {
padding-top: 2px;
padding-left: 6px;
padding-right: 6px;
padding-bottom: 2px;
background-color: white;
border-radius: 9999px;
-gtk-outline-radius: 9999px;
}
button.rounded-button {
padding: 4px;
min-width: 24px;
min-height: 24px;
border-radius: 9999px;
-gtk-outline-radius: 9999px;
}
button.large {
......
......@@ -160,7 +160,7 @@
</child>
<child>
<object class="GtkBox" id="error_box">
<property name="visible">True</property>
<property name="visible">False</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
......@@ -183,7 +183,7 @@
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-top">12</property>
<property name="label" translatable="yes">...</property>
<property name="label" translatable="yes">Error</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.2"/>
......
......@@ -23,7 +23,10 @@ from gi.repository import Gdk, Gtk, Handy
Handy.init()
import json
from .search import Search
from .instances import Instances
@Gtk.Template(resource_path='/sm/puri/Stream/ui/window.ui')
class StreamWindow(Handy.ApplicationWindow):
......@@ -50,15 +53,19 @@ class StreamWindow(Handy.ApplicationWindow):
@Gtk.Template.Callback()
def search_entry(self, search_box):