diff --git a/README.md b/README.md index 47ea5e2..b7feb13 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,4 @@ This program is intended for use on linux phones, and allow watching video streams, either directly on the phone via gstreamer or through chrome cast. -Currently Swedish Public Service TV and YouTube is supported. \ No newline at end of file +Currently Swedish Public Service TV is supported. diff --git a/dpkg.lst b/dpkg.lst index cd264d1..33100c2 100644 --- a/dpkg.lst +++ b/dpkg.lst @@ -1,28 +1,5 @@ python3-bs4 -gir1.2-gstreamer-1.0:amd64 -gstreamer1.0-alsa:amd64 -gstreamer1.0-clutter-3.0:amd64 -gstreamer1.0-gl:amd64 -gstreamer1.0-gtk3:amd64 -gstreamer1.0-libav:amd64 -gstreamer1.0-nice:amd64 -gstreamer1.0-packagekit -gstreamer1.0-pipewire:amd64 -gstreamer1.0-plugins-bad:amd64 -gstreamer1.0-plugins-base:amd64 -gstreamer1.0-plugins-base-apps -gstreamer1.0-plugins-good:amd64 -gstreamer1.0-plugins-rtp -gstreamer1.0-plugins-ugly:amd64 -gstreamer1.0-pulseaudio:amd64 -gstreamer1.0-tools -gstreamer1.0-x:amd64 -libgstreamer-gl1.0-0:amd64 -libgstreamer-plugins-bad1.0-0:amd64 -libgstreamer-plugins-base1.0-0:amd64 -libgstreamer-plugins-good1.0-0:amd64 -libgstreamer1.0-0:amd64 -python3-wxgtk-media4.0 python3-wxgtk4.0 python3-feedparser -youtube-dl +python3-vlc +python3-pychromecast diff --git a/install.sh b/install.sh index 1699dec..c9c61bd 100755 --- a/install.sh +++ b/install.sh @@ -11,35 +11,7 @@ elif [[ "${1}" == "-h" ]]; then exit 0 fi -sudo apt install python3-pip \ - python3-bs4 \ - gir1.2-gstreamer-1.0 \ - gstreamer1.0-alsa \ - gstreamer1.0-clutter-3.0 \ - gstreamer1.0-gl \ - gstreamer1.0-gtk3 \ - gstreamer1.0-libav \ - gstreamer1.0-nice \ - gstreamer1.0-packagekit \ - gstreamer1.0-pipewire \ - gstreamer1.0-plugins-bad \ - gstreamer1.0-plugins-base \ - gstreamer1.0-plugins-base-apps \ - gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-rtp \ - gstreamer1.0-plugins-ugly \ - gstreamer1.0-pulseaudio \ - gstreamer1.0-tools \ - gstreamer1.0-x \ - libgstreamer-gl1.0-0 \ - libgstreamer-plugins-bad1.0-0 \ - libgstreamer-plugins-base1.0-0 \ - libgstreamer1.0-0 \ - python3-wxgtk-media4.0 \ - python3-wxgtk4.0 \ - python3-feedparser \ - youtube-dl -sudo pip3 install pychromecast +sudo apt install $(cat dpkg.lst | tr '\n' ' ') sudo cp src/main.py /usr/local/bin/cast sudo chmod +x /usr/local/bin/cast sudo cp -a src/{Channel,ChannelProvider,Items,Utils} /usr/lib/python3/dist-packages/ diff --git a/requirements.txt b/requirements.txt index 668ee46..a3edcf3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ pychromecast -youtube-search-python diff --git a/src/Channel/SVT/__init__.py b/src/Channel/SVT/__init__.py index 28a087e..2e02e20 100644 --- a/src/Channel/SVT/__init__.py +++ b/src/Channel/SVT/__init__.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import json -import threading from datetime import datetime import feedparser @@ -64,7 +63,7 @@ class SVT(Channel): if not resolved_link: continue thumbnail = make_bitmap_from_url( - thumbnail_link, wx.Size(self.m_screen_width, 150)) + thumbnail_link, wx.Size(int(self.m_screen_width), 150)) item = Item(description, resolved_link, self.m_provider_name, published_parsed, thumbnail, title) self.m_items.append(item) @@ -86,8 +85,10 @@ class SVT(Channel): def resolve_link(self,svt_id) -> str: + url = 'https://api.svt.se/video/{}'.format(svt_id) + print(url) api = json.loads( - requests.get('https://api.svt.se/video/{}'.format(svt_id)).text) + requests.get(url).text) resolved_link = '' try: diff --git a/src/Channel/YouTube/__init__.py b/src/Channel/YouTube/__init__.py deleted file mode 100644 index 61dda19..0000000 --- a/src/Channel/YouTube/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -import feedparser -import wx - -from Channel import Channel -from Items import Item -from Utils import add_video, hash_string, make_bitmap_from_url, video_exists - -#from youtubesearchpython.search import VideosSearch - - -class YouTube(Channel): - def __init__(self, channel_id: str, name: str, logo: wx.Bitmap) -> None: - self.m_name = name - rss_url = 'https://www.youtube.com/feeds/videos.xml?channel_id={}'.format( - channel_id) - self.m_logo = logo - self.m_items: list[Item] = list() - super().__init__(channel_id, 'YouTube', rss_url, self.m_logo, name) - - def parse_feed(self) -> None: - feed = feedparser.parse(self.get_feed()) - entries = feed['entries'] - - for entry in entries: - video_id = hash_string(entry['id']) - if video_exists(video_id, self.m_id): - continue - title = str(entry['title']) - thumbnail_link = str(entry['media_thumbnail'][0]['url']) - description = str(entry['description']) - #video_search = VideosSearch(entry['id'], limit = 1).result()['result'][0] - resolved_link = entry['link'] - print(resolved_link) - - published_parsed = entry['published_parsed'] - - if not resolved_link: - continue - thumbnail = make_bitmap_from_url(thumbnail_link, - wx.Size(self.m_screen_width, 150)) - item = Item(description, resolved_link, self.m_provider_name, - published_parsed, thumbnail, title) - self.m_items.append(item) - - add_video(video_id, self.m_id, self.m_provider_name, description, - - resolved_link, published_parsed, thumbnail, title, 0) diff --git a/src/Utils/__init__.py b/src/Utils/__init__.py index a373530..004e570 100644 --- a/src/Utils/__init__.py +++ b/src/Utils/__init__.py @@ -1,18 +1,14 @@ #!/usr/bin/env python3 import hashlib import io -import json import sqlite3 import time from datetime import datetime from os import environ, makedirs, path from typing import Callable, Union -from urllib.parse import urlparse -from youtubesearchpython import ChannelsSearch import requests import wx -import youtube_dl from Items import Item @@ -27,34 +23,6 @@ SUB_TABLE = 'subscriptions' VIDEO_TABLE = 'videos' -def add_subscription(channel_id: str, - name: str, - basepath: str = BASEPATH, - filename: str = DB_FILE_NAME) -> None: - fullpath = path.join(basepath, filename) - thumbpath = path.join(basepath, 'thumbnails') - thumbnail = path.join(thumbpath, channel_id) - fullpath = path.join(basepath, filename) - if not path.isdir(thumbpath): - makedirs(thumbpath) - if not path.isfile(thumbnail): - channels_search = ChannelsSearch(name, limit=1).result()['result'][0] #type: ignore - bitmap = make_bitmap_from_url('https:' + channels_search['thumbnails'][0]['url']) - bitmap.SaveFile(thumbnail, wx.BITMAP_TYPE_PNG) - con = sqlite3.connect(fullpath) - cur = con.cursor() - create_query: str = '''CREATE TABLE IF NOT EXISTS {} - (channel_id TEXT PRIMARY KEY, channel_name TEXT, thumb_path TEXT)'''.format( - SUB_TABLE) - cur.execute(create_query) - con.commit() - upsert_query: str = '''INSERT INTO {} (channel_id, channel_name, thumb_path) - VALUES(?,?,?) ON CONFLICT(channel_id) DO NOTHING'''.format( - SUB_TABLE ) - cur.execute(upsert_query, [channel_id, name, thumbnail]) - con.commit() - - def add_video(video_id: str, channel_id: str, provider_id: str, @@ -103,8 +71,6 @@ def add_video(video_id: str, def get_default_logo(providerid: str = 'default') -> wx.Bitmap: if providerid == 'SVT': return wx.Bitmap('{}/assets/SVT.png'.format(MYPATH)) - if providerid == 'YouTube': - return wx.Bitmap('{}/assets/YouTube.png'.format(MYPATH)) else: return wx.Bitmap('{}/assets/Default.png'.format(MYPATH)) @@ -213,18 +179,6 @@ def hash_string(string: str) -> str: return hash_object.hexdigest() -def import_from_newpipe(filename) -> None: - - if path.isfile(filename): - with open(filename, 'r') as subs: - sub_data = json.loads(subs.read()) - - for channel in sub_data['subscriptions']: - if channel['service_id'] == 0: - channel_id = urlparse(channel['url']).path.split('/').pop() - add_subscription(channel_id, channel['name']) - - def make_sized_button(parent_pnl: wx.Panel, bitmap_or_str: Union[wx.Bitmap, str], text: str, callback: Callable) -> wx.BoxSizer: @@ -265,9 +219,9 @@ def make_bitmap_from_url(logo_url: str, size: wx.Size = SIZE) -> wx.Bitmap: content_bytes = io.BytesIO(content) image = wx.Image(content_bytes, type=wx.BITMAP_TYPE_ANY, index=-1) scale_factor = image.GetWidth() / size.GetWidth() - size.SetWidth(image.GetWidth() / scale_factor) + size.SetWidth(int(image.GetWidth() / scale_factor)) height = image.GetHeight() - size.SetHeight(height / scale_factor) + size.SetHeight(int(height / scale_factor)) image.Rescale(size.GetWidth(), size.GetHeight()) return wx.Bitmap(image) @@ -275,9 +229,9 @@ def make_bitmap_from_url(logo_url: str, size: wx.Size = SIZE) -> wx.Bitmap: def make_bitmap_from_file(path, size: wx.Size = SIZE) -> wx.Bitmap: image = wx.Image(path, type=wx.BITMAP_TYPE_ANY, index=-1) scale_factor = image.GetWidth() / size.GetWidth() - size.SetWidth(image.GetWidth() / scale_factor) + size.SetWidth(int(image.GetWidth() / scale_factor)) height = image.GetHeight() - size.SetHeight(height / scale_factor) + size.SetHeight(int(height / scale_factor)) image.Rescale(size.GetWidth(), size.GetHeight()) return wx.Bitmap(image) @@ -324,27 +278,6 @@ def resolve_svt_channel(svt_id: str) -> dict: return channels[svt_id] -def resolve_youtube_link(link): - ydl_opts = { - 'format': - 'worstvideo[ext=mp4]+worstaudio[ext=m4a]/worstvideo+worstaudio', - 'container': 'webm dash' - } - - with youtube_dl.YoutubeDL(ydl_opts) as ydl: - try: - video = ydl.extract_info(link, download=False) - - for form in video['formats']: # type: ignore - if form['height']: - if form['height'] < 480 and form['acodec'] != 'none': - link = form['url'] - except youtube_dl.utils.ExtractorError and youtube_dl.utils.DownloadError: - pass - - return link - - def video_exists(video_id: str, channel_id: str, basepath: str = BASEPATH, diff --git a/src/Utils/assets/Cast.png b/src/Utils/assets/Cast.png new file mode 100644 index 0000000..0907f52 Binary files /dev/null and b/src/Utils/assets/Cast.png differ diff --git a/src/cast.desktop b/src/cast.desktop index 5737a92..6a61748 100644 --- a/src/cast.desktop +++ b/src/cast.desktop @@ -8,4 +8,4 @@ Exec=/usr/local/bin/cast Icon=video-single-display-symbolic Terminal=false Categories=Security;Utility; -Keywords=YouTube;SVT; +Keywords=SVT; diff --git a/src/main.py b/src/main.py index 8ae811c..8dea657 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import os import threading import time from typing import Callable @@ -8,13 +7,11 @@ import pychromecast import wx import wx.lib.scrolledpanel as scrolled import wx.media -from youtubesearchpython import ChannelsSearch +from vlc import Instance -from Channel import SVT, YouTube +from Channel import SVT from ChannelProvider import ChannelProvider -from Utils import (get_default_logo, get_subscriptions, import_from_newpipe, - make_bitmap_from_file, make_bitmap_from_url, - make_sized_button, resolve_youtube_link) +from Utils import MYPATH, make_bitmap_from_file, make_sized_button WIDTH = int(720 / 2) HEIGHT = int(1440 / 2) @@ -42,15 +39,17 @@ class Cast(wx.Frame): self.m_chromecast_thr = threading.Thread(target=self.get_chromecasts, args=(), kwargs={}) + self.m_vlc = Instance() + self.m_vlc_medialist = self.m_vlc.media_list_new() + self.m_vlc_listplayer = self.m_vlc.media_list_player_new() self.m_chromecast_thr.start() self.m_sizer: wx.Sizer = wx.BoxSizer(wx.VERTICAL) self.m_panel: wx.lib.scrolledpanel.ScrolledPanel = scrolled.ScrolledPanel( # type: ignore self, -1, style=wx.VSCROLL) - self.m_control = None self.m_panel.SetupScrolling(rate_y=SCROLL_RATE, scrollToTop=True) self.m_panel.SetSizer(self.m_sizer) self.m_providers: list[ChannelProvider] = self.get_providers() - self.show_provider_list(None) + self.show_channel_list(None, 0) def add_back_button(self, callback: Callable) -> None: backbtn = wx.Button(self.m_panel, @@ -60,38 +59,6 @@ class Cast(wx.Frame): backbtn.Bind(wx.EVT_BUTTON, callback) self.m_sizer.Add(backbtn) - def add_youtube_buttons(self) -> None: - def search_callback(event, text: wx.TextCtrl) -> None: - reply = text.GetLineText(0) - text.Clear() - channels_search = ChannelsSearch( - reply, limit=1).result()['result'][0] #type: ignore - if 'id' in channels_search: - found_channel = YouTube.YouTube( - channels_search['id'], - channels_search['title'], - logo=make_bitmap_from_url( - 'https:' + channels_search['thumbnails'][0]['url'], - size=wx.Size(68, 100))) #type: ignore - self.m_selected_provider.append_channel(found_channel) - - text: wx.TextCtrl = wx.TextCtrl(self.m_panel, size=(WIDTH, BTN_HEIGHT)) - self.m_sizer.Add(text) - searchbtn = wx.Button(self.m_panel, - -1, - label="Search", - size=(WIDTH, BTN_HEIGHT)) - self.m_sizer.Add(searchbtn) - searchbtn.Bind( - wx.EVT_BUTTON, - lambda event, textctl=text: search_callback(event, textctl)) - importbtn = wx.Button(self.m_panel, - -1, - label="Import from NewPipe", - size=(WIDTH, BTN_HEIGHT)) - importbtn.Bind(wx.EVT_BUTTON, lambda event: self.show_importer(event)) - self.m_sizer.Add(importbtn) - def get_chromecasts(self) -> None: """ [TODO:description] @@ -122,44 +89,45 @@ class Cast(wx.Frame): event, cindex), ) + stop_button = wx.Button(self.m_panel, + -1, + "Stop", + size=(WIDTH / 4, BTN_HEIGHT)) inner_sizer.Add(play_button, FLAGS) inner_sizer.Add(pause_button, FLAGS) inner_sizer.Add(back_button, FLAGS) + inner_sizer.Add(stop_button, FLAGS) if not self.m_chromecast_thr.is_alive( ) and not self.m_selected_chromecast: - chromecast_button = wx.Button(self.m_panel, + btm = make_bitmap_from_file('{}/assets/Cast.png'.format(MYPATH), wx.Size(24,24)) + cast_button = wx.BitmapButton(self.m_panel, -1, - "Cast", + bitmap=btm, size=(WIDTH / 4, BTN_HEIGHT)) - chromecast_button.Bind( + cast_button.Bind( wx.EVT_BUTTON, lambda event, muri=uri, cindex=channel_index: self. select_chromecast(event, muri, cindex), ) - inner_sizer.Add(chromecast_button, FLAGS) - elif self.m_selected_chromecast: + inner_sizer.Add(cast_button, FLAGS) - chromecast_button = wx.Button(self.m_panel, - -1, - "Stop Cast", - size=(WIDTH / 4, BTN_HEIGHT)) - chromecast_button.Bind( - wx.EVT_BUTTON, - lambda event, muri=uri, cindex=channel_index: self. - stop_callback(event, muri, cindex), - ) - inner_sizer.Add(chromecast_button, FLAGS) if self.m_selected_chromecast: self.cast(wx.media.EVT_MEDIA_LOADED, uri), play_button.Bind(wx.EVT_BUTTON, self.play_cast) pause_button.Bind(wx.EVT_BUTTON, self.pause_cast) + stop_button.Bind( + wx.EVT_BUTTON, + lambda event, muri=uri, cindex=channel_index: self. + stop_callback(event, muri, cindex), + ) else: self.Bind(wx.media.EVT_MEDIA_LOADED, lambda event: wx.Frame.SetFocus(self)) play_button.Bind(wx.EVT_BUTTON, self.play) pause_button.Bind(wx.EVT_BUTTON, self.pause) + stop_button.Bind(wx.EVT_BUTTON, self.stop) inner_sizer.Fit(self) inner_sizer.Layout() @@ -168,7 +136,6 @@ class Cast(wx.Frame): def get_providers(self) -> list[ChannelProvider]: providers = list() - channels = list() svt = ChannelProvider( "SVT", channels=[ @@ -182,87 +149,21 @@ class Cast(wx.Frame): ) providers.append(svt) - subscriptions = get_subscriptions() - if subscriptions: - for channel in subscriptions: - logo = make_bitmap_from_file(channel[2]) - channels.append(YouTube.YouTube(channel[0], channel[1], logo)) - else: - logo = make_bitmap_from_url( - 'https://yt3.ggpht.com/ytc/AKedOLQ5L9xUSDxB2j6V3VC8L_HEwiKeHM21CgbSUyqe=s88-c-k-c0x00ffffff-no-rj' - ) - channels.append( - YouTube.YouTube("UCs6A_0Jm21SIvpdKyg9Gmxw", "Pine 64", logo)) - - youtube = ChannelProvider("YouTube", channels=channels) - providers.append(youtube) return providers - def show_importer(self, _) -> None: - - with wx.FileDialog(self, - "Open Newpipe json file", - wildcard="Json files (*.json)|*.json", - style=wx.FD_OPEN - | wx.FD_FILE_MUST_EXIST) as file_dialog: - - if file_dialog.ShowModal() == wx.ID_CANCEL: - return # the user changed their mind - - # Proceed loading the file chosen by the user - subfile = file_dialog.GetPath() - channels = list() - logo = get_default_logo('YouTube') - if os.path.isfile(subfile): - import_from_newpipe(subfile) - subscriptions = get_subscriptions() - for channel in subscriptions: - yt_chan = YouTube.YouTube(channel[0], channel[1], logo) - yt_chan.refresh() - channels.append(yt_chan) - # Index 1 is YouTube - self.m_providers[1].set_channels(channels) - self.m_providers[1].make_latest() - self.show_channel_list(None, self.m_selected_provider_index) - - def show_provider_list(self, _) -> None: - self.m_sizer.Clear(delete_windows=True) - self.m_sizer = wx.BoxSizer(wx.VERTICAL) - closebtn = wx.Button(self.m_panel, - -1, - label="Close", - size=(WIDTH, BTN_HEIGHT)) - closebtn.Bind(wx.EVT_BUTTON, lambda event: self.Destroy()) - self.m_sizer.Add(closebtn, 0, wx.ALL, 1) - provider_index = 0 - - for provider in self.m_providers: - bitmap = provider.get_logo_as_bitmap() - callback = lambda event, pindex=provider_index: self.show_channel_list( - event, pindex) - btn_sizer: wx.BoxSizer = make_sized_button(self.m_panel, bitmap, - provider.get_name(), - callback) - self.m_sizer.Add(btn_sizer, 0, wx.ALL, 1) - provider_index += 1 - - self.m_panel.SetupScrolling(rate_y=SCROLL_RATE, scrollToTop=True) - self.m_panel.SetSizer(self.m_sizer) - self.m_sizer.Fit(self) - self.m_sizer.Layout() def show_channel_list(self, _, provider_index) -> None: self.m_selected_provider_index = provider_index self.m_selected_provider = self.m_providers[provider_index] self.m_sizer.Clear(delete_windows=True) self.m_sizer = wx.BoxSizer(wx.VERTICAL) - #self.m_sizer.AddSpacer(SPACER_HEIGHT * 4) - bck_callback = lambda event: self.show_provider_list(event) - self.add_back_button(bck_callback) - - if self.m_selected_provider.get_name() == "YouTube": - self.add_youtube_buttons() + closebtn = wx.Button(self.m_panel, + -1, + label="Close", + size=(WIDTH, BTN_HEIGHT)) + closebtn.Bind(wx.EVT_BUTTON, lambda event: self.Destroy()) + self.m_sizer.Add(closebtn, 0, wx.ALL, 1) channel_index = 0 @@ -290,27 +191,10 @@ class Cast(wx.Frame): back_callback = lambda event: self.show_channel_list( event, self.m_selected_provider_index) self.add_back_button(back_callback) - if self.m_selected_provider.get_name() == 'YouTube' or ( - self.m_selected_provider.get_name() == 'SVT' - and self.m_selected_channel.get_id() == 'feed'): + if self.m_selected_provider.get_name() == 'SVT' and self.m_selected_channel.get_id() == 'feed': def refresh_callback(event): - if self.m_selected_provider.get_name( - ) == 'YouTube' and channel_index == 0: - with wx.BusyInfo("Please wait, working..."): - for chan in self.m_selected_provider.get_channels(): - chan.refresh() - wait = 1 - while wait > 0: - wait = 0 - for chan in self.m_selected_provider.get_channels( - ): - if chan.wait(): - wait += 1 - time.sleep(1) - wx.GetApp().Yield() - else: - self.m_selected_channel.refresh() + self.m_selected_channel.refresh() self.show_video_list(event, channel_index) refreshbtn = wx.Button(self.m_panel, @@ -386,22 +270,12 @@ class Cast(wx.Frame): :param _ event: unused :param uri str: the link to the video stream """ - if 'youtube' in uri: - uri = resolve_youtube_link(uri) + media = self.m_vlc.media_new(uri) + self.m_vlc_medialist.add_media(media) + self.m_vlc_listplayer.set_media_list(self.m_vlc_medialist) + self.m_sizer.Clear(delete_windows=True) self.m_sizer = wx.BoxSizer(wx.VERTICAL) - if not self.m_selected_chromecast: - self.m_control = wx.media.MediaCtrl( - self.m_panel, - size=(WIDTH,HEIGHT/2), - szBackend=wx.media.MEDIABACKEND_GSTREAMER, - ) - self.m_sizer.Add(self.m_control, FLAGS) - self.Bind(wx.media.EVT_MEDIA_FINISHED, - lambda event: self.show_video_list(event, 0)) - self.Bind(wx.EVT_POWER_SUSPENDING, - lambda event: wx.EVT_POWER_SUSPENDING.Veto(event)) - self.load_uri(uri) self.m_sizer.Add(self.get_player_controls(channel_index, uri), FLAGS) self.m_panel.SetupScrolling(rate_y=SCROLL_RATE, scrollToTop=True) self.m_panel.SetSizer(self.m_sizer) @@ -424,7 +298,7 @@ class Cast(wx.Frame): self.m_sizer.Add(cancel_btn) for cast in self.m_chromecasts: - friendly_name = cast.cast_info.friendly_name + friendly_name = cast.device.friendly_name btn = wx.Button(self.m_panel, id=-1, label=friendly_name, @@ -498,14 +372,14 @@ class Cast(wx.Frame): self.m_sizer.Fit(self) self.m_sizer.Layout() - def load_uri(self, uri): - self.m_control.LoadURI(uri) - def play(self, _): - self.m_control.Play() + self.m_vlc_listplayer.play() def pause(self, _): - self.m_control.Pause() + self.m_vlc_listplayer.pause() + + def stop(self, _): + self.m_vlc_listplayer.stop() def quit(self, _): self.Destroy() diff --git a/src/yt_subs.json b/src/yt_subs.json deleted file mode 100644 index 3677de6..0000000 --- a/src/yt_subs.json +++ /dev/null @@ -1 +0,0 @@ -{"app_version":"0.21.14","app_version_int":980,"subscriptions":[{"service_id":0,"url":"https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw","name":"3Blue1Brown"},{"service_id":0,"url":"https://www.youtube.com/channel/UCHL9bfHTxCMi-7vfxQ-AYtg","name":"Abroad in Japan"},{"service_id":0,"url":"https://www.youtube.com/channel/UCiDJtJKMICpb9B1qf7qjEOA","name":"Adam Savage\u2019s Tested"},{"service_id":0,"url":"https://www.youtube.com/channel/UC3ts8coMP645hZw9JSD3pqQ","name":"Andreas Kling"},{"service_id":3,"url":"https://framatube.org/video-channels/fsf_channel","name":"Animated free software awareness videos "},{"service_id":0,"url":"https://www.youtube.com/channel/UCciQ8wFcVoIIMi-lfu8-cjQ","name":"Anton Petrov"},{"service_id":0,"url":"https://www.youtube.com/channel/UCIuCEEUjuBMBlNbU4Zmv_sw","name":"Arnaud Cousergue"},{"service_id":0,"url":"https://www.youtube.com/channel/UCPM5DQ0mxQdLAlnu6R4d1YA","name":"Art of One Dojo"},{"service_id":0,"url":"https://www.youtube.com/channel/UCUXLiFJcHU_Nn_601pi4zjg","name":"BACOUPLE"},{"service_id":0,"url":"https://www.youtube.com/channel/UCH4BNI0-FOK2dMXoFtViWHw","name":"Be Smart"},{"service_id":0,"url":"https://www.youtube.com/channel/UCS0N5baNlQWJCUrhCEo8WlA","name":"Ben Eater"},{"service_id":0,"url":"https://www.youtube.com/channel/UCGMTesZlKa0Lokb7ZNqOJXQ","name":"BilliSpeaks"},{"service_id":0,"url":"https://www.youtube.com/channel/UCKTehwyGCKF-b2wo0RKwrcg","name":"Bisqwit"},{"service_id":0,"url":"https://www.youtube.com/channel/UCJLLl6AraX1POemgLfhirwg","name":"Bits inside by René Rebe"},{"service_id":0,"url":"https://www.youtube.com/channel/UC_SvYP0k05UKiJ_2ndB02IA","name":"blackpenredpen"},{"service_id":0,"url":"https://www.youtube.com/channel/UCgBvlhT2XE8ryUumHiMom8w","name":"Bujinkan Paul Kasumi An & Yokohama Dojo"},{"service_id":0,"url":"https://www.youtube.com/channel/UCj3yW-PqJdek3zC4NNdoTpA","name":"Bujinkan Seijitsu dojo"},{"service_id":0,"url":"https://www.youtube.com/channel/UCvkUSUle3I-K1CYKc7SCAxA","name":"caleb"},{"service_id":0,"url":"https://www.youtube.com/channel/UCB1J6siDdmhwah7q0O2WJBg","name":"Charles Dowding"},{"service_id":3,"url":"https://share.tube/video-channels/chrisweredigital","name":"Chris Were Digital"},{"service_id":0,"url":"https://www.youtube.com/channel/UC2MJylovjrLtsGP0_4UrqrQ","name":"Cody'sBLab"},{"service_id":0,"url":"https://www.youtube.com/channel/UCu6mSoMNzHQiBIOCkHUa2Aw","name":"Cody'sLab"},{"service_id":0,"url":"https://www.youtube.com/channel/UC9-y-6csu5WGm29I7JiwpnA","name":"Computerphile"},{"service_id":0,"url":"https://www.youtube.com/channel/UCD5eL38hFtSLiVFP9cCUJEA","name":"Daniel Stenberg"},{"service_id":0,"url":"https://www.youtube.com/channel/UCVls1GmFKf6WlTraIb_IaJg","name":"DistroTube"},{"service_id":0,"url":"https://www.youtube.com/channel/UCUQo7nzH1sXVpzL92VesANw","name":"DIY Perks"},{"service_id":0,"url":"https://www.youtube.com/channel/UCSX0NhNdBA-ZnEFkYFzdw4A","name":"Dogen"},{"service_id":0,"url":"https://www.youtube.com/channel/UCYNbYGl89UUowy8oXkipC-Q","name":"Dr. Becky"},{"service_id":0,"url":"https://www.youtube.com/channel/UCSbyncU597LMwb3HhnAI_4w","name":"Epic Gardening"},{"service_id":0,"url":"https://www.youtube.com/channel/UC8EQAfueDGNeqb1ALm0LjHA","name":"Exploring Alternatives"},{"service_id":0,"url":"https://www.youtube.com/channel/UCD5B6VoXv41fJ-IW8Wrhz9A","name":"Fermilab"},{"service_id":0,"url":"https://www.youtube.com/channel/UCv1Kcz-CuGM6mxzL3B1_Eiw","name":"Gardiner Bryant"},{"service_id":0,"url":"https://www.youtube.com/channel/UC78Ib99EBhMN3NemVjYm3Ig","name":"Grant Sanderson"},{"service_id":0,"url":"https://www.youtube.com/channel/UC6mIxFTvXkWQVEHPsEdflzQ","name":"GreatScott!"},{"service_id":0,"url":"https://www.youtube.com/channel/UCOT2iLov0V7Re7ku_3UBtcQ","name":"hankschannel"},{"service_id":0,"url":"https://www.youtube.com/channel/UCsP7Bpw36J666Fct5M8u-ZA","name":"How To Cook That"},{"service_id":0,"url":"https://www.youtube.com/channel/UCfIqCzQJXvYj9ssCoHq327g","name":"How To Make Everything"},{"service_id":0,"url":"https://www.youtube.com/channel/UCeaKRrrpWiQFJJmiuon2WoQ","name":"Huw Richards"},{"service_id":0,"url":"https://www.youtube.com/channel/UCypN3QKwo-Fajp391i0IQ0A","name":"ingomar200"},{"service_id":0,"url":"https://www.youtube.com/channel/UC5fdssPqmmGhkhsJi4VcckA","name":"Innuendo Studios"},{"service_id":0,"url":"https://www.youtube.com/channel/UC-yuWVUplUJZvieEligKBkA","name":"javidx9"},{"service_id":0,"url":"https://www.youtube.com/channel/UCRIeMHsEdzA9RroG19kXdYg","name":"Jesse Enkamp"},{"service_id":0,"url":"https://www.youtube.com/channel/UC_iD0xppBwwsrM9DegC5cQQ","name":"Jon Gjengset"},{"service_id":0,"url":"https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q","name":"Kurzgesagt \u2013 In a Nutshell"},{"service_id":0,"url":"https://www.youtube.com/channel/UCNhX3WQEkraW3VHPyup8jkQ","name":"Langfocus"},{"service_id":0,"url":"https://www.youtube.com/channel/UCxuZNRnlprC70l1bnI0n-XQ","name":"Learn Japanese From Zero!"},{"service_id":0,"url":"https://www.youtube.com/channel/UCBkqDNqao03ldC3u78-Pp8g","name":"Linfamy"},{"service_id":0,"url":"https://www.youtube.com/channel/UCR-_DvrwKkk3rpQbzCRFHOw","name":"LINMOBnet"},{"service_id":0,"url":"https://www.youtube.com/channel/UC84u7JhM9EIAYzyjdf6cBbA","name":"Linus Groh"},{"service_id":0,"url":"https://www.youtube.com/channel/UClcE-kVhqyiHCcjYwcpfj9w","name":"LiveOverflow"},{"service_id":0,"url":"https://www.youtube.com/channel/UCe0Ha5QljsCV5UqIkobBrcQ","name":"Liz Zorab - Byther Farm"},{"service_id":0,"url":"https://www.youtube.com/channel/UCm9K6rby98W8JigLoZOh6FQ","name":"LockPickingLawyer"},{"service_id":0,"url":"https://www.youtube.com/channel/UCOMrUmOTPD_AnSivjxptxpA","name":"Louis Weisz"},{"service_id":0,"url":"https://www.youtube.com/channel/UCL5rTpHIfmKzmRcp7yiKjJw","name":"Lovely Greens"},{"service_id":0,"url":"https://www.youtube.com/channel/UC4UXU2ZkeAwEFlLv1Yt1UMQ","name":"Martijn Braam"},{"service_id":0,"url":"https://www.youtube.com/channel/UCOjOqr5SIDpgBssNz1XiVJw","name":"Mary's Test Kitchen"},{"service_id":0,"url":"https://www.youtube.com/channel/UC1_uAIS3r8Vu6JjXWvastJg","name":"Mathologer"},{"service_id":0,"url":"https://www.youtube.com/channel/UCzV9N7eGedBchEQjQhPapyQ","name":"Matt_Parker_2"},{"service_id":0,"url":"https://www.youtube.com/channel/UCVGVbOl6F5rGF4wSYS6Y5yQ","name":"MIgardener"},{"service_id":0,"url":"https://www.youtube.com/channel/UCsQCbl3a9FtYvA55BxdzYiQ","name":"Miku Real Japanese"},{"service_id":0,"url":"https://www.youtube.com/channel/UCjeKoCr7YTNn0bmMO1HdyVw","name":"mimei"},{"service_id":0,"url":"https://www.youtube.com/channel/UCRutxRBx4rteBZBwtSoJATg","name":"MIMEI LAND"},{"service_id":0,"url":"https://www.youtube.com/channel/UCWQG9Bq1pFJLjwLUgorN1Qw","name":"Namiryu Dojo"},{"service_id":0,"url":"https://www.youtube.com/channel/UCcJceGUaevGlP7s2xzL9akA","name":"Next Level Gardening"},{"service_id":0,"url":"https://www.youtube.com/channel/UC1D3yD4wlPMico0dss264XA","name":"NileBlue"},{"service_id":0,"url":"https://www.youtube.com/channel/UCFhXFikryT4aFcLkLw2LBLA","name":"NileRed"},{"service_id":0,"url":"https://www.youtube.com/channel/UCoxcjq-8xIDTYp3uz647V5A","name":"Numberphile"},{"service_id":0,"url":"https://www.youtube.com/channel/UCyp1gCHZJU_fGWFf2rtMkCg","name":"Numberphile2"},{"service_id":0,"url":"https://www.youtube.com/channel/UCzR-rom72PHN9Zg7RML9EbA","name":"PBS Eons"},{"service_id":0,"url":"https://www.youtube.com/channel/UC7_gcs09iThXybpVgjHZ_7g","name":"PBS Space Time"},{"service_id":0,"url":"https://www.youtube.com/channel/UC6uo7Swuhid4KcK97n2l7_Q","name":"PDRさん"},{"service_id":0,"url":"https://www.youtube.com/channel/UCo-f3WS0648Hg8Jqxx_ui-A","name":"PDRさんのゴミ箱"},{"service_id":0,"url":"https://www.youtube.com/channel/UCtESv1e7ntJaLJYKIO1FoYw","name":"Periodic Videos"},{"service_id":0,"url":"https://www.youtube.com/channel/UCTpmmkp1E4nmZqWPS-dl5bg","name":"Quanta Magazine"},{"service_id":0,"url":"https://www.youtube.com/channel/UC176GAQozKKjhz62H8u9vQQ","name":"Real Science"},{"service_id":0,"url":"https://www.youtube.com/channel/UCFJxE0l3cVYU4kHzi4qVEkw","name":"Rebecca Watson"},{"service_id":0,"url":"https://www.youtube.com/channel/UCq9U-gJ1LtDCE4W5BhEDFSQ","name":"RED Gardens"},{"service_id":0,"url":"https://www.youtube.com/channel/UC_oqZXtcxfJTaw1j2M1H1XQ","name":"Sauce Stache"},{"service_id":0,"url":"https://www.youtube.com/channel/UCZYTClx2T1of7BRZ86-8fow","name":"SciShow"},{"service_id":0,"url":"https://www.youtube.com/channel/UCrMePiHCWG4Vwqv3t7W9EFg","name":"SciShow Space"},{"service_id":0,"url":"https://www.youtube.com/channel/UCJZTjBlrnDHYmf0F-eYXA3Q","name":"Self Sufficient Me"},{"service_id":0,"url":"https://www.youtube.com/channel/UCm8xNi3kuBHE99QH-N_VJVg","name":"Sharmeleon"},{"service_id":0,"url":"https://www.youtube.com/channel/UC6107grRI4m0o2-emgoDnAA","name":"SmarterEveryDay"},{"service_id":0,"url":"https://www.youtube.com/channel/UCMj4YareOFuEfxUBbncWF9w","name":"Sora The Troll"},{"service_id":0,"url":"https://www.youtube.com/channel/UCSju5G2aFaWMqn-_0YBtq5A","name":"Stand-up Maths"},{"service_id":0,"url":"https://www.youtube.com/channel/UCEIwxahdLz7bap-VDs9h35A","name":"Steve Mould"},{"service_id":0,"url":"https://www.youtube.com/channel/UCH4L3iapFMHQ0tfPOPNiAJA","name":"Susanne Vandraren"},{"service_id":0,"url":"https://www.youtube.com/channel/UCrW38UKhlPoApXiuKNghuig","name":"Systems with JT"},{"service_id":0,"url":"https://www.youtube.com/channel/UC4JX40jDee_tINbkjycV4Sg","name":"Tech With Tim"},{"service_id":0,"url":"https://www.youtube.com/channel/UC1VLQPn9cYSqx8plbk9RxxQ","name":"The Action Lab"},{"service_id":0,"url":"https://www.youtube.com/channel/UC3ETCazlHenpXEsrEJH-k5A","name":"The Anime Man"},{"service_id":0,"url":"https://www.youtube.com/channel/UCQ-W1KE9EYfdxhL6S4twUNw","name":"The Cherno"},{"service_id":0,"url":"https://www.youtube.com/channel/UCvjgXvBlbQiydffZU7m1_aw","name":"The Coding Train"},{"service_id":0,"url":"https://www.youtube.com/channel/UC5UAwBUum7CPN5buc-_N1Fw","name":"The Linux Experiment"},{"service_id":0,"url":"https://www.youtube.com/channel/UCF2IdSvpCUXPLQ1Q_r5JW1A","name":"The PhysicsMaths Wizard"},{"service_id":0,"url":"https://www.youtube.com/channel/UCYeF244yNGuFefuFKqxIAXw","name":"The Royal Institution"},{"service_id":0,"url":"https://www.youtube.com/channel/UCV5vCi3jPJdURZwAOO_FNfQ","name":"The Thought Emporium"},{"service_id":0,"url":"https://www.youtube.com/channel/UCgWip0vxtqu34rZrFeCpUow","name":"Tim R Morgan"},{"service_id":0,"url":"https://www.youtube.com/channel/UCZDA1kA3y3EIg25BpcHSpwQ","name":"Tinkernut"},{"service_id":0,"url":"https://www.youtube.com/channel/UC1zZE_kJ8rQHgLTVfobLi_g","name":"TKOR"},{"service_id":0,"url":"https://www.youtube.com/channel/UCAKZ2vtm_-hfqeCGTNNbZqA","name":"Tokidoki Traveller"},{"service_id":0,"url":"https://www.youtube.com/channel/UC1TmvgkTb_5jzKcvx6Pt0Dw","name":"Tokyo Creative"},{"service_id":0,"url":"https://www.youtube.com/channel/UCRfo-DAifrP3lzcxUHtGm_A","name":"Tom Rocks Maths"},{"service_id":0,"url":"https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A","name":"Tom Scott"},{"service_id":0,"url":"https://www.youtube.com/channel/UCxr2d4As312LulcajAkKJYw","name":"Townsends"},{"service_id":0,"url":"https://www.youtube.com/channel/UCBtxQ0Oef0e2o-9iDe6Ia_w","name":"TUSENKONSTNÄRERNA"},{"service_id":0,"url":"https://www.youtube.com/channel/UCHnyfMqiRRG1u-2MsSQLbXA","name":"Veritasium"},{"service_id":0,"url":"https://www.youtube.com/channel/UCGaVdbSav8xWuFWTadK6loA","name":"vlogbrothers"},{"service_id":0,"url":"https://www.youtube.com/channel/UC6nSFpj9HTCZ5t-N3Rm3-HA","name":"Vsauce"},{"service_id":0,"url":"https://www.youtube.com/channel/UChsbD6Clp-ZPqKwXJR3V7DQ","name":"Weird Explorer"},{"service_id":0,"url":"https://www.youtube.com/channel/UCf-ruwCgdtpCzuM7ODY5c9g","name":"Yuko Sensei"},{"service_id":0,"url":"https://www.youtube.com/channel/UCYQ2j85UM2QZ_fTW5e_DddQ","name":"忍者オヤジ ポール"}]} \ No newline at end of file