diff --git a/plugin.video.youtube/addon.xml b/plugin.video.youtube/addon.xml index b2b9fd0316..29f59368b8 100644 --- a/plugin.video.youtube/addon.xml +++ b/plugin.video.youtube/addon.xml @@ -1,28 +1,54 @@ - + - + - + video - + + + executable + -[new] quality and stream feature selection -[new] enable HLS live streams -[new] stream and language labelling -[chg] separately enable local/remote history -[chg] restore unsupported compatibility for Kodi v19 (Matrix) -[fix] fix various playback issues |contrib: various| -[fix] Limit host connections getting subscription feeds |contrib: cas--| -[fix] fix handling of connection failures -[upd] Translations updated from Kodi Weblate +### New +- Add display of extra video information (premieres, views, comments, likes) #18, #464, #503 +- Add support for Clips #450 +- Add ability to combine playlists #480 +- Add support for timestamps in links #502 +- Add initial support for higher bitrate streams #505 +- Add ability to limit video FPS at max resolution #539 +- Add local Watch Later and History for use when not logged in or custom playlist not set +- Update main menu items: + - New Recommended videos (similar to YouTube home page, will use login if available) + - Old Recommended videos renamed to Related videos (requires local/remote history enabled) + - Popular right now renamed to Trending + +### Changed +- Local history made optional and enabled by default +- Existing user data will be lost due to changes in data format: + - Search, local history, and local watch later is stored per user + - Function and data cache will be wiped (will also become per user in future) +- Disable OPUS audio by default #537 + +### Fixed +- Fix sharing links #115, #250, #538 +- Fix date and sorting issues #411, #425, #434 +- Fix issue with switching between H264/AV1 streams #532 +- Fix prompt for subtitles #534 +- Fix issues with corrupt user data #536 +- Fix issues with live streams #530, #540 +- Fix issues with loading large playlists #545 +- Fix Recommendations, Related Videos, and Auto-play next #508 +- Fix queuing from current playlist #549 +- Fix issues with randomising playlists #485 +- Workaround for crashes #113, #540 resources/media/icon.png @@ -55,6 +81,7 @@ Wtyczka YouTube Видеодополнение YouTube Doplnok pre YouTube + Plugin för YouTube YouTube için eklenti Plugin dành cho YouTube 油管插件 @@ -80,6 +107,7 @@ YouTube jest jednym z największych na świecie serwisów udostępniania wideo. YouTube - популярнейший видеохостинговый сайт, предоставляющий пользователям услуги хранения, доставки и показа видео. YouTube je jednou z najväčších stránok na zdieľanie videa na svete. + YouTube är en av de största videodelningssajterna i världen. YouTube, dünya üzerindeki en büyük video paylaşma platformlarından birisidir. YouTube là một trong những trang web chia sẻ video lớn nhất trên thế giới. 油管是世界上最大的视频分享网站之一。 @@ -101,6 +129,7 @@ Ta wtyczka nie jest zatwierdzona przez Google Плагин не поддерживается Google Tento doplnok nie je schválený spoločnosťou Google + Detta plugin är inte godkänt av Google Bu eklenti Google tarafından üretilmemiştir Plugin này không được xác nhận bởi Google 此插件未被谷歌认可 diff --git a/plugin.video.youtube/resources/language/resource.language.da_dk/strings.po b/plugin.video.youtube/resources/language/resource.language.da_dk/strings.po index 2e45c218b5..a85040c083 100644 --- a/plugin.video.youtube/resources/language/resource.language.da_dk/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.da_dk/strings.po @@ -5,9 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2023-05-06 00:35+0000\n" +"PO-Revision-Date: 2023-12-12 13:11+0000\n" "Last-Translator: Christian Gade \n" "Language-Team: Danish \n" "Language: da_dk\n" @@ -15,7 +15,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.15.2\n" +"X-Generator: Weblate 5.2.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -75,15 +75,15 @@ msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -1194,7 +1194,7 @@ msgstr "" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" diff --git a/plugin.video.youtube/resources/language/resource.language.el_gr/strings.po b/plugin.video.youtube/resources/language/resource.language.el_gr/strings.po index 69687e40fb..34fdb6c3e3 100644 --- a/plugin.video.youtube/resources/language/resource.language.el_gr/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.el_gr/strings.po @@ -5,17 +5,17 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2022-12-09 20:12+0000\n" -"Last-Translator: Paradigm Shifter \n" +"PO-Revision-Date: 2023-10-27 12:11+0000\n" +"Last-Translator: Christian Gade \n" "Language-Team: Greek \n" "Language: el_gr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.14.2\n" +"X-Generator: Weblate 5.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -75,15 +75,15 @@ msgstr "720p (ΥΕ)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -1194,7 +1194,7 @@ msgstr "" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" diff --git a/plugin.video.youtube/resources/language/resource.language.en_au/strings.po b/plugin.video.youtube/resources/language/resource.language.en_au/strings.po index fe488c862a..ecf6f327ac 100644 --- a/plugin.video.youtube/resources/language/resource.language.en_au/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.en_au/strings.po @@ -31,10 +31,13 @@ msgstr "" # msgctxt "Addon Summary" # msgid "Plugin for YouTube" # msgstr "" + # msgctxt "Addon Description" # msgid "YouTube is a one of the biggest video-sharing websites of the world." # msgstr "" + # Kodion Settings + msgctxt "#30000" msgid "General" msgstr "" @@ -47,9 +50,14 @@ msgctxt "#30002" msgid "Password" msgstr "" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 + msgctxt "#30007" -msgid "Use MPEG-DASH" +msgid "Use InputStream Adaptive" msgstr "" msgctxt "#30008" @@ -178,6 +186,7 @@ msgstr "" # Kodion Common # empty strings from id 30039 to 30099 + msgctxt "#30100" msgid "Favourites" msgstr "" @@ -264,6 +273,7 @@ msgstr "" # YouTube # empty strings from id 30121 to 30199 + msgctxt "#30200" msgid "API" msgstr "" @@ -290,6 +300,7 @@ msgstr "" # YouTube # empty strings from id 30206 to 30499 + msgctxt "#30500" msgid "Channels" msgstr "" @@ -343,7 +354,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" @@ -455,7 +466,7 @@ msgid "Play with..." msgstr "" msgctxt "#30541" -msgid "Show channel name in description" +msgid "Show channel name and video details in description" msgstr "" msgctxt "#30542" @@ -607,7 +618,7 @@ msgid "Force SSL certificate verification" msgstr "" msgctxt "#30579" -msgid "MPEG-DASH is enabled in the YouTube settings, however InputStream Adaptive appears to be disabled. Would you like to enable InputStream Adaptive now?" +msgid "InputStream Adaptive is activated in the YouTube settings, however the add-on has been disabled. Would you like to enable InputStream Adaptive now?" msgstr "" msgctxt "#30580" @@ -759,11 +770,11 @@ msgid "Must be signed in." msgstr "" msgctxt "#30617" -msgid "MPEG-DASH" +msgid "InputStream Adaptive" msgstr "" msgctxt "#30618" -msgid "Enable mpeg-dash proxy" +msgid "Enable MPEG-DASH proxy" msgstr "" msgctxt "#30619" @@ -775,7 +786,7 @@ msgid "Port %s already in use. Cannot start http server." msgstr "" msgctxt "#30621" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)" msgstr "" msgctxt "#30622" @@ -1043,7 +1054,7 @@ msgid "data cache" msgstr "" msgctxt "#30688" -msgid "Use for videos" +msgid "Use MPEG-DASH for videos" msgstr "" msgctxt "#30689" @@ -1183,7 +1194,7 @@ msgid "Enable HDR video" msgstr "" msgctxt "#30723" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)[CR]> 1080p and HDR requires InputStream Adaptive >= 2.3.14" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)[CR]HDR and >1080p video requires InputStream Adaptive >= 2.3.14" msgstr "" msgctxt "#30724" @@ -1345,3 +1356,31 @@ msgstr "" msgctxt "#30763" msgid "Multi-audio" msgstr "" + +msgctxt "#30764" +msgid "Requests connect timeout" +msgstr "" + +msgctxt "#30765" +msgid "Requests read timeout" +msgstr "" + +msgctxt "#30766" +msgid "Premieres" +msgstr "" + +msgctxt "#30767" +msgid "Views" +msgstr "" + +msgctxt "#30768" +msgid "Disable high framerate video at maximum video quality" +msgstr "" + +msgctxt "#30769" +msgid "Clear Watch Later list" +msgstr "" + +msgctxt "#30770" +msgid "Are you sure you want to clear your Watch Later list?" +msgstr "" diff --git a/plugin.video.youtube/resources/language/resource.language.en_gb/strings.po b/plugin.video.youtube/resources/language/resource.language.en_gb/strings.po index 6856bd1094..9c994c905e 100644 --- a/plugin.video.youtube/resources/language/resource.language.en_gb/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.en_gb/strings.po @@ -50,10 +50,14 @@ msgctxt "#30002" msgid "Password" msgstr "" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 msgctxt "#30007" -msgid "Use MPEG-DASH" +msgid "Use InputStream Adaptive" msgstr "" msgctxt "#30008" @@ -350,7 +354,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" @@ -462,7 +466,7 @@ msgid "Play with..." msgstr "" msgctxt "#30541" -msgid "Show channel name in description" +msgid "Show channel name and video details in description" msgstr "" msgctxt "#30542" @@ -614,7 +618,7 @@ msgid "Force SSL certificate verification" msgstr "" msgctxt "#30579" -msgid "MPEG-DASH is enabled in the YouTube settings, however InputStream Adaptive appears to be disabled. Would you like to enable InputStream Adaptive now?" +msgid "InputStream Adaptive is activated in the YouTube settings, however the add-on has been disabled. Would you like to enable InputStream Adaptive now?" msgstr "" msgctxt "#30580" @@ -766,11 +770,11 @@ msgid "Must be signed in." msgstr "" msgctxt "#30617" -msgid "MPEG-DASH" +msgid "InputStream Adaptive" msgstr "" msgctxt "#30618" -msgid "Enable mpeg-dash proxy" +msgid "Enable MPEG-DASH proxy" msgstr "" msgctxt "#30619" @@ -782,7 +786,7 @@ msgid "Port %s already in use. Cannot start http server." msgstr "" msgctxt "#30621" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)" msgstr "" msgctxt "#30622" @@ -1050,7 +1054,7 @@ msgid "data cache" msgstr "" msgctxt "#30688" -msgid "Use for videos" +msgid "Use MPEG-DASH for videos" msgstr "" msgctxt "#30689" @@ -1190,7 +1194,7 @@ msgid "Enable HDR video" msgstr "" msgctxt "#30723" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)[CR]> 1080p and HDR requires InputStream Adaptive >= 2.3.14" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)[CR]HDR and >1080p video requires InputStream Adaptive >= 2.3.14" msgstr "" msgctxt "#30724" @@ -1352,3 +1356,31 @@ msgstr "" msgctxt "#30763" msgid "Multi-audio" msgstr "" + +msgctxt "#30764" +msgid "Requests connect timeout" +msgstr "" + +msgctxt "#30765" +msgid "Requests read timeout" +msgstr "" + +msgctxt "#30766" +msgid "Premieres" +msgstr "" + +msgctxt "#30767" +msgid "Views" +msgstr "" + +msgctxt "#30768" +msgid "Disable high framerate video at maximum video quality" +msgstr "" + +msgctxt "#30769" +msgid "Clear Watch Later list" +msgstr "" + +msgctxt "#30770" +msgid "Are you sure you want to clear your Watch Later list?" +msgstr "" diff --git a/plugin.video.youtube/resources/language/resource.language.en_nz/strings.po b/plugin.video.youtube/resources/language/resource.language.en_nz/strings.po index a29883cd8c..872d3f7fe8 100644 --- a/plugin.video.youtube/resources/language/resource.language.en_nz/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.en_nz/strings.po @@ -34,7 +34,9 @@ msgstr "" # msgctxt "Addon Description" # msgid "YouTube is a one of the biggest video-sharing websites of the world." # msgstr "" + # Kodion Settings + msgctxt "#30000" msgid "General" msgstr "" @@ -47,9 +49,14 @@ msgctxt "#30002" msgid "Password" msgstr "" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 + msgctxt "#30007" -msgid "Use MPEG-DASH" +msgid "Use InputStream Adaptive" msgstr "" msgctxt "#30008" @@ -343,7 +350,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" @@ -455,7 +462,7 @@ msgid "Play with..." msgstr "" msgctxt "#30541" -msgid "Show channel name in description" +msgid "Show channel name and video details in description" msgstr "" msgctxt "#30542" @@ -607,7 +614,7 @@ msgid "Force SSL certificate verification" msgstr "" msgctxt "#30579" -msgid "MPEG-DASH is enabled in the YouTube settings, however InputStream Adaptive appears to be disabled. Would you like to enable InputStream Adaptive now?" +msgid "InputStream Adaptive is activated in the YouTube settings, however the add-on has been disabled. Would you like to enable InputStream Adaptive now?" msgstr "" msgctxt "#30580" @@ -759,11 +766,11 @@ msgid "Must be signed in." msgstr "" msgctxt "#30617" -msgid "MPEG-DASH" +msgid "InputStream Adaptive" msgstr "" msgctxt "#30618" -msgid "Enable mpeg-dash proxy" +msgid "Enable MPEG-DASH proxy" msgstr "" msgctxt "#30619" @@ -775,7 +782,7 @@ msgid "Port %s already in use. Cannot start http server." msgstr "" msgctxt "#30621" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)" msgstr "" msgctxt "#30622" @@ -1043,7 +1050,7 @@ msgid "data cache" msgstr "" msgctxt "#30688" -msgid "Use for videos" +msgid "Use MPEG-DASH for videos" msgstr "" msgctxt "#30689" @@ -1183,7 +1190,7 @@ msgid "Enable HDR video" msgstr "" msgctxt "#30723" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)[CR]> 1080p and HDR requires InputStream Adaptive >= 2.3.14" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)[CR]HDR and >1080p video requires InputStream Adaptive >= 2.3.14" msgstr "" msgctxt "#30724" @@ -1345,3 +1352,31 @@ msgstr "" msgctxt "#30763" msgid "Multi-audio" msgstr "" + +msgctxt "#30764" +msgid "Requests connect timeout" +msgstr "" + +msgctxt "#30765" +msgid "Requests read timeout" +msgstr "" + +msgctxt "#30766" +msgid "Premieres" +msgstr "" + +msgctxt "#30767" +msgid "Views" +msgstr "" + +msgctxt "#30768" +msgid "Disable high framerate video at maximum video quality" +msgstr "" + +msgctxt "#30769" +msgid "Clear Watch Later list" +msgstr "" + +msgctxt "#30770" +msgid "Are you sure you want to clear your Watch Later list?" +msgstr "" diff --git a/plugin.video.youtube/resources/language/resource.language.en_us/strings.po b/plugin.video.youtube/resources/language/resource.language.en_us/strings.po index 3ad4c774e0..e60a50d0ab 100644 --- a/plugin.video.youtube/resources/language/resource.language.en_us/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.en_us/strings.po @@ -32,10 +32,13 @@ msgstr "" # msgctxt "Addon Summary" # msgid "Plugin for YouTube" # msgstr "" + # msgctxt "Addon Description" # msgid "YouTube is a one of the biggest video-sharing websites of the world." # msgstr "" + # Kodion Settings + msgctxt "#30000" msgid "General" msgstr "General" @@ -48,9 +51,14 @@ msgctxt "#30002" msgid "Password" msgstr "Password" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 + msgctxt "#30007" -msgid "Use MPEG-DASH" +msgid "Use InputStream Adaptive" msgstr "" msgctxt "#30008" @@ -179,6 +187,7 @@ msgstr "" # Kodion Common # empty strings from id 30039 to 30099 + msgctxt "#30100" msgid "Favourites" msgstr "Favorites" @@ -265,6 +274,7 @@ msgstr "" # YouTube # empty strings from id 30121 to 30199 + msgctxt "#30200" msgid "API" msgstr "" @@ -291,6 +301,7 @@ msgstr "" # YouTube # empty strings from id 30206 to 30499 + msgctxt "#30500" msgid "Channels" msgstr "Channels" @@ -344,7 +355,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" @@ -456,7 +467,7 @@ msgid "Play with..." msgstr "" msgctxt "#30541" -msgid "Show channel name in description" +msgid "Show channel name and video details in description" msgstr "" msgctxt "#30542" @@ -608,7 +619,7 @@ msgid "Force SSL certificate verification" msgstr "" msgctxt "#30579" -msgid "MPEG-DASH is enabled in the YouTube settings, however InputStream Adaptive appears to be disabled. Would you like to enable InputStream Adaptive now?" +msgid "InputStream Adaptive is activated in the YouTube settings, however the add-on has been disabled. Would you like to enable InputStream Adaptive now?" msgstr "" msgctxt "#30580" @@ -760,11 +771,11 @@ msgid "Must be signed in." msgstr "" msgctxt "#30617" -msgid "MPEG-DASH" +msgid "InputStream Adaptive" msgstr "" msgctxt "#30618" -msgid "Enable mpeg-dash proxy" +msgid "Enable MPEG-DASH proxy" msgstr "" msgctxt "#30619" @@ -776,7 +787,7 @@ msgid "Port %s already in use. Cannot start http server." msgstr "" msgctxt "#30621" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)" msgstr "" msgctxt "#30622" @@ -1044,7 +1055,7 @@ msgid "data cache" msgstr "" msgctxt "#30688" -msgid "Use for videos" +msgid "Use MPEG-DASH for videos" msgstr "" msgctxt "#30689" @@ -1184,7 +1195,7 @@ msgid "Enable HDR video" msgstr "" msgctxt "#30723" -msgid "Proxy is required for mpeg-dash vods (see HTTP Server)[CR]> 1080p and HDR requires InputStream Adaptive >= 2.3.14" +msgid "Proxy is required for MPEG-DASH VODs (see HTTP Server)[CR]HDR and >1080p video requires InputStream Adaptive >= 2.3.14" msgstr "" msgctxt "#30724" @@ -1346,3 +1357,31 @@ msgstr "" msgctxt "#30763" msgid "Multi-audio" msgstr "" + +msgctxt "#30764" +msgid "Requests connect timeout" +msgstr "" + +msgctxt "#30765" +msgid "Requests read timeout" +msgstr "" + +msgctxt "#30766" +msgid "Premieres" +msgstr "" + +msgctxt "#30767" +msgid "Views" +msgstr "" + +msgctxt "#30768" +msgid "Disable high framerate video at maximum video quality" +msgstr "" + +msgctxt "#30769" +msgid "Clear Watch Later list" +msgstr "" + +msgctxt "#30770" +msgid "Are you sure you want to clear your Watch Later list?" +msgstr "" diff --git a/plugin.video.youtube/resources/language/resource.language.es_mx/strings.po b/plugin.video.youtube/resources/language/resource.language.es_mx/strings.po index fdecaa638d..539c034347 100644 --- a/plugin.video.youtube/resources/language/resource.language.es_mx/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.es_mx/strings.po @@ -5,17 +5,17 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2021-12-14 15:13+0000\n" -"Last-Translator: Edson Armando \n" +"PO-Revision-Date: 2023-10-27 12:11+0000\n" +"Last-Translator: Christian Gade \n" "Language-Team: Spanish (Mexico) \n" "Language: es_mx\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.9.1\n" +"X-Generator: Weblate 5.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -75,15 +75,15 @@ msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -1194,7 +1194,7 @@ msgstr "" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" diff --git a/plugin.video.youtube/resources/language/resource.language.it_it/strings.po b/plugin.video.youtube/resources/language/resource.language.it_it/strings.po index 834be5844c..e099dbe1ff 100644 --- a/plugin.video.youtube/resources/language/resource.language.it_it/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.it_it/strings.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: XBMC-Addons\n" "Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2023-09-14 03:24+0000\n" +"PO-Revision-Date: 2023-12-07 05:42+0000\n" "Last-Translator: Massimo Pissarello \n" "Language-Team: Italian \n" "Language: it_it\n" @@ -16,7 +16,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.0.1\n" +"X-Generator: Weblate 5.2.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -260,7 +260,7 @@ msgstr "Elimina" msgctxt "#30119" msgid "Please wait..." -msgstr "Attendi..." +msgstr "Attendere prego..." msgctxt "#30120" msgid "Clear" diff --git a/plugin.video.youtube/resources/language/resource.language.ko_kr/strings.po b/plugin.video.youtube/resources/language/resource.language.ko_kr/strings.po index 9d6de68b7f..ed2b0e3567 100644 --- a/plugin.video.youtube/resources/language/resource.language.ko_kr/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.ko_kr/strings.po @@ -5,9 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2023-05-06 00:35+0000\n" +"PO-Revision-Date: 2023-10-19 09:11+0000\n" "Last-Translator: Minho Park \n" "Language-Team: Korean \n" "Language: ko_kr\n" @@ -15,7 +15,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 4.15.2\n" +"X-Generator: Weblate 5.0.2\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -62,7 +62,7 @@ msgstr "동영상 품질 항상 확인" msgctxt "#30010" msgid "Maximum video quality" -msgstr "" +msgstr "최대 비디오 품질" msgctxt "#30011" msgid "480p" @@ -74,15 +74,15 @@ msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -98,7 +98,7 @@ msgstr "1080p 라이브 / 720p (HD)" msgctxt "#30019" msgid "144p" -msgstr "" +msgstr "144p" msgctxt "#30020" msgid "Allow 3D" @@ -992,7 +992,7 @@ msgstr "이어 볼 지점 다시 설정" msgctxt "#30675" msgid "Use local playback history (watched, resume tracking)" -msgstr "" +msgstr "로컬 재생 기록 사용 (시청함, 이어 보기 탐색)" msgctxt "#30676" msgid "Just now" @@ -1052,7 +1052,7 @@ msgstr "실시간에 사용" msgctxt "#30690" msgid "InputStream Adaptive >= 2.0.12 is required for adaptive live streams" -msgstr "" +msgstr "적응형 라이브 스트림에는 InputStream Adaptive >= 2.0.12가 필요함" msgctxt "#30691" msgid "Airing now" @@ -1180,7 +1180,7 @@ msgstr "기본으로 WEBM 적응 설정 (4K)" msgctxt "#30722" msgid "Enable HDR video" -msgstr "" +msgstr "HDR 비디오 사용" msgctxt "#30723" msgid "Proxy is required for mpeg-dash vods (see HTTP Server)[CR]> 1080p and HDR requires InputStream Adaptive >= 2.3.14" @@ -1188,11 +1188,11 @@ msgstr "mpeg-dash vods (HTTP 서버 참고)[CR]> 1080p에 프록시가 필요하 msgctxt "#30724" msgid "Enable high framerate video" -msgstr "" +msgstr "높은 프레임 비디오 사용" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" @@ -1200,11 +1200,11 @@ msgstr "업로드한 동영상" msgctxt "#30727" msgid "Enable H.264 video" -msgstr "" +msgstr "H.264 비디오 사용" msgctxt "#30728" msgid "Enable VP9 video" -msgstr "" +msgstr "VP9 비디오 사용" msgctxt "#30729" msgid "Remote friendly search" @@ -1252,99 +1252,99 @@ msgstr "대체 #2" msgctxt "#30740" msgid "HLS" -msgstr "" +msgstr "HLS" msgctxt "#30741" msgid "Multi-stream HLS" -msgstr "" +msgstr "멀티스트림 HLS" msgctxt "#30742" msgid "Adaptive HLS" -msgstr "" +msgstr "적응형 HLS" msgctxt "#30743" msgid "MPEG-DASH" -msgstr "" +msgstr "MPEG-DASH" msgctxt "#30744" msgid "Original" -msgstr "" +msgstr "원본" msgctxt "#30745" msgid "Dubbed" -msgstr "" +msgstr "더빙" msgctxt "#30746" msgid "Descriptive" -msgstr "" +msgstr "설명" msgctxt "#30747" msgid "Alternate" -msgstr "" +msgstr "번갈아 나오기" msgctxt "#30748" msgid "Stream features" -msgstr "" +msgstr "스트림 기능" msgctxt "#30749" msgid "Enable AV1 video" -msgstr "" +msgstr "AV1 비디오 활성화" msgctxt "#30750" msgid "Enable Vorbis audio" -msgstr "" +msgstr "Vorbis 오디오 사용" msgctxt "#30751" msgid "Enable Opus audio" -msgstr "" +msgstr "Opus 오디오 사용" msgctxt "#30752" msgid "Enable AAC audio" -msgstr "" +msgstr "AAC 오디오 사용" msgctxt "#30753" msgid "Enable surround sound audio" -msgstr "" +msgstr "서라운드 사운드 사용" msgctxt "#30754" msgid "Enable AC-3 audio" -msgstr "" +msgstr "AC-3 오디오 사용" msgctxt "#30755" msgid "Enable EAC-3 audio" -msgstr "" +msgstr "EAC-3 오디오 사용" msgctxt "#30756" msgid "Enable DTS audio" -msgstr "" +msgstr "DTS 오디오 사용" msgctxt "#30757" msgid "Remove similar/duplicate streams" -msgstr "" +msgstr "유사/중복 스트림 제거" msgctxt "#30758" msgid "Stream selection" -msgstr "" +msgstr "스트림 선택" msgctxt "#30759" msgid "Quality selection" -msgstr "" +msgstr "품질 선택" msgctxt "#30760" msgid "Automatic + Quality selection" -msgstr "" +msgstr "자동 + 품질 선택" msgctxt "#30761" msgid "Update playback history on Youtube" -msgstr "" +msgstr "YouTube의 재생 기록 업데이트" msgctxt "#30762" msgid "Multi-language" -msgstr "" +msgstr "다국어" msgctxt "#30763" msgid "Multi-audio" -msgstr "" +msgstr "다중 오디오" #~ msgctxt "#30010" #~ msgid "Video quality" diff --git a/plugin.video.youtube/resources/language/resource.language.pt_br/strings.po b/plugin.video.youtube/resources/language/resource.language.pt_br/strings.po index a6b843374a..b110c6927f 100644 --- a/plugin.video.youtube/resources/language/resource.language.pt_br/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.pt_br/strings.po @@ -5,9 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2022-03-09 20:53+0000\n" +"PO-Revision-Date: 2023-10-27 12:11+0000\n" "Last-Translator: Christian Gade \n" "Language-Team: Portuguese (Brazil) \n" "Language: pt_br\n" @@ -15,7 +15,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 4.11.2\n" +"X-Generator: Weblate 5.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -73,15 +73,15 @@ msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -1189,7 +1189,7 @@ msgstr "" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" diff --git a/plugin.video.youtube/resources/language/resource.language.ro_ro/strings.po b/plugin.video.youtube/resources/language/resource.language.ro_ro/strings.po index b0963afb29..795cc1c22f 100644 --- a/plugin.video.youtube/resources/language/resource.language.ro_ro/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.ro_ro/strings.po @@ -5,9 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2022-03-09 20:53+0000\n" +"PO-Revision-Date: 2023-10-27 12:11+0000\n" "Last-Translator: Christian Gade \n" "Language-Team: Romanian \n" "Language: ro_ro\n" @@ -15,7 +15,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2;\n" -"X-Generator: Weblate 4.11.2\n" +"X-Generator: Weblate 5.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -74,15 +74,15 @@ msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -1192,7 +1192,7 @@ msgstr "" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" diff --git a/plugin.video.youtube/resources/language/resource.language.ru_ru/strings.po b/plugin.video.youtube/resources/language/resource.language.ru_ru/strings.po index 08378e08be..f229feb2a6 100644 --- a/plugin.video.youtube/resources/language/resource.language.ru_ru/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.ru_ru/strings.po @@ -5,17 +5,17 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2022-12-27 12:15+0000\n" -"Last-Translator: Andrei Stepanov \n" +"PO-Revision-Date: 2023-10-27 12:11+0000\n" +"Last-Translator: Christian Gade \n" "Language-Team: Russian \n" "Language: ru_ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 4.15\n" +"X-Generator: Weblate 5.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" @@ -75,15 +75,15 @@ msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" @@ -1194,7 +1194,7 @@ msgstr "" msgctxt "#30725" msgid "1440p (QHD)" -msgstr "" +msgstr "1440p (QHD)" msgctxt "#30726" msgid "Uploads" diff --git a/plugin.video.youtube/resources/language/resource.language.sv_se/strings.po b/plugin.video.youtube/resources/language/resource.language.sv_se/strings.po index cd677cb2d5..fd8ceb49d5 100644 --- a/plugin.video.youtube/resources/language/resource.language.sv_se/strings.po +++ b/plugin.video.youtube/resources/language/resource.language.sv_se/strings.po @@ -5,9 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" "POT-Creation-Date: 2015-09-21 11:01+0000\n" -"PO-Revision-Date: 2022-10-24 14:04+0000\n" +"PO-Revision-Date: 2023-11-03 15:11+0000\n" "Last-Translator: Christian Gade \n" "Language-Team: Swedish \n" "Language: sv_se\n" @@ -15,19 +15,19 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.14.1\n" +"X-Generator: Weblate 5.1\n" msgctxt "Addon Summary" msgid "Plugin for YouTube" -msgstr "" +msgstr "Plugin för YouTube" msgctxt "Addon Description" msgid "YouTube is one of the biggest video-sharing websites of the world." -msgstr "" +msgstr "YouTube är en av de största videodelningssajterna i världen." msgctxt "Addon Disclaimer" msgid "This plugin is not endorsed by Google" -msgstr "" +msgstr "Detta plugin är inte godkänt av Google" # msgctxt "Addon Summary" # msgid "Plugin for YouTube" @@ -51,80 +51,80 @@ msgstr "Lösenord" # empty strings from id 30003 to 30006 msgctxt "#30007" msgid "Use MPEG-DASH" -msgstr "" +msgstr "Använd MPEG-DASH" msgctxt "#30008" msgid "Configure InputStream Adaptive" -msgstr "" +msgstr "Konfigurera InputStream Adaptive" msgctxt "#30009" msgid "Always ask for the video quality" -msgstr "" +msgstr "Fråga alltid efter videokvalitet" msgctxt "#30010" msgid "Maximum video quality" -msgstr "" +msgstr "Bästa videokvalitet" msgctxt "#30011" msgid "480p" -msgstr "" +msgstr "480p" msgctxt "#30012" msgid "720p (HD)" -msgstr "" +msgstr "720p (HD)" msgctxt "#30013" msgid "1080p (FHD)" -msgstr "" +msgstr "1080p (FHD)" msgctxt "#30014" msgid "2160p (4K)" -msgstr "" +msgstr "2160p (4K)" msgctxt "#30015" msgid "4320p (8K)" -msgstr "" +msgstr "4320p (8K)" msgctxt "#30016" msgid "240p" -msgstr "" +msgstr "240p" msgctxt "#30017" msgid "360p" -msgstr "" +msgstr "360p" msgctxt "#30018" msgid "1080p Live / 720p (HD)" -msgstr "" +msgstr "1080p Live / 720p (HD)" msgctxt "#30019" msgid "144p" -msgstr "" +msgstr "144p" # empty strings 30019 msgctxt "#30020" msgid "Allow 3D" -msgstr "" +msgstr "Tillåt 3D" msgctxt "#30021" msgid "Show fanart" -msgstr "" +msgstr "Show fanart" msgctxt "#30022" msgid "Items per page" -msgstr "" +msgstr "Antal per sida" msgctxt "#30023" msgid "Search history size" -msgstr "" +msgstr "Sökhistoriestorlek" msgctxt "#30024" msgid "Cache size (MB)" -msgstr "" +msgstr "Cache-storlek (MB)" msgctxt "#30025" msgid "Enable setup-wizard" -msgstr "" +msgstr "Tillåt inställningsguide" msgctxt "#30026" msgid "Override view" diff --git a/plugin.video.youtube/resources/lib/__init__.py b/plugin.video.youtube/resources/lib/__init__.py index d2d4112fb5..2c7daf8bc0 100644 --- a/plugin.video.youtube/resources/lib/__init__.py +++ b/plugin.video.youtube/resources/lib/__init__.py @@ -8,4 +8,7 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + + __all__ = ['youtube_plugin'] diff --git a/plugin.video.youtube/resources/lib/default.py b/plugin.video.youtube/resources/lib/plugin.py similarity index 84% rename from plugin.video.youtube/resources/lib/default.py rename to plugin.video.youtube/resources/lib/plugin.py index 10385f513e..ea6555469c 100644 --- a/plugin.video.youtube/resources/lib/default.py +++ b/plugin.video.youtube/resources/lib/plugin.py @@ -8,8 +8,10 @@ See LICENSES/GPL-2.0-only for more information. """ -from youtube_plugin.kodion import runner +from __future__ import absolute_import, division, unicode_literals + from youtube_plugin import youtube +from youtube_plugin.kodion import runner __provider__ = youtube.Provider() runner.run(__provider__) diff --git a/plugin.video.youtube/resources/lib/startup.py b/plugin.video.youtube/resources/lib/service.py similarity index 81% rename from plugin.video.youtube/resources/lib/startup.py rename to plugin.video.youtube/resources/lib/service.py index 36f71ca9cf..31b84ed004 100644 --- a/plugin.video.youtube/resources/lib/startup.py +++ b/plugin.video.youtube/resources/lib/service.py @@ -8,6 +8,8 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from youtube_plugin.kodion import service service.run() diff --git a/plugin.video.youtube/resources/lib/youtube_authentication.py b/plugin.video.youtube/resources/lib/youtube_authentication.py index e3f373f352..5316406925 100644 --- a/plugin.video.youtube/resources/lib/youtube_authentication.py +++ b/plugin.video.youtube/resources/lib/youtube_authentication.py @@ -7,16 +7,24 @@ See LICENSES/GPL-2.0-only for more information. """ -from youtube_plugin.youtube.provider import Provider -from youtube_plugin.kodion.impl import Context +from __future__ import absolute_import, division, unicode_literals + +from youtube_plugin.kodion.constants import ADDON_ID +from youtube_plugin.kodion.context import Context from youtube_plugin.youtube.helper import yt_login +from youtube_plugin.youtube.provider import Provider +from youtube_plugin.youtube.youtube_exceptions import LoginException -# noinspection PyUnresolvedReferences -from youtube_plugin.youtube.youtube_exceptions import LoginException # NOQA +__all__ = ( + 'LoginException', + 'reset_access_tokens', + 'sign_in', + 'sign_out', +) -SIGN_IN = 'in' -SIGN_OUT = 'out' +_SIGN_IN = 'in' +_SIGN_OUT = 'out' def __add_new_developer(addon_id): @@ -25,8 +33,7 @@ def __add_new_developer(addon_id): :param addon_id: id of the add-on being added :return: """ - params = {'addon_id': addon_id} - context = Context(params=params, plugin_id='plugin.video.youtube') + context = Context(params={'addon_id': addon_id}) access_manager = context.get_access_manager() developers = access_manager.get_developers() @@ -36,44 +43,43 @@ def __add_new_developer(addon_id): context.log_debug('Creating developer user: |%s|' % addon_id) -def __auth(addon_id, mode=SIGN_IN): +def __auth(addon_id, mode=_SIGN_IN): """ :param addon_id: id of the add-on being signed in :param mode: SIGN_IN or SIGN_OUT :return: addon provider, context and client """ - if not addon_id or addon_id == 'plugin.video.youtube': - context = Context(plugin_id='plugin.video.youtube') + if not addon_id or addon_id == ADDON_ID: + context = Context() context.log_error('Developer authentication: |%s| Invalid addon_id' % addon_id) return __add_new_developer(addon_id) - params = {'addon_id': addon_id} provider = Provider() - context = Context(params=params, plugin_id='plugin.video.youtube') + context = Context(params={'addon_id': addon_id}) - _ = provider.get_client(context=context) # NOQA + _ = provider.get_client(context=context) logged_in = provider.is_logged_in() - if mode == SIGN_IN: + if mode == _SIGN_IN: if logged_in: return True else: provider.reset_client() yt_login.process(mode, provider, context, sign_out_refresh=False) - elif mode == SIGN_OUT: + elif mode == _SIGN_OUT: if not logged_in: return True else: provider.reset_client() try: yt_login.process(mode, provider, context, sign_out_refresh=False) - except: + except LoginException: reset_access_tokens(addon_id) else: raise Exception('Unknown mode: |%s|' % mode) - _ = provider.get_client(context=context) # NOQA - if mode == SIGN_IN: + _ = provider.get_client(context=context) + if mode == _SIGN_IN: return provider.is_logged_in() else: return not provider.is_logged_in() @@ -101,8 +107,8 @@ def sign_in(addon_id): try: signed_in = youtube_authentication.sign_in(addon_id='plugin.video.example') # refreshes access tokens if already signed in - except youtube_authentication.LoginException as e: - error_message = e.get_message() + except youtube_authentication.LoginException as exc: + error_message = exc.get_message() # handle error signed_in = False @@ -114,7 +120,7 @@ def sign_in(addon_id): :return: boolean, True when signed in """ - return __auth(addon_id, mode=SIGN_IN) + return __auth(addon_id, mode=_SIGN_IN) def sign_out(addon_id): @@ -145,7 +151,7 @@ def sign_out(addon_id): :return: boolean, True when signed out """ - return __auth(addon_id, mode=SIGN_OUT) + return __auth(addon_id, mode=_SIGN_OUT) def reset_access_tokens(addon_id): @@ -154,12 +160,11 @@ def reset_access_tokens(addon_id): :param addon_id: id of the add-on having it's access tokens reset :return: """ - if not addon_id or addon_id == 'plugin.video.youtube': - context = Context(plugin_id='plugin.video.youtube') + if not addon_id or addon_id == ADDON_ID: + context = Context() context.log_error('Developer reset access tokens: |%s| Invalid addon_id' % addon_id) return - params = {'addon_id': addon_id} - context = Context(params=params, plugin_id='plugin.video.youtube') + context = Context(params={'addon_id': addon_id}) access_manager = context.get_access_manager() access_manager.update_dev_access_token(addon_id, access_token='', refresh_token='') diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/__init__.py index 02f46fa36d..44b1ba27b1 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/__init__.py @@ -8,6 +8,9 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + + key_sets = { 'youtube-tv': { 'id': 'ODYxNTU2NzA4NDU0LWQ2ZGxtM2xoMDVpZGQ4bnBlazE4azZiZThiYTNvYzY4', @@ -23,4 +26,4 @@ } } -__all__ = ['kodion', 'youtube', 'key_sets', 'refresh'] +__all__ = ['kodion', 'youtube', 'key_sets', 'script'] diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/__init__.py index 0591b2ea3a..9df0afa430 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/__init__.py @@ -18,12 +18,19 @@ from .abstract_provider import AbstractProvider # import specialized implementation into the kodion namespace -from .impl import Context - -from .constants import * +from .context import Context from . import logger -__all__ = ['KodionException', 'RegisterProviderPath', 'AbstractProvider', 'Context', 'utils', 'json_store', 'logger'] + +__all__ = ( + 'AbstractProvider', + 'Context', + 'KodionException', + 'RegisterProviderPath', + 'json_store', + 'logger', + 'utils', +) __version__ = '1.5.4' diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/abstract_provider.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/abstract_provider.py index ff8ffd444f..5f85ea08bc 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/abstract_provider.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/abstract_provider.py @@ -8,15 +8,20 @@ See LICENSES/GPL-2.0-only for more information. """ -import json +from __future__ import absolute_import, division, unicode_literals + import re -from urllib.parse import quote -from urllib.parse import unquote +from .constants import paths, content +from .compatibility import quote, unquote from .exceptions import KodionException -from . import items -from . import constants -from . import utils +from .items import ( + DirectoryItem, + NewSearchItem, + SearchHistoryItem, + menu_items +) +from .utils import to_unicode class AbstractProvider(object): @@ -24,16 +29,6 @@ class AbstractProvider(object): RESULT_UPDATE_LISTING = 'update_listing' def __init__(self): - self._local_map = { - 'kodion.wizard.view.default': 30027, - 'kodion.wizard.view.episodes': 30028, - 'kodion.wizard.view.movies': 30029, - 'kodion.wizard.view.tvshows': 30032, - 'kodion.wizard.view.songs': 30033, - 'kodion.wizard.view.artists': 30034, - 'kodion.wizard.view.albums': 30035 - } - # map for regular expression (path) to method (names) self._dict_path = {} @@ -41,12 +36,33 @@ def __init__(self): # register some default paths self.register_path(r'^/$', '_internal_root') - self.register_path(r''.join(['^/', constants.paths.WATCH_LATER, '/(?Padd|remove|list)/?$']), - '_internal_watch_later') - self.register_path(r''.join(['^/', constants.paths.FAVORITES, '/(?Padd|remove|list)/?$']), '_internal_favorite') - self.register_path(r''.join(['^/', constants.paths.SEARCH, '/(?Pinput|query|list|remove|clear|rename)/?$']), - '_internal_search') - self.register_path(r'(?P.*\/)extrafanart\/([\?#].+)?$', '_internal_on_extra_fanart') + + self.register_path(r''.join([ + '^/', + paths.WATCH_LATER, + '/(?Padd|clear|list|remove)/?$' + ]), '_internal_watch_later') + + self.register_path(r''.join([ + '^/', + paths.FAVORITES, + '/(?Padd|clear|list|remove)/?$' + ]), '_internal_favorite') + + self.register_path(r''.join([ + '^/', + paths.SEARCH, + '/(?Pinput|query|list|remove|clear|rename)/?$' + ]), '_internal_search') + + self.register_path(r''.join([ + '^/', + paths.HISTORY, + '/$' + ]), 'on_playback_history') + + self.register_path(r'(?P.*\/)extrafanart\/([\?#].+)?$', + '_internal_on_extra_fanart') """ Test each method of this class for the appended attribute '_re_match' by the @@ -55,12 +71,10 @@ def __init__(self): """ for method_name in dir(self): - method = getattr(self, method_name) - if hasattr(method, 'kodion_re_path'): - self.register_path(method.kodion_re_path, method_name) - - def get_alternative_fanart(self, context): - return context.get_fanart() + method = getattr(self, method_name, None) + path = method and getattr(method, 'kodion_re_path', None) + if path: + self.register_path(path, method_name) def register_path(self, re_path, method_name): """ @@ -71,35 +85,33 @@ def register_path(self, re_path, method_name): """ self._dict_path[re_path] = method_name - def _process_wizard(self, context): - # start the setup wizard - wizard_steps = [] - if context.get_settings().is_setup_wizard_enabled(): - context.get_settings().set_bool(constants.setting.SETUP_WIZARD, False) - wizard_steps.extend(self.get_wizard_steps(context)) + def run_wizard(self, context): + settings = context.get_settings() + ui = context.get_ui() + + settings.set_bool(settings.SETUP_WIZARD, False) - if wizard_steps and context.get_ui().on_yes_no_input(context.get_name(), - context.localize(constants.localize.SETUP_WIZARD_EXECUTE)): + wizard_steps = self.get_wizard_steps(context) + wizard_steps.extend(ui.get_view_manager().get_wizard_steps()) + + if (wizard_steps and ui.on_yes_no_input( + context.get_name(), context.localize('setup_wizard.execute') + )): for wizard_step in wizard_steps: wizard_step[0](*wizard_step[1]) - def get_wizard_supported_views(self): - return ['default'] - def get_wizard_steps(self, context): # can be overridden by the derived class return [] def navigate(self, context): - self._process_wizard(context) - path = context.get_path() for key in self._dict_path: re_match = re.search(key, path, re.UNICODE) if re_match is not None: method_name = self._dict_path.get(key, '') - method = getattr(self, method_name) + method = getattr(self, method_name, None) if method is not None: result = method(context, re_match) if not isinstance(result, tuple): @@ -117,79 +129,110 @@ def on_extra_fanart(context, re_match): :param re_match: :return: """ - return None + return def _internal_on_extra_fanart(self, context, re_match): path = re_match.group('path') new_context = context.clone(new_path=path) return self.on_extra_fanart(new_context, re_match) + def on_playback_history(self, context, re_match): + raise NotImplementedError() + def on_search(self, search_text, context, re_match): raise NotImplementedError() def on_root(self, context, re_match): raise NotImplementedError() - def on_watch_later(self, context, re_match): - pass - def _internal_root(self, context, re_match): return self.on_root(context, re_match) @staticmethod def _internal_favorite(context, re_match): - context.add_sort_method(constants.sort_method.LABEL_IGNORE_THE) - params = context.get_params() - command = re_match.group('command') - if command == 'add': - fav_item = items.from_json(params['item']) - context.get_favorite_list().add(fav_item) - elif command == 'remove': - fav_item = items.from_json(params['item']) - context.get_favorite_list().remove(fav_item) - context.get_ui().refresh_container() - elif command == 'list': + if not command: + return False - directory_items = context.get_favorite_list().list() + if command == 'list': + items = context.get_favorite_list().get_items() - for directory_item in directory_items: - context_menu = [(context.localize(constants.localize.WATCH_LATER_REMOVE), - 'RunPlugin(%s)' % context.create_uri([constants.paths.FAVORITES, 'remove'], - params={'item': items.to_jsons(directory_item)}))] - directory_item.set_context_menu(context_menu) + for item in items: + context_menu = [ + menu_items.favorites_remove( + context, item.video_id + ), + ] + item.set_context_menu(context_menu) - return directory_items - else: - pass + return items - def _internal_watch_later(self, context, re_match): - self.on_watch_later(context, re_match) - - params = context.get_params() + video_id = params.get('video_id') + if not video_id: + return False - command = re_match.group('command') if command == 'add': - item = items.from_json(params['item']) - context.get_watch_later_list().add(item) - elif command == 'remove': - item = items.from_json(params['item']) - context.get_watch_later_list().remove(item) + item = params.get('item') + if item: + context.get_favorite_list().add(video_id, item) + return True + + if command == 'remove': + context.get_favorite_list().remove(video_id) context.get_ui().refresh_container() - elif command == 'list': - video_items = context.get_watch_later_list().list() + return True + + return False + + @staticmethod + def _internal_watch_later(context, re_match): + params = context.get_params() + command = re_match.group('command') + if not command: + return False + + if command == 'list': + context.set_content(content.VIDEO_CONTENT, sub_type='watch_later') + video_items = context.get_watch_later_list().get_items() for video_item in video_items: - context_menu = [(context.localize(constants.localize.WATCH_LATER_REMOVE), - 'RunPlugin(%s)' % context.create_uri([constants.paths.WATCH_LATER, 'remove'], - params={'item': items.to_jsons(video_item)}))] + context_menu = [ + menu_items.watch_later_local_remove( + context, video_item.video_id + ), + menu_items.watch_later_local_clear( + context + ) + ] video_item.set_context_menu(context_menu) return video_items - else: - # do something - pass + + if (command == 'clear' and context.get_ui().on_yes_no_input( + context.get_name(), + context.localize('watch_later.clear.confirm') + )): + context.get_watch_later_list().clear() + context.get_ui().refresh_container() + return True + + video_id = params.get('video_id') + if not video_id: + return False + + if command == 'add': + item = params.get('item') + if item: + context.get_watch_later_list().add(video_id, item) + return True + + if command == 'remove': + context.get_watch_later_list().remove(video_id) + context.get_ui().refresh_container() + return True + + return False @property def data_cache(self): @@ -202,56 +245,76 @@ def data_cache(self, context): def _internal_search(self, context, re_match): params = context.get_params() + ui = context.get_ui() command = re_match.group('command') search_history = context.get_search_history() + if command == 'remove': query = params['q'] search_history.remove(query) - context.get_ui().refresh_container() + ui.refresh_container() return True - elif command == 'rename': + + if command == 'rename': query = params['q'] - result, new_query = context.get_ui().on_keyboard_input(context.localize(constants.localize.SEARCH_RENAME), - query) + result, new_query = ui.on_keyboard_input( + context.localize('search.rename'), query + ) if result: search_history.rename(query, new_query) - context.get_ui().refresh_container() + ui.refresh_container() return True - elif command == 'clear': + + if command == 'clear': search_history.clear() - context.get_ui().refresh_container() + ui.refresh_container() return True - elif command == 'input': + + if command == 'query': + incognito = context.get_param('incognito', False) + channel_id = context.get_param('channel_id', '') + query = params['q'] + query = to_unicode(query) + + if not incognito and not channel_id: + try: + search_history.update(query) + except: + pass + if isinstance(query, bytes): + query = query.decode('utf-8') + return self.on_search(query, context, re_match) + + if command == 'input': self.data_cache = context - folder_path = context.get_ui().get_info_label('Container.FolderPath') + folder_path = context.get_infolabel('Container.FolderPath') query = None + # came from page 1 of search query by '..'/back + # user doesn't want to input on this path if (folder_path.startswith('plugin://%s' % context.get_id()) and re.match('.+/(?:query|input)/.*', folder_path)): - cached_query = self.data_cache.get_item(self.data_cache.ONE_DAY, 'search_query') - # came from page 1 of search query by '..'/back, user doesn't want to input on this path - if cached_query and cached_query.get('search_query', {}).get('query'): - query = cached_query.get('search_query', {}).get('query') - query = utils.to_unicode(query) - query = unquote(query) + cached = self.data_cache.get_item('search_query', + self.data_cache.ONE_DAY) + cached = cached and cached.get('query') + if cached: + query = unquote(to_unicode(cached)) else: - result, input_query = context.get_ui().on_keyboard_input(context.localize(constants.localize.SEARCH_TITLE)) + result, input_query = ui.on_keyboard_input( + context.localize('search.title') + ) if result: query = input_query if not query: return False - incognito = str(context.get_param('incognito', False)).lower() == 'true' + incognito = context.get_param('incognito', False) channel_id = context.get_param('channel_id', '') - query = utils.to_utf8(query) - try: - self._data_cache.set('search_query', json.dumps({'query': quote(query)})) - except KeyError: - encoded = json.dumps({'query': quote(query.encode('utf8'))}) - self._data_cache.set('search_query', encoded) + self._data_cache.set_item('search_query', + {'query': quote(query)}) if not incognito and not channel_id: try: @@ -263,44 +326,29 @@ def _internal_search(self, context, re_match): query = query.decode('utf-8') return self.on_search(query, context, re_match) - elif command == 'query': - incognito = str(context.get_param('incognito', False)).lower() == 'true' - channel_id = context.get_param('channel_id', '') - query = params['q'] - query = utils.to_unicode(query) - - if not incognito and not channel_id: - try: - search_history.update(query) - except: - pass - if isinstance(query, bytes): - query = query.decode('utf-8') - return self.on_search(query, context, re_match) - else: - context.set_content_type(constants.content_type.FILES) - result = [] - - location = str(context.get_param('location', False)).lower() == 'true' + context.set_content(content.VIDEO_CONTENT) + result = [] - # 'New Search...' - new_search_item = items.NewSearchItem(context, fanart=self.get_alternative_fanart(context), location=location) - result.append(new_search_item) + location = context.get_param('location', False) - for search in search_history.list(): - # little fallback for old history entries - if isinstance(search, items.DirectoryItem): - search = search.get_name() + # 'New Search...' + new_search_item = NewSearchItem( + context, location=location + ) + result.append(new_search_item) - # we create a new instance of the SearchItem - search_history_item = items.SearchHistoryItem(context, search, fanart=self.get_alternative_fanart(context), location=location) - result.append(search_history_item) + for search in search_history.get_items(): + # little fallback for old history entries + if isinstance(search, DirectoryItem): + search = search.get_name() - if search_history.is_empty(): - # context.execute('RunPlugin(%s)' % context.create_uri([constants.paths.SEARCH, 'input'])) - pass + # we create a new instance of the SearchItem + search_history_item = SearchHistoryItem( + context, search, location=location + ) + result.append(search_history_item) - return result, {self.RESULT_CACHE_TO_DISC: False} + return result, {self.RESULT_CACHE_TO_DISC: False} def handle_exception(self, context, exception_to_handle): return True diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/compatibility/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/compatibility/__init__.py new file mode 100644 index 0000000000..19cc2ebcd3 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/compatibility/__init__.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +try: + from html import unescape + from http import server as BaseHTTPServer + from urllib.parse import ( + parse_qs, + parse_qsl, + quote, + unquote, + urlencode, + urljoin, + urlsplit, + ) + + import xbmc + import xbmcaddon + import xbmcgui + import xbmcplugin + import xbmcvfs + + from infotagger.listitem import set_info_tag + + xbmc.LOGNOTICE = xbmc.LOGINFO + xbmc.LOGSEVERE = xbmc.LOGFATAL + + string_type = str + +except ImportError: + import BaseHTTPServer + from contextlib import contextmanager as _contextmanager + from urllib import ( + quote as _quote, + unquote as _unquote, + urlencode as _urlencode, + ) + from urlparse import ( + parse_qs, + parse_qsl, + urljoin, + urlsplit, + ) + from xml.sax.saxutils import unescape + + from kodi_six import ( + xbmc, + xbmcaddon, + xbmcgui, + xbmcplugin, + xbmcvfs, + ) + + + def quote(data, *args, **kwargs): + return _quote(data.encode('utf-8'), *args, **kwargs) + + + def unquote(data): + return _unquote(data.encode('utf-8')) + + + def urlencode(data, *args, **kwargs): + if isinstance(data, dict): + data = data.items() + return _urlencode({ + key.encode('utf-8'): ( + [part.encode('utf-8') if isinstance(part, unicode) + else str(part) + for part in value] if isinstance(value, (list, tuple)) + else value.encode('utf-8') if isinstance(value, unicode) + else str(value) + ) + for key, value in data + }, *args, **kwargs) + + + _File = xbmcvfs.File + + + @_contextmanager + def _file_closer(*args, **kwargs): + file = None + try: + file = _File(*args, **kwargs) + yield file + finally: + if file: + file.close() + + + xbmcvfs.File = _file_closer + xbmcvfs.translatePath = xbmc.translatePath + + + def set_info_tag(listitem, infolabels, tag_type, *_args, **_kwargs): + listitem.setInfo(tag_type, infolabels) + return ListItemInfoTag(listitem, tag_type) + + + class ListItemInfoTag(object): + __slots__ = ('__li__',) + + def __init__(self, listitem, *_args, **_kwargs): + self.__li__ = listitem + + def add_stream_info(self, *args, **kwargs): + return self.__li__.addStreamInfo(*args, **kwargs) + + def set_resume_point(self, + infoproperties, + resume_key='ResumeTime', + total_key='TotalTime'): + if resume_key in infoproperties: + infoproperties[resume_key] = str(infoproperties[resume_key]) + if total_key in infoproperties: + infoproperties[total_key] = str(infoproperties[total_key]) + + + string_type = basestring + +__all__ = ( + 'BaseHTTPServer', + 'parse_qs', + 'parse_qsl', + 'quote', + 'set_info_tag', + 'string_type', + 'unescape', + 'unquote', + 'urlencode', + 'urljoin', + 'urlsplit', + 'xbmc', + 'xbmcaddon', + 'xbmcgui', + 'xbmcplugin', + 'xbmcvfs', +) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/__init__.py index 83f50817bc..d392adc67c 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/__init__.py @@ -8,11 +8,32 @@ See LICENSES/GPL-2.0-only for more information. """ -from . import const_settings as setting -from . import const_localize as localize -from . import const_sort_methods as sort_method -from . import const_content_types as content_type -from . import const_paths as paths +from __future__ import absolute_import, division, unicode_literals +from . import ( + const_content_types as content, + const_paths as paths, + const_settings as settings, + const_sort_methods as sort, +) -__all__ = ['setting', 'localize', 'sort_method', 'content_type', 'paths'] + +ADDON_ID = 'plugin.video.youtube' +ADDON_PATH = 'special://home/addons/{id}'.format(id=ADDON_ID) +DATA_PATH = 'special://profile/addon_data/{id}'.format(id=ADDON_ID) +MEDIA_PATH = ADDON_PATH + '/resources/media' +RESOURCE_PATH = ADDON_PATH + '/resources' +TEMP_PATH = 'special://temp/{id}'.format(id=ADDON_ID) + +__all__ = ( + 'ADDON_ID', + 'ADDON_PATH', + 'DATA_PATH', + 'MEDIA_PATH', + 'RESOURCE_PATH', + 'TEMP_PATH', + 'content', + 'paths', + 'settings', + 'sort', +) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_content_types.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_content_types.py index 0c2998b590..0003408a7e 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_content_types.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_content_types.py @@ -8,6 +8,12 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + +VIDEO_CONTENT = 'videos' +LIST_CONTENT = 'files' +VIDEO_TYPE = 'video' + FILES = 'files' SONGS = 'songs' ARTISTS = 'artists' diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_localize.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_localize.py deleted file mode 100644 index e944a0fa01..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_localize.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -SELECT_VIDEO_QUALITY = 30010 - -COMMON_PLEASE_WAIT = 30119 - -FAVORITES = 30100 -FAVORITES_ADD = 30101 -FAVORITES_REMOVE = 30108 - -SEARCH = 30102 -SEARCH_TITLE = 30102 -SEARCH_NEW = 30110 -SEARCH_RENAME = 30113 -SEARCH_REMOVE = 30108 -SEARCH_CLEAR = 30120 - -SETUP_WIZARD_EXECUTE = 30030 -SETUP_VIEW_DEFAULT = 30027 -SETUP_VIEW_VIDEOS = 30028 - -LIBRARY = 30103 -HIGHLIGHTS = 30104 -ARCHIVE = 30105 -NEXT_PAGE = 30106 - -WATCH_LATER = 30107 -WATCH_LATER_ADD = 30107 -WATCH_LATER_REMOVE = 30108 - -LATEST_VIDEOS = 30109 - -CONFIRM_DELETE = 30114 -CONFIRM_REMOVE = 30115 -DELETE_CONTENT = 30116 -REMOVE_CONTENT = 30117 - -WATCH_LATER_RETRIEVAL_PAGE = 30711 diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_paths.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_paths.py index 59e254ac48..cd14cf4bbb 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_paths.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_paths.py @@ -8,6 +8,17 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + + SEARCH = 'kodion/search' FAVORITES = 'kodion/favorites' WATCH_LATER = 'kodion/watch_later' +HISTORY = 'kodion/playback_history' + +API = '/youtube/api' +API_SUBMIT = '/youtube/api/submit' +DRM = '/youtube/widevine' +IP = '/youtube/client_ip' +MPD = '/youtube/manifest/dash/' +PING = '/youtube/ping' diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_settings.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_settings.py index 6f64ca4607..ee74bdb40b 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_settings.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_settings.py @@ -8,6 +8,9 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + + THUMB_SIZE = 'kodion.thumbnail.size' # (int) SHOW_FANART = 'kodion.fanart.show' # (bool) SAFE_SEARCH = 'kodion.safe.search' # (int) @@ -19,7 +22,7 @@ SUBTITLE_LANGUAGE = 'kodion.subtitle.languages.num' # (int) SUBTITLE_DOWNLOAD = 'kodion.subtitle.download' # (bool) SETUP_WIZARD = 'kodion.setup_wizard' # (bool) -VERIFY_SSL = 'simple.requests.ssl.verify' # (bool) +LANGUAGE = 'youtube.language' # (str) LOCATION = 'youtube.location' # (str) LOCATION_RADIUS = 'youtube.location.radius' # (int) PLAY_COUNT_MIN_PERCENT = 'kodion.play_count.percent' # (int) @@ -27,6 +30,7 @@ USE_REMOTE_HISTORY = 'kodion.history.remote' # (bool) REMOTE_FRIENDLY_SEARCH = 'youtube.search.remote.friendly' # (bool) HIDE_SHORT_VIDEOS = 'youtube.hide_shorts' # (bool) +DETAILED_DESCRIPTION = 'youtube.view.description.details' # (bool) SUPPORT_ALTERNATIVE_PLAYER = 'kodion.support.alternative_player' # (bool) ALTERNATIVE_PLAYER_WEB_URLS = 'kodion.alternative_player.web.urls' # (bool) @@ -35,17 +39,27 @@ VIDEO_QUALITY = 'kodion.video.quality' # (int) VIDEO_QUALITY_ASK = 'kodion.video.quality.ask' # (bool) -USE_MPD = 'kodion.video.quality.mpd' # (bool) -LIVE_STREAMS = 'kodion.mpd.live_stream.selection' # (int) +USE_ISA = 'kodion.video.quality.isa' # (bool) +LIVE_STREAMS = 'kodion.live_stream.selection' # (int) MPD_VIDEOS = 'kodion.mpd.videos' # (bool) MPD_QUALITY_SELECTION = 'kodion.mpd.quality.selection' # (int) MPD_STREAM_FEATURES = 'kodion.mpd.stream.features' # (list[string]) MPD_STREAM_SELECT = 'kodion.mpd.stream.select' # (int) -HTTPD_PORT = 'kodion.mpd.proxy.port' # (number) +VERIFY_SSL = 'requests.ssl.verify' # (bool) +CONNECT_TIMEOUT = 'requests.timeout.connect' # (int) +READ_TIMEOUT = 'requests.timeout.read' # (int) + +HTTPD_PORT = 'kodion.http.port' # (number) HTTPD_LISTEN = 'kodion.http.listen' # (string) HTTPD_WHITELIST = 'kodion.http.ip.whitelist' # (string) API_CONFIG_PAGE = 'youtube.api.config.page' # (bool) +API_KEY = 'youtube.api.key' # (string) +API_ID = 'youtube.api.id' # (string) +API_SECRET = 'youtube.api.secret' # (string) CLIENT_SELECTION = 'youtube.client.selection' # (int) + +WATCH_LATER_PLAYLIST = 'youtube.folder.watch_later.playlist' # (str) +HISTORY_PLAYLIST = 'youtube.folder.history.playlist' # (str) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py index e33facda75..8b2f6437d2 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py @@ -8,62 +8,66 @@ See LICENSES/GPL-2.0-only for more information. """ -_xbmc = True +from __future__ import absolute_import, division, unicode_literals -try: - from xbmcplugin import * -except: - _xbmc = False - _count = 0 +import sys +from ..compatibility import xbmcplugin -def _const(name): - if _xbmc: - return eval(name) - else: - global _count - _count += 1 - return _count +xbmcplugin = xbmcplugin.__dict__ +namespace = sys.modules[__name__] +names = [ + 'NONE', # 0 + 'LABEL', # 1 + 'LABEL_IGNORE_THE', # 2 + 'DATE', # 3 + 'SIZE', # 4 + 'FILE', # 5 + 'DRIVE_TYPE', # 6 + 'TRACKNUM', # 7 + 'DURATION', # 8 + 'TITLE', # 9 + 'TITLE_IGNORE_THE', # 10 + 'ARTIST', # 11 + 'ARTIST_IGNORE_THE', # 13 + 'ALBUM', # 14 + 'ALBUM_IGNORE_THE', # 15 + 'GENRE', # 16 + 'COUNTRY', # 17 + 'VIDEO_YEAR', # 18 + 'VIDEO_RATING', # 19 + 'VIDEO_USER_RATING', # 20 + 'DATEADDED', # 21 + 'PROGRAM_COUNT', # 22 + 'PLAYLIST_ORDER', # 23 + 'EPISODE', # 24 + 'VIDEO_TITLE', # 25 + 'VIDEO_SORT_TITLE', # 26 + 'VIDEO_SORT_TITLE_IGNORE_THE', # 27 + 'PRODUCTIONCODE', # 28 + 'SONG_RATING', # 29 + 'SONG_USER_RATING', # 30 + 'MPAA_RATING', # 31 + 'VIDEO_RUNTIME', # 32 + 'STUDIO', # 33 + 'STUDIO_IGNORE_THE', # 34 + 'FULLPATH', # 35 + 'LABEL_IGNORE_FOLDERS', # 36 + 'LASTPLAYED', # 37 + 'PLAYCOUNT', # 38 + 'LISTENERS', # 39 + 'UNSORTED', # 40 + 'CHANNEL', # 41 + 'BITRATE', # 43 + 'DATE_TAKEN', # 44 + 'VIDEO_ORIGINAL_TITLE', # 49 + 'VIDEO_ORIGINAL_TITLE_IGNORE_THE', # 50 +] -ALBUM = _const('SORT_METHOD_ALBUM') -ALBUM_IGNORE_THE = _const('SORT_METHOD_ALBUM_IGNORE_THE') -ARTIST = _const('SORT_METHOD_ARTIST') -ARTIST_IGNORE_THE = _const('SORT_METHOD_ARTIST_IGNORE_THE') -BIT_RATE = _const('SORT_METHOD_BITRATE') -# CHANNEL = _const('SORT_METHOD_CHANNEL') -# COUNTRY = _const('SORT_METHOD_COUNTRY') -DATE = _const('SORT_METHOD_DATE') -DATE_ADDED = _const('SORT_METHOD_DATEADDED') -# DATE_TAKEN = _const('SORT_METHOD_DATE_TAKEN') -DRIVE_TYPE = _const('SORT_METHOD_DRIVE_TYPE') -DURATION = _const('SORT_METHOD_DURATION') -EPISODE = _const('SORT_METHOD_EPISODE') -FILE = _const('SORT_METHOD_FILE') -# FULL_PATH = _const('SORT_METHOD_FULLPATH') -GENRE = _const('SORT_METHOD_GENRE') -LABEL = _const('SORT_METHOD_LABEL') -# LABEL_IGNORE_FOLDERS = _const('SORT_METHOD_LABEL_IGNORE_FOLDERS') -LABEL_IGNORE_THE = _const('SORT_METHOD_LABEL_IGNORE_THE') -# LAST_PLAYED = _const('SORT_METHOD_LASTPLAYED') -LISTENERS = _const('SORT_METHOD_LISTENERS') -MPAA_RATING = _const('SORT_METHOD_MPAA_RATING') -NONE = _const('SORT_METHOD_NONE') -# PLAY_COUNT = _const('SORT_METHOD_PLAYCOUNT') -PLAYLIST_ORDER = _const('SORT_METHOD_PLAYLIST_ORDER') -PRODUCTION_CODE = _const('SORT_METHOD_PRODUCTIONCODE') -PROGRAM_COUNT = _const('SORT_METHOD_PROGRAM_COUNT') -SIZE = _const('SORT_METHOD_SIZE') -SONG_RATING = _const('SORT_METHOD_SONG_RATING') -STUDIO = _const('SORT_METHOD_STUDIO') -STUDIO_IGNORE_THE = _const('SORT_METHOD_STUDIO_IGNORE_THE') -TITLE = _const('SORT_METHOD_TITLE') -TITLE_IGNORE_THE = _const('SORT_METHOD_TITLE_IGNORE_THE') -TRACK_NUMBER = _const('SORT_METHOD_TRACKNUM') -UNSORTED = _const('SORT_METHOD_UNSORTED') -VIDEO_RATING = _const('SORT_METHOD_VIDEO_RATING') -VIDEO_RUNTIME = _const('SORT_METHOD_VIDEO_RUNTIME') -VIDEO_SORT_TITLE = _const('SORT_METHOD_VIDEO_SORT_TITLE') -VIDEO_SORT_TITLE_IGNORE_THE = _const('SORT_METHOD_VIDEO_SORT_TITLE_IGNORE_THE') -VIDEO_TITLE = _const('SORT_METHOD_VIDEO_TITLE') -VIDEO_YEAR = _const('SORT_METHOD_VIDEO_YEAR') +for name in names: + fullname = 'SORT_METHOD_' + name + setattr(namespace, name, + xbmcplugin[fullname] if fullname in xbmcplugin else -1) + +del sys, xbmcplugin, namespace, names, name, fullname diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/__init__.py new file mode 100644 index 0000000000..1d90e85c3b --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .xbmc.xbmc_context import XbmcContext as Context + + +__all__ = ('Context',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/abstract_context.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/abstract_context.py new file mode 100644 index 0000000000..19c8d5d784 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/abstract_context.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import os + +from .. import logger +from ..compatibility import urlencode +from ..json_store import AccessManager +from ..sql_store import ( + DataCache, + FavoriteList, + FunctionCache, + PlaybackHistory, + SearchHistory, + WatchLaterList, +) +from ..utils import create_path, create_uri_path, current_system_version + + +class AbstractContext(object): + _BOOL_PARAMS = { + 'ask_for_quality', + 'audio_only', + 'confirmed', + 'clip', + 'enable', + 'hide_folders', + 'hide_live', + 'hide_playlists', + 'hide_search', + 'incognito', + 'location', + 'logged_in', + 'play', + 'prompt_for_subtitles', + 'refresh', + 'refresh_container' + 'resume', + 'screensaver', + 'strm', + } + _INT_PARAMS = { + 'live', + 'offset', + 'page', + } + _FLOAT_PARAMS = { + 'seek', + 'start', + 'end' + } + _LIST_PARAMS = { + 'channel_ids', + 'playlist_ids', + } + _STRING_PARAMS = { + 'api_key', + 'action', + 'addon_id', + 'category_label', + 'channel_id', + 'channel_name', + 'client_id', + 'client_secret', + 'click_tracking', + 'event_type', + 'item', + 'item_id', + 'next_page_token', + 'page_token', + 'parent_id', + 'playlist', # deprecated + 'playlist_id', + 'playlist_name', + 'q', + 'rating', + 'search_type', + 'subscription_id', + 'uri', + 'videoid', # deprecated + 'video_id', + 'visitor', + } + + def __init__(self, path='/', params=None, plugin_name='', plugin_id=''): + if not params: + params = {} + + self._cache_path = None + self._debug_path = None + + self._function_cache = None + self._data_cache = None + self._search_history = None + self._playback_history = None + self._favorite_list = None + self._watch_later_list = None + self._access_manager = None + + self._plugin_name = str(plugin_name) + self._version = 'UNKNOWN' + self._plugin_id = plugin_id + self._path = create_path(path) + self._params = params + self._utils = None + + # create valid uri + self.parse_params() + self._uri = self.create_uri(self._path, self._params) + + @staticmethod + def format_date_short(date_obj, str_format=None): + raise NotImplementedError() + + @staticmethod + def format_time(time_obj, str_format=None): + raise NotImplementedError() + + def get_language(self): + raise NotImplementedError() + + def get_language_name(self, lang_id=None): + raise NotImplementedError() + + def get_region(self): + raise NotImplementedError() + + def get_cache_path(self): + if not self._cache_path: + self._cache_path = os.path.join(self.get_data_path(), 'kodion') + return self._cache_path + + def get_playback_history(self): + if not self._playback_history: + uuid = self.get_access_manager().get_current_user_id() + filename = 'history.sqlite' + filepath = os.path.join(self.get_data_path(), uuid, filename) + self._playback_history = PlaybackHistory(filepath) + return self._playback_history + + def get_data_cache(self): + if not self._data_cache: + settings = self.get_settings() + cache_size = settings.get_int(settings.CACHE_SIZE, -1) + if cache_size <= 0: + cache_size = 10 + else: + cache_size /= 2.0 + filename = 'data_cache.sqlite' + filepath = os.path.join(self.get_cache_path(), filename) + self._data_cache = DataCache(filepath, max_file_size_mb=cache_size) + return self._data_cache + + def get_function_cache(self): + if not self._function_cache: + settings = self.get_settings() + cache_size = settings.get_int(settings.CACHE_SIZE, -1) + if cache_size <= 0: + cache_size = 10 + else: + cache_size /= 2.0 + filename = 'cache.sqlite' + filepath = os.path.join(self.get_cache_path(), filename) + self._function_cache = FunctionCache(filepath, + max_file_size_mb=cache_size) + return self._function_cache + + def get_search_history(self): + if not self._search_history: + settings = self.get_settings() + search_size = settings.get_int(settings.SEARCH_SIZE, 50) + uuid = self.get_access_manager().get_current_user_id() + filename = 'search.sqlite' + filepath = os.path.join(self.get_data_path(), uuid, filename) + self._search_history = SearchHistory(filepath, + max_item_count=search_size) + return self._search_history + + def get_favorite_list(self): + if not self._favorite_list: + uuid = self.get_access_manager().get_current_user_id() + filename = 'favorites.sqlite' + filepath = os.path.join(self.get_data_path(), uuid, filename) + self._favorite_list = FavoriteList(filepath) + return self._favorite_list + + def get_watch_later_list(self): + if not self._watch_later_list: + uuid = self.get_access_manager().get_current_user_id() + filename = 'watch_later.sqlite' + filepath = os.path.join(self.get_data_path(), uuid, filename) + self._watch_later_list = WatchLaterList(filepath) + return self._watch_later_list + + def get_access_manager(self): + if not self._access_manager: + self._access_manager = AccessManager(self) + return self._access_manager + + def get_video_playlist(self): + raise NotImplementedError() + + def get_audio_playlist(self): + raise NotImplementedError() + + def get_video_player(self): + raise NotImplementedError() + + def get_audio_player(self): + raise NotImplementedError() + + def get_ui(self): + raise NotImplementedError() + + def get_system_version(self): + return current_system_version + + def create_uri(self, path='/', params=None): + if not params: + params = {} + + uri = create_uri_path(path) + if uri: + uri = "%s://%s%s" % ('plugin', str(self._plugin_id), uri) + else: + uri = "%s://%s/" % ('plugin', str(self._plugin_id)) + + if params: + uri = '?'.join((uri, urlencode(params))) + + return uri + + def get_path(self): + return self._path + + def set_path(self, value): + self._path = value + + def get_params(self): + return self._params + + def get_param(self, name, default=None): + return self._params.get(name, default) + + def parse_params(self, params=None): + if not params: + params = self._params + to_delete = [] + + for param, value in params.items(): + try: + if param in self._BOOL_PARAMS: + parsed_value = str(value).lower() in ('true', '1') + elif param in self._INT_PARAMS: + parsed_value = int(value) + elif param in self._FLOAT_PARAMS: + parsed_value = float(value) + elif param in self._LIST_PARAMS: + parsed_value = [ + val for val in value.split(',') if val + ] + elif param in self._STRING_PARAMS: + parsed_value = str(value) + else: + self.log_debug('Unknown parameter - |{0}: {1}|'.format( + param, value + )) + to_delete.append(param) + continue + except (TypeError, ValueError): + self.log_error('Invalid parameter value - |{0}: {1}|'.format( + param, value + )) + to_delete.append(param) + continue + + self._params[param] = parsed_value + + for param in to_delete: + del params[param] + + def set_param(self, name, value): + self.parse_params({name: value}) + + def get_data_path(self): + """ + Returns the path for read/write access of files + :return: + """ + raise NotImplementedError() + + def get_addon_path(self): + raise NotImplementedError() + + def get_icon(self): + return self.create_resource_path('media/icon.png') + + def get_fanart(self): + return self.create_resource_path('media/fanart.jpg') + + def create_resource_path(self, *args): + path_comps = [] + for arg in args: + path_comps.extend(arg.split('/')) + path = os.path.join(self.get_addon_path(), 'resources', *path_comps) + return path + + def get_uri(self): + return self._uri + + def get_name(self): + return self._plugin_name + + def get_version(self): + return self._version + + def get_id(self): + return self._plugin_id + + def get_handle(self): + raise NotImplementedError() + + def get_settings(self): + raise NotImplementedError() + + def localize(self, text_id, default_text=None): + raise NotImplementedError() + + def set_content(self, content_type, sub_type=None, category_label=None): + raise NotImplementedError() + + def add_sort_method(self, *sort_methods): + raise NotImplementedError() + + def log(self, text, log_level=logger.NOTICE): + logger.log(text, log_level, self.get_id()) + + def log_warning(self, text): + self.log(text, logger.WARNING) + + def log_error(self, text): + self.log(text, logger.ERROR) + + def log_notice(self, text): + self.log(text, logger.NOTICE) + + def log_debug(self, text): + self.log(text, logger.DEBUG) + + def log_info(self, text): + self.log(text, logger.INFO) + + def clone(self, new_path=None, new_params=None): + raise NotImplementedError() + + @staticmethod + def execute(command): + raise NotImplementedError() + + @staticmethod + def sleep(milli_seconds): + raise NotImplementedError() + + @staticmethod + def get_infolabel(name): + raise NotImplementedError() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/xbmc/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/xbmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py new file mode 100644 index 0000000000..9e77ae4e1b --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -0,0 +1,608 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import json +import os +import sys +import weakref + +from ..abstract_context import AbstractContext +from ...compatibility import ( + parse_qsl, + quote, + unquote, + urlsplit, + xbmc, + xbmcaddon, + xbmcplugin, + xbmcvfs, +) +from ...constants import ADDON_ID, content, sort +from ...player.xbmc.xbmc_player import XbmcPlayer +from ...player.xbmc.xbmc_playlist import XbmcPlaylist +from ...settings.xbmc.xbmc_plugin_settings import XbmcPluginSettings +from ...ui.xbmc.xbmc_context_ui import XbmcContextUI +from ...utils import ( + current_system_version, + loose_version, + make_dirs, + to_unicode, +) + + +class XbmcContext(AbstractContext): + LOCAL_MAP = { + 'api.id': 30202, + 'api.key': 30201, + 'api.key.incorrect': 30648, + 'api.personal.enabled': 30598, + 'api.personal.failed': 30599, + 'api.secret': 30203, + 'archive': 30105, + 'are_you_sure': 30703, + 'auto_remove_watch_later': 30515, + 'browse_channels': 30512, + 'cache.data': 30687, + 'cache.function': 30557, + 'cancel': 30615, + 'channels': 30500, + 'client.id.incorrect': 30649, + 'client.ip': 30700, + 'client.ip.failed': 30701, + 'client.secret.incorrect': 30650, + 'content.delete': 30116, + 'content.delete.confirm': 30114, + 'content.remove': 30117, + 'content.remove.confirm': 30115, + 'datetime.a_minute_ago': 30677, + 'datetime.airing_now': 30691, + 'datetime.airing_soon': 30693, + 'datetime.airing_today_at': 30696, + 'datetime.an_hour_ago': 30679, + 'datetime.in_a_minute': 30692, + 'datetime.in_over_an_hour': 30694, + 'datetime.in_over_two_hours': 30695, + 'datetime.just_now': 30676, + 'datetime.recently': 30678, + 'datetime.three_hours_ago': 30681, + 'datetime.today_at': 30684, + 'datetime.tomorrow_at': 30697, + 'datetime.two_days_ago': 30683, + 'datetime.two_hours_ago': 30680, + 'datetime.yesterday_at': 30682, + 'delete': 30118, + 'disliked.video': 30717, + 'error.no_video_streams_found': 30549, + 'error.rtmpe_not_supported': 30542, + 'failed': 30576, + 'failed.watch_later.retry': 30614, + 'failed.watch_later.retry.2': 30709, + 'failed.watch_later.retry.3': 30710, + 'favorites': 30100, + 'favorites.add': 30101, + 'favorites.remove': 30108, + 'go_to_channel': 30502, + 'highlights': 30104, + 'history': 30509, + 'history.clear': 30609, + 'history.clear.confirm': 30610, + 'history.list.remove': 30572, + 'history.list.remove.confirm': 30573, + 'history.list.set': 30571, + 'history.list.set.confirm': 30574, + 'history.mark.unwatched': 30669, + 'history.mark.watched': 30670, + 'history.remove': 30108, + 'history.reset.resume_point': 30674, + 'httpd.not.running': 30699, + 'inputstreamhelper.is_installed': 30625, + 'isa.enable.confirm': 30579, + 'key.requirement': 30731, + 'latest_videos': 30109, + 'library': 30103, + 'liked.video': 30716, + 'live': 30539, + 'live.completed': 30647, + 'live.upcoming': 30646, + 'must_be_signed_in': 30616, + 'my_channel': 30507, + 'my_location': 30654, + 'my_subscriptions': 30510, + 'my_subscriptions.filter.add': 30587, + 'my_subscriptions.filter.added': 30589, + 'my_subscriptions.filter.remove': 30588, + 'my_subscriptions.filter.removed': 30590, + 'my_subscriptions.filtered': 30584, + 'next_page': 30106, + 'none': 30561, + 'perform_geolocation': 30653, + 'playback.history': 30673, + 'playlist.added_to': 30714, + 'playlist.create': 30522, + 'playlist.play.all': 30531, + 'playlist.play.default': 30532, + 'playlist.play.from_here': 30537, + 'playlist.play.reverse': 30533, + 'playlist.play.select': 30535, + 'playlist.play.shuffle': 30534, + 'playlist.progress.updating': 30536, + 'playlist.removed_from': 30715, + 'playlist.select': 30521, + 'playlists': 30501, + 'please_wait': 30119, + 'prompt': 30566, + 'purchases': 30622, + 'recommendations': 30551, + 'refresh': 30543, + 'related_videos': 30514, + 'remove': 30108, + 'removed': 30666, + 'rename': 30113, + 'renamed': 30667, + 'requires.krypton': 30624, + 'reset.access_manager.confirm': 30581, + 'retry': 30612, + 'saved.playlists': 30611, + 'search': 30102, + 'search.clear': 30120, + 'search.history': 30558, + 'search.new': 30110, + 'search.quick': 30605, + 'search.quick.incognito': 30606, + 'search.remove': 30108, + 'search.rename': 30113, + 'search.title': 30102, + 'select.listen.ip': 30644, + 'select_video_quality': 30010, + 'settings': 30577, + 'setup_wizard.adjust': 30526, + 'setup_wizard.adjust.language_and_region': 30527, + 'setup_wizard.execute': 30030, + 'setup_wizard.select_language': 30524, + 'setup_wizard.select_region': 30525, + 'sign.enter_code': 30519, + 'sign.go_to': 30518, + 'sign.in': 30111, + 'sign.out': 30112, + 'sign.twice.text': 30547, + 'sign.twice.title': 30546, + 'stats.commentCount': 30732, + # 'stats.favoriteCount': 30100, + 'stats.likeCount': 30733, + 'stats.viewCount': 30767, + 'stream.alternate': 30747, + 'stream.automatic': 30583, + 'stream.descriptive': 30746, + 'stream.dubbed': 30745, + 'stream.multi_audio': 30763, + 'stream.multi_language': 30762, + 'stream.original': 30744, + 'subscribe': 30506, + 'subscribe_to': 30517, + 'subscribed.to.channel': 30719, + 'subscriptions': 30504, + 'subtitles.download': 30705, + 'subtitles.download.pre': 30706, + 'subtitles.language': 30560, + 'subtitles.no_auto_generated': 30602, + 'subtitles.with_fallback': 30601, + 'succeeded': 30575, + 'trending': 30513, + 'unrated.video': 30718, + 'unsubscribe': 30505, + 'unsubscribed.from.channel': 30720, + 'untitled': 30707, + 'upcoming': 30766, + 'updated_': 30597, + 'uploads': 30726, + 'user.changed': 30659, + 'user.default': 30532, + 'user.enter_name': 30658, + 'user.new': 30656, + 'user.remove': 30662, + 'user.rename': 30663, + 'user.switch': 30655, + 'user.switch.now': 30665, + 'user.unnamed': 30657, + 'video.add_to_playlist': 30520, + 'video.comments': 30732, + 'video.comments.edited': 30735, + 'video.comments.likes': 30733, + 'video.comments.replies': 30734, + 'video.description.links': 30544, + 'video.description.links.not_found': 30545, + 'video.disliked': 30538, + 'video.liked': 30508, + 'video.more': 30548, + 'video.play.ask_for_quality': 30730, + 'video.play.audio_only': 30708, + 'video.play.with': 30540, + 'video.play.with_subtitles': 30702, + 'video.queue': 30511, + 'video.rate': 30528, + 'video.rate.dislike': 30530, + 'video.rate.like': 30529, + 'video.rate.none': 30108, + 'watch_later': 30107, + 'watch_later.add': 30107, + 'watch_later.added_to': 30713, + 'watch_later.clear': 30769, + 'watch_later.clear.confirm': 30770, + 'watch_later.list.remove': 30568, + 'watch_later.list.remove.confirm': 30569, + 'watch_later.list.set': 30567, + 'watch_later.list.set.confirm': 30570, + 'watch_later.remove': 30108, + 'watch_later.retrieval_page': 30711, + 'youtube': 30003, + } + + def __init__(self, + path='/', + params=None, + plugin_name='', + plugin_id='', + override=True): + super(XbmcContext, self).__init__(path, params, plugin_name, plugin_id) + + if plugin_id: + self._addon = xbmcaddon.Addon(id=plugin_id) + else: + self._addon = xbmcaddon.Addon(id=ADDON_ID) + + """ + I don't know what xbmc/kodi is doing with a simple uri, but we have to extract the information from the + sys parameters and re-build our clean uri. + Also we extract the path and parameters - man, that would be so simple with the normal url-parsing routines. + """ + num_args = len(sys.argv) + if override and num_args: + uri = sys.argv[0] + is_plugin_invocation = uri.startswith('plugin://') + if is_plugin_invocation: + # first the path of the uri + self._uri = uri + parsed_url = urlsplit(uri) + self._path = unquote(parsed_url.path) + + # after that try to get the params + if num_args > 2: + params = sys.argv[2][1:] + if params: + self._uri = '?'.join((self._uri, params)) + self.parse_params(dict(parse_qsl(params))) + + # then Kodi resume status + if num_args > 3 and sys.argv[3].lower() == 'resume:true': + self._params['resume'] = True + elif num_args: + uri = sys.argv[0] + is_plugin_invocation = uri.startswith('plugin://') + else: + is_plugin_invocation = False + + self._ui = None + self._video_playlist = None + self._audio_playlist = None + self._video_player = None + self._audio_player = None + self._plugin_handle = int(sys.argv[1]) if is_plugin_invocation else -1 + self._plugin_id = plugin_id or ADDON_ID + self._plugin_name = plugin_name or self._addon.getAddonInfo('name') + self._version = self._addon.getAddonInfo('version') + self._addon_path = make_dirs(self._addon.getAddonInfo('path')) + self._data_path = make_dirs(self._addon.getAddonInfo('profile')) + self._settings = XbmcPluginSettings(self._addon) + + def get_region(self): + pass # implement from abstract + + def addon(self): + return self._addon + + def is_plugin_path(self, uri, uri_path=''): + return uri.startswith('plugin://%s/%s' % (self.get_id(), uri_path)) + + @staticmethod + def format_date_short(date_obj, str_format=None): + if str_format is None: + str_format = xbmc.getRegion('dateshort') + return date_obj.strftime(str_format) + + @staticmethod + def format_time(time_obj, str_format=None): + if str_format is None: + str_format = (xbmc.getRegion('time') + .replace("%H%H", "%H") + .replace(':%S', '')) + return time_obj.strftime(str_format) + + def get_language(self): + """ + The xbmc.getLanguage() method is fucked up!!! We always return 'en-US' for now + """ + + """ + if self.get_system_version().get_release_name() == 'Frodo': + return 'en-US' + + try: + language = xbmc.getLanguage(0, region=True) + language = language.split('-') + language = '%s-%s' % (language[0].lower(), language[1].upper()) + return language + except Exception as exc: + self.log_error('Failed to get system language (%s)', exc.__str__()) + return 'en-US' + """ + + return 'en-US' + + def get_language_name(self, lang_id=None): + if lang_id is None: + lang_id = self.get_language() + return xbmc.convertLanguage(lang_id, xbmc.ENGLISH_NAME).split(';')[0] + + def get_video_playlist(self): + if not self._video_playlist: + self._video_playlist = XbmcPlaylist('video', weakref.proxy(self)) + return self._video_playlist + + def get_audio_playlist(self): + if not self._audio_playlist: + self._audio_playlist = XbmcPlaylist('audio', weakref.proxy(self)) + return self._audio_playlist + + def get_video_player(self): + if not self._video_player: + self._video_player = XbmcPlayer('video', weakref.proxy(self)) + return self._video_player + + def get_audio_player(self): + if not self._audio_player: + self._audio_player = XbmcPlayer('audio', weakref.proxy(self)) + return self._audio_player + + def get_ui(self): + if not self._ui: + self._ui = XbmcContextUI(self._addon, weakref.proxy(self)) + return self._ui + + def get_handle(self): + return self._plugin_handle + + def get_data_path(self): + return self._data_path + + def get_debug_path(self): + if not self._debug_path: + self._debug_path = os.path.join(self.get_data_path(), 'debug') + if not xbmcvfs.exists(self._debug_path): + xbmcvfs.mkdir(self._debug_path) + return self._debug_path + + def get_addon_path(self): + return self._addon_path + + def get_settings(self): + return self._settings + + def localize(self, text_id, default_text=None): + if default_text is None: + default_text = 'Undefined string ID: |{0}|'.format(text_id) + + if not isinstance(text_id, int): + try: + text_id = self.LOCAL_MAP[text_id] + except KeyError: + try: + text_id = int(text_id) + except ValueError: + return default_text + if text_id <= 0: + return default_text + + """ + We want to use all localization strings! + Addons should only use the range 30000 thru 30999 + (see: http://kodi.wiki/view/Language_support) but we do it anyway. + I want some of the localized strings for the views of a skin. + """ + source = self._addon if 30000 <= text_id < 31000 else xbmc + result = source.getLocalizedString(text_id) + result = to_unicode(result) if result else default_text + return result + + def set_content(self, content_type, sub_type=None, category_label=None): + self.log_debug('Setting content-type: |{type}| for |{path}|'.format( + type=(sub_type or content_type), path=self.get_path() + )) + xbmcplugin.setContent(self._plugin_handle, content_type) + if category_label is None: + category_label = self.get_param('category_label') + if category_label: + xbmcplugin.setPluginCategory(self._plugin_handle, category_label) + if sub_type == 'history': + self.add_sort_method( + (sort.LASTPLAYED, '%T \u2022 %P', '%D | %J'), + (sort.PLAYCOUNT, '%T \u2022 %P', '%D | %J'), + (sort.UNSORTED, '%T \u2022 %P', '%D | %J'), + (sort.LABEL_IGNORE_THE, '%T \u2022 %P', '%D | %J'), + ) + else: + self.add_sort_method( + (sort.UNSORTED, '%T \u2022 %P', '%D | %J'), + (sort.LABEL_IGNORE_THE, '%T \u2022 %P', '%D | %J'), + ) + if content_type == content.VIDEO_CONTENT: + self.add_sort_method( + (sort.PROGRAM_COUNT, '%T \u2022 %P | %D | %J', '%C'), + (sort.VIDEO_RATING, '%T \u2022 %P | %D | %J', '%R'), + (sort.DATE, '%T \u2022 %P | %D', '%J'), + (sort.DATEADDED, '%T \u2022 %P | %D', '%a'), + (sort.VIDEO_RUNTIME, '%T \u2022 %P | %J', '%D'), + (sort.TRACKNUM, '[%N. ]%T \u2022 %P', '%D | %J'), + ) + + def add_sort_method(self, *sort_methods): + args = slice(None if current_system_version.compatible(19, 0) else 2) + for sort_method in sort_methods: + xbmcplugin.addSortMethod(self._plugin_handle, *sort_method[args]) + + def clone(self, new_path=None, new_params=None): + if not new_path: + new_path = self.get_path() + + if not new_params: + new_params = self.get_params() + + new_context = XbmcContext(path=new_path, + params=new_params, + plugin_name=self._plugin_name, + plugin_id=self._plugin_id, + override=False) + new_context._function_cache = self._function_cache + new_context._search_history = self._search_history + new_context._favorite_list = self._favorite_list + new_context._watch_later_list = self._watch_later_list + new_context._access_manager = self._access_manager + new_context._ui = self._ui + new_context._video_playlist = self._video_playlist + new_context._video_player = self._video_player + + return new_context + + @staticmethod + def execute(command): + xbmc.executebuiltin(command) + + @staticmethod + def sleep(milli_seconds): + xbmc.sleep(milli_seconds) + + def addon_enabled(self, addon_id): + rpc_request = json.dumps({"jsonrpc": "2.0", + "method": "Addons.GetAddonDetails", + "id": 1, + "params": {"addonid": "%s" % addon_id, + "properties": ["enabled"]} + }) + response = json.loads(xbmc.executeJSONRPC(rpc_request)) + try: + return response['result']['addon']['enabled'] is True + except KeyError: + message = response['error']['message'] + code = response['error']['code'] + error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) + self.log_error(error) + return False + + def set_addon_enabled(self, addon_id, enabled=True): + rpc_request = json.dumps({"jsonrpc": "2.0", + "method": "Addons.SetAddonEnabled", + "id": 1, + "params": {"addonid": "%s" % addon_id, + "enabled": enabled} + }) + response = json.loads(xbmc.executeJSONRPC(rpc_request)) + try: + return response['result'] == 'OK' + except KeyError: + message = response['error']['message'] + code = response['error']['code'] + error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) + self.log_error(error) + return False + + def send_notification(self, method, data): + data = json.dumps(data) + self.log_debug('send_notification: |%s| -> |%s|' % (method, data)) + data = '\\"[\\"%s\\"]\\"' % quote(data) + self.execute('NotifyAll({0},{1},{2})'.format(ADDON_ID, method, data)) + + def use_inputstream_adaptive(self): + if self._settings.use_isa(): + if self.addon_enabled('inputstream.adaptive'): + success = True + elif self.get_ui().on_yes_no_input( + self.get_name(), self.localize('isa.enable.confirm') + ): + success = self.set_addon_enabled('inputstream.adaptive') + else: + success = False + else: + success = False + return success + + # Values of capability map can be any of the following: + # - required version number, as string for comparison with actual installed + # InputStream.Adaptive version + # - any Falsy value to exclude capability regardless of version + # - True to include capability regardless of version + _ISA_CAPABILITIES = { + 'live': '2.0.12', + 'drm': '2.2.12', + # audio codecs + 'vorbis': '2.3.14', + 'opus': '19.0.0', # unknown when Opus audio support was implemented + 'mp4a': True, + 'ac-3': '2.1.15', + 'ec-3': '2.1.15', + 'dts': '2.1.15', + # video codecs + 'avc1': True, + 'av01': '20.3.0', + 'vp8': False, + 'vp9': '2.3.14', + } + + def inputstream_adaptive_capabilities(self, capability=None): + # Returns a list of inputstream.adaptive capabilities + # If capability param is provided, returns version of ISA where the + # capability is available + + try: + addon = xbmcaddon.Addon('inputstream.adaptive') + inputstream_version = addon.getAddonInfo('version') + except RuntimeError: + inputstream_version = '' + + if not self.use_inputstream_adaptive() or not inputstream_version: + return frozenset() if capability is None else None + + isa_loose_version = loose_version(inputstream_version) + if capability is None: + capabilities = frozenset( + capability + for (capability, version) in self._ISA_CAPABILITIES.items() + if version is True + or version and isa_loose_version >= loose_version(version) + ) + return capabilities + version = self._ISA_CAPABILITIES.get(capability) + return (version is True + or version and isa_loose_version >= loose_version(version)) + + @staticmethod + def inputstream_adaptive_auto_stream_selection(): + try: + addon = xbmcaddon.Addon('inputstream.adaptive') + return addon.getSetting('STREAMSELECTION') == '0' + except RuntimeError: + return False + + def abort_requested(self): + return self.get_ui().get_property('abort_requested').lower() == 'true' + + @staticmethod + def get_infolabel(name): + return xbmc.getInfoLabel(name) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/debug.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/debug.py index 1fff386613..896a7a3b13 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/debug.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/debug.py @@ -2,14 +2,19 @@ """ Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube + Copyright (C) 2016-present plugin.video.youtube SPDX-License-Identifier: GPL-2.0-only See LICENSES/GPL-2.0-only for more information. """ -import os +from __future__ import absolute_import, division, unicode_literals + import json +import os +from io import open + +from .logger import log_debug def debug_here(host='localhost'): @@ -39,14 +44,11 @@ def runtime(context, addon_version, elapsed, single_file=True): with open(debug_file, 'a') as _: pass # touch - with open(debug_file, 'r') as f: + with open(debug_file, 'r', encoding='utf-8') as f: contents = f.read() - with open(debug_file, 'w') as f: - if not contents: - contents = default_contents - else: - contents = json.loads(contents) + with open(debug_file, 'w', encoding='utf-8') as f: + contents = json.loads(contents) if contents else default_contents if not single_file: items = contents.get('runtimes', []) items.append({"path": context.get_path(), "parameters": context.get_params(), "runtime": round(elapsed, 4)}) @@ -56,3 +58,168 @@ def runtime(context, addon_version, elapsed, single_file=True): items.append({"parameters": context.get_params(), "runtime": round(elapsed, 4)}) contents['runtimes'][context.get_path()] = items f.write(json.dumps(contents, indent=4)) + + +class Profiler(object): + """Class used to profile a block of code""" + + __slots__ = ('__weakref__', '_enabled', '_profiler', '_reuse', 'name',) + + from cProfile import Profile as _Profile + from pstats import Stats as _Stats + try: + from StringIO import StringIO as _StringIO + except ImportError: + from io import StringIO as _StringIO + from functools import wraps as _wraps + _wraps = staticmethod(_wraps) + from weakref import ref as _ref + + class Proxy(_ref): + def __call__(self, *args, **kwargs): + return super(Profiler.Proxy, self).__call__().__call__( + *args, **kwargs + ) + + def __enter__(self, *args, **kwargs): + return super(Profiler.Proxy, self).__call__().__enter__( + *args, **kwargs + ) + + def __exit__(self, *args, **kwargs): + return super(Profiler.Proxy, self).__call__().__exit__( + *args, **kwargs + ) + + _instances = set() + + def __new__(cls, *args, **kwargs): + self = super(Profiler, cls).__new__(cls) + cls._instances.add(self) + if not kwargs.get('enabled') or kwargs.get('lazy'): + self.__init__(*args, **kwargs) + return cls.Proxy(self) + return self + + def __init__(self, enabled=True, lazy=True, name=__name__, reuse=False): + self._enabled = enabled + self._profiler = None + self._reuse = reuse + self.name = name + + if enabled and not lazy: + self._create_profiler() + + def __del__(self): + self.__class__._instances.discard(self) # pylint: disable=protected-access + + def __enter__(self): + if not self._enabled: + return + + if not self._profiler: + self._create_profiler() + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + if not self._enabled: + return + + log_debug('Profiling stats: {0}'.format(self.get_stats( + reuse=self._reuse + ))) + if not self._reuse: + self.__del__() + + def __call__(self, func=None, name=__name__, reuse=False): + """Decorator used to profile function calls""" + + if not func: + self._reuse = reuse + self.name = name + return self + + @self.__class__._wraps(func) # pylint: disable=protected-access + def wrapper(*args, **kwargs): + """Wrapper to: + 1) create a new Profiler instance; + 2) run the function being profiled; + 3) print out profiler result to the log; and + 4) return result of function call""" + + name = getattr(func, '__qualname__', None) + if name: + # If __qualname__ is available (Python 3.3+) then use it + pass + + elif args and getattr(args[0], func.__name__, None): + if isinstance(args[0], type): + class_name = args[0].__name__ + else: + class_name = args[0].__class__.__name__ + name = '{0}.{1}'.format(class_name, func.__name__) + + elif (func.__class__ + and not isinstance(func.__class__, type) + and func.__class__.__name__ != 'function'): + name = '{0}.{1}'.format(func.__class__.__name__, func.__name__) + + elif func.__module__: + name = '{0}.{1}'.format(func.__module__, func.__name__) + + else: + name = func.__name__ + + self.name = name + with self: + result = func(*args, **kwargs) + + return result + + if not self._enabled: + self.__del__() + return func + return wrapper + + def _create_profiler(self): + self._profiler = self._Profile() + self._profiler.enable() + + def disable(self): + if self._profiler: + self._profiler.disable() + + def enable(self, flush=False): + self._enabled = True + if flush or not self._profiler: + self._create_profiler() + else: + self._profiler.enable() + + def get_stats(self, flush=True, reuse=False): + if not (self._enabled and self._profiler): + return None + + self.disable() + + output_stream = self._StringIO() + try: + self._Stats( + self._profiler, + stream=output_stream + ).strip_dirs().sort_stats('cumulative', 'time').print_stats(20) + # Occurs when no stats were able to be generated from profiler + except TypeError: + pass + output = output_stream.getvalue() + output_stream.close() + + if reuse: + # If stats are accumulating then enable existing/new profiler + self.enable(flush) + + return output + + def print_stats(self): + log_debug('Profiling stats: {0}'.format(self.get_stats( + reuse=self._reuse + ))) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/exceptions.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/exceptions.py index 7b4aa469bb..543a28e658 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/exceptions.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/exceptions.py @@ -10,9 +10,12 @@ class KodionException(Exception): - def __init__(self, message): - Exception.__init__(self, message) - self._message = message + def __init__(self, message, **kwargs): + super(KodionException, self).__init__(message) + attrs = self.__dict__ + for attr, value in kwargs.items(): + if attr not in attrs: + setattr(self, attr, value) def get_message(self): - return self._message + return str(self) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/__init__.py deleted file mode 100644 index ddb3e0b934..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -from .xbmc.xbmc_plugin_settings import XbmcPluginSettings as Settings -from .xbmc.xbmc_context import XbmcContext as Context -from .xbmc.xbmc_context_ui import XbmcContextUI as ContextUI -from .xbmc.xbmc_runner import XbmcRunner as Runner - - -__all__ = ['Settings', 'Context', 'ContextUI', 'Runner'] diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_context.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_context.py deleted file mode 100644 index 99d1cb7022..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_context.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import os -from urllib.parse import urlencode - -from .. import constants -from .. import logger -from ..utils import * - - -class AbstractContext(object): - def __init__(self, path=u'/', params=None, plugin_name=u'', plugin_id=u''): - if not params: - params = {} - - self._system_version = None - - self._cache_path = None - self._debug_path = None - - self._function_cache = None - self._data_cache = None - self._search_history = None - self._playback_history = None - self._favorite_list = None - self._watch_later_list = None - self._access_manager = None - - self._plugin_name = str(plugin_name) - self._version = 'UNKNOWN' - self._plugin_id = plugin_id - self._path = create_path(path) - self._params = params - self._utils = None - self._view_mode = None - - # create valid uri - self._uri = self.create_uri(self._path, self._params) - - def format_date_short(self, date_obj): - raise NotImplementedError() - - def format_time(self, time_obj): - raise NotImplementedError() - - def get_language(self): - raise NotImplementedError() - - def get_language_name(self): - raise NotImplementedError() - - def get_region(self): - raise NotImplementedError() - - def get_cache_path(self): - if not self._cache_path: - self._cache_path = os.path.join(self.get_data_path(), 'kodion') - return self._cache_path - - def get_playback_history(self): - if not self._playback_history: - uuid = self.get_access_manager().get_current_user_id() - db_file = os.path.join(os.path.join(self.get_data_path(), 'playback'), str(uuid)) - self._playback_history = PlaybackHistory(db_file) - return self._playback_history - - def get_data_cache(self): - if not self._data_cache: - max_cache_size_mb = self.get_settings().get_int(constants.setting.CACHE_SIZE, -1) - if max_cache_size_mb <= 0: - max_cache_size_mb = 5 - else: - max_cache_size_mb = max_cache_size_mb / 2.0 - self._data_cache = DataCache(os.path.join(self.get_cache_path(), 'data_cache'), - max_file_size_mb=max_cache_size_mb) - return self._data_cache - - def get_function_cache(self): - if not self._function_cache: - max_cache_size_mb = self.get_settings().get_int(constants.setting.CACHE_SIZE, -1) - if max_cache_size_mb <= 0: - max_cache_size_mb = 5 - else: - max_cache_size_mb = max_cache_size_mb / 2.0 - self._function_cache = FunctionCache(os.path.join(self.get_cache_path(), 'cache'), - max_file_size_mb=max_cache_size_mb) - return self._function_cache - - def get_search_history(self): - if not self._search_history: - max_search_history_items = self.get_settings().get_int(constants.setting.SEARCH_SIZE, 50) - self._search_history = SearchHistory(os.path.join(self.get_cache_path(), 'search'), - max_search_history_items) - return self._search_history - - def get_favorite_list(self): - if not self._favorite_list: - self._favorite_list = FavoriteList(os.path.join(self.get_cache_path(), 'favorites')) - return self._favorite_list - - def get_watch_later_list(self): - if not self._watch_later_list: - self._watch_later_list = WatchLaterList(os.path.join(self.get_cache_path(), 'watch_later')) - return self._watch_later_list - - def get_access_manager(self): - if not self._access_manager: - self._access_manager = AccessManager(self) - return self._access_manager - - def get_video_playlist(self): - raise NotImplementedError() - - def get_audio_playlist(self): - raise NotImplementedError() - - def get_video_player(self): - raise NotImplementedError() - - def get_audio_player(self): - raise NotImplementedError() - - def get_ui(self): - raise NotImplementedError() - - def get_system_version(self): - if not self._system_version: - self._system_version = SystemVersion(version='', releasename='', appname='') - - return self._system_version - - def create_uri(self, path=u'/', params=None): - if not params: - params = {} - - uri = create_uri_path(path) - if uri: - uri = "%s://%s%s" % ('plugin', str(self._plugin_id), uri) - else: - uri = "%s://%s/" % ('plugin', str(self._plugin_id)) - - if len(params) > 0: - # make a copy of the map - uri_params = {} - uri_params.update(params) - - # encode in utf-8 - for param in uri_params: - if isinstance(params[param], int): - params[param] = str(params[param]) - - uri_params[param] = to_utf8(params[param]) - uri = '?'.join([uri, urlencode(uri_params)]) - - return uri - - def get_path(self): - return self._path - - def set_path(self, value): - self._path = value - - def get_params(self): - return self._params - - def get_param(self, name, default=None): - return self.get_params().get(name, default) - - def set_param(self, name, value): - self._params[name] = value - - def get_data_path(self): - """ - Returns the path for read/write access of files - :return: - """ - raise NotImplementedError() - - def get_native_path(self): - raise NotImplementedError() - - def get_icon(self): - return self.create_resource_path('media/icon.png') - - def get_fanart(self): - return self.create_resource_path('media/fanart.jpg') - - def create_resource_path(self, *args): - path_comps = [] - for arg in args: - path_comps.extend(arg.split('/')) - path = os.path.join(self.get_native_path(), 'resources', *path_comps) - return path - - def get_uri(self): - return self._uri - - def get_name(self): - return self._plugin_name - - def get_version(self): - return self._version - - def get_id(self): - return self._plugin_id - - def get_handle(self): - raise NotImplementedError() - - def get_settings(self): - raise NotImplementedError() - - def localize(self, text_id, default_text=u''): - raise NotImplementedError() - - def set_content_type(self, content_type): - raise NotImplementedError() - - def add_sort_method(self, *sort_methods): - raise NotImplementedError() - - def log(self, text, log_level=logger.NOTICE): - logger.log(text, log_level, self.get_id()) - - def log_warning(self, text): - self.log(text, logger.WARNING) - - def log_error(self, text): - self.log(text, logger.ERROR) - - def log_notice(self, text): - self.log(text, logger.NOTICE) - - def log_debug(self, text): - self.log(text, logger.DEBUG) - - def log_info(self, text): - self.log(text, logger.INFO) - - def clone(self, new_path=None, new_params=None): - raise NotImplementedError() - - def execute(self, command): - raise NotImplementedError() - - def sleep(self, milli_seconds): - raise NotImplementedError() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_progress_dialog.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_progress_dialog.py deleted file mode 100644 index 743711ba65..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_progress_dialog.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - - -class AbstractProgressDialog(object): - def __init__(self, total=100): - self._total = int(total) - self._position = 0 - - def get_total(self): - return self._total - - def get_position(self): - return self._position - - def close(self): - raise NotImplementedError() - - def set_total(self, total): - self._total = int(total) - - def update(self, steps=1, text=None): - raise NotImplementedError() - - def is_aborted(self): - raise NotImplementedError() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py deleted file mode 100644 index 47ec7ed2e7..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py +++ /dev/null @@ -1,268 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import sys - -from ..constants import setting as SETTINGS -from ..logger import log_debug - - -class AbstractSettings(object): - def __init__(self): - object.__init__(self) - - def get_string(self, setting_id, default_value=None): - raise NotImplementedError() - - def set_string(self, setting_id, value): - raise NotImplementedError() - - def open_settings(self): - raise NotImplementedError() - - def get_int(self, setting_id, default_value, converter=None): - if not converter: - def converter(x): - return x - - value = self.get_string(setting_id) - if value is None or value == '': - return default_value - - try: - return converter(int(value)) - except Exception as ex: - log_debug("Failed to get setting '%s' as 'int' (%s)" % setting_id, ex.__str__()) - - return default_value - - def set_int(self, setting_id, value): - self.set_string(setting_id, str(value)) - - def set_bool(self, setting_id, value): - if value: - self.set_string(setting_id, 'true') - else: - self.set_string(setting_id, 'false') - - def get_bool(self, setting_id, default_value): - value = self.get_string(setting_id) - if value is None or value == '': - return default_value - - if value != 'false' and value != 'true': - return default_value - - return value == 'true' - - def get_items_per_page(self): - return self.get_int(SETTINGS.ITEMS_PER_PAGE, 50) - - def get_video_quality(self, quality_map_override=None): - vq_dict = {0: 240, - 1: 360, - 2: 480, # 576 seems not to work well - 3: 720, - 4: 1080} - - if quality_map_override is not None: - vq_dict = quality_map_override - - vq = self.get_int(SETTINGS.VIDEO_QUALITY, 1) - return vq_dict[vq] - - def ask_for_video_quality(self): - return self.get_bool(SETTINGS.VIDEO_QUALITY_ASK, False) - - def show_fanart(self): - return self.get_bool(SETTINGS.SHOW_FANART, True) - - def get_search_history_size(self): - return self.get_int(SETTINGS.SEARCH_SIZE, 50) - - def is_setup_wizard_enabled(self): - return self.get_bool(SETTINGS.SETUP_WIZARD, False) - - def is_support_alternative_player_enabled(self): - return self.get_bool(SETTINGS.SUPPORT_ALTERNATIVE_PLAYER, False) - - def alternative_player_web_urls(self): - return self.get_bool(SETTINGS.ALTERNATIVE_PLAYER_WEB_URLS, False) - - def use_mpd(self): - return self.get_bool(SETTINGS.USE_MPD, False) - - def subtitle_languages(self): - return self.get_int(SETTINGS.SUBTITLE_LANGUAGE, 0) - - def subtitle_download(self): - return self.get_bool(SETTINGS.SUBTITLE_DOWNLOAD, False) - - def audio_only(self): - return self.get_bool(SETTINGS.AUDIO_ONLY, False) - - def set_subtitle_languages(self, value): - return self.set_int(SETTINGS.SUBTITLE_LANGUAGE, value) - - def set_subtitle_download(self, value): - return self.set_bool(SETTINGS.SUBTITLE_DOWNLOAD, value) - - def use_thumbnail_size(self): - size = self.get_int(SETTINGS.THUMB_SIZE, 0) - sizes = {0: 'medium', 1: 'high'} - return sizes[size] - - def safe_search(self): - index = self.get_int(SETTINGS.SAFE_SEARCH, 0) - values = {0: 'moderate', 1: 'none', 2: 'strict'} - return values[index] - - def age_gate(self): - return self.get_bool(SETTINGS.AGE_GATE, True) - - def verify_ssl(self): - verify = self.get_bool(SETTINGS.VERIFY_SSL, False) - if sys.version_info <= (2, 7, 9): - verify = False - return verify - - def allow_dev_keys(self): - return self.get_bool(SETTINGS.ALLOW_DEV_KEYS, False) - - def use_mpd_videos(self): - if self.use_mpd(): - return self.get_bool(SETTINGS.MPD_VIDEOS, False) - return False - - _LIVE_STREAM_TYPES = { - 0: 'mpegts', - 1: 'hls', - 2: 'ia_hls', - 3: 'ia_mpd', - } - - def get_live_stream_type(self): - if self.use_mpd(): - stream_type = self.get_int(SETTINGS.LIVE_STREAMS + '.1', 0) - else: - stream_type = self.get_int(SETTINGS.LIVE_STREAMS + '.2', 0) - return self._LIVE_STREAM_TYPES.get(stream_type) or self._LIVE_STREAM_TYPES[0] - - def use_adaptive_live_streams(self): - if self.use_mpd(): - return self.get_int(SETTINGS.LIVE_STREAMS + '.1', 0) > 1 - return self.get_int(SETTINGS.LIVE_STREAMS + '.2', 0) > 1 - - def use_mpd_live_streams(self): - if self.use_mpd(): - return self.get_int(SETTINGS.LIVE_STREAMS + '.1', 0) == 3 - return False - - def httpd_port(self): - return self.get_int(SETTINGS.HTTPD_PORT, 50152) - - def httpd_listen(self, default='0.0.0.0', for_request=False): - ip_address = self.get_string(SETTINGS.HTTPD_LISTEN, default) - try: - ip_address = ip_address.strip() - except AttributeError: - pass - if not ip_address: - ip_address = default - if for_request and ip_address == default: - ip_address = '127.0.0.1' - return ip_address - - def set_httpd_listen(self, value): - return self.set_string(SETTINGS.HTTPD_LISTEN, value) - - def httpd_whitelist(self): - return self.get_string(SETTINGS.HTTPD_WHITELIST, '') - - def api_config_page(self): - return self.get_bool(SETTINGS.API_CONFIG_PAGE, False) - - def get_location(self): - location = self.get_string(SETTINGS.LOCATION, '').replace(' ', '').strip() - coords = location.split(',') - latitude = longitude = None - if len(coords) == 2: - try: - latitude = float(coords[0]) - longitude = float(coords[1]) - if latitude > 90.0 or latitude < -90.0: - latitude = None - if longitude > 180.0 or longitude < -180.0: - longitude = None - except ValueError: - latitude = longitude = None - if latitude and longitude: - return '{lat},{long}'.format(lat=latitude, long=longitude) - else: - return '' - - def set_location(self, value): - self.set_string(SETTINGS.LOCATION, value) - - def get_location_radius(self): - return ''.join([str(self.get_int(SETTINGS.LOCATION_RADIUS, 500)), 'km']) - - def get_play_count_min_percent(self): - return self.get_int(SETTINGS.PLAY_COUNT_MIN_PERCENT, 0) - - def use_local_history(self): - return self.get_bool(SETTINGS.USE_LOCAL_HISTORY, False) - - def use_remote_history(self): - return self.get_bool(SETTINGS.USE_REMOTE_HISTORY, False) - - # Selections based on max width and min height at common (utra-)wide aspect ratios - _QUALITY_SELECTIONS = { # Setting | Resolution - 7: {'width': 7680, 'height': 3148, 'label': '4320p{0} (8K){1}'}, # 7 | 4320p 8K - 6: {'width': 3840, 'height': 1080, 'label': '2160p{0} (4K){1}'}, # 6 | 2160p 4K - 5: {'width': 2560, 'height': 984, 'label': '1440p{0} (QHD){1}'}, # 5 | 1440p 2.5K / QHD - 4.1: {'width': 2048, 'height': 858, 'label': '1152p{0} (2K){1}'}, # N/A | 1152p 2K / QWXGA - 4: {'width': 1920, 'height': 787, 'label': '1080p{0} (FHD){1}'}, # 4 | 1080p FHD - 3: {'width': 1280, 'height': 525, 'label': '720p{0} (HD){1}'}, # 3 | 720p HD - 2: {'width': 854, 'height': 350, 'label': '480p{0}{1}'}, # 2 | 480p - 1: {'width': 640, 'height': 263, 'label': '360p{0}{1}'}, # 1 | 360p - 0: {'width': 426, 'height': 175, 'label': '240p{0}{1}'}, # 0 | 240p - -1: {'width': 256, 'height': 105, 'label': '144p{0}{1}'}, # N/A | 144p - -2: {'width': 0, 'height': 0, 'label': '{2}p{0}{1}'}, # N/A | Custom - } - - def get_mpd_video_qualities(self): - if not self.use_mpd_videos(): - return [] - selected = self.get_int(SETTINGS.MPD_QUALITY_SELECTION, 4) - return [quality for key, quality in self._QUALITY_SELECTIONS.items() - if selected >= key] - - def stream_features(self): - return self.get_string(SETTINGS.MPD_STREAM_FEATURES, '').split(',') - - _STREAM_SELECT = { - 1: 'auto', - 2: 'list', - 3: 'auto+list', - } - - def stream_select(self): - select_type = self.get_int(SETTINGS.MPD_STREAM_SELECT, 1) - return self._STREAM_SELECT.get(select_type) or self._STREAM_SELECT[1] - - def remote_friendly_search(self): - return self.get_bool(SETTINGS.REMOTE_FRIENDLY_SEARCH, False) - - def hide_short_videos(self): - return self.get_bool(SETTINGS.HIDE_SHORT_VIDEOS, False) - - def client_selection(self): - return self.get_int(SETTINGS.CLIENT_SELECTION, 0) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/__init__.py deleted file mode 100644 index 86fe5e5c03..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -__all__ = [] diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py deleted file mode 100644 index 03b9cee76a..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py +++ /dev/null @@ -1,351 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import datetime -import json -import os -import sys -import weakref -from urllib.parse import quote -from urllib.parse import unquote -from urllib.parse import urlparse -from urllib.parse import parse_qsl - -import xbmc -import xbmcaddon -import xbmcplugin -import xbmcvfs - -from ..abstract_context import AbstractContext -from .xbmc_plugin_settings import XbmcPluginSettings -from .xbmc_context_ui import XbmcContextUI -from .xbmc_playlist import XbmcPlaylist -from .xbmc_player import XbmcPlayer -from ... import utils - -try: - xbmc.translatePath = xbmcvfs.translatePath -except AttributeError: - pass - - -class XbmcContext(AbstractContext): - def __init__(self, path='/', params=None, plugin_name='', plugin_id='', override=True): - AbstractContext.__init__(self, path, params, plugin_name, plugin_id) - - if plugin_id: - self._addon = xbmcaddon.Addon(id=plugin_id) - else: - self._addon = xbmcaddon.Addon(id='plugin.video.youtube') - - """ - I don't know what xbmc/kodi is doing with a simple uri, but we have to extract the information from the - sys parameters and re-build our clean uri. - Also we extract the path and parameters - man, that would be so simple with the normal url-parsing routines. - """ - # first the path of the uri - if override: - self._uri = sys.argv[0] - comps = urlparse(self._uri) - self._path = unquote(comps.path) - - # after that try to get the params - if len(sys.argv) > 2: - params = sys.argv[2][1:] - if len(params) > 0: - self._uri = '?'.join([self._uri, params]) - - self._params = {} - params = dict(parse_qsl(params)) - for _param in params: - item = params[_param] - self._params[_param] = item - - self._ui = None - self._video_playlist = None - self._audio_playlist = None - self._video_player = None - self._audio_player = None - self._plugin_handle = int(sys.argv[1]) if len(sys.argv) > 1 else None - self._plugin_id = plugin_id or self._addon.getAddonInfo('id') - self._plugin_name = plugin_name or self._addon.getAddonInfo('name') - self._version = self._addon.getAddonInfo('version') - self._native_path = xbmc.translatePath(self._addon.getAddonInfo('path')) - self._settings = XbmcPluginSettings(self._addon) - - """ - Set the data path for this addon and create the folder - """ - try: - self._data_path = xbmc.translatePath(self._addon.getAddonInfo('profile')).decode('utf-8') - except AttributeError: - self._data_path = xbmc.translatePath(self._addon.getAddonInfo('profile')) - - if not xbmcvfs.exists(self._data_path): - xbmcvfs.mkdir(self._data_path) - - def get_region(self): - pass # implement from abstract - - def addon(self): - return self._addon - - def is_plugin_path(self, uri, uri_path): - return uri.startswith('plugin://%s/%s/' % (self.get_id(), uri_path)) - - def format_date_short(self, date_obj): - date_format = xbmc.getRegion('dateshort') - _date_obj = date_obj - if isinstance(_date_obj, datetime.date): - _date_obj = datetime.datetime(_date_obj.year, _date_obj.month, _date_obj.day) - - return _date_obj.strftime(date_format) - - def format_time(self, time_obj): - time_format = xbmc.getRegion('time') - _time_obj = time_obj - if isinstance(_time_obj, datetime.time): - _time_obj = datetime.time(_time_obj.hour, _time_obj.minute, _time_obj.second) - - return _time_obj.strftime(time_format.replace("%H%H", "%H")) - - def get_language(self): - """ - The xbmc.getLanguage() method is fucked up!!! We always return 'en-US' for now - """ - - ''' - if self.get_system_version().get_release_name() == 'Frodo': - return 'en-US' - - try: - language = xbmc.getLanguage(0, region=True) - language = language.split('-') - language = '%s-%s' % (language[0].lower(), language[1].upper()) - return language - except Exception, ex: - self.log_error('Failed to get system language (%s)', ex.__str__()) - return 'en-US' - ''' - - return 'en-US' - - def get_language_name(self, lang_id=None): - if lang_id is None: - lang_id = self.get_language() - return xbmc.convertLanguage(lang_id, xbmc.ENGLISH_NAME).split(';')[0] - - def get_video_playlist(self): - if not self._video_playlist: - self._video_playlist = XbmcPlaylist('video', weakref.proxy(self)) - return self._video_playlist - - def get_audio_playlist(self): - if not self._audio_playlist: - self._audio_playlist = XbmcPlaylist('audio', weakref.proxy(self)) - return self._audio_playlist - - def get_video_player(self): - if not self._video_player: - self._video_player = XbmcPlayer('video', weakref.proxy(self)) - return self._video_player - - def get_audio_player(self): - if not self._audio_player: - self._audio_player = XbmcPlayer('audio', weakref.proxy(self)) - return self._audio_player - - def get_ui(self): - if not self._ui: - self._ui = XbmcContextUI(self._addon, weakref.proxy(self)) - return self._ui - - def get_handle(self): - return self._plugin_handle - - def get_data_path(self): - return self._data_path - - def get_debug_path(self): - if not self._debug_path: - self._debug_path = os.path.join(self.get_data_path(), 'debug') - if not xbmcvfs.exists(self._debug_path): - xbmcvfs.mkdir(self._debug_path) - return self._debug_path - - def get_native_path(self): - return self._native_path - - def get_settings(self): - return self._settings - - def localize(self, text_id, default_text=u''): - result = None - if isinstance(text_id, int): - """ - We want to use all localization strings! - Addons should only use the range 30000 thru 30999 (see: http://kodi.wiki/view/Language_support) but we - do it anyway. I want some of the localized strings for the views of a skin. - """ - if text_id >= 0 and (text_id < 30000 or text_id > 30999): - result = xbmc.getLocalizedString(text_id) - if result is not None and result: - result = utils.to_unicode(result) - - if not result: - try: - result = self._addon.getLocalizedString(int(text_id)) - if result is not None and result: - result = utils.to_unicode(result) - except ValueError: - pass - - if not result: - result = default_text - - return result - - def set_content_type(self, content_type): - self.log_debug('Setting content-type: "%s" for "%s"' % (content_type, self.get_path())) - xbmcplugin.setContent(self._plugin_handle, content_type) - - def add_sort_method(self, *sort_methods): - for sort_method in sort_methods: - xbmcplugin.addSortMethod(self._plugin_handle, sort_method) - - def clone(self, new_path=None, new_params=None): - if not new_path: - new_path = self.get_path() - - if not new_params: - new_params = self.get_params() - - new_context = XbmcContext(path=new_path, params=new_params, plugin_name=self._plugin_name, - plugin_id=self._plugin_id, override=False) - new_context._function_cache = self._function_cache - new_context._search_history = self._search_history - new_context._favorite_list = self._favorite_list - new_context._watch_later_list = self._watch_later_list - new_context._access_manager = self._access_manager - new_context._ui = self._ui - new_context._video_playlist = self._video_playlist - new_context._video_player = self._video_player - - return new_context - - def execute(self, command): - xbmc.executebuiltin(command) - - def sleep(self, milli_seconds): - xbmc.sleep(milli_seconds) - - def addon_enabled(self, addon_id): - rpc_request = json.dumps({"jsonrpc": "2.0", - "method": "Addons.GetAddonDetails", - "id": 1, - "params": {"addonid": "%s" % addon_id, - "properties": ["enabled"]} - }) - response = json.loads(xbmc.executeJSONRPC(rpc_request)) - try: - return response['result']['addon']['enabled'] is True - except KeyError: - message = response['error']['message'] - code = response['error']['code'] - error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) - self.log_debug(error) - return False - - def set_addon_enabled(self, addon_id, enabled=True): - rpc_request = json.dumps({"jsonrpc": "2.0", - "method": "Addons.SetAddonEnabled", - "id": 1, - "params": {"addonid": "%s" % addon_id, - "enabled": enabled} - }) - response = json.loads(xbmc.executeJSONRPC(rpc_request)) - try: - return response['result'] == 'OK' - except KeyError: - message = response['error']['message'] - code = response['error']['code'] - error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) - self.log_debug(error) - return False - - def send_notification(self, method, data): - data = json.dumps(data) - self.log_debug('send_notification: |%s| -> |%s|' % (method, data)) - data = '\\"[\\"%s\\"]\\"' % quote(data) - self.execute('NotifyAll(plugin.video.youtube,%s,%s)' % (method, data)) - - def use_inputstream_adaptive(self): - if self._settings.use_mpd_videos() or self._settings.use_adaptive_live_streams(): - if self.addon_enabled('inputstream.adaptive'): - success = True - elif self.get_ui().on_yes_no_input(self.get_name(), self.localize(30579)): - success = self.set_addon_enabled('inputstream.adaptive') - else: - success = False - else: - success = False - return success - - # Values of capability map can be any of the following: - # - required version number as string for comparison with actual installed InputStream.Adaptive version - # - any Falsey value to exclude capability regardless of version - # - True to include capability regardless of version - _IA_CAPABILITIES = { - 'live': '2.0.12', - 'drm': '2.2.12', - # audio codecs - 'vorbis': '2.3.14', - 'opus': '19.0.7', - 'mp4a': True, - 'ac-3': '2.1.15', - 'ec-3': '2.1.15', - 'dts': '2.1.15', - # video codecs - 'avc1': True, - 'av01': '20.3.0', - 'vp8': False, - 'vp9': '2.3.14', - } - - def inputstream_adaptive_capabilities(self, capability=None): - # return a list inputstream.adaptive capabilities, if capability set return version required - - try: - inputstream_version = xbmcaddon.Addon('inputstream.adaptive').getAddonInfo('version') - except RuntimeError: - inputstream_version = '' - - if not self.use_inputstream_adaptive() or not inputstream_version: - return frozenset() if capability is None else None - - ia_loose_version = utils.loose_version(inputstream_version) - if capability is None: - capabilities = frozenset( - capability for capability, version in self._IA_CAPABILITIES.items() - if version is True - or version and ia_loose_version >= utils.loose_version(version) - ) - return capabilities - version = self._IA_CAPABILITIES.get(capability) - return version is True or version and ia_loose_version >= utils.loose_version(version) - - def inputstream_adaptive_auto_stream_selection(self): - try: - return xbmcaddon.Addon('inputstream.adaptive').getSetting('STREAMSELECTION') == '0' - except RuntimeError: - return False - - def abort_requested(self): - return str(self.get_ui().get_home_window_property('abort_requested')).lower() == 'true' diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py deleted file mode 100644 index 147d4e5ebc..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import xbmc -import xbmcgui - -from ..abstract_context_ui import AbstractContextUI -from .xbmc_progress_dialog import XbmcProgressDialog -from .xbmc_progress_dialog_bg import XbmcProgressDialogBG -from ... import constants -from ... import utils - - -class XbmcContextUI(AbstractContextUI): - def __init__(self, xbmc_addon, context): - AbstractContextUI.__init__(self) - - self._xbmc_addon = xbmc_addon - - self._context = context - self._view_mode = None - - def create_progress_dialog(self, heading, text=None, background=False): - if background and self._context.get_system_version().get_version() > (12, 3): - return XbmcProgressDialogBG(heading, text) - - return XbmcProgressDialog(heading, text) - - def get_skin_id(self): - return xbmc.getSkinDir() - - def on_keyboard_input(self, title, default='', hidden=False): - # fallback for Frodo - if self._context.get_system_version().get_version() <= (12, 3): - keyboard = xbmc.Keyboard(default, title, hidden) - keyboard.doModal() - if keyboard.isConfirmed() and keyboard.getText(): - text = utils.to_unicode(keyboard.getText()) - return True, text - else: - return False, u'' - - # Starting with Gotham (13.X > ...) - dialog = xbmcgui.Dialog() - result = dialog.input(title, utils.to_unicode(default), type=xbmcgui.INPUT_ALPHANUM) - if result: - text = utils.to_unicode(result) - return True, text - - return False, u'' - - def on_numeric_input(self, title, default=''): - dialog = xbmcgui.Dialog() - result = dialog.input(title, str(default), type=xbmcgui.INPUT_NUMERIC) - if result: - return True, int(result) - - return False, None - - def on_yes_no_input(self, title, text, nolabel='', yeslabel=''): - dialog = xbmcgui.Dialog() - return dialog.yesno(title, text, nolabel=nolabel, yeslabel=yeslabel) - - def on_ok(self, title, text): - dialog = xbmcgui.Dialog() - return dialog.ok(title, text) - - def on_remove_content(self, content_name): - text = self._context.localize(constants.localize.REMOVE_CONTENT) % utils.to_unicode(content_name) - return self.on_yes_no_input(self._context.localize(constants.localize.CONFIRM_REMOVE), text) - - def on_delete_content(self, content_name): - text = self._context.localize(constants.localize.DELETE_CONTENT) % utils.to_unicode(content_name) - return self.on_yes_no_input(self._context.localize(constants.localize.CONFIRM_DELETE), text) - - def on_select(self, title, items=None): - if items is None: - items = [] - - use_details = (isinstance(items[0], tuple) and len(items[0]) == 4) - - _dict = {} - _items = [] - i = 0 - for item in items: - if isinstance(item, tuple): - if use_details: - new_item = xbmcgui.ListItem(label=item[0], label2=item[1]) - new_item.setArt({'icon': item[3], 'thumb': item[3]}) - _items.append(new_item) - _dict[i] = item[2] - else: - _dict[i] = item[1] - _items.append(item[0]) - else: - _dict[i] = i - _items.append(item) - - i += 1 - - dialog = xbmcgui.Dialog() - if use_details: - result = dialog.select(title, _items, useDetails=use_details) - else: - result = dialog.select(title, _items) - - return _dict.get(result, -1) - - def show_notification(self, message, header='', image_uri='', time_milliseconds=5000, audible=True): - _header = header - if not _header: - _header = self._context.get_name() - _header = utils.to_utf8(_header) - - _image = image_uri - if not _image: - _image = self._context.get_icon() - - if isinstance(message, str): - message = utils.to_unicode(message) - - try: - _message = utils.to_utf8(message.decode('unicode-escape')) - except (AttributeError, UnicodeEncodeError): - _message = utils.to_utf8(message) - - try: - _message = _message.replace(',', ' ') - _message = _message.replace('\n', ' ') - except TypeError: - _message = _message.replace(b',', b' ') - _message = _message.replace(b'\n', b' ') - _message = utils.to_unicode(_message) - _header = utils.to_unicode(_header) - - # xbmc.executebuiltin("Notification(%s, %s, %d, %s)" % (_header, _message, time_milliseconds, _image)) - xbmcgui.Dialog().notification(_header, _message, _image, time_milliseconds, audible) - - def open_settings(self): - self._xbmc_addon.openSettings() - - def refresh_container(self): - script_uri = "{}/resources/lib/youtube_plugin/refresh.py".format(self._xbmc_addon.getAddonInfo('path')) - xbmc.executebuiltin('RunScript(%s)' % script_uri) - - @staticmethod - def get_info_label(value): - return xbmc.getInfoLabel(value) - - @staticmethod - def set_home_window_property(property_id, value): - property_id = ''.join(['plugin.video.youtube-', property_id]) - xbmcgui.Window(10000).setProperty(property_id, value) - - @staticmethod - def get_home_window_property(property_id): - property_id = ''.join(['plugin.video.youtube-', property_id]) - return xbmcgui.Window(10000).getProperty(property_id) or None - - @staticmethod - def clear_home_window_property(property_id): - property_id = ''.join(['plugin.video.youtube-', property_id]) - xbmcgui.Window(10000).clearProperty(property_id) - - @staticmethod - def bold(value): - return ''.join(['[B]', value, '[/B]']) - - @staticmethod - def uppercase(value): - return ''.join(['[UPPERCASE]', value, '[/UPPERCASE]']) - - @staticmethod - def color(color, value): - return ''.join(['[COLOR=', color.lower(), ']', value, '[/COLOR]']) - - def set_focus_next_item(self): - cid = xbmcgui.Window(xbmcgui.getCurrentWindowId()).getFocusId() - try: - current_position = int(self.get_info_label('Container.Position')) + 1 - self._context.execute('SetFocus(%s,%s)' % (cid, str(current_position))) - except ValueError: - pass diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_items.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_items.py deleted file mode 100644 index c1341a0230..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_items.py +++ /dev/null @@ -1,243 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import xbmcgui - -try: - from infotagger.listitem import ListItemInfoTag -except ImportError: - class ListItemInfoTag: - __slots__ = ('__li__', '__type__' ) - - def __init__(self, list_item, tag_type): - self.__li__ = list_item - self.__type__ = tag_type - - def add_stream_info(self, *args, **kwargs): - return self.__li__.addStreamInfo(*args, **kwargs) - - def set_info(self, *args, **kwargs): - return self.__li__.setInfo(self.__type__, *args, **kwargs) - - -from ...items import VideoItem, AudioItem, UriItem -from ... import utils -from . import info_labels - - -def to_play_item(context, play_item): - context.log_debug('Converting PlayItem |%s|' % play_item.get_uri()) - - - is_strm = str(context.get_param('strm', False)).lower() == 'true' - - thumb = play_item.get_image() if play_item.get_image() else u'DefaultVideo.png' - title = play_item.get_title() if play_item.get_title() else play_item.get_name() - fanart = '' - settings = context.get_settings() - if is_strm: - list_item = xbmcgui.ListItem(offscreen=True) - else: - list_item = xbmcgui.ListItem(label=utils.to_unicode(title), offscreen=True) - - info_tag = ListItemInfoTag(list_item, tag_type='video') - - if not is_strm: - list_item.setProperty('IsPlayable', 'true') - - if play_item.get_fanart() and settings.show_fanart(): - fanart = play_item.get_fanart() - - list_item.setArt({'icon': thumb, 'thumb': thumb, 'fanart': fanart}) - - if settings.is_support_alternative_player_enabled() and \ - settings.alternative_player_web_urls() and \ - not play_item.get_license_key(): - play_item.set_uri('https://www.youtube.com/watch?v={video_id}'.format(video_id=play_item.video_id)) - - ia_enabled = context.addon_enabled('inputstream.adaptive') - - if ia_enabled and play_item.use_mpd_video() and not play_item.live: - list_item.setContentLookup(False) - list_item.setMimeType('application/xml+dash') - list_item.setProperty('inputstream', 'inputstream.adaptive') - list_item.setProperty('inputstream.adaptive.manifest_type', 'mpd') - if 'auto' in settings.stream_select(): - list_item.setProperty('inputstream.adaptive.stream_selection_type', 'adaptive') - - if play_item.get_headers(): - list_item.setProperty('inputstream.adaptive.manifest_headers', play_item.get_headers()) - list_item.setProperty('inputstream.adaptive.stream_headers', play_item.get_headers()) - - if play_item.get_license_key(): - list_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha') - list_item.setProperty('inputstream.adaptive.license_key', play_item.get_license_key()) - - elif ia_enabled and play_item.live and settings.use_adaptive_live_streams(): - if settings.use_mpd_live_streams(): - manifest_type = 'mpd' - mime_type = 'application/xml+dash' - # MPD manifest update is currently broken - # Following line will force a full update but restart live stream from start - # list_item.setProperty('inputstream.adaptive.manifest_update_parameter', 'full') - else: - manifest_type = 'hls' - mime_type = 'application/x-mpegURL' - - list_item.setContentLookup(False) - list_item.setMimeType(mime_type) - list_item.setProperty('inputstream', 'inputstream.adaptive') - list_item.setProperty('inputstream.adaptive.manifest_type', manifest_type) - - if play_item.get_headers(): - list_item.setProperty('inputstream.adaptive.manifest_headers', play_item.get_headers()) - list_item.setProperty('inputstream.adaptive.stream_headers', play_item.get_headers()) - - if play_item.get_license_key(): - list_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha') - list_item.setProperty('inputstream.adaptive.license_key', play_item.get_license_key()) - - else: - uri = play_item.get_uri() - if 'mime=' in uri: - try: - mime_type = uri.split('mime=', 1)[-1].split('&', 1)[0].replace('%2F', '/', 1) - list_item.setMimeType(mime_type) - list_item.setContentLookup(False) - except: - pass - - if not is_strm: - if play_item.get_play_count() == 0: - if play_item.get_start_percent(): - list_item.setProperty('StartPercent', play_item.get_start_percent()) - - if play_item.get_start_time(): - list_item.setProperty('StartOffset', play_item.get_start_time()) - - if play_item.subtitles: - list_item.setSubtitles(play_item.subtitles) - - _info_labels = info_labels.create_from_item(play_item) - - # This should work for all versions of XBMC/KODI. - if 'duration' in _info_labels: - duration = _info_labels['duration'] - info_tag.add_stream_info('video', {'duration': duration}) - - info_tag.set_info(_info_labels) - - return list_item - - -def to_video_item(context, video_item): - context.log_debug('Converting VideoItem |%s|' % video_item.get_uri()) - thumb = video_item.get_image() if video_item.get_image() else u'DefaultVideo.png' - title = video_item.get_title() if video_item.get_title() else video_item.get_name() - fanart = '' - settings = context.get_settings() - - item = xbmcgui.ListItem(label=utils.to_unicode(title), offscreen=True) - info_tag = ListItemInfoTag(item, tag_type='video') - - if video_item.get_fanart() and settings.show_fanart(): - fanart = video_item.get_fanart() - - item.setArt({'icon': thumb, 'thumb': thumb, 'fanart': fanart}) - - if video_item.get_context_menu() is not None: - item.addContextMenuItems(video_item.get_context_menu(), replaceItems=video_item.replace_context_menu()) - - item.setProperty('IsPlayable', 'true') - - if not video_item.live: - published_at = video_item.get_aired_utc() - scheduled_start = video_item.get_scheduled_start_utc() - use_dt = scheduled_start or published_at - if use_dt: - local_dt = utils.datetime_parser.utc_to_local(use_dt) - item.setProperty('PublishedSince', - utils.to_unicode(utils.datetime_parser.datetime_to_since(context, local_dt))) - item.setProperty('PublishedLocal', str(local_dt)) - else: - item.setProperty('PublishedSince', context.localize('30539')) - - _info_labels = info_labels.create_from_item(video_item) - - if video_item.get_play_count() == 0: - if video_item.get_start_percent(): - item.setProperty('StartPercent', video_item.get_start_percent()) - - if video_item.get_start_time(): - item.setProperty('StartOffset', video_item.get_start_time()) - - # This should work for all versions of XBMC/KODI. - if 'duration' in _info_labels: - duration = _info_labels['duration'] - info_tag.add_stream_info('video', {'duration': duration}) - - info_tag.set_info(_info_labels) - - if video_item.get_channel_id(): # make channel_id property available for keymapping - item.setProperty('channel_id', video_item.get_channel_id()) - - if video_item.get_subscription_id(): # make subscription_id property available for keymapping - item.setProperty('subscription_id', video_item.get_subscription_id()) - - if video_item.get_playlist_id(): # make playlist_id property available for keymapping - item.setProperty('playlist_id', video_item.get_playlist_id()) - - if video_item.get_playlist_item_id(): # make playlist_item_id property available for keymapping - item.setProperty('playlist_item_id', video_item.get_playlist_item_id()) - - return item - - -def to_audio_item(context, audio_item): - context.log_debug('Converting AudioItem |%s|' % audio_item.get_uri()) - thumb = audio_item.get_image() if audio_item.get_image() else u'DefaultAudio.png' - title = audio_item.get_name() - fanart = '' - settings = context.get_settings() - item = xbmcgui.ListItem(label=utils.to_unicode(title), offscreen=True) - - info_tag = ListItemInfoTag(item, tag_type='music') - if audio_item.get_fanart() and settings.show_fanart(): - fanart = audio_item.get_fanart() - - item.setArt({'icon': thumb, 'thumb': thumb, 'fanart': fanart}) - - if audio_item.get_context_menu() is not None: - item.addContextMenuItems(audio_item.get_context_menu(), replaceItems=audio_item.replace_context_menu()) - - item.setProperty('IsPlayable', 'true') - - info_tag.set_info(info_labels.create_from_item(audio_item)) - return item - - -def to_uri_item(context, base_item): - context.log_debug('Converting UriItem') - item = xbmcgui.ListItem(path=base_item.get_uri(), offscreen=True) - item.setProperty('IsPlayable', 'true') - return item - - -def to_playback_item(context, base_item): - if isinstance(base_item, UriItem): - return to_uri_item(context, base_item) - - if isinstance(base_item, AudioItem): - return to_audio_item(context, base_item) - - if isinstance(base_item, VideoItem): - return to_play_item(context, base_item) - - return None diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_playlist.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_playlist.py deleted file mode 100644 index e95bfb436b..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_playlist.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import json - -import xbmc -from ..abstract_playlist import AbstractPlaylist -from . import xbmc_items - - -class XbmcPlaylist(AbstractPlaylist): - def __init__(self, playlist_type, context): - AbstractPlaylist.__init__(self) - - self._context = context - self._playlist = None - if playlist_type == 'video': - self._playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - elif playlist_type == 'audio': - self._playlist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) - - def clear(self): - self._playlist.clear() - - def add(self, base_item): - item = xbmc_items.to_video_item(self._context, base_item) - if item: - self._playlist.add(base_item.get_uri(), listitem=item) - - def shuffle(self): - self._playlist.shuffle() - - def unshuffle(self): - self._playlist.unshuffle() - - def size(self): - return self._playlist.size() - - def get_items(self): - rpc_request = json.dumps( - { - "jsonrpc": "2.0", - "method": "Playlist.GetItems", - "params": { - "properties": ["title", "file"], - "playlistid": self._playlist.getPlayListId() - }, - "id": 1 - }) - - response = json.loads(xbmc.executeJSONRPC(rpc_request)) - - if 'result' in response: - if 'items' in response['result']: - return response['result']['items'] - return [] - else: - if 'error' in response: - message = response['error']['message'] - code = response['error']['code'] - error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) - else: - error = 'Requested |%s| received error |%s|' % (rpc_request, str(response)) - self._context.log_debug(error) - return [] diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_plugin_settings.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_plugin_settings.py deleted file mode 100644 index 3970d9c003..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_plugin_settings.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -from ..abstract_settings import AbstractSettings - - -class XbmcPluginSettings(AbstractSettings): - def __init__(self, xbmc_addon): - AbstractSettings.__init__(self) - - self._xbmc_addon = xbmc_addon - - def get_string(self, setting_id, default_value=None): - return self._xbmc_addon.getSetting(setting_id) - - def set_string(self, setting_id, value): - self._xbmc_addon.setSetting(setting_id, value) - - def open_settings(self): - self._xbmc_addon.openSetting() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_progress_dialog.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_progress_dialog.py deleted file mode 100644 index 5aa6535d47..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_progress_dialog.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import xbmcgui -from ..abstract_progress_dialog import AbstractProgressDialog - - -class XbmcProgressDialog(AbstractProgressDialog): - def __init__(self, heading, text): - AbstractProgressDialog.__init__(self, 100) - self._dialog = xbmcgui.DialogProgress() - self._dialog.create(heading, text) - - # simple reset because KODI won't do it :( - self._position = 1 - self.update(steps=-1) - - def close(self): - if self._dialog: - self._dialog.close() - self._dialog = None - - def update(self, steps=1, text=None): - self._position += steps - position = int(float((100.0 // self._total)) * self._position) - - if isinstance(text, str): - self._dialog.update(position, text) - else: - self._dialog.update(position) - - def is_aborted(self): - return self._dialog.iscanceled() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_progress_dialog_bg.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_progress_dialog_bg.py deleted file mode 100644 index be9b4d0aee..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_progress_dialog_bg.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import xbmcgui -from ..abstract_progress_dialog import AbstractProgressDialog - - -class XbmcProgressDialogBG(AbstractProgressDialog): - def __init__(self, heading, text): - AbstractProgressDialog.__init__(self, 100) - self._dialog = xbmcgui.DialogProgressBG() - self._dialog.create(heading, text) - - # simple reset because KODI won't do it :( - self._position = 1 - self.update(steps=-1) - - def close(self): - if self._dialog: - self._dialog.close() - self._dialog = None - - def update(self, steps=1, text=None): - self._position += steps - position = int((100.0 / float(self._total)) * float(self._position)) - - if isinstance(text, str): - self._dialog.update(percent=position, message=text) - else: - self._dialog.update(percent=position) - - def is_aborted(self): - return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py deleted file mode 100644 index 46fda8a722..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import xbmcgui -import xbmcplugin - -from ..abstract_provider_runner import AbstractProviderRunner -from ...exceptions import KodionException -from ...items import * -from ... import AbstractProvider -from . import info_labels -from . import xbmc_items - - -class XbmcRunner(AbstractProviderRunner): - def __init__(self): - AbstractProviderRunner.__init__(self) - self.handle = None - self.settings = None - - def run(self, provider, context=None): - - self.handle = context.get_handle() - - try: - results = provider.navigate(context) - except KodionException as ex: - if provider.handle_exception(context, ex): - context.log_error(ex.__str__()) - xbmcgui.Dialog().ok("Exception in ContentProvider", ex.__str__()) - xbmcplugin.endOfDirectory(self.handle, succeeded=False) - return - - self.settings = context.get_settings() - - result = results[0] - options = {} - options.update(results[1]) - - if isinstance(result, bool) and not result: - xbmcplugin.endOfDirectory(self.handle, succeeded=False) - elif isinstance(result, VideoItem) or isinstance(result, AudioItem) or isinstance(result, UriItem): - self._set_resolved_url(context, result) - elif isinstance(result, DirectoryItem): - self._add_directory(context, result) - elif isinstance(result, list): - item_count = len(result) - for item in result: - if isinstance(item, DirectoryItem): - self._add_directory(context, item, item_count) - elif isinstance(item, VideoItem): - self._add_video(context, item, item_count) - elif isinstance(item, AudioItem): - self._add_audio(context, item, item_count) - elif isinstance(item, ImageItem): - self._add_image(context, item, item_count) - - xbmcplugin.endOfDirectory( - self.handle, succeeded=True, - updateListing=options.get(AbstractProvider.RESULT_UPDATE_LISTING, False), - cacheToDisc=options.get(AbstractProvider.RESULT_CACHE_TO_DISC, True)) - else: - # handle exception - pass - - def _set_resolved_url(self, context, base_item, succeeded=True): - item = xbmc_items.to_playback_item(context, base_item) - item.setPath(base_item.get_uri()) - xbmcplugin.setResolvedUrl(self.handle, succeeded=succeeded, listitem=item) - - """ - # just to be sure :) - if not isLiveStream: - tries = 100 - while tries>0: - xbmc.sleep(50) - if xbmc.Player().isPlaying() and xbmc.getCondVisibility("Player.Paused"): - xbmc.Player().pause() - break - tries-=1 - """ - - def _add_directory(self, context, directory_item, item_count=0): - art = {'icon': 'DefaultFolder.png', - 'thumb': directory_item.get_image()} - - item = xbmcgui.ListItem(label=directory_item.get_name(), offscreen=True) - - info_tag = xbmc_items.ListItemInfoTag(item, tag_type='video') - - # only set fanart is enabled - if directory_item.get_fanart() and self.settings.show_fanart(): - art['fanart'] = directory_item.get_fanart() - - - item.setArt(art) - - if directory_item.get_context_menu() is not None: - item.addContextMenuItems(directory_item.get_context_menu(), - replaceItems=directory_item.replace_context_menu()) - - info_tag.set_info(info_labels.create_from_item(directory_item)) - item.setPath(directory_item.get_uri()) - - is_folder = True - if directory_item.is_action(): - is_folder = False - item.setProperty('isPlayable', 'false') - - if directory_item.next_page: - item.setProperty('specialSort', 'bottom') - - if directory_item.get_channel_subscription_id(): # make channel_subscription_id property available for keymapping - item.setProperty('channel_subscription_id', directory_item.get_channel_subscription_id()) - - xbmcplugin.addDirectoryItem(handle=self.handle, - url=directory_item.get_uri(), - listitem=item, - isFolder=is_folder, - totalItems=item_count) - - def _add_video(self, context, video_item, item_count=0): - item = xbmc_items.to_video_item(context, video_item) - item.setPath(video_item.get_uri()) - xbmcplugin.addDirectoryItem(handle=self.handle, - url=video_item.get_uri(), - listitem=item, - totalItems=item_count) - - def _add_image(self, context, image_item, item_count): - art = {'icon': 'DefaultPicture.png', - 'thumb': image_item.get_image()} - - item = xbmcgui.ListItem(label=image_item.get_name(), offscreen=True) - - if image_item.get_fanart() and self.settings.show_fanart(): - art['fanart'] = image_item.get_fanart() - - item.setArt(art) - - if image_item.get_context_menu() is not None: - item.addContextMenuItems(image_item.get_context_menu(), replaceItems=image_item.replace_context_menu()) - - item.setInfo(type='picture', infoLabels=info_labels.create_from_item(image_item)) - - item.setPath(image_item.get_uri()) - xbmcplugin.addDirectoryItem(handle=self.handle, - url=image_item.get_uri(), - listitem=item, - totalItems=item_count) - - def _add_audio(self, context, audio_item, item_count): - item = xbmc_items.to_audio_item(context, audio_item) - item.setPath(audio_item.get_uri()) - xbmcplugin.addDirectoryItem(handle=self.handle, - url=audio_item.get_uri(), - listitem=item, - totalItems=item_count) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/__init__.py index 536a4ce380..948f32655f 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/__init__.py @@ -8,22 +8,35 @@ See LICENSES/GPL-2.0-only for more information. """ -from .utils import to_json, from_json, to_jsons +from __future__ import absolute_import, division, unicode_literals -from .uri_item import UriItem -from .base_item import BaseItem +from . import menu_items from .audio_item import AudioItem +from .base_item import BaseItem from .directory_item import DirectoryItem -from .watch_later_item import WatchLaterItem from .favorites_item import FavoritesItem -from .search_item import SearchItem +from .image_item import ImageItem from .new_search_item import NewSearchItem -from .search_history_item import SearchHistoryItem from .next_page_item import NextPageItem +from .search_history_item import SearchHistoryItem +from .search_item import SearchItem +from .uri_item import UriItem +from .utils import from_json from .video_item import VideoItem -from .image_item import ImageItem +from .watch_later_item import WatchLaterItem -__all__ = ['BaseItem', 'AudioItem', 'DirectoryItem', 'VideoItem', 'ImageItem', 'WatchLaterItem', 'FavoritesItem', - 'SearchItem', 'NewSearchItem', 'SearchHistoryItem', 'NextPageItem', 'UriItem', - 'from_json', 'to_json', 'to_jsons'] +__all__ = ('AudioItem', + 'BaseItem', + 'DirectoryItem', + 'FavoritesItem', + 'ImageItem', + 'NewSearchItem', + 'NextPageItem', + 'SearchHistoryItem', + 'SearchItem', + 'UriItem', + 'VideoItem', + 'WatchLaterItem', + 'from_json', + 'menu_items',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/audio_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/audio_item.py index 33e5c530d7..2261139402 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/audio_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/audio_item.py @@ -8,15 +8,18 @@ See LICENSES/GPL-2.0-only for more information. """ -from .base_item import BaseItem +from __future__ import absolute_import, division, unicode_literals -from html import unescape +from .base_item import BaseItem +from ..compatibility import unescape class AudioItem(BaseItem): - def __init__(self, name, uri, image=u'', fanart=u''): - BaseItem.__init__(self, name, uri, image, fanart) - self._duration = None + _playable = True + + def __init__(self, name, uri, image='', fanart=''): + super(AudioItem, self).__init__(name, uri, image, fanart) + self._duration = -1 self._track_number = None self._year = None self._genre = None diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/base_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/base_item.py index 0385052ab8..ea0cdfa5df 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/base_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/base_item.py @@ -8,18 +8,23 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals -import hashlib -import datetime +import json +from datetime import date, datetime +from hashlib import md5 -from html import unescape +from ..compatibility import string_type, unescape +from ..constants import MEDIA_PATH class BaseItem(object): VERSION = 3 INFO_DATE = 'date' # (string) iso 8601 - def __init__(self, name, uri, image=u'', fanart=u''): + _playable = False + + def __init__(self, name, uri, image='', fanart=''): self._version = BaseItem.VERSION try: @@ -29,13 +34,18 @@ def __init__(self, name, uri, image=u'', fanart=u''): self._uri = uri - self._image = u'' + self._image = None self.set_image(image) + self._fanart = None + self.set_fanart(fanart) - self._fanart = fanart self._context_menu = None self._replace_context_menu = False + self._added_utc = None + self._count = None self._date = None + self._dateadded = None + self._short_details = None self._next_page = False @@ -46,15 +56,47 @@ def __str__(self): obj_str = "------------------------------\n'%s'\nURI: %s\nImage: %s\n------------------------------" % (name, uri, image) return obj_str + def to_dict(self): + return {'type': self.__class__.__name__, 'data': self.__dict__} + + def dumps(self): + def _encoder(obj): + if isinstance(obj, (date, datetime)): + class_name = obj.__class__.__name__ + + if 'fromisoformat' in dir(obj): + return { + '__class__': class_name, + '__isoformat__': obj.isoformat(), + } + + if class_name == 'datetime': + if obj.tzinfo: + format_string = '%Y-%m-%dT%H:%M:%S%z' + else: + format_string = '%Y-%m-%dT%H:%M:%S' + else: + format_string = '%Y-%m-%d' + + return { + '__class__': class_name, + '__format_string__': format_string, + '__value__': obj.strftime(format_string) + } + + return json.JSONEncoder().default(obj) + + return json.dumps(self.to_dict(), ensure_ascii=False, default=_encoder) + def get_id(self): """ Returns a unique id of the item. :return: unique id of the item. """ - m = hashlib.md5() - m.update(self._name.encode('utf-8')) - m.update(self._uri.encode('utf-8')) - return m.hexdigest() + md5_hash = md5() + md5_hash.update(self._name.encode('utf-8')) + md5_hash.update(self._uri.encode('utf-8')) + return md5_hash.hexdigest() def get_name(self): """ @@ -64,10 +106,7 @@ def get_name(self): return self._name def set_uri(self, uri): - if isinstance(uri, str): - self._uri = uri - else: - self._uri = '' + self._uri = uri if uri and isinstance(uri, string_type) else '' def get_uri(self): """ @@ -77,8 +116,12 @@ def get_uri(self): return self._uri def set_image(self, image): - if image is None: + if not image: self._image = '' + return + + if '{media}/' in image: + self._image = image.format(media=MEDIA_PATH) else: self._image = image @@ -86,7 +129,14 @@ def get_image(self): return self._image def set_fanart(self, fanart): - self._fanart = fanart + if not fanart: + self._fanart = '{0}/fanart.jpg'.format(MEDIA_PATH) + return + + if '{media}/' in fanart: + self._fanart = fanart.format(media=MEDIA_PATH) + else: + self._fanart = fanart def get_fanart(self): return self._fanart @@ -102,16 +152,56 @@ def replace_context_menu(self): return self._replace_context_menu def set_date(self, year, month, day, hour=0, minute=0, second=0): - date = datetime.datetime(year, month, day, hour, minute, second) - self._date = date.isoformat(sep=' ') + self._date = datetime(year, month, day, hour, minute, second) def set_date_from_datetime(self, date_time): - self.set_date(year=date_time.year, month=date_time.month, day=date_time.day, hour=date_time.hour, - minute=date_time.minute, second=date_time.second) - - def get_date(self): + self._date = date_time + + def get_date(self, as_text=False, short=False): + if not self._date: + return '' + if short: + return self._date.date().strftime('%x') + if as_text: + return self._date.strftime('%x %X') return self._date + def set_dateadded(self, year, month, day, hour=0, minute=0, second=0): + self._dateadded = datetime(year, + month, + day, + hour, + minute, + second) + + def set_dateadded_from_datetime(self, date_time): + self._dateadded = date_time + + def get_dateadded(self, as_text=False): + if not self._dateadded: + return '' + if as_text: + return self._dateadded.strftime('%x %X') + return self._dateadded + + def set_added_utc(self, date_time): + self._added_utc = date_time + + def get_added_utc(self): + return self._added_utc + + def get_short_details(self): + return self._short_details + + def set_short_details(self, details): + self._short_details = details or '' + + def get_count(self): + return self._count + + def set_count(self, count): + self._count = int(count or 0) + @property def next_page(self): return self._next_page @@ -119,3 +209,7 @@ def next_page(self): @next_page.setter def next_page(self, value): self._next_page = bool(value) + + @property + def playable(cls): + return cls._playable diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/directory_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/directory_item.py index 9c3749ebd2..8f74a8793a 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/directory_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/directory_item.py @@ -8,14 +8,30 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .base_item import BaseItem +from ..compatibility import urlencode class DirectoryItem(BaseItem): - def __init__(self, name, uri, image=u'', fanart=u''): - BaseItem.__init__(self, name, uri, image, fanart) + def __init__(self, + name, + uri, + image='', + fanart='', + action=False, + category_label=None): + if category_label is None: + category_label = name + if category_label: + uri = ('&' if '?' in uri else '?').join(( + uri, + urlencode({'category_label': category_label}), + )) + super(DirectoryItem, self).__init__(name, uri, image, fanart) self._plot = self.get_name() - self._is_action = False + self._is_action = action self._channel_subscription_id = None self._channel_id = None diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/favorites_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/favorites_item.py index 462c89d5cd..580c343cad 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/favorites_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/favorites_item.py @@ -8,21 +8,25 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .directory_item import DirectoryItem -from .. import constants +from ..constants import paths class FavoritesItem(DirectoryItem): - def __init__(self, context, alt_name=None, image=None, fanart=None): - name = alt_name + def __init__(self, context, name=None, image=None, fanart=None): if not name: - name = context.localize(constants.localize.FAVORITES) + name = context.localize('favorites') if image is None: - image = context.create_resource_path('media/favorites.png') + image = '{media}/favorites.png' + + super(FavoritesItem, self).__init__(name, + context.create_uri( + [paths.FAVORITES, 'list'] + ), + image=image) - DirectoryItem.__init__(self, name, context.create_uri([constants.paths.FAVORITES, 'list']), image=image) if fanart: self.set_fanart(fanart) - else: - self.set_fanart(context.get_fanart()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/image_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/image_item.py index b0f52b252d..1edb640e69 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/image_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/image_item.py @@ -8,12 +8,14 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .base_item import BaseItem class ImageItem(BaseItem): - def __init__(self, name, uri, image=u'', fanart=u''): - BaseItem.__init__(self, name, uri, image, fanart) + def __init__(self, name, uri, image='', fanart=''): + super(ImageItem, self).__init__(name, uri, image, fanart) self._title = None def set_title(self, title): diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/menu_items.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/menu_items.py new file mode 100644 index 0000000000..24a2529196 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/menu_items.py @@ -0,0 +1,503 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from ..constants import paths + + +def more_for_video(context, video_id, logged_in=False, refresh_container=False): + return ( + context.localize('video.more'), + 'RunPlugin({0})'.format(context.create_uri( + ('video', 'more',), + { + 'video_id': video_id, + 'logged_in': logged_in, + 'refresh_container': refresh_container, + } + )) + ) + + +def related_videos(context, video_id): + return ( + context.localize('related_videos'), + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('special', 'related_videos',), + { + 'video_id': video_id, + } + )) + ) + + +def video_comments(context, video_id): + return ( + context.localize('video.comments'), + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('special', 'parent_comments',), + { + 'video_id': video_id, + } + )) + ) + + +def content_from_description(context, video_id): + return ( + context.localize('video.description.links'), + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('special', 'description_links',), + { + 'video_id': video_id, + } + )) + ) + + +def play_with(context): + return ( + context.localize('video.play.with'), + 'Action(SwitchPlayer)' + ) + + +def refresh(context): + return ( + context.localize('refresh'), + 'Container.Refresh' + ) + + +def queue_video(context): + return ( + context.localize('video.queue'), + 'Action(Queue)' + ) + + +def play_all_from_playlist(context, playlist_id, video_id=''): + if video_id: + return ( + context.localize('playlist.play.from_here'), + 'RunPlugin({0})'.format(context.create_uri( + ('play',), + { + 'playlist_id': playlist_id, + 'video_id': video_id, + 'play': True, + } + )) + ) + return ( + context.localize('playlist.play.all'), + 'RunPlugin({0})'.format(context.create_uri( + ('play',), + { + 'playlist_id': playlist_id, + 'play': True, + } + )) + ) + + +def add_video_to_playlist(context, video_id): + return ( + context.localize('video.add_to_playlist'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'select', 'playlist',), + { + 'video_id': video_id, + } + )) + ) + + +def remove_video_from_playlist(context, playlist_id, video_id, video_name): + return ( + context.localize('remove'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'remove', 'video',), + { + 'playlist_id': playlist_id, + 'video_id': video_id, + 'video_name': video_name, + } + )) + ) + + +def rename_playlist(context, playlist_id, playlist_name): + return ( + context.localize('rename'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'rename', 'playlist',), + { + 'playlist_id': playlist_id, + 'playlist_name': playlist_name + } + )) + ) + + +def delete_playlist(context, playlist_id, playlist_name): + return ( + context.localize('delete'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'remove', 'playlist',), + { + 'playlist_id': playlist_id, + 'playlist_name': playlist_name + } + )) + ) + + +def remove_as_watch_later(context, playlist_id, playlist_name): + return ( + context.localize('watch_later.list.remove'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'remove', 'watch_later',), + { + 'playlist_id': playlist_id, + 'playlist_name': playlist_name + } + )) + ) + + +def set_as_watch_later(context, playlist_id, playlist_name): + return ( + context.localize('watch_later.list.set'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'set', 'watch_later',), + { + 'playlist_id': playlist_id, + 'playlist_name': playlist_name + } + )) + ) + + +def remove_as_history(context, playlist_id, playlist_name): + return ( + context.localize('history.list.remove'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'remove', 'history',), + { + 'playlist_id': playlist_id, + 'playlist_name': playlist_name + } + )) + ) + + +def set_as_history(context, playlist_id, playlist_name): + return ( + context.localize('history.list.set'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'set', 'history',), + { + 'playlist_id': playlist_id, + 'playlist_name': playlist_name + } + )) + ) + + +def remove_my_subscriptions_filter(context, channel_name): + return ( + context.localize('my_subscriptions.filter.remove'), + 'RunPlugin({0})'.format(context.create_uri( + ('my_subscriptions', 'filter',), + { + 'channel_name': channel_name, + 'action': 'remove' + } + )) + ) + + +def add_my_subscriptions_filter(context, channel_name): + return ( + context.localize('my_subscriptions.filter.add'), + 'RunPlugin({0})'.format(context.create_uri( + ('my_subscriptions', 'filter',), + { + 'channel_name': channel_name, + 'action': 'add', + } + )) + ) + + +def rate_video(context, video_id, refresh_container=False): + return ( + context.localize('video.rate'), + 'RunPlugin({0})'.format(context.create_uri( + ('video', 'rate',), + { + 'video_id': video_id, + 'refresh_container': refresh_container, + } + )) + ) + + +def watch_later_add(context, playlist_id, video_id): + return ( + context.localize('watch_later.add'), + 'RunPlugin({0})'.format(context.create_uri( + ('playlist', 'add', 'video',), + { + 'playlist_id': playlist_id, + 'video_id': video_id, + } + )) + ) + + +def watch_later_local_add(context, item): + return ( + context.localize('watch_later.add'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.WATCH_LATER, 'add',), + { + 'video_id': item.video_id, + 'item': item.dumps(), + } + )) + ) + + +def watch_later_local_remove(context, video_id): + return ( + context.localize('watch_later.remove'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.WATCH_LATER, 'remove',), + { + 'video_id': video_id, + } + )) + ) + + +def watch_later_local_clear(context): + return ( + context.localize('watch_later.clear'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.WATCH_LATER, 'clear',) + )) + ) + + +def go_to_channel(context, channel_id, channel_name): + return ( + context.localize('go_to_channel') % context.get_ui().bold(channel_name), + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('channel', channel_id,) + )) + ) + + +def subscribe_to_channel(context, channel_id, channel_name=''): + if not channel_name: + return ( + context.localize('subscribe'), + 'RunPlugin({0})'.format(context.create_uri( + ('subscriptions', 'add',), + { + 'subscription_id': channel_id, + } + )) + ) + return ( + context.localize('subscribe_to') % context.get_ui().bold(channel_name), + 'RunPlugin({0})'.format(context.create_uri( + ('subscriptions', 'add',), + { + 'subscription_id': channel_id, + } + )) + ) + + +def unsubscribe_from_channel(context, channel_id): + return ( + context.localize('unsubscribe'), + 'RunPlugin({0})'.format(context.create_uri( + ('subscriptions', 'remove',), + { + 'subscription_id': channel_id, + } + )) + ) + + +def play_with_subtitles(context, video_id): + return ( + context.localize('video.play.with_subtitles'), + 'RunPlugin({0})'.format(context.create_uri( + ('play',), + { + 'video_id': video_id, + 'prompt_for_subtitles': True, + } + )) + ) + + +def play_audio_only(context, video_id): + return ( + context.localize('video.play.audio_only'), + 'RunPlugin({0})'.format(context.create_uri( + ('play',), + { + 'video_id': video_id, + 'audio_only': True, + } + )) + ) + + +def play_ask_for_quality(context, video_id): + return ( + context.localize('video.play.ask_for_quality'), + 'RunPlugin({0})'.format(context.create_uri( + ('play',), + { + 'video_id': video_id, + 'ask_for_quality': True, + } + )) + ) + + +def history_remove(context, video_id): + return ( + context.localize('history.remove'), + 'RunPlugin({0})'.format(context.create_uri( + [paths.HISTORY], + { + 'action': 'remove', + 'video_id': video_id + } + )) + ) + + +def history_clear(context): + return ( + context.localize('history.clear'), + 'RunPlugin({0})'.format(context.create_uri( + [paths.HISTORY], + { + 'action': 'clear' + } + )) + ) + + +def history_mark_watched(context, video_id): + return ( + context.localize('history.mark.watched'), + 'RunPlugin({0})'.format(context.create_uri( + [paths.HISTORY], + { + 'video_id': video_id, + 'action': 'mark_watched', + } + )) + ) + + +def history_mark_unwatched(context, video_id): + return ( + context.localize('history.mark.unwatched'), + 'RunPlugin({0})'.format(context.create_uri( + [paths.HISTORY], + { + 'video_id': video_id, + 'action': 'mark_unwatched', + } + )) + ) + + +def history_reset_resume(context, video_id): + return ( + context.localize('history.reset.resume_point'), + 'RunPlugin({0})'.format(context.create_uri( + [paths.HISTORY], + { + 'video_id': video_id, + 'action': 'reset_resume', + } + )) + ) + + +def favorites_add(context, item): + return ( + context.localize('favorites.add'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.FAVORITES, 'add',), + { + 'video_id': item.video_id, + 'item': item.dumps(), + } + )) + ) + + +def favorites_remove(context, video_id): + return ( + context.localize('favorites.remove'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.FAVORITES, 'remove',), + { + 'vide_id': video_id, + } + )) + ) + + +def search_remove(context, query): + return ( + context.localize('search.remove'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.SEARCH, 'remove',), + { + 'q': query, + } + )) + ) + + +def search_rename(context, query): + return ( + context.localize('search.rename'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.SEARCH, 'rename',), + { + 'q': query, + } + )) + ) + + +def search_clear(context): + return ( + context.localize('search.clear'), + 'RunPlugin({0})'.format(context.create_uri( + (paths.SEARCH, 'clear',) + )) + ) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/new_search_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/new_search_item.py index 8661f300f0..73a052e988 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/new_search_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/new_search_item.py @@ -8,31 +8,43 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .directory_item import DirectoryItem -from .. import constants +from ..constants import paths class NewSearchItem(DirectoryItem): - def __init__(self, context, alt_name=None, image=None, fanart=None, incognito=False, channel_id='', addon_id='', location=False): - name = alt_name + def __init__(self, + context, + name=None, + image=None, + fanart=None, + incognito=False, + channel_id='', + addon_id='', + location=False): if not name: - name = context.get_ui().bold(context.localize(constants.localize.SEARCH_NEW)) + name = context.get_ui().bold(context.localize('search.new')) if image is None: - image = context.create_resource_path('media/new_search.png') + image = '{media}/new_search.png' - item_params = {} + params = {} if addon_id: - item_params.update({'addon_id': addon_id}) + params['addon_id'] = addon_id if incognito: - item_params.update({'incognito': incognito}) + params['incognito'] = incognito if channel_id: - item_params.update({'channel_id': channel_id}) + params['channel_id'] = channel_id if location: - item_params.update({'location': location}) + params['location'] = location + + super(NewSearchItem, self).__init__(name, + context.create_uri( + [paths.SEARCH, 'input'], + params=params + ), image=image) - DirectoryItem.__init__(self, name, context.create_uri([constants.paths.SEARCH, 'input'], params=item_params), image=image) if fanart: self.set_fanart(fanart) - else: - self.set_fanart(context.get_fanart()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/next_page_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/next_page_item.py index 5b54b78684..556dd8aa76 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/next_page_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/next_page_item.py @@ -8,23 +8,26 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .directory_item import DirectoryItem -from .. import constants class NextPageItem(DirectoryItem): def __init__(self, context, current_page=1, image=None, fanart=None): - new_params = {} - new_params.update(context.get_params()) - new_params['page'] = str(current_page + 1) - name = context.localize(constants.localize.NEXT_PAGE, 'Next Page') - if name.find('%d') != -1: - name %= current_page + 1 - - DirectoryItem.__init__(self, name, context.create_uri(context.get_path(), new_params), image=image) + next_page = current_page + 1 + new_params = dict(context.get_params(), page=next_page) + name = context.localize('next_page') % next_page + + super(NextPageItem, self).__init__(name, + context.create_uri( + context.get_path(), + new_params + ), + image=image, + category_label=False) + if fanart: self.set_fanart(fanart) - else: - self.set_fanart(context.get_fanart()) self.next_page = True diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_history_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_history_item.py index 28242c4363..afd3496836 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_history_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_history_item.py @@ -8,29 +8,35 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + +from . import menu_items from .directory_item import DirectoryItem -from .. import constants +from ..constants import paths class SearchHistoryItem(DirectoryItem): def __init__(self, context, query, image=None, fanart=None, location=False): if image is None: - image = context.create_resource_path('media/search.png') + image = '{media}/search.png' params = {'q': query} if location: params['location'] = location - DirectoryItem.__init__(self, query, context.create_uri([constants.paths.SEARCH, 'query'], params=params), image=image) + super(SearchHistoryItem, self).__init__(query, + context.create_uri( + [paths.SEARCH, 'query'], + params=params + ), + image=image) + if fanart: self.set_fanart(fanart) - else: - self.set_fanart(context.get_fanart()) - - context_menu = [(context.localize(constants.localize.SEARCH_REMOVE), - 'RunPlugin(%s)' % context.create_uri([constants.paths.SEARCH, 'remove'], params={'q': query})), - (context.localize(constants.localize.SEARCH_RENAME), - 'RunPlugin(%s)' % context.create_uri([constants.paths.SEARCH, 'rename'], params={'q': query})), - (context.localize(constants.localize.SEARCH_CLEAR), - 'RunPlugin(%s)' % context.create_uri([constants.paths.SEARCH, 'clear']))] + + context_menu = [ + menu_items.search_remove(context, query), + menu_items.search_rename(context, query), + menu_items.search_clear(context), + ] self.set_context_menu(context_menu) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_item.py index 273da2831a..e505b67be7 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/search_item.py @@ -8,25 +8,35 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .directory_item import DirectoryItem -from .. import constants +from ..constants import paths class SearchItem(DirectoryItem): - def __init__(self, context, alt_name=None, image=None, fanart=None, location=False): - name = alt_name + def __init__(self, + context, + name=None, + image=None, + fanart=None, + location=False): if not name: - name = context.localize(constants.localize.SEARCH) + name = context.localize('search') if image is None: - image = context.create_resource_path('media/search.png') + image = '{media}/search.png' - params = dict() + params = {} if location: - params = {'location': location} + params['location'] = location + + super(SearchItem, self).__init__(name, + context.create_uri( + [paths.SEARCH, 'list'], + params=params + ), + image=image) - DirectoryItem.__init__(self, name, context.create_uri([constants.paths.SEARCH, 'list'], params=params), image=image) if fanart: self.set_fanart(fanart) - else: - self.set_fanart(context.get_fanart()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/uri_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/uri_item.py index d12a3f6624..c5926afb2c 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/uri_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/uri_item.py @@ -8,9 +8,13 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .base_item import BaseItem class UriItem(BaseItem): - def __init__(self, uri): - BaseItem.__init__(self, name=u'', uri=uri) + def __init__(self, uri, playable=None): + super(UriItem, self).__init__(name=uri, uri=uri) + if playable is not None: + self._playable = playable diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/utils.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/utils.py index db7f235a43..d277802d35 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/utils.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/utils.py @@ -8,73 +8,62 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import json +from datetime import date, datetime -from .video_item import VideoItem -from .directory_item import DirectoryItem from .audio_item import AudioItem +from .directory_item import DirectoryItem from .image_item import ImageItem +from .video_item import VideoItem +from ..compatibility import string_type +from ..utils.datetime_parser import strptime -def from_json(json_data): - """ - Creates a instance of the given json dump or dict. - :param json_data: - :return: - """ - - def _from_json(_json_data): - mapping = {'VideoItem': lambda: VideoItem(u'', u''), - 'DirectoryItem': lambda: DirectoryItem(u'', u''), - 'AudioItem': lambda: AudioItem(u'', u''), - 'ImageItem': lambda: ImageItem(u'', u'')} - - item = None - item_type = _json_data.get('type', None) - for key in mapping: - if item_type == key: - item = mapping[key]() - break - - if item is None: - return _json_data - - data = _json_data.get('data', {}) - for key in data: - if hasattr(item, key): - setattr(item, key, data[key]) +_ITEM_TYPES = { + 'AudioItem': AudioItem, + 'DirectoryItem': DirectoryItem, + 'ImageItem': ImageItem, + 'VideoItem': VideoItem, +} - return item - if isinstance(json_data, str): - json_data = json.loads(json_data) - return _from_json(json_data) +def _decoder(obj): + date_in_isoformat = obj.get('__isoformat__') + if date_in_isoformat: + if obj['__class__'] == 'date': + return date.fromisoformat(date_in_isoformat) + return datetime.fromisoformat(date_in_isoformat) + format_string = obj.get('__format_string__') + if format_string: + value = obj['__value__'] + value = strptime(value, format_string) + if obj['__class__'] == 'date': + return value.date() + return value -def to_jsons(base_item): - return json.dumps(to_json(base_item)) + return obj -def to_json(base_item): +def from_json(json_data, *_args): """ - Convert the given @base_item to json - :param base_item: - :return: json string + Creates an instance of the given json dump or dict. + :param json_data: + :return: """ + if isinstance(json_data, string_type): + json_data = json.loads(json_data, object_hook=_decoder) - def _to_json(obj): - if isinstance(obj, dict): - return obj.__dict__ - - mapping = {VideoItem: 'VideoItem', - DirectoryItem: 'DirectoryItem', - AudioItem: 'AudioItem', - ImageItem: 'ImageItem'} + item_type = json_data.get('type') + if not item_type or item_type not in _ITEM_TYPES: + return json_data - for key in mapping: - if isinstance(obj, key): - return {'type': mapping[key], 'data': obj.__dict__} + item = _ITEM_TYPES[item_type](name='', uri='') - return obj.__dict__ + for key, value in json_data.get('data', {}).items(): + if hasattr(item, key): + setattr(item, key, value) - return _to_json(base_item) + return item diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/video_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/video_item.py index 7d19fd4d95..258b6ab056 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/video_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/video_item.py @@ -8,24 +8,28 @@ See LICENSES/GPL-2.0-only for more information. """ -import re +from __future__ import absolute_import, division, unicode_literals + import datetime +import re from .base_item import BaseItem +from ..compatibility import unescape +from ..utils import duration_to_seconds, seconds_to_duration -from html import unescape __RE_IMDB__ = re.compile(r'(http(s)?://)?www.imdb.(com|de)/title/(?P[t0-9]+)(/)?') class VideoItem(BaseItem): - def __init__(self, name, uri, image=u'', fanart=u''): - BaseItem.__init__(self, name, uri, image, fanart) + _playable = True + + def __init__(self, name, uri, image='', fanart=''): + super(VideoItem, self).__init__(name, uri, image, fanart) self._genre = None self._aired = None - self._aired_utc = None self._scheduled_start_utc = None - self._duration = None + self._duration = -1 self._director = None self._premiered = None self._episode = None @@ -40,12 +44,13 @@ def __init__(self, name, uri, image=u'', fanart=u''): self._studio = None self._artist = None self._play_count = None - self._uses_mpd = None + self._uses_isa = None self._mediatype = None self._last_played = None self._start_percent = None self._start_time = None self._live = False + self._upcoming = False self.subtitles = None self._headers = None self.license_key = None @@ -54,9 +59,10 @@ def __init__(self, name, uri, image=u'', fanart=u''): self._subscription_id = None self._playlist_id = None self._playlist_item_id = None + self._production_code = None def set_play_count(self, play_count): - self._play_count = int(play_count) + self._play_count = int(play_count or 0) def get_play_count(self): return self._play_count @@ -103,13 +109,16 @@ def get_year(self): return self._year def set_premiered(self, year, month, day): - date = datetime.date(year, month, day) - self._premiered = date.isoformat() + self._premiered = datetime.date(year, month, day) def set_premiered_from_datetime(self, date_time): - self.set_premiered(year=date_time.year, month=date_time.month, day=date_time.day) + self._premiered = date_time.date() - def get_premiered(self): + def get_premiered(self, as_text=True): + if not self._premiered: + return '' + if as_text: + return self._premiered.strftime('%x') return self._premiered def set_plot(self, plot): @@ -165,36 +174,39 @@ def set_season(self, season): def get_season(self): return self._season - def set_duration(self, hours, minutes, seconds=0): - _seconds = seconds - _seconds += minutes * 60 - _seconds += hours * 60 * 60 - self.set_duration_from_seconds(_seconds) + def set_duration(self, hours=0, minutes=0, seconds=0, duration=''): + if duration: + _seconds = duration_to_seconds(duration) + else: + _seconds = seconds + minutes * 60 + hours * 3600 + self._duration = _seconds or 0 def set_duration_from_minutes(self, minutes): - self.set_duration_from_seconds(int(minutes) * 60) + self._duration = int(minutes) * 60 def set_duration_from_seconds(self, seconds): - self._duration = int(seconds) + self._duration = int(seconds or 0) - def get_duration(self): + def get_duration(self, as_text=False): + if as_text: + return seconds_to_duration(self._duration) return self._duration def set_aired(self, year, month, day): - date = datetime.date(year, month, day) - self._aired = date.isoformat() - - def set_aired_utc(self, dt): - self._aired_utc = dt - - def get_aired_utc(self): - return self._aired_utc + self._aired = datetime.date(year, month, day) def set_aired_from_datetime(self, date_time): - self.set_aired(year=date_time.year, month=date_time.month, day=date_time.day) + self._aired = date_time.date() - def set_scheduled_start_utc(self, dt): - self._scheduled_start_utc = dt + def get_aired(self, as_text=True): + if not self._aired: + return '' + if as_text: + return self._aired.strftime('%x') + return self._aired + + def set_scheduled_start_utc(self, date_time): + self._scheduled_start_utc = date_time def get_scheduled_start_utc(self): return self._scheduled_start_utc @@ -207,8 +219,13 @@ def live(self): def live(self, value): self._live = value - def get_aired(self): - return self._aired + @property + def upcoming(self): + return self._upcoming + + @upcoming.setter + def upcoming(self, value): + self._upcoming = value def set_genre(self, genre): self._genre = genre @@ -216,33 +233,38 @@ def set_genre(self, genre): def get_genre(self): return self._genre - def set_date(self, year, month, day, hour=0, minute=0, second=0): - date = datetime.datetime(year, month, day, hour, minute, second) - self._date = date.isoformat(sep=' ') + def set_isa_video(self, value=True): + self._uses_isa = value - def set_date_from_datetime(self, date_time): - self.set_date(year=date_time.year, month=date_time.month, day=date_time.day, hour=date_time.hour, - minute=date_time.minute, second=date_time.second) + def use_isa_video(self): + return self._uses_isa - def get_date(self): - return self._date - - def set_use_mpd_video(self, value=True): - self._uses_mpd = value + def use_hls_video(self): + uri = self.get_uri() + if 'manifest/hls' in uri or uri.endswith('.m3u8'): + return True + return False def use_mpd_video(self): - return self._uses_mpd is True and ('manifest/dash' in self.get_uri() or self.get_uri().endswith('.mpd')) + uri = self.get_uri() + if 'manifest/dash' in uri or uri.endswith('.mpd'): + return True + return False def set_mediatype(self, mediatype): self._mediatype = mediatype def get_mediatype(self): - if self._mediatype not in ['video', 'movie', 'tvshow', 'season', 'episode', 'musicvideo']: + if (self._mediatype not in {'video', + 'movie', + 'tvshow', 'season', 'episode', + 'musicvideo'}): self._mediatype = 'video' return self._mediatype def set_subtitles(self, value): - self.subtitles = value if value and isinstance(value, list) else None + if value and isinstance(value, (list, tuple)): + self.subtitles = value def set_headers(self, value): self._headers = value @@ -263,13 +285,13 @@ def get_last_played(self): return self._last_played def set_start_percent(self, start_percent): - self._start_percent = start_percent + self._start_percent = start_percent or 0 def get_start_percent(self): return self._start_percent def set_start_time(self, start_time): - self._start_time = start_time + self._start_time = start_time or 0 def get_start_time(self): return self._start_time @@ -305,3 +327,9 @@ def get_playlist_item_id(self): def set_playlist_item_id(self, value): self._playlist_item_id = value + + def get_code(self): + return self._production_code + + def set_code(self, value): + self._production_code = value or '' diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/watch_later_item.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/watch_later_item.py index b896fdecb7..6ae79116e6 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/watch_later_item.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/items/watch_later_item.py @@ -8,21 +8,25 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .directory_item import DirectoryItem -from .. import constants +from ..constants import paths class WatchLaterItem(DirectoryItem): - def __init__(self, context, alt_name=None, image=None, fanart=None): - name = alt_name + def __init__(self, context, name=None, image=None, fanart=None): if not name: - name = context.localize(constants.localize.WATCH_LATER) + name = context.localize('watch_later') if image is None: - image = context.create_resource_path('media/watch_later.png') + image = '{media}/watch_later.png' + + super(WatchLaterItem, self).__init__(name, + context.create_uri( + [paths.WATCH_LATER, 'list'] + ), + image=image) - DirectoryItem.__init__(self, name, context.create_uri([constants.paths.WATCH_LATER, 'list']), image=image) if fanart: self.set_fanart(fanart) - else: - self.set_fanart(context.get_fanart()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/__init__.py index d4ff961a5e..0e05ebe418 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/__init__.py @@ -7,9 +7,10 @@ See LICENSES/GPL-2.0-only for more information. """ -from .json_store import JSONStore +from __future__ import absolute_import, division, unicode_literals + +from .access_manager import AccessManager from .api_keys import APIKeyStore -from .login_tokens import LoginTokenStore -__all__ = ['JSONStore', 'APIKeyStore', 'LoginTokenStore'] +__all__ = ('AccessManager', 'APIKeyStore',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/access_manager.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/access_manager.py new file mode 100644 index 0000000000..fc723091d2 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/access_manager.py @@ -0,0 +1,602 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +import time +import uuid +from hashlib import md5 + +from .json_store import JSONStore +from ..constants import ADDON_ID + + +__author__ = 'bromix' + + +class AccessManager(JSONStore): + DEFAULT_NEW_USER = { + 'access_token': '', + 'refresh_token': '', + 'token_expires': -1, + 'last_key_hash': '', + 'name': 'Default', + 'watch_later': 'WL', + 'watch_history': 'HL' + } + + def __init__(self, context): + super(AccessManager, self).__init__('access_manager.json') + self._settings = context.get_settings() + access_manager_data = self._data['access_manager'] + self._user = access_manager_data.get('current_user', 0) + self._last_origin = access_manager_data.get('last_origin', ADDON_ID) + + def set_defaults(self, reset=False): + data = {} if reset else self.get_data() + if 'access_manager' not in data: + data = { + 'access_manager': { + 'users': { + 0: self.DEFAULT_NEW_USER.copy() + } + } + } + if 'users' not in data['access_manager']: + data['access_manager']['users'] = { + 0: self.DEFAULT_NEW_USER.copy() + } + if 0 not in data['access_manager']['users']: + data['access_manager']['users'][0] = self.DEFAULT_NEW_USER.copy() + if 'current_user' not in data['access_manager']: + data['access_manager']['current_user'] = 0 + if 'last_origin' not in data['access_manager']: + data['access_manager']['last_origin'] = ADDON_ID + if 'developers' not in data['access_manager']: + data['access_manager']['developers'] = {} + + # clean up + if data['access_manager']['current_user'] == 'default': + data['access_manager']['current_user'] = 0 + if 'access_token' in data['access_manager']: + del data['access_manager']['access_token'] + if 'refresh_token' in data['access_manager']: + del data['access_manager']['refresh_token'] + if 'token_expires' in data['access_manager']: + del data['access_manager']['token_expires'] + if 'default' in data['access_manager']: + if ((data['access_manager']['default'].get('access_token') + or data['access_manager']['default'].get('refresh_token')) + and not data['access_manager']['users'][0].get( + 'access_token') + and not data['access_manager']['users'][0].get( + 'refresh_token')): + if 'name' not in data['access_manager']['default']: + data['access_manager']['default']['name'] = 'Default' + data['access_manager']['users'][0] = data['access_manager'][ + 'default'] + del data['access_manager']['default'] + # end clean up + + current_user = data['access_manager']['current_user'] + if 'watch_later' not in data['access_manager']['users'][current_user]: + data['access_manager']['users'][current_user]['watch_later'] = 'WL' + if 'watch_history' not in data['access_manager']['users'][current_user]: + data['access_manager']['users'][current_user][ + 'watch_history'] = 'HL' + + # ensure all users have uuid + uuids = set() + for user in data['access_manager']['users'].values(): + c_uuid = user.get('id') + while not c_uuid or c_uuid in uuids: + c_uuid = uuid.uuid4().hex + uuids.add(c_uuid) + user['id'] = c_uuid + # end uuid check + + self.save(data) + + @staticmethod + def _process_data(data): + # process users, change str keys (old format) to int (current format) + users = data['access_manager']['users'] + if '0' in users: + data['access_manager']['users'] = { + int(key): value + for key, value in users.items() + } + current_user = data['access_manager']['current_user'] + try: + data['access_manager']['current_user'] = int(current_user) + except (TypeError, ValueError): + pass + return data + + def get_data(self, process=_process_data.__func__): + return super(AccessManager, self).get_data(process) + + def load(self, process=_process_data.__func__): + return super(AccessManager, self).load(process) + + def save(self, data, update=False, process=_process_data.__func__): + return super(AccessManager, self).save(data, update, process) + + def get_current_user_details(self): + """ + :return: current user + """ + return self.get_users()[self._user] + + def get_current_user_id(self): + """ + :return: uuid of the current user + """ + return self.get_users()[self._user]['id'] + + def get_new_user(self, username=''): + """ + :param username: string, users name + :return: a new user dict + """ + uuids = [ + user.get('id') + for user in self.get_users().values() + ] + new_uuid = None + while not new_uuid or new_uuid in uuids: + new_uuid = uuid.uuid4().hex + return { + 'access_token': '', + 'refresh_token': '', + 'token_expires': -1, + 'last_key_hash': '', + 'name': username, + 'id': new_uuid, + 'watch_later': 'WL', + 'watch_history': 'HL' + } + + def get_users(self): + """ + Returns users + :return: users + """ + return self._data['access_manager'].get('users', {}) + + def add_user(self, username='', user=None): + """ + Add single new user to users collection + :param username: str, chosen name of new user + :param user: int, optional index for new user + :return: tuple, (index, details) of newly added user + """ + users = self.get_users() + new_user_details = self.get_new_user(username) + new_user = max(users) + 1 if users and user is None else user or 0 + data = { + 'access_manager': { + 'users': { + new_user: new_user_details, + }, + }, + } + self.save(data, update=True) + return new_user, new_user_details + + def remove_user(self, user): + """ + Remove user from collection of current users + :param user: int, user index + :return: + """ + users = self.get_users() + if user in users: + data = { + 'access_manager': { + 'users': { + user: KeyError, + }, + }, + } + self.save(data, update=True) + + def set_users(self, users): + """ + Updates all users + :param users: dict, users + :return: + """ + data = self.get_data() + data['access_manager']['users'] = users + self.save(data) + + def set_user(self, user, switch_to=False): + """ + Updates the user + :param user: string, username + :param switch_to: boolean, change current user + :return: + """ + try: + user = int(user) + except (TypeError, ValueError): + pass + + self._user = user + if switch_to: + data = { + 'access_manager': { + 'current_user': user, + }, + } + self.save(data, update=True) + + def get_current_user(self): + """ + Returns the current user + :return: user + """ + return self._user + + def get_username(self, user=None): + """ + Returns the username of the current or nominated user + :return: username + """ + if user is None: + user = self._user + users = self.get_users() + if user in users: + return users[user].get('name') + return '' + + def set_username(self, user, username): + """ + Sets the username of the nominated user + :return: True if username was set, false otherwise + """ + users = self.get_users() + if user in users: + data = { + 'access_manager': { + 'users': { + user: { + 'name': username, + }, + }, + }, + } + self.save(data, update=True) + return True + return False + + def get_watch_later_id(self): + """ + Returns the current users watch later playlist id + :return: the current users watch later playlist id + """ + current_user = self.get_current_user_details() + current_id = current_user.get('watch_later', 'WL') + settings_id = self._settings.get_watch_later_playlist() + + if settings_id and current_id != settings_id: + current_id = self.set_watch_later_id(settings_id) + + if current_id and current_id.lower().strip() == 'wl': + return '' + return current_id + + def set_watch_later_id(self, playlist_id): + """ + Sets the current users watch later playlist id + :param playlist_id: string, watch later playlist id + :return: + """ + if playlist_id.lower().strip() == 'wl': + playlist_id = '' + + self._settings.set_watch_later_playlist(playlist_id) + data = { + 'access_manager': { + 'users': { + self._user: { + 'watch_later': playlist_id, + }, + }, + }, + } + self.save(data, update=True) + return playlist_id + + def get_watch_history_id(self): + """ + Returns the current users watch history playlist id + :return: the current users watch history playlist id + """ + current_user = self.get_current_user_details() + current_id = current_user.get('watch_history', 'HL') + settings_id = self._settings.get_history_playlist() + + if settings_id and current_id != settings_id: + current_id = self.set_watch_history_id(settings_id) + + if current_id and current_id.lower().strip() == 'hl': + return '' + return current_id + + def set_watch_history_id(self, playlist_id): + """ + Sets the current users watch history playlist id + :param playlist_id: string, watch history playlist id + :return: + """ + if playlist_id.lower().strip() == 'hl': + playlist_id = '' + + self._settings.set_history_playlist(playlist_id) + data = { + 'access_manager': { + 'users': { + self._user: { + 'watch_history': playlist_id, + }, + }, + }, + } + self.save(data, update=True) + return playlist_id + + def set_last_origin(self, origin): + """ + Updates the origin + :param origin: string, origin + :return: + """ + self._last_origin = origin + data = { + 'access_manager': { + 'last_origin': origin, + }, + } + self.save(data, update=True) + + def get_last_origin(self): + """ + Returns the last origin + :return: + """ + return self._last_origin + + def get_access_token(self): + """ + Returns the access token for some API + :return: access_token + """ + return self.get_current_user_details().get('access_token', '') + + def get_refresh_token(self): + """ + Returns the refresh token + :return: refresh token + """ + return self.get_current_user_details().get('refresh_token', '') + + def has_refresh_token(self): + return self.get_refresh_token() != '' + + def is_access_token_expired(self): + """ + Returns True if the access_token is expired otherwise False. + If no expiration date was provided and an access_token exists + this method will always return True + :return: + """ + current_user = self.get_current_user_details() + access_token = current_user.get('access_token', '') + expires = int(current_user.get('token_expires', -1)) + + # with no access_token it must be expired + if not access_token: + return True + + # in this case no expiration date was set + if expires == -1: + return False + + now = int(time.time()) + return expires <= now + + def update_access_token(self, + access_token, + unix_timestamp=None, + refresh_token=None): + """ + Updates the old access token with the new one. + :param access_token: + :param unix_timestamp: + :param refresh_token: + :return: + """ + current_user = { + 'access_token': access_token, + } + + if unix_timestamp is not None: + current_user['token_expires'] = int(unix_timestamp) + + if refresh_token is not None: + current_user['refresh_token'] = refresh_token + + data = { + 'access_manager': { + 'users': { + self._user: current_user, + }, + }, + } + self.save(data, update=True) + + def set_last_key_hash(self, key_hash): + data = { + 'access_manager': { + 'users': { + self._user: { + 'last_key_hash': key_hash, + }, + }, + }, + } + self.save(data, update=True) + + @staticmethod + def get_new_developer(): + """ + :return: a new developer dict + """ + return { + 'access_token': '', + 'refresh_token': '', + 'token_expires': -1, + 'last_key_hash': '' + } + + def get_developers(self): + """ + Returns developers + :return: dict, developers + """ + return self._data['access_manager'].get('developers', {}) + + def get_developer(self, addon_id): + return self.get_developers().get(addon_id, {}) + + def set_developers(self, developers): + """ + Updates the users + :param developers: dict, developers + :return: + """ + data = self.get_data() + data['access_manager']['developers'] = developers + self.save(data) + + def get_dev_access_token(self, addon_id): + """ + Returns the access token for some API + :param addon_id: addon id + :return: access_token + """ + return self.get_developer(addon_id).get('access_token', '') + + def get_dev_refresh_token(self, addon_id): + """ + Returns the refresh token + :return: refresh token + """ + return self.get_developer(addon_id).get('refresh_token', '') + + def developer_has_refresh_token(self, addon_id): + return self.get_dev_refresh_token(addon_id) != '' + + def is_dev_access_token_expired(self, addon_id): + """ + Returns True if the access_token is expired otherwise False. + If no expiration date was provided and an access_token exists + this method will always return True + :return: + """ + developer = self.get_developer(addon_id) + access_token = developer.get('access_token', '') + expires = int(developer.get('token_expires', -1)) + + # with no access_token it must be expired + if not access_token: + return True + + # in this case no expiration date was set + if expires == -1: + return False + + now = int(time.time()) + return expires <= now + + def update_dev_access_token(self, + addon_id, + access_token, + unix_timestamp=None, + refresh_token=None): + """ + Updates the old access token with the new one. + :param addon_id: + :param access_token: + :param unix_timestamp: + :param refresh_token: + :return: + """ + developer = { + 'access_token': access_token + } + + if unix_timestamp is not None: + developer['token_expires'] = int(unix_timestamp) + + if refresh_token is not None: + developer['refresh_token'] = refresh_token + + data = { + 'access_manager': { + 'developers': { + addon_id: developer, + }, + }, + } + self.save(data, update=True) + + def get_dev_last_key_hash(self, addon_id): + return self.get_developer(addon_id).get('last_key_hash', '') + + def set_dev_last_key_hash(self, addon_id, key_hash): + data = { + 'access_manager': { + 'developers': { + addon_id: { + 'last_key_hash': key_hash, + }, + }, + }, + } + self.save(data, update=True) + + def dev_keys_changed(self, addon_id, api_key, client_id, client_secret): + last_hash = self.get_dev_last_key_hash(addon_id) + current_hash = self.calc_key_hash(api_key, client_id, client_secret) + + if not last_hash and current_hash: + self.set_dev_last_key_hash(addon_id, current_hash) + return False + + if last_hash != current_hash: + self.set_dev_last_key_hash(addon_id, current_hash) + return True + + return False + + @staticmethod + def calc_key_hash(key, id, secret): + md5_hash = md5() + try: + md5_hash.update(key.encode('utf-8')) + md5_hash.update(id.encode('utf-8')) + md5_hash.update(secret.encode('utf-8')) + except: + md5_hash.update(key) + md5_hash.update(id) + md5_hash.update(secret) + + return md5_hash.hexdigest() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/api_keys.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/api_keys.py index 0faf0aa376..488ea8ab42 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/api_keys.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/api_keys.py @@ -7,15 +7,17 @@ See LICENSES/GPL-2.0-only for more information. """ -from . import JSONStore +from __future__ import absolute_import, division, unicode_literals + +from .json_store import JSONStore class APIKeyStore(JSONStore): def __init__(self): - JSONStore.__init__(self, 'api_keys.json') + super(APIKeyStore, self).__init__('api_keys.json') - def set_defaults(self): - data = self.get_data() + def set_defaults(self, reset=False): + data = {} if reset else self.get_data() if 'keys' not in data: data = {'keys': {'personal': {'api_key': '', 'client_id': '', 'client_secret': ''}, 'developer': {}}} if 'personal' not in data['keys']: diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/json_store.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/json_store.py index 3d071a7b93..baede56d44 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/json_store.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/json_store.py @@ -7,79 +7,102 @@ See LICENSES/GPL-2.0-only for more information. """ -import os -import json -from copy import deepcopy - -import xbmcaddon -import xbmcvfs -import xbmc +from __future__ import absolute_import, division, unicode_literals -from .. import logger +import json +import os +from io import open -try: - xbmc.translatePath = xbmcvfs.translatePath -except AttributeError: - pass +from ..constants import DATA_PATH +from ..logger import log_debug, log_error +from ..utils import make_dirs, merge_dicts, to_unicode class JSONStore(object): - def __init__(self, filename): - addon_id = 'plugin.video.youtube' - addon = xbmcaddon.Addon(addon_id) - - try: - self.base_path = xbmc.translatePath(addon.getAddonInfo('profile')).decode('utf-8') - except AttributeError: - self.base_path = xbmc.translatePath(addon.getAddonInfo('profile')) + BASE_PATH = make_dirs(DATA_PATH) - self.filename = os.path.join(self.base_path, filename) + def __init__(self, filename): + if self.BASE_PATH: + self.filename = os.path.join(self.BASE_PATH, filename) + else: + log_error('JSONStore.__init__ - unable to access temp directory') + self.filename = None - self._data = None + self._data = {} self.load() self.set_defaults() - def set_defaults(self): + def set_defaults(self, reset=False): raise NotImplementedError - def save(self, data): - if data != self._data: - self._data = deepcopy(data) - if not xbmcvfs.exists(self.base_path): - if not self.make_dirs(self.base_path): - logger.log_debug('JSONStore Save |{filename}| failed to create directories.'.format(filename=self.filename.encode("utf-8"))) - return - with open(self.filename, 'w') as jsonfile: - logger.log_debug('JSONStore Save |{filename}|'.format(filename=self.filename.encode("utf-8"))) - json.dump(self._data, jsonfile, indent=4, sort_keys=True) - - def load(self): - if xbmcvfs.exists(self.filename) and xbmcvfs.Stat(self.filename).st_size() > 0: - with open(self.filename, 'r') as jsonfile: - data = json.load(jsonfile) - self._data = data - logger.log_debug('JSONStore Load |{filename}|'.format(filename=self.filename.encode("utf-8"))) - else: - self._data = dict() - - def get_data(self): - return deepcopy(self._data) - - @staticmethod - def make_dirs(path): - if not path.endswith('/'): - path = ''.join([path, '/']) - path = xbmc.translatePath(path) - if not xbmcvfs.exists(path): - try: - _ = xbmcvfs.mkdirs(path) - except: - pass - if not xbmcvfs.exists(path): - try: - os.makedirs(path) - except: - pass - return xbmcvfs.exists(path) - - return True + def save(self, data, update=False, process=None): + if not self.filename: + return + + if update: + data = merge_dicts(self._data, data) + if data == self._data: + log_debug('JSONStore.save - data unchanged:\n|{filename}|'.format( + filename=self.filename + )) + return + log_debug('JSONStore.save - saving:\n|{filename}|'.format( + filename=self.filename + )) + try: + if not data: + raise ValueError + _data = json.loads(json.dumps(data, ensure_ascii=False)) + with open(self.filename, mode='w', encoding='utf-8') as jsonfile: + jsonfile.write(to_unicode(json.dumps(_data, + ensure_ascii=False, + indent=4, + sort_keys=True))) + self._data = process(_data) if process is not None else _data + except (IOError, OSError): + log_error('JSONStore.save - access error:\n|{filename}|'.format( + filename=self.filename + )) + return + except (TypeError, ValueError): + log_error('JSONStore.save - invalid data:\n|{data}|'.format( + data=data + )) + self.set_defaults(reset=True) + + def load(self, process=None): + if not self.filename: + return + + log_debug('JSONStore.load - loading:\n|{filename}|'.format( + filename=self.filename + )) + try: + with open(self.filename, mode='r', encoding='utf-8') as jsonfile: + data = jsonfile.read() + if not data: + raise ValueError + _data = json.loads(data) + self._data = process(_data) if process is not None else _data + except (IOError, OSError): + log_error('JSONStore.load - access error:\n|{filename}|'.format( + filename=self.filename + )) + except (TypeError, ValueError): + log_error('JSONStore.load - invalid data:\n|{data}|'.format( + data=data + )) + + def get_data(self, process=None): + try: + if not self._data: + raise ValueError + _data = json.loads(json.dumps(self._data, ensure_ascii=False)) + return process(_data) if process is not None else _data + except (TypeError, ValueError): + log_error('JSONStore.get_data - invalid data:\n|{data}|'.format( + data=self._data + )) + self.set_defaults(reset=True) + _data = json.loads(json.dumps(self._data, ensure_ascii=False)) + return process(_data) if process is not None else _data diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/login_tokens.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/login_tokens.py deleted file mode 100644 index e575ae3ff8..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/json_store/login_tokens.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import uuid -from . import JSONStore - - -# noinspection PyTypeChecker -class LoginTokenStore(JSONStore): - def __init__(self): - JSONStore.__init__(self, 'access_manager.json') - - def set_defaults(self): - data = self.get_data() - if 'access_manager' not in data: - data = {'access_manager': {'users': {'0': {'access_token': '', 'refresh_token': '', 'token_expires': -1, - 'last_key_hash': '', 'name': 'Default', 'watch_later': ' WL', 'watch_history': 'HL'}}}} - if 'users' not in data['access_manager']: - data['access_manager']['users'] = {'0': {'access_token': '', 'refresh_token': '', 'token_expires': -1, - 'last_key_hash': '', 'name': 'Default', 'watch_later': ' WL', 'watch_history': 'HL'}} - if '0' not in data['access_manager']['users']: - data['access_manager']['users']['0'] = {'access_token': '', 'refresh_token': '', 'token_expires': -1, - 'last_key_hash': '', 'name': 'Default', 'watch_later': ' WL', 'watch_history': 'HL'} - if 'current_user' not in data['access_manager']: - data['access_manager']['current_user'] = '0' - if 'last_origin' not in data['access_manager']: - data['access_manager']['last_origin'] = 'plugin.video.youtube' - if 'developers' not in data['access_manager']: - data['access_manager']['developers'] = dict() - - # clean up - if data['access_manager']['current_user'] == 'default': - data['access_manager']['current_user'] = '0' - if 'access_token' in data['access_manager']: - del data['access_manager']['access_token'] - if 'refresh_token' in data['access_manager']: - del data['access_manager']['refresh_token'] - if 'token_expires' in data['access_manager']: - del data['access_manager']['token_expires'] - if 'default' in data['access_manager']: - if (data['access_manager']['default'].get('access_token') or - data['access_manager']['default'].get('refresh_token')) and \ - (not data['access_manager']['users']['0'].get('access_token') and - not data['access_manager']['users']['0'].get('refresh_token')): - if 'name' not in data['access_manager']['default']: - data['access_manager']['default']['name'] = 'Default' - data['access_manager']['users']['0'] = data['access_manager']['default'] - del data['access_manager']['default'] - # end clean up - - current_user = data['access_manager']['current_user'] - if 'watch_later' not in data['access_manager']['users'][current_user]: - data['access_manager']['users'][current_user]['watch_later'] = ' WL' - if 'watch_history' not in data['access_manager']['users'][current_user]: - data['access_manager']['users'][current_user]['watch_history'] = 'HL' - - # ensure all users have uuid - uuids = list() - uuid_update = False - for k in list(data['access_manager']['users'].keys()): - c_uuid = data['access_manager']['users'][k].get('id') - if c_uuid: - uuids.append(c_uuid) - else: - if not uuid_update: - uuid_update = True - - if uuid_update: - for k in list(data['access_manager']['users'].keys()): - c_uuid = data['access_manager']['users'][k].get('id') - if not c_uuid: - g_uuid = uuid.uuid4().hex - while g_uuid in uuids: - g_uuid = uuid.uuid4().hex - data['access_manager']['users'][k]['id'] = g_uuid - # end uuid check - - self.save(data) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/logger.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/logger.py index df709b3bb9..b3968f2d60 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/logger.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/logger.py @@ -8,43 +8,44 @@ See LICENSES/GPL-2.0-only for more information. """ -import xbmc -import xbmcaddon +from __future__ import absolute_import, division, unicode_literals + +from .compatibility import xbmc, xbmcaddon +from .constants import ADDON_ID + DEBUG = xbmc.LOGDEBUG INFO = xbmc.LOGINFO -NOTICE = INFO +NOTICE = xbmc.LOGNOTICE WARNING = xbmc.LOGWARNING ERROR = xbmc.LOGERROR FATAL = xbmc.LOGFATAL -SEVERE = FATAL +SEVERE = xbmc.LOGSEVERE NONE = xbmc.LOGNONE -_ADDON_ID = 'plugin.video.youtube' - -def log(text, log_level=DEBUG, addon_id=_ADDON_ID): +def log(text, log_level=DEBUG, addon_id=ADDON_ID): if not addon_id: addon_id = xbmcaddon.Addon().getAddonInfo('id') log_line = '[%s] %s' % (addon_id, text) xbmc.log(msg=log_line, level=log_level) -def log_debug(text, addon_id=_ADDON_ID): +def log_debug(text, addon_id=ADDON_ID): log(text, DEBUG, addon_id) -def log_info(text, addon_id=_ADDON_ID): +def log_info(text, addon_id=ADDON_ID): log(text, INFO, addon_id) -def log_notice(text, addon_id=_ADDON_ID): +def log_notice(text, addon_id=ADDON_ID): log(text, NOTICE, addon_id) -def log_warning(text, addon_id=_ADDON_ID): +def log_warning(text, addon_id=ADDON_ID): log(text, WARNING, addon_id) -def log_error(text, addon_id=_ADDON_ID): +def log_error(text, addon_id=ADDON_ID): log(text, ERROR, addon_id) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/__init__.py new file mode 100644 index 0000000000..45fe2ae783 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .http_server import get_client_ip_address, get_http_server, is_httpd_live +from .ip_api import Locator +from .requests import BaseRequestsClass, InvalidJSONError + + +__all__ = ( + 'get_client_ip_address', + 'get_http_server', + 'is_httpd_live', + 'BaseRequestsClass', + 'InvalidJSONError', + 'Locator', +) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/http_server.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/http_server.py new file mode 100644 index 0000000000..aa20ca1767 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -0,0 +1,575 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import json +import os +import re +from io import open +from socket import error as socket_error +from textwrap import dedent + +from .requests import BaseRequestsClass +from ..compatibility import ( + BaseHTTPServer, + parse_qs, + urlsplit, + xbmc, + xbmcaddon, + xbmcgui, + xbmcvfs, +) +from ..constants import ADDON_ID, TEMP_PATH, paths +from ..logger import log_debug, log_error +from ..settings import Settings + + +_addon = xbmcaddon.Addon(ADDON_ID) +_settings = Settings(_addon) +_i18n = _addon.getLocalizedString +_addon_name = _addon.getAddonInfo('name') +_addon_icon = _addon.getAddonInfo('icon') +del _addon + +_server_requests = BaseRequestsClass() + + +class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler, object): + BASE_PATH = xbmcvfs.translatePath(TEMP_PATH) + chunk_size = 1024 * 64 + local_ranges = ( + '10.', + '172.16.', + '192.168.', + '127.0.0.1', + 'localhost', + '::1', + ) + + def __init__(self, *args, **kwargs): + self.whitelist_ips = _settings.httpd_whitelist() + super(RequestHandler, self).__init__(*args, **kwargs) + + def connection_allowed(self): + client_ip = self.client_address[0] + log_lines = ['HTTPServer: Connection from |%s|' % client_ip] + conn_allowed = client_ip.startswith(self.local_ranges) + log_lines.append('Local range: |%s|' % str(conn_allowed)) + if not conn_allowed: + conn_allowed = client_ip in self.whitelist_ips + log_lines.append('Whitelisted: |%s|' % str(conn_allowed)) + + if not conn_allowed: + log_debug('HTTPServer: Connection from |{client_ip| not allowed'. + format(client_ip=client_ip)) + elif self.path != paths.PING: + log_debug(' '.join(log_lines)) + return conn_allowed + + # noinspection PyPep8Naming + def do_GET(self): + api_config_enabled = _settings.api_config_page() + + # Strip trailing slash if present + stripped_path = self.path.rstrip('/') + if stripped_path != paths.PING: + log_debug('HTTPServer: GET uri path |{path}|'.format(path=self.path)) + + if not self.connection_allowed(): + self.send_error(403) + + elif stripped_path == paths.IP: + client_json = json.dumps({"ip": "{ip}" + .format(ip=self.client_address[0])}) + self.send_response(200) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(client_json))) + self.end_headers() + self.wfile.write(client_json.encode('utf-8')) + + elif self.path.startswith(paths.MPD): + filepath = os.path.join(self.BASE_PATH, self.path[len(paths.MPD):]) + file_chunk = True + log_debug('HTTPServer: GET filepath |{path}|'.format(path=filepath)) + try: + with open(filepath, 'rb') as f: + self.send_response(200) + self.send_header('Content-Type', 'application/dash+xml') + self.send_header('Content-Length', + str(os.path.getsize(filepath))) + self.end_headers() + while file_chunk: + file_chunk = f.read(self.chunk_size) + if file_chunk: + self.wfile.write(file_chunk) + except IOError: + response = ('File Not Found: |{path}| -> |{filepath}|' + .format(path=self.path, filepath=filepath)) + self.send_error(404, response) + + elif api_config_enabled and stripped_path == paths.API: + html = self.api_config_page() + html = html.encode('utf-8') + + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(html))) + self.end_headers() + + for chunk in self.get_chunks(html): + self.wfile.write(chunk) + + elif api_config_enabled and self.path.startswith(paths.API_SUBMIT): + xbmc.executebuiltin('Dialog.Close(addonsettings, true)') + + query = urlsplit(self.path).query + params = parse_qs(query) + updated = [] + + api_key = params.get('api_key', [None])[0] + api_id = params.get('api_id', [None])[0] + api_secret = params.get('api_secret', [None])[0] + # Bookmark this page + footer = _i18n(30638) if api_key and api_id and api_secret else '' + + if re.search(r'api_key=(?:&|$)', query): + api_key = '' + if re.search(r'api_id=(?:&|$)', query): + api_id = '' + if re.search(r'api_secret=(?:&|$)', query): + api_secret = '' + + if api_key is not None and api_key != _settings.api_key(): + _settings.api_key(new_key=api_key) + updated.append(_i18n(30201)) # API Key + + if api_id is not None and api_id != _settings.api_id(): + _settings.api_id(new_id=api_id) + updated.append(_i18n(30202)) # API ID + + if api_secret is not None and api_secret != _settings.api_secret(): + _settings.api_secret(new_secret=api_secret) + updated.append(_i18n(30203)) # API Secret + + if api_key and api_id and api_secret: + enabled = _i18n(30636) # Personal keys enabled + else: + enabled = _i18n(30637) # Personal keys disabled + + if updated: + # Successfully updated + updated = _i18n(30631) % ', '.join(updated) + else: + # No changes, not updated + updated = _i18n(30635) + + html = self.api_submit_page(updated, enabled, footer) + html = html.encode('utf-8') + + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(html))) + self.end_headers() + + for chunk in self.get_chunks(html): + self.wfile.write(chunk) + + elif stripped_path == paths.PING: + self.send_error(204) + + else: + self.send_error(501) + + # noinspection PyPep8Naming + def do_HEAD(self): + log_debug('HTTPServer: HEAD uri path |{path}|'.format(path=self.path)) + + if not self.connection_allowed(): + self.send_error(403) + + elif self.path.startswith(paths.MPD): + filepath = os.path.join(self.BASE_PATH, self.path[len(paths.MPD):]) + if not os.path.isfile(filepath): + response = ('File Not Found: |{path}| -> |{filepath}|' + .format(path=self.path, filepath=filepath)) + self.send_error(404, response) + else: + self.send_response(200) + self.send_header('Content-Type', 'application/dash+xml') + self.send_header('Content-Length', + str(os.path.getsize(filepath))) + self.end_headers() + + else: + self.send_error(501) + + # noinspection PyPep8Naming + def do_POST(self): + log_debug('HTTPServer: POST uri path |{path}|'.format(path=self.path)) + + if not self.connection_allowed(): + self.send_error(403) + + elif self.path.startswith(paths.DRM): + home = xbmcgui.Window(10000) + + lic_url = home.getProperty('-'.join((ADDON_ID, 'license_url'))) + if not lic_url: + self.send_error(404) + return + + lic_token = home.getProperty('-'.join((ADDON_ID, 'license_token'))) + if not lic_token: + self.send_error(403) + return + + size_limit = None + + length = int(self.headers['Content-Length']) + post_data = self.rfile.read(length) + + li_headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer %s' % lic_token + } + + response = _server_requests.request(lic_url, + method='POST', + headers=li_headers, + data=post_data, + stream=True) + if not response or not response.ok: + self.send_error(response and response.status_code or 500) + return + + response_length = int(response.headers.get('content-length')) + content = response.raw.read(response_length) + + content_split = content.split('\r\n\r\n'.encode('utf-8')) + response_header = content_split[0].decode('utf-8', 'ignore') + response_body = content_split[1] + + match = re.search(r'^Authorized-Format-Types:\s*' + r'(?P.+?)\r*$', + response_header, + re.MULTILINE) + if match: + authorized_types = match.group('authorized_types').split(',') + log_debug('HTTPServer: Found authorized formats |{auth_fmts}|' + .format(auth_fmts=authorized_types)) + + fmt_to_px = { + 'SD': (1280 * 528) - 1, + 'HD720': 1280 * 720, + 'HD': 7680 * 4320 + } + if 'HD' in authorized_types: + size_limit = fmt_to_px['HD'] + elif 'HD720' in authorized_types: + if xbmc.getCondVisibility('system.platform.android') == 1: + size_limit = fmt_to_px['HD720'] + else: + size_limit = fmt_to_px['SD'] + elif 'SD' in authorized_types: + size_limit = fmt_to_px['SD'] + + self.send_response(200) + + if size_limit: + self.send_header('X-Limit-Video', + 'max={0}px'.format(size_limit)) + for header, value in response.headers.items(): + if re.match('^[Cc]ontent-[Ll]ength$', header): + self.send_header(header, str(len(response_body))) + else: + self.send_header(header, value) + self.end_headers() + + for chunk in self.get_chunks(response_body): + self.wfile.write(chunk) + + else: + self.send_error(501) + + # noinspection PyShadowingBuiltins + def log_message(self, format, *args): + return + + def get_chunks(self, data): + for i in range(0, len(data), self.chunk_size): + yield data[i:i + self.chunk_size] + + @staticmethod + def api_config_page(): + api_key = _settings.api_key() + api_id = _settings.api_id() + api_secret = _settings.api_secret() + html = Pages.api_configuration.get('html') + css = Pages.api_configuration.get('css') + html = html.format( + css=css, + title=_i18n(30634), # YouTube Add-on API Configuration + api_key_head=_i18n(30201), # API Key + api_id_head=_i18n(30202), # API ID + api_secret_head=_i18n(30203), # API Secret + api_id_value=api_id, + api_key_value=api_key, + api_secret_value=api_secret, + submit=_i18n(30630), # Save + header=_i18n(30634), # YouTube Add-on API Configuration + ) + return html + + @staticmethod + def api_submit_page(updated_keys, enabled, footer): + html = Pages.api_submit.get('html') + css = Pages.api_submit.get('css') + html = html.format( + css=css, + title=_i18n(30634), # YouTube Add-on API Configuration + updated=updated_keys, + enabled=enabled, + footer=footer, + header=_i18n(30634), # YouTube Add-on API Configuration + ) + return html + + +class Pages(object): + api_configuration = { + 'html': dedent('''\ + + + + + + {{title}} + + + +
+
{{header}}
+
+ + + + +
+
+ + + '''.format(action_url=paths.API_SUBMIT)), + 'css': ''.join('\t\t\t'.expandtabs(2) + line for line in dedent(''' + body { + background: #141718; + } + .center { + margin: auto; + width: 600px; + padding: 10px; + } + .config_form { + width: 575px; + height: 145px; + font-size: 16px; + background: #1a2123; + padding: 30px 30px 15px 30px; + border: 5px solid #1a2123; + } + h5 { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + color: #fff; + font-weight: 600; + width: 575px; + height: 20px; + background: #0f84a5; + padding: 5px 30px 5px 30px; + border: 5px solid #0f84a5; + margin: 0px; + } + .config_form input[type=submit], + .config_form input[type=button], + .config_form input[type=text], + .config_form textarea, + .config_form label { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + color: #fff; + } + .config_form label { + display:block; + margin-bottom: 10px; + } + .config_form label > span { + display: inline-block; + float: left; + width: 150px; + } + .config_form input[type=text] { + background: transparent; + border: none; + border-bottom: 1px solid #147a96; + width: 400px; + outline: none; + padding: 0px 0px 0px 0px; + } + .config_form input[type=text]:focus { + border-bottom: 1px dashed #0f84a5; + } + .config_form input[type=submit], + .config_form input[type=button] { + width: 150px; + background: #141718; + border: 1px solid #147a96; + padding: 8px 0px 8px 10px; + border-radius: 5px; + color: #fff; + margin-top: 10px + } + .config_form input[type=submit]:hover, + .config_form input[type=button]:hover { + background: #0f84a5; + } + ''').splitlines(True)) + '\t\t'.expandtabs(2) + } + + api_submit = { + 'html': dedent('''\ + + + + + + {title} + + + +
+
{header}
+
+

{updated}

+

{enabled}

+

+ {footer} +

+
+
+ + + '''), + 'css': ''.join('\t\t\t'.expandtabs(2) + line for line in dedent(''' + body { + background: #141718; + } + .center { + margin: auto; + width: 600px; + padding: 10px; + } + .text_center { + margin: 2em auto auto; + width: 600px; + padding: 10px; + text-align: center; + } + .content { + width: 575px; + height: 145px; + background: #1a2123; + padding: 30px 30px 15px 30px; + border: 5px solid #1a2123; + } + h5 { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + color: #fff; + font-weight: 600; + width: 575px; + height: 20px; + background: #0f84a5; + padding: 5px 30px 5px 30px; + border: 5px solid #0f84a5; + margin: 0px; + } + p { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + color: #fff; + float: left; + width: 575px; + margin: 0.5em auto; + } + small { + font-family: Arial, Helvetica, sans-serif; + font-size: 12px; + color: #fff; + } + ''').splitlines(True)) + '\t\t'.expandtabs(2) + } + + +def get_http_server(address=None, port=None): + address = _settings.httpd_listen(for_request=False, ip_address=address) + port = _settings.httpd_port(port) + try: + server = BaseHTTPServer.HTTPServer((address, port), RequestHandler) + return server + except socket_error as exc: + log_error('HTTPServer: Failed to start |{address}:{port}| |{response}|' + .format(address=address, port=port, response=str(exc))) + xbmcgui.Dialog().notification(_addon_name, + str(exc), + _addon_icon, + time=5000, + sound=False) + return None + + +def is_httpd_live(address=None, port=None): + address = _settings.httpd_listen(for_request=True, ip_address=address) + port = _settings.httpd_port(port=port) + url = 'http://{address}:{port}{path}'.format(address=address, + port=port, + path=paths.PING) + response = _server_requests.request(url) + result = response and response.status_code + if result == 204: + return True + + log_debug('HTTPServer: Ping |{address}:{port}| |{response}|' + .format(address=address, + port=port, + response=result or 'failed')) + return False + + +def get_client_ip_address(address=None, port=None): + ip_address = None + address = _settings.httpd_listen(for_request=True, ip_address=address) + port = _settings.httpd_port(port=port) + url = 'http://{address}:{port}{path}'.format(address=address, + port=port, + path=paths.IP) + response = _server_requests.request(url) + if response and response.status_code == 200: + response_json = response.json() + if response_json: + ip_address = response_json.get('ip') + return ip_address diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/ip_api.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/ip_api.py new file mode 100644 index 0000000000..4c6ea9062b --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/ip_api.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .requests import BaseRequestsClass +from .. import logger + + +class Locator(BaseRequestsClass): + + def __init__(self): + self._base_url = 'http://ip-api.com' + self._response = {} + + super(Locator, self).__init__() + + def response(self): + return self._response + + def locate_requester(self): + request_url = '/'.join((self._base_url, 'json')) + response = self.request(request_url) + self._response = response and response.json() or {} + + def success(self): + successful = self.response().get('status', 'fail') == 'success' + if successful: + logger.log_debug('Location request was successful') + else: + logger.log_error(self.response().get('message', 'Location request failed with no error message')) + return successful + + def coordinates(self): + lat = None + lon = None + if self.success(): + lat = self._response.get('lat') + lon = self._response.get('lon') + if lat is None or lon is None: + logger.log_error('No coordinates returned') + return None + logger.log_debug('Coordinates found') + return {'lat': lat, 'lon': lon} diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/requests.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/requests.py new file mode 100644 index 0000000000..70abae3838 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/network/requests.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import atexit +from traceback import format_stack + +from requests import Session +from requests.adapters import HTTPAdapter, Retry +from requests.exceptions import InvalidJSONError, RequestException + +from ..compatibility import xbmcaddon +from ..constants import ADDON_ID +from ..logger import log_error +from ..settings import Settings + + +__all__ = ( + 'BaseRequestsClass', + 'InvalidJSONError' +) + +_settings = Settings(xbmcaddon.Addon(id=ADDON_ID)) + + +class BaseRequestsClass(object): + _http_adapter = HTTPAdapter( + pool_maxsize=10, + pool_block=True, + max_retries=Retry( + total=3, + backoff_factor=0.1, + status_forcelist={500, 502, 503, 504}, + allowed_methods=None, + ) + ) + + _session = Session() + _session.mount('https://', _http_adapter) + atexit.register(_session.close) + + def __init__(self, exc_type=None): + self._verify = _settings.verify_ssl() + self._timeout = _settings.get_timeout() + if isinstance(exc_type, tuple): + self._default_exc = (RequestException,) + exc_type + elif exc_type: + self._default_exc = (RequestException, exc_type) + else: + self._default_exc = (RequestException,) + + def __del__(self): + self._session.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + self._session.close() + + def request(self, url, method='GET', + params=None, data=None, headers=None, cookies=None, files=None, + auth=None, timeout=None, allow_redirects=None, proxies=None, + hooks=None, stream=None, verify=None, cert=None, json=None, + # Custom event hook implementation + # See _response_hook and _error_hook in login_client.py + # for example usage + response_hook=None, + response_hook_kwargs=None, + error_hook=None, + error_hook_kwargs=None, + error_title=None, error_info=None, raise_exc=False, **_): + if timeout is None: + timeout = self._timeout + if verify is None: + verify = self._verify + if allow_redirects is None: + allow_redirects = True + + response = None + try: + response = self._session.request(method, url, + params=params, + data=data, + headers=headers, + cookies=cookies, + files=files, + auth=auth, + timeout=timeout, + allow_redirects=allow_redirects, + proxies=proxies, + hooks=hooks, + stream=stream, + verify=verify, + cert=cert, + json=json) + if not getattr(response, 'status_code', None): + raise self._default_exc[0](response=response) + + if response_hook: + if response_hook_kwargs is None: + response_hook_kwargs = {} + response_hook_kwargs['response'] = response + response = response_hook(**response_hook_kwargs) + else: + response.raise_for_status() + + except self._default_exc as exc: + exc_response = exc.response or response + response_text = exc_response and exc_response.text + stack_trace = format_stack() + error_details = {'exc': exc} + + if error_hook: + if error_hook_kwargs is None: + error_hook_kwargs = {} + error_hook_kwargs['exc'] = exc + error_hook_kwargs['response'] = exc_response + error_response = error_hook(**error_hook_kwargs) + _title, _info, _detail, _response, _trace, _exc = error_response + if _title is not None: + error_title = _title + if _info is not None: + error_info = _info + if _detail is not None: + error_details.update(_detail) + if _response is not None: + response = _response + response_text = str(_response) + if _trace is not None: + stack_trace = _trace + if _exc is not None: + raise_exc = _exc + + if error_title is None: + error_title = 'Request failed' + + if error_info is None: + try: + error_info = 'Status: {0.status_code} - {0.reason}'.format( + exc.response + ) + except AttributeError: + error_info = str(exc) + elif '{' in error_info: + try: + error_info = error_info.format(**error_details) + except (AttributeError, IndexError, KeyError): + error_info = str(exc) + + if response_text: + response_text = 'Request response:\n{0}'.format(response_text) + + if stack_trace: + stack_trace = ( + 'Stack trace (most recent call last):\n{0}'.format( + ''.join(stack_trace) + ) + ) + + log_error('\n'.join([part for part in [ + error_title, error_info, response_text, stack_trace + ] if part])) + + if raise_exc: + if not callable(raise_exc): + raise_exc = self._default_exc[-1] + raise_exc = raise_exc(error_title) + + if isinstance(raise_exc, BaseException): + raise_exc.__cause__ = exc + raise raise_exc + raise exc + + return response diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/__init__.py new file mode 100644 index 0000000000..4c8c94b612 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .xbmc.xbmc_player import XbmcPlayer as Player +from .xbmc.xbmc_playlist import XbmcPlaylist as Playlist + + +__all__ = ('Player', 'Playlist',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_player.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/abstract_player.py similarity index 100% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_player.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/abstract_player.py diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_playlist.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/abstract_playlist.py similarity index 100% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_playlist.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/abstract_playlist.py diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_player.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_player.py similarity index 91% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_player.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_player.py index 36268e4944..498acb85b1 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_player.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_player.py @@ -8,13 +8,15 @@ See LICENSES/GPL-2.0-only for more information. """ -import xbmc +from __future__ import absolute_import, division, unicode_literals + from ..abstract_player import AbstractPlayer +from ...compatibility import xbmc class XbmcPlayer(AbstractPlayer): def __init__(self, player_type, context): - AbstractPlayer.__init__(self) + super(XbmcPlayer, self).__init__() self._player_type = player_type if player_type == 'audio': diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py new file mode 100644 index 0000000000..e3b61af485 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import json + +from ..abstract_playlist import AbstractPlaylist +from ...compatibility import xbmc +from ...items import VideoItem +from ...ui.xbmc import xbmc_items + + +class XbmcPlaylist(AbstractPlaylist): + def __init__(self, playlist_type, context): + super(XbmcPlaylist, self).__init__() + + self._context = context + self._playlist = None + if playlist_type == 'video': + self._playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + elif playlist_type == 'audio': + self._playlist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + + def clear(self): + self._playlist.clear() + + def add(self, base_item): + uri, item, _ = xbmc_items.video_listitem(self._context, base_item) + if item: + self._playlist.add(uri, listitem=item) + + def shuffle(self): + self._playlist.shuffle() + + def unshuffle(self): + self._playlist.unshuffle() + + def size(self): + return self._playlist.size() + + def get_items(self, properties=None, dumps=False): + rpc_request = json.dumps({ + 'jsonrpc': '2.0', + 'method': 'Playlist.GetItems', + 'params': { + 'properties': properties if properties else ['title', 'file'], + 'playlistid': self._playlist.getPlayListId() + }, + 'id': 1 + }) + + response = json.loads(xbmc.executeJSONRPC(rpc_request)) + + if 'result' in response: + if 'items' in response['result']: + result = response['result']['items'] + else: + result = [] + return json.dumps(result, ensure_ascii=False) if dumps else result + + if 'error' in response: + message = response['error']['message'] + code = response['error']['code'] + error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) + else: + error = 'Requested |%s| received error |%s|' % (rpc_request, str(response)) + self._context.log_error(error) + return '[]' if dumps else [] + + def add_items(self, items, loads=False): + if loads: + items = json.loads(items) + + # Playlist.GetItems allows retrieving full playlist item details, but + # Playlist.Add only allows for file/path/id etc. + # Have to add items individually rather than using JSON-RPC + + for item in items: + self.add(VideoItem(item.get('title', ''), item['file'])) + + # rpc_request = json.dumps({ + # 'jsonrpc': '2.0', + # 'method': 'Playlist.Add', + # 'params': { + # 'playlistid': self._playlist.getPlayListId(), + # 'item': items, + # }, + # 'id': 1 + # }) + # response = json.loads(xbmc.executeJSONRPC(rpc_request)) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/__init__.py new file mode 100644 index 0000000000..19fca2ffd8 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .xbmc.xbmc_runner import XbmcRunner as Runner + + +__all__ = ('Runner',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_provider_runner.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/abstract_provider_runner.py similarity index 89% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_provider_runner.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/abstract_provider_runner.py index 515cf45446..d1aed39592 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_provider_runner.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/abstract_provider_runner.py @@ -13,5 +13,5 @@ class AbstractProviderRunner(object): def __init__(self): pass - def run(self, provider, context=None): + def run(self, provider, context): raise NotImplementedError() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/xbmc/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/xbmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py new file mode 100644 index 0000000000..b94d781a7c --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from traceback import format_stack + +from ..abstract_provider_runner import AbstractProviderRunner +from ...compatibility import xbmcgui, xbmcplugin +from ...exceptions import KodionException +from ...items import AudioItem, DirectoryItem, ImageItem, UriItem, VideoItem +from ...player import Playlist +from ...ui.xbmc.xbmc_items import ( + audio_listitem, + directory_listitem, + image_listitem, + playback_item, + video_listitem +) + + +class XbmcRunner(AbstractProviderRunner): + def __init__(self): + super(XbmcRunner, self).__init__() + self.handle = None + + def run(self, provider, context): + self.handle = context.get_handle() + settings = context.get_settings() + ui = context.get_ui() + + if ui.get_property('busy').lower() == 'true': + ui.clear_property('busy') + if ui.busy_dialog_active(): + playlist = Playlist('video', context) + playlist.clear() + + xbmcplugin.endOfDirectory(self.handle, succeeded=False) + + items = ui.get_property('playlist') + if items: + ui.clear_property('playlist') + context.log_error('Multiple busy dialogs active - playlist' + ' reloading to prevent Kodi crashing') + playlist.add_items(items, loads=True) + return False + + if settings.is_setup_wizard_enabled(): + provider.run_wizard(context) + + try: + results = provider.navigate(context) + except KodionException as exc: + if provider.handle_exception(context, exc): + context.log_error('XbmcRunner.run - {exc}:\n{details}'.format( + exc=exc, details=''.join(format_stack()) + )) + xbmcgui.Dialog().ok("Error in ContentProvider", exc.__str__()) + xbmcplugin.endOfDirectory(self.handle, succeeded=False) + return False + + result, options = results + if isinstance(result, bool): + xbmcplugin.endOfDirectory(self.handle, succeeded=result) + return result + + show_fanart = settings.show_fanart() + + if isinstance(result, (VideoItem, AudioItem, UriItem)): + return self._set_resolved_url(context, result, show_fanart) + + if isinstance(result, DirectoryItem): + item_count = 1 + items = [directory_listitem(context, result, show_fanart)] + elif isinstance(result, (list, tuple)): + item_count = len(result) + items = [ + directory_listitem(context, item, show_fanart) + if isinstance(item, DirectoryItem) + else video_listitem(context, item, show_fanart) + if isinstance(item, VideoItem) + else audio_listitem(context, item, show_fanart) + if isinstance(item, AudioItem) + else image_listitem(context, item, show_fanart) + if isinstance(item, ImageItem) + else None + for item in result + ] + else: + # handle exception + return False + + succeeded = xbmcplugin.addDirectoryItems( + self.handle, items, item_count + ) + xbmcplugin.endOfDirectory( + self.handle, + succeeded=succeeded, + updateListing=options.get(provider.RESULT_UPDATE_LISTING, False), + cacheToDisc=options.get(provider.RESULT_CACHE_TO_DISC, True) + ) + return succeeded + + def _set_resolved_url(self, context, base_item, show_fanart): + uri = base_item.get_uri() + + if base_item.playable: + ui = context.get_ui() + if not context.is_plugin_path(uri) and ui.busy_dialog_active(): + ui.set_property('busy', 'true') + playlist = Playlist('video', context) + ui.set_property('playlist', playlist.get_items(dumps=True)) + + item = playback_item(context, base_item, show_fanart) + xbmcplugin.setResolvedUrl(self.handle, + succeeded=True, + listitem=item) + return True + + if context.is_plugin_path(uri): + context.log_debug('Redirecting to: |{0}|'.format(uri)) + context.execute('RunPlugin({0})'.format(uri)) + else: + context.log_debug('Running script: |{0}|'.format(uri)) + context.execute('RunScript({0})'.format(uri)) + + xbmcplugin.endOfDirectory(self.handle, + succeeded=False, + updateListing=False, + cacheToDisc=False) + return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/runner.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/runner.py index 32104852cc..9f62065e2d 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/runner.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/runner.py @@ -8,13 +8,16 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import copy +import platform import timeit -from .impl import Runner -from .impl import Context - from . import debug +from .context import Context +from .plugin import Runner + __all__ = ['run'] @@ -28,34 +31,31 @@ def run(provider, context=None): start_time = timeit.default_timer() if not context: - context = Context(plugin_id='plugin.video.youtube') + context = Context() context.log_debug('Starting Kodion framework by bromix...') - python_version = 'Unknown version of Python' - try: - import platform - - python_version = str(platform.python_version()) - python_version = 'Python %s' % python_version - except: - # do nothing - pass - - version = context.get_system_version() - name = context.get_name() + addon_version = context.get_version() + python_version = 'Python {0}'.format(platform.python_version()) + redacted = '' - context_params = copy.deepcopy(context.get_params()) - if 'api_key' in context_params: - context_params['api_key'] = redacted - if 'client_id' in context_params: - context_params['client_id'] = redacted - if 'client_secret' in context_params: - context_params['client_secret'] = redacted - - context.log_notice('Running: %s (%s) on %s with %s\n\tPath: %s\n\tParams: %s' % - (name, addon_version, version, python_version, - context.get_path(), str(context_params))) + params = copy.deepcopy(context.get_params()) + if 'api_key' in params: + params['api_key'] = redacted + if 'client_id' in params: + params['client_id'] = redacted + if 'client_secret' in params: + params['client_secret'] = redacted + + context.log_notice('Running: {plugin} ({version}) on {kodi} with {python}\n' + 'Path: {path}\n' + 'Params: {params}' + .format(plugin=context.get_name(), + version=addon_version, + kodi=context.get_system_version(), + python=python_version, + path=context.get_path(), + params=params)) __RUNNER__.run(provider, context) provider.tear_down(context) @@ -63,6 +63,10 @@ def run(provider, context=None): elapsed = timeit.default_timer() - start_time if __DEBUG_RUNTIME: - debug.runtime(context, addon_version, elapsed, single_file=__DEBUG_RUNTIME_SINGLE_FILE) + debug.runtime(context, + addon_version, + elapsed, + single_file=__DEBUG_RUNTIME_SINGLE_FILE) - context.log_debug('Shutdown of Kodion after |%s| seconds' % str(round(elapsed, 4))) + context.log_debug('Shutdown of Kodion after |{elapsed:.4}| seconds' + .format(elapsed=elapsed)) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/service.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/service.py index a1e3fefc09..d549a75e16 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/service.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/service.py @@ -8,87 +8,52 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from datetime import datetime -import time -from .impl import Context +from .context import Context +from .constants import TEMP_PATH +from .utils import PlayerMonitor, ServiceMonitor, rm_dir from ..youtube.provider import Provider -from .utils import YouTubeMonitor -from .utils import YouTubePlayer - - -def strptime(stamp, stamp_fmt): - # noinspection PyUnresolvedReferences - import _strptime - try: - time.strptime('01 01 2012', '%d %m %Y') # dummy call - except: - pass - return time.strptime(stamp, stamp_fmt) - - -def get_stamp_diff(current_stamp): - stamp_format = '%Y-%m-%d %H:%M:%S.%f' - current_datetime = datetime.now() - if not current_stamp: - return 86400 # 24 hrs - try: - stamp_datetime = datetime(*(strptime(current_stamp, stamp_format)[0:6])) - except ValueError: # current_stamp has no microseconds - stamp_format = '%Y-%m-%d %H:%M:%S' - stamp_datetime = datetime(*(strptime(current_stamp, stamp_format)[0:6])) - - time_delta = current_datetime - stamp_datetime - total_seconds = 0 - if time_delta: - total_seconds = ((time_delta.seconds + time_delta.days * 24 * 3600) * 10 ** 6) // (10 ** 6) - return total_seconds def run(): - sleep_time = 10 - ping_delay_time = 60 - ping_timestamp = None - first_run = True - - context = Context(plugin_id='plugin.video.youtube') - + context = Context() context.log_debug('YouTube service initialization...') - - monitor = YouTubeMonitor() - player = YouTubePlayer(provider=Provider(), context=context) - - # wipe add-on temp folder on updates/restarts (subtitles, and mpd files) - monitor.remove_temp_dir() - - # wipe function cache on updates/restarts (fix cipher related issues on update, valid for one day otherwise) + context.get_ui().clear_property('abort_requested') + # wipe function cache on updates/restarts to fix cipher related issues on + # update, valid for one day otherwise try: context.get_function_cache().clear() - except: - # prevent service to failing due to cache related issues + except Exception: + # prevent service failing due to cache related issues pass - context.get_ui().clear_home_window_property('abort_requested') + monitor = ServiceMonitor() + player = PlayerMonitor(provider=Provider(), context=context) - while not monitor.abortRequested(): - - ping_diff = get_stamp_diff(ping_timestamp) + # wipe add-on temp folder on updates/restarts (subtitles, and mpd files) + rm_dir(TEMP_PATH) - if (ping_timestamp is None) or (ping_diff >= ping_delay_time): - ping_timestamp = str(datetime.now()) + sleep_time = 10 + ping_delay = 60 + ping_time = None + while not monitor.abortRequested(): + now = datetime.now() + if not ping_time or (ping_time - now).total_seconds() >= ping_delay: + ping_time = now if monitor.httpd and not monitor.ping_httpd(): monitor.restart_httpd() - if first_run: - first_run = False - if monitor.waitForAbort(sleep_time): break - context.get_ui().set_home_window_property('abort_requested', 'true') + context.get_ui().set_property('abort_requested', 'true') - player.cleanup_threads(only_ended=False) # clean up any/all playback monitoring threads + # clean up any/all playback monitoring threads + player.cleanup_threads(only_ended=False) if monitor.httpd: monitor.shutdown_httpd() # shutdown http server diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/__init__.py new file mode 100644 index 0000000000..d5432f4b32 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .xbmc.xbmc_plugin_settings import XbmcPluginSettings as Settings + + +__all__ = ('Settings',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py new file mode 100644 index 0000000000..2582a9bb39 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import sys + +from ..constants import settings + + +class AbstractSettings(object): + _vars = vars() + for name, value in settings.__dict__.items(): + _vars[name] = value + del _vars + + VALUE_FROM_STR = { + 'false': False, + 'true': True, + } + + _echo = False + _cache = {} + _type = None + + @classmethod + def flush(cls, xbmc_addon): + raise NotImplementedError() + + def get_bool(self, setting, default=None, echo=None): + raise NotImplementedError() + + def set_bool(self, setting, value, echo=None): + raise NotImplementedError() + + def get_int(self, setting, default=-1, converter=None, echo=None): + raise NotImplementedError() + + def set_int(self, setting, value, echo=None): + raise NotImplementedError() + + def get_string(self, setting, default='', echo=None): + raise NotImplementedError() + + def set_string(self, setting, value, echo=None): + raise NotImplementedError() + + def get_string_list(self, setting, default=None, echo=None): + raise NotImplementedError() + + def set_string_list(self, setting, value, echo=None): + raise NotImplementedError() + + def open_settings(self): + raise NotImplementedError() + + def get_items_per_page(self): + return self.get_int(settings.ITEMS_PER_PAGE, 50) + + def get_video_quality(self, quality_map_override=None): + vq_dict = {0: 240, + 1: 360, + 2: 480, # 576 seems not to work well + 3: 720, + 4: 1080} + + if quality_map_override is not None: + vq_dict = quality_map_override + + vq = self.get_int(settings.VIDEO_QUALITY, 1) + return vq_dict[vq] + + def ask_for_video_quality(self): + return self.get_bool(settings.VIDEO_QUALITY_ASK, False) + + def show_fanart(self): + return self.get_bool(settings.SHOW_FANART, True) + + def get_search_history_size(self): + return self.get_int(settings.SEARCH_SIZE, 50) + + def is_setup_wizard_enabled(self): + return self.get_bool(settings.SETUP_WIZARD, False) + + def is_support_alternative_player_enabled(self): + return self.get_bool(settings.SUPPORT_ALTERNATIVE_PLAYER, False) + + def alternative_player_web_urls(self): + return self.get_bool(settings.ALTERNATIVE_PLAYER_WEB_URLS, False) + + def use_isa(self): + return self.get_bool(settings.USE_ISA, False) + + def subtitle_languages(self): + return self.get_int(settings.SUBTITLE_LANGUAGE, 0) + + def subtitle_download(self): + return self.get_bool(settings.SUBTITLE_DOWNLOAD, False) + + def audio_only(self): + return self.get_bool(settings.AUDIO_ONLY, False) + + def set_subtitle_languages(self, value): + return self.set_int(settings.SUBTITLE_LANGUAGE, value) + + def set_subtitle_download(self, value): + return self.set_bool(settings.SUBTITLE_DOWNLOAD, value) + + def use_thumbnail_size(self): + size = self.get_int(settings.THUMB_SIZE, 0) + sizes = {0: 'medium', 1: 'high'} + return sizes[size] + + def safe_search(self): + index = self.get_int(settings.SAFE_SEARCH, 0) + values = {0: 'moderate', 1: 'none', 2: 'strict'} + return values[index] + + def age_gate(self): + return self.get_bool(settings.AGE_GATE, True) + + def verify_ssl(self): + verify = self.get_bool(settings.VERIFY_SSL, False) + if sys.version_info <= (2, 7, 9): + verify = False + return verify + + def get_timeout(self): + connect_timeout = self.get_int(settings.CONNECT_TIMEOUT, 9) + 0.5 + read_timout = self.get_int(settings.READ_TIMEOUT, 27) + return connect_timeout, read_timout + + def allow_dev_keys(self): + return self.get_bool(settings.ALLOW_DEV_KEYS, False) + + def use_mpd_videos(self): + if self.use_isa(): + return self.get_bool(settings.MPD_VIDEOS, False) + return False + + _LIVE_STREAM_TYPES = { + 0: 'mpegts', + 1: 'hls', + 2: 'isa_hls', + 3: 'isa_mpd', + } + + def get_live_stream_type(self): + if self.use_isa(): + stream_type = self.get_int(settings.LIVE_STREAMS + '.1', 0) + else: + stream_type = self.get_int(settings.LIVE_STREAMS + '.2', 0) + return self._LIVE_STREAM_TYPES.get(stream_type) or self._LIVE_STREAM_TYPES[0] + + def use_isa_live_streams(self): + if self.use_isa(): + return self.get_int(settings.LIVE_STREAMS + '.1', 0) > 1 + return False + + def use_mpd_live_streams(self): + if self.use_isa(): + return self.get_int(settings.LIVE_STREAMS + '.1', 0) == 3 + return False + + def httpd_port(self, port=None): + default_port = 50152 + + if port is None: + port = self.get_int(settings.HTTPD_PORT, default_port) + + try: + port = int(port) + except ValueError: + return default_port + return port + + def httpd_listen(self, for_request=False, ip_address=None): + default_address = '0.0.0.0' + default_octets = [0, 0, 0, 0,] + + if not ip_address: + ip_address = self.get_string(settings.HTTPD_LISTEN, + default_address) + + try: + octets = [octet for octet in map(int, ip_address.split('.')) + if 0 <= octet <= 255] + if len(octets) != 4: + raise ValueError + except ValueError: + octets = default_octets + + if for_request and octets == default_octets: + return '127.0.0.1' + return '.'.join(map(str, octets)) + + def set_httpd_listen(self, value): + return self.set_string(settings.HTTPD_LISTEN, value) + + def httpd_whitelist(self): + allow_list = self.get_string(settings.HTTPD_WHITELIST, '') + allow_list = ''.join(allow_list.split()).split(',') + allow_list = [ + self.httpd_listen(for_request=True, ip_address=ip_address) + for ip_address in allow_list + ] + return allow_list + + def api_config_page(self): + return self.get_bool(settings.API_CONFIG_PAGE, False) + + def api_id(self, new_id=None): + if new_id is not None: + self.set_string(settings.API_ID, new_id) + return new_id + return self.get_string(settings.API_ID) + + def api_key(self, new_key=None): + if new_key is not None: + self.set_string(settings.API_KEY, new_key) + return new_key + return self.get_string(settings.API_KEY) + + def api_secret(self, new_secret=None): + if new_secret is not None: + self.set_string(settings.API_SECRET, new_secret) + return new_secret + return self.get_string(settings.API_SECRET) + + def get_location(self): + location = self.get_string(settings.LOCATION, '').replace(' ', '').strip() + coords = location.split(',') + latitude = longitude = None + if len(coords) == 2: + try: + latitude = float(coords[0]) + longitude = float(coords[1]) + if latitude > 90.0 or latitude < -90.0: + latitude = None + if longitude > 180.0 or longitude < -180.0: + longitude = None + except ValueError: + latitude = longitude = None + if latitude and longitude: + return '{lat},{long}'.format(lat=latitude, long=longitude) + return '' + + def set_location(self, value): + self.set_string(settings.LOCATION, value) + + def get_location_radius(self): + return ''.join((self.get_int(settings.LOCATION_RADIUS, 500, str), 'km')) + + def get_play_count_min_percent(self): + return self.get_int(settings.PLAY_COUNT_MIN_PERCENT, 0) + + def use_local_history(self): + return self.get_bool(settings.USE_LOCAL_HISTORY, False) + + def use_remote_history(self): + return self.get_bool(settings.USE_REMOTE_HISTORY, False) + + # Selections based on max width and min height at common (utra-)wide aspect ratios + _QUALITY_SELECTIONS = { # Setting | Resolution + 7: {'width': 7680, 'height': 3148, 'label': '4320p{0} (8K){1}'}, # 7 | 4320p 8K + 6: {'width': 3840, 'height': 1080, 'label': '2160p{0} (4K){1}'}, # 6 | 2160p 4K + 5: {'width': 2560, 'height': 984, 'label': '1440p{0} (QHD){1}'}, # 5 | 1440p 2.5K / QHD + 4.1: {'width': 2048, 'height': 858, 'label': '1152p{0} (2K){1}'}, # N/A | 1152p 2K / QWXGA + 4: {'width': 1920, 'height': 787, 'label': '1080p{0} (FHD){1}'}, # 4 | 1080p FHD + 3: {'width': 1280, 'height': 525, 'label': '720p{0} (HD){1}'}, # 3 | 720p HD + 2: {'width': 854, 'height': 350, 'label': '480p{0}{1}'}, # 2 | 480p + 1: {'width': 640, 'height': 263, 'label': '360p{0}{1}'}, # 1 | 360p + 0: {'width': 426, 'height': 175, 'label': '240p{0}{1}'}, # 0 | 240p + -1: {'width': 256, 'height': 105, 'label': '144p{0}{1}'}, # N/A | 144p + -2: {'width': 0, 'height': 0, 'label': '{2}p{0}{1}'}, # N/A | Custom + } + + def get_mpd_video_qualities(self): + if not self.use_mpd_videos(): + return [] + selected = self.get_int(settings.MPD_QUALITY_SELECTION, 4) + return [quality + for key, quality in sorted(self._QUALITY_SELECTIONS.items(), + reverse=True) + if selected >= key] + + def stream_features(self): + return self.get_string_list(settings.MPD_STREAM_FEATURES) + + _STREAM_SELECT = { + 1: 'auto', + 2: 'list', + 3: 'auto+list', + } + + def stream_select(self): + select_type = self.get_int(settings.MPD_STREAM_SELECT, 1) + return self._STREAM_SELECT.get(select_type) or self._STREAM_SELECT[1] + + def remote_friendly_search(self): + return self.get_bool(settings.REMOTE_FRIENDLY_SEARCH, False) + + def hide_short_videos(self): + return self.get_bool(settings.HIDE_SHORT_VIDEOS, False) + + def client_selection(self): + return self.get_int(settings.CLIENT_SELECTION, 0) + + def show_detailed_description(self): + return self.get_bool(settings.DETAILED_DESCRIPTION, True) + + def get_language(self): + return self.get_string(settings.LANGUAGE, 'en_US').replace('_', '-') + + def get_watch_later_playlist(self): + return self.get_string(settings.WATCH_LATER_PLAYLIST, '').strip() + + def set_watch_later_playlist(self, value): + return self.set_string(settings.WATCH_LATER_PLAYLIST, value) + + def get_history_playlist(self): + return self.get_string(settings.HISTORY_PLAYLIST, '').strip() + + def set_history_playlist(self, value): + return self.set_string(settings.HISTORY_PLAYLIST, value) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/xbmc/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/xbmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py new file mode 100644 index 0000000000..710e6cef21 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from ..abstract_settings import AbstractSettings +from ...compatibility import xbmcaddon +from ...logger import log_debug +from ...utils.methods import get_kodi_setting +from ...utils.system_version import current_system_version + + +class XbmcPluginSettings(AbstractSettings): + def __init__(self, xbmc_addon): + super(XbmcPluginSettings, self).__init__() + + self.flush(xbmc_addon) + + if current_system_version.compatible(20, 0): + _class = xbmcaddon.Settings + + self.__dict__.update({ + '_get_bool': _class.getBool, + '_set_bool': _class.setBool, + '_get_int': _class.getInt, + '_set_int': _class.setInt, + '_get_str': _class.getString, + '_set_str': _class.setString, + '_get_str_list': _class.getStringList, + '_set_str_list': _class.setStringList, + }) + else: + _class = xbmcaddon.Addon + + def _get_string_list(store, setting): + return _class.getSetting(store, setting).split(',') + + def _set_string_list(store, setting, value): + value = ','.join(value) + return _class.setSetting(store, setting, value) + + self.__dict__.update({ + '_get_bool': _class.getSettingBool, + '_set_bool': _class.setSettingBool, + '_get_int': _class.getSettingInt, + '_set_int': _class.setSettingInt, + '_get_str': _class.getSettingString, + '_set_str': _class.setSettingString, + '_get_str_list': _get_string_list, + '_set_str_list': _set_string_list, + }) + + @classmethod + def flush(cls, xbmc_addon): + cls._echo = get_kodi_setting('debug.showloginfo') + cls._cache = {} + if current_system_version.compatible(20, 0): + cls._type = xbmc_addon.getSettings + else: + cls._type = xbmcaddon.Addon + + def get_bool(self, setting, default=None, echo=None): + if setting in self._cache: + return self._cache[setting] + + error = False + try: + value = bool(self._get_bool(self._type(), setting)) + except (TypeError, ValueError) as exc: + error = exc + try: + value = self.get_string(setting, echo=False).lower() + value = AbstractSettings.VALUE_FROM_STR.get(value, default) + except TypeError as exc: + error = exc + value = default + except RuntimeError as exc: + error = exc + value = default + + if self._echo and echo is not False: + log_debug('Get |{setting}|: {value} (bool, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + self._cache[setting] = value + return value + + def set_bool(self, setting, value, echo=None): + try: + error = not self._set_bool(self._type(), setting, value) + if error: + error = 'failed' + else: + self._cache[setting] = value + except (RuntimeError, TypeError) as exc: + error = exc + + if self._echo and echo is not False: + log_debug('Set |{setting}|: {value} (bool, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + return not error + + def get_int(self, setting, default=-1, process=None, echo=None): + if setting in self._cache: + return self._cache[setting] + + error = False + try: + value = int(self._get_int(self._type(), setting)) + if process: + value = process(value) + except (TypeError, ValueError) as exc: + error = exc + try: + value = self.get_string(setting, echo=False) + value = int(value) + except (TypeError, ValueError) as exc: + error = exc + value = default + except RuntimeError as exc: + error = exc + value = default + + if self._echo and echo is not False: + log_debug('Get |{setting}|: {value} (int, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + self._cache[setting] = value + return value + + def set_int(self, setting, value, echo=None): + try: + error = not self._set_int(self._type(), setting, value) + if error: + error = 'failed' + else: + self._cache[setting] = value + except (RuntimeError, TypeError) as exc: + error = exc + + if self._echo and echo is not False: + log_debug('Set |{setting}|: {value} (int, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + return not error + + def get_string(self, setting, default='', echo=None): + if setting in self._cache: + return self._cache[setting] + + error = False + try: + value = self._get_str(self._type(), setting) or default + except (RuntimeError, TypeError) as exc: + error = exc + value = default + + if self._echo and echo is not False: + log_debug('Get |{setting}|: "{value}" (str, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + self._cache[setting] = value + return value + + def set_string(self, setting, value, echo=None): + try: + error = not self._set_str(self._type(), setting, value) + if error: + error = 'failed' + else: + self._cache[setting] = value + except (RuntimeError, TypeError) as exc: + error = exc + + if self._echo and echo is not False: + log_debug('Set |{setting}|: "{value}" (str, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + return not error + + def get_string_list(self, setting, default=None, echo=None): + if setting in self._cache: + return self._cache[setting] + + error = False + try: + value = self._get_str_list(self._type(), setting) + if not value: + value = [] if default is None else default + except (RuntimeError, TypeError) as exc: + error = exc + value = default + + if self._echo and echo is not False: + log_debug('Get |{setting}|: "{value}" (str list, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + self._cache[setting] = value + return value + + def set_string_list(self, setting, value, echo=None): + try: + error = not self._set_str_list(self._type(), setting, value) + if error: + error = 'failed' + else: + self._cache[setting] = value + except (RuntimeError, TypeError) as exc: + error = exc + + if self._echo and echo is not False: + log_debug('Set |{setting}|: "{value}" (str list, {status})'.format( + setting=setting, + value=value, + status=error if error else 'success' + )) + return not error diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/__init__.py new file mode 100644 index 0000000000..46da38d54c --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from .data_cache import DataCache +from .favorite_list import FavoriteList +from .function_cache import FunctionCache +from .playback_history import PlaybackHistory +from .search_history import SearchHistory +from .watch_later_list import WatchLaterList + + +__all__ = ( + 'DataCache', + 'FavoriteList', + 'FunctionCache', + 'PlaybackHistory', + 'SearchHistory', + 'WatchLaterList', +) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py new file mode 100644 index 0000000000..791af6f257 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2019 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .storage import Storage + + +class DataCache(Storage): + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath, max_file_size_mb=5): + max_file_size_kb = max_file_size_mb * 1024 + super(DataCache, self).__init__(filepath, + max_file_size_kb=max_file_size_kb) + + def get_items(self, content_ids, seconds): + result = self._get_by_ids(content_ids, seconds=seconds, as_dict=True) + return result + + def get_item(self, content_id, seconds): + result = self._get(content_id, seconds=seconds) + return result + + def set_item(self, content_id, item): + self._set(content_id, item) + + def set_items(self, items): + self._set_many(items) + + def remove(self, content_id): + self._remove(content_id) + + def update(self, content_id, item): + self._set(str(content_id), item) + + def _optimize_item_count(self, limit=-1, defer=False): + return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/favorite_list.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/favorite_list.py new file mode 100644 index 0000000000..97e81cef07 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/favorite_list.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .storage import Storage +from ..items import from_json + + +class FavoriteList(Storage): + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath): + super(FavoriteList, self).__init__(filepath) + + @staticmethod + def _sort_item(item): + return item.get_name().upper() + + def get_items(self): + result = self._get_by_ids(process=from_json, values_only=True) + return sorted(result, key=self._sort_item, reverse=False) + + def add(self, item_id, item): + self._set(item_id, item) + + def remove(self, item_id): + self._remove(item_id) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py new file mode 100644 index 0000000000..1ac1f4fc2b --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2019 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from functools import partial +from hashlib import md5 + +from .storage import Storage + + +class FunctionCache(Storage): + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath, max_file_size_mb=5): + max_file_size_kb = max_file_size_mb * 1024 + super(FunctionCache, self).__init__(filepath, + max_file_size_kb=max_file_size_kb) + + self._enabled = True + + def enabled(self): + """ + Enables the caching + :return: + """ + self._enabled = True + + def disable(self): + """ + Disable caching e.g. for tests + :return: + """ + self._enabled = False + + @staticmethod + def _create_id_from_func(partial_func): + """ + Creats an id from the given function + :param partial_func: + :return: id for the given function + """ + md5_hash = md5() + md5_hash.update(partial_func.func.__module__.encode('utf-8')) + md5_hash.update(partial_func.func.__name__.encode('utf-8')) + md5_hash.update(str(partial_func.args).encode('utf-8')) + md5_hash.update(str(partial_func.keywords).encode('utf-8')) + return md5_hash.hexdigest() + + def _get_cached_data(self, partial_func, seconds=None): + cache_id = self._create_id_from_func(partial_func) + return self._get(cache_id, seconds=seconds), cache_id + + def get_cached_only(self, func, *args, **keywords): + partial_func = partial(func, *args, **keywords) + + # if caching is disabled call the function + if not self._enabled: + return partial_func() + + # only return before cached data + data, _ = self._get_cached_data(partial_func) + return data + + def get(self, func, seconds, *args, **keywords): + """ + Returns the cached data of the given function. + :param func, function to cache + :param seconds: time to live in seconds + :return: + """ + + partial_func = partial(func, *args, **keywords) + + # if caching is disabled call the function + if not self._enabled: + return partial_func() + + data, cache_id = self._get_cached_data(partial_func, seconds=seconds) + if data is None: + data = partial_func() + self._set(cache_id, data) + return data + + def _optimize_item_count(self, limit=-1, defer=False): + # override method Storage._optimize_item_count + # for function cache do not optimize by item count, use database size. + return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py new file mode 100644 index 0000000000..9b49ac1969 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .storage import Storage, fromtimestamp + + +class PlaybackHistory(Storage): + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath): + super(PlaybackHistory, self).__init__(filepath) + + @staticmethod + def _add_last_played(value, item): + value['last_played'] = fromtimestamp(item[1]) + return value + + def get_items(self, keys=None, limit=-1): + result = self._get_by_ids(keys, + oldest_first=False, + process=self._add_last_played, + as_dict=True, + limit=limit) + return result + + def get_item(self, key): + result = self._get(key, process=self._add_last_played) + return result + + def remove(self, video_id): + self._remove(video_id) + + def update(self, video_id, play_data): + self._set(video_id, play_data) + + def _optimize_item_count(self, limit=-1, defer=False): + return False + + def _optimize_file_size(self, limit=-1, defer=False): + return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/search_history.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/search_history.py similarity index 50% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/search_history.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/search_history.py index 726fcc6a92..5aefe1591e 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/search_history.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/search_history.py @@ -8,41 +8,34 @@ See LICENSES/GPL-2.0-only for more information. """ -import hashlib +from __future__ import absolute_import, division, unicode_literals + +from hashlib import md5 from .storage import Storage -from .methods import to_utf8 class SearchHistory(Storage): - def __init__(self, filename, max_items=10): - Storage.__init__(self, filename, max_item_count=max_items) - - def is_empty(self): - return self._is_empty() - - def list(self): - result = [] - - keys = self._get_ids(oldest_first=False) - for i, key in enumerate(keys): - if i >= self._max_item_count: - break - item = self._get(key) - - if item: - result.append(item[0]) - + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath, max_item_count=10): + super(SearchHistory, self).__init__(filepath, + max_item_count=max_item_count) + + def get_items(self): + result = self._get_by_ids(oldest_first=False, + limit=self._max_item_count, + values_only=True) return result - def clear(self): - self._clear() - @staticmethod def _make_id(search_text): - m = hashlib.md5() - m.update(to_utf8(search_text)) - return m.hexdigest() + md5_hash = md5() + md5_hash.update(search_text.encode('utf-8')) + return md5_hash.hexdigest() def rename(self, old_search_text, new_search_text): self.remove(old_search_text) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/storage.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/storage.py new file mode 100644 index 0000000000..ce76b3bcb1 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/storage.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2019 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import os +import pickle +import sqlite3 +import time +from traceback import format_stack + +from ..logger import log_error +from ..utils.datetime_parser import fromtimestamp, since_epoch +from ..utils.methods import make_dirs + + +class Storage(object): + ONE_MINUTE = 60 + ONE_HOUR = 60 * ONE_MINUTE + ONE_DAY = 24 * ONE_HOUR + ONE_WEEK = 7 * ONE_DAY + ONE_MONTH = 4 * ONE_WEEK + + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + + _sql = { + 'clear': ( + 'DELETE' + ' FROM {table};' + ), + 'create_table': ( + 'CREATE TABLE' + ' IF NOT EXISTS {table} (' + ' key TEXT PRIMARY KEY,' + ' timestamp REAL,' + ' value BLOB,' + ' size INTEGER' + ' );' + ), + 'drop_old_table': ( + 'DELETE' + ' FROM sqlite_master' + ' WHERE type = "table"' + ' and name IS NOT "{table}";' + ), + 'get': ( + 'SELECT *' + ' FROM {table}' + ' WHERE key = ?;' + ), + 'get_by_key': ( + 'SELECT *' + ' FROM {table}' + ' WHERE key in ({{0}});' + ), + 'get_many': ( + 'SELECT *' + ' FROM {table}' + ' ORDER BY timestamp' + ' LIMIT {{0}};' + ), + 'get_many_desc': ( + 'SELECT *' + ' FROM {table}' + ' ORDER BY timestamp DESC' + ' LIMIT {{0}};' + ), + 'has_old_table': ( + 'SELECT EXISTS (' + ' SELECT 1' + ' FROM sqlite_master' + ' WHERE type = "table"' + ' and name IS NOT "{table}"' + ');' + ), + 'is_empty': ( + 'SELECT EXISTS (' + ' SELECT 1' + ' FROM {table}' + ');' + ), + 'prune_by_count': ( + 'DELETE' + ' FROM {table}' + ' WHERE rowid IN (' + ' SELECT rowid' + ' FROM {table}' + ' ORDER BY timestamp DESC' + ' LIMIT {{0}}' + ' OFFSET {{1}}' + ' );' + ), + 'prune_by_size': ( + 'DELETE' + ' FROM {table}' + ' WHERE rowid IN (' + ' SELECT rowid' + ' FROM {table}' + ' WHERE (' + ' SELECT SUM(size)' + ' FROM {table} AS _' + ' WHERE timestamp<={table}.timestamp' + ' ) <= {{0}}' + ' );' + ), + 'remove': ( + 'DELETE' + ' FROM {table}' + ' WHERE key = ?;' + ), + 'remove_by_key': ( + 'DELETE' + ' FROM {table}' + ' WHERE key in ({{0}});' + ), + 'set': ( + 'REPLACE' + ' INTO {table}' + ' (key, timestamp, value, size)' + ' VALUES (?,?,?,?);' + ), + 'set_flat': ( + 'REPLACE' + ' INTO {table}' + ' (key, timestamp, value, size)' + ' VALUES {{0}};' + ), + } + + def __init__(self, filepath, max_item_count=-1, max_file_size_kb=-1): + self._filepath = filepath + self._db = None + self._cursor = None + self._max_item_count = max_item_count + self._max_file_size_kb = max_file_size_kb + + if not self._sql: + statements = { + name: sql.format(table=self._table_name) + for name, sql in Storage._sql.items() + } + self.__class__._sql.update(statements) + + def set_max_item_count(self, max_item_count): + self._max_item_count = max_item_count + + def set_max_file_size_kb(self, max_file_size_kb): + self._max_file_size_kb = max_file_size_kb + + def __del__(self): + self._close() + + def __enter__(self): + if not self._db or not self._cursor: + self._open() + return self._db, self._cursor + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + self._close() + + def _open(self): + if not os.path.exists(self._filepath): + make_dirs(os.path.dirname(self._filepath)) + self.__class__._table_created = False + self.__class__._table_updated = True + + try: + db = sqlite3.connect(self._filepath, + check_same_thread=False, + timeout=1, + isolation_level=None) + except sqlite3.OperationalError as exc: + log_error('SQLStorage._execute - {exc}:\n{details}'.format( + exc=exc, details=''.join(format_stack()) + )) + return False + + cursor = db.cursor() + cursor.arraysize = 100 + + sql_script = [ + 'PRAGMA busy_timeout = 1000;', + 'PRAGMA read_uncommitted = TRUE;', + 'PRAGMA secure_delete = FALSE;', + 'PRAGMA synchronous = NORMAL;', + 'PRAGMA locking_mode = NORMAL;' + 'PRAGMA temp_store = MEMORY;', + 'PRAGMA mmap_size = 4096000;', + 'PRAGMA page_size = 4096;', + 'PRAGMA cache_size = 1000;', + 'PRAGMA journal_mode = WAL;', + ] + statements = [] + + if not self._table_created: + statements.append( + self._sql['create_table'] + ) + + if not self._table_updated: + for result in cursor.execute(self._sql['has_old_table']): + if result[0] == 1: + statements.extend(( + 'PRAGMA writable_schema = 1;', + self._sql['drop_old_table'], + 'PRAGMA writable_schema = 0;', + )) + break + + if statements: + transaction_begin = len(sql_script) + 1 + sql_script.extend(('BEGIN;', 'COMMIT;', 'VACUUM;')) + sql_script[transaction_begin:transaction_begin] = statements + cursor.executescript('\n'.join(sql_script)) + + self.__class__._table_created = True + self.__class__._table_updated = True + self._db = db + self._cursor = cursor + + def _close(self): + if self._cursor: + self._execute(self._cursor, 'PRAGMA optimize') + self._cursor.close() + self._cursor = None + if self._db: + # Not needed if using self._db as a context manager + # self._db.commit() + self._db.close() + self._db = None + + @staticmethod + def _execute(cursor, query, values=None, many=False): + if values is None: + values = () + """ + Tests revealed that sqlite has problems to release the database in time + This happens no so often, but just to be sure, we try at least 3 times + to execute our statement. + """ + for _ in range(3): + try: + if many: + return cursor.executemany(query, values) + return cursor.execute(query, values) + except sqlite3.OperationalError as exc: + log_error('SQLStorage._execute - {exc}:\n{details}'.format( + exc=exc, details=''.join(format_stack()) + )) + time.sleep(0.1) + except sqlite3.Error as exc: + log_error('SQLStorage._execute - {exc}:\n{details}'.format( + exc=exc, details=''.join(format_stack()) + )) + return [] + return [] + + def _optimize_file_size(self, defer=False): + # do nothing - optimize only if max size limit has been set + if self._max_file_size_kb <= 0: + return False + + try: + file_size_kb = (os.path.getsize(self._filepath) // 1024) + if file_size_kb <= self._max_file_size_kb: + return False + except OSError: + return False + + prune_size = 1024 * int(file_size_kb - self._max_file_size_kb / 2) + query = self._sql['prune_by_size'].format(prune_size) + if defer: + return query + with self as (db, cursor), db: + self._execute(cursor, query) + self._execute(cursor, 'VACUUM') + return True + + def _optimize_item_count(self, limit=-1, defer=False): + # do nothing - optimize only if max item limit has been set + if self._max_item_count < 0: + return False + + # clear db if max item count has been set to 0 + if not self._max_item_count: + if not self.is_empty(): + return self.clear(defer) + return False + + query = self._sql['prune_by_count'].format( + limit, self._max_item_count + ) + if defer: + return query + with self as (db, cursor), db: + self._execute(cursor, query) + self._execute(cursor, 'VACUUM') + return True + + def _set(self, item_id, item): + values = self._encode(item_id, item) + optimize_query = self._optimize_item_count(1, defer=True) + with self as (db, cursor), db: + if optimize_query: + self._execute(cursor, 'BEGIN') + self._execute(cursor, optimize_query) + self._execute(cursor, self._sql['set'], values=values) + + def _set_many(self, items, flatten=False): + now = since_epoch() + num_items = len(items) + + if flatten: + values = [enc_part + for item in items.items() + for enc_part in self._encode(*item, timestamp=now)] + query = self._sql['set_flat'].format( + '(?,?,?,?),' * (num_items - 1) + '(?,?,?,?)' + ) + else: + values = [self._encode(*item, timestamp=now) + for item in items.items()] + query = self._sql['set'] + + optimize_query = self._optimize_item_count(num_items, defer=True) + with self as (db, cursor), db: + self._execute(cursor, 'BEGIN') + if optimize_query: + self._execute(cursor, optimize_query) + self._execute(cursor, query, many=(not flatten), values=values) + self._optimize_file_size() + + def clear(self, defer=False): + query = self._sql['clear'] + if defer: + return query + with self as (db, cursor), db: + self._execute(cursor, query) + self._execute(cursor, 'VACUUM') + return True + + def is_empty(self): + with self as (db, cursor), db: + result = self._execute(cursor, self._sql['is_empty']) + for item in result: + is_empty = item[0] == 0 + break + else: + is_empty = True + return is_empty + + @staticmethod + def _decode(obj, process=None, item=None): + decoded_obj = pickle.loads(obj) + if process: + return process(decoded_obj, item) + return decoded_obj + + @staticmethod + def _encode(key, obj, timestamp=None): + timestamp = timestamp or since_epoch() + blob = sqlite3.Binary(pickle.dumps( + obj, protocol=pickle.HIGHEST_PROTOCOL + )) + size = getattr(blob, 'nbytes', None) + if not size: + size = int(memoryview(blob).itemsize) * len(blob) + return str(key), timestamp, blob, size + + def _get(self, item_id, process=None, seconds=None): + with self as (db, cursor), db: + result = self._execute(cursor, self._sql['get'], [str(item_id)]) + item = result.fetchone() if result else None + if not item: + return None + cut_off = since_epoch() - seconds if seconds else 0 + if not cut_off or item[1] >= cut_off: + return self._decode(item[2], process, item) + return None + + def _get_by_ids(self, item_ids=None, oldest_first=True, limit=-1, + seconds=None, process=None, + as_dict=False, values_only=False): + if not item_ids: + if oldest_first: + query = self._sql['get_many'] + else: + query = self._sql['get_many_desc'] + query = query.format(limit) + else: + num_ids = len(item_ids) + query = self._sql['get_by_key'].format('?,' * (num_ids - 1) + '?') + item_ids = tuple(item_ids) + + cut_off = since_epoch() - seconds if seconds else 0 + with self as (db, cursor), db: + result = self._execute(cursor, query, item_ids) + if as_dict: + result = { + item[0]: self._decode(item[2], process, item) + for item in result if not cut_off or item[1] >= cut_off + } + elif values_only: + result = [ + self._decode(item[2], process, item) + for item in result if not cut_off or item[1] >= cut_off + ] + else: + result = [ + (item[0], + fromtimestamp(item[1]), + self._decode(item[2], process, item)) + for item in result if not cut_off or item[1] >= cut_off + ] + return result + + def _remove(self, item_id): + with self as (db, cursor), db: + self._execute(cursor, self._sql['remove'], [item_id]) + + def _remove_many(self, item_ids): + num_ids = len(item_ids) + query = self._sql['remove_by_key'].format('?,' * (num_ids - 1) + '?') + with self as (db, cursor), db: + self._execute(cursor, query, tuple(item_ids)) + self._execute(cursor, 'VACUUM') diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/watch_later_list.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/watch_later_list.py new file mode 100644 index 0000000000..b98ebdafe4 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/sql_store/watch_later_list.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .storage import Storage +from ..items import from_json + + +class WatchLaterList(Storage): + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath): + super(WatchLaterList, self).__init__(filepath) + + def get_items(self): + result = self._get_by_ids(process=from_json, values_only=True) + return result + + def add(self, video_id, item): + self._set(video_id, item) + + def remove(self, video_id): + self._remove(video_id) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/__init__.py new file mode 100644 index 0000000000..8f09da745e --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .xbmc.xbmc_context_ui import XbmcContextUI as ContextUI + + +__all__ = ('ContextUI',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py similarity index 87% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py index 7ed3feffef..73e8da3322 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py @@ -8,6 +8,8 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + class AbstractContextUI(object): def __init__(self): @@ -16,16 +18,13 @@ def __init__(self): def create_progress_dialog(self, heading, text=None, background=False): raise NotImplementedError() - def get_skin_id(self): - raise NotImplementedError() - def on_keyboard_input(self, title, default='', hidden=False): raise NotImplementedError() def on_numeric_input(self, title, default=''): raise NotImplementedError() - def on_yes_no_input(self, title, text): + def on_yes_no_input(self, title, text, nolabel='', yeslabel=''): raise NotImplementedError() def on_ok(self, title, text): @@ -40,7 +39,8 @@ def on_select(self, title, items=None): def open_settings(self): raise NotImplementedError() - def show_notification(self, message, header='', image_uri='', time_milliseconds=5000): + def show_notification(self, message, header='', image_uri='', + time_ms=5000, audible=True): raise NotImplementedError() @staticmethod diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py new file mode 100644 index 0000000000..83ab3ac576 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from ..compatibility import string_type + + +class AbstractProgressDialog(object): + def __init__(self, dialog, heading, text, total=100): + self._dialog = dialog() + self._dialog.create(heading, text) + + # simple reset because KODI won't do it :( + self._total = int(total) + self._position = 1 + self.update(steps=-1) + + def __enter__(self): + return self + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + self.close() + + def get_total(self): + return self._total + + def get_position(self): + return self._position + + def close(self): + if self._dialog: + self._dialog.close() + self._dialog = None + + def set_total(self, total): + self._total = int(total) + + def update(self, steps=1, text=None): + self._position += steps + + if not self._total: + position = 0 + elif self._position >= self._total: + position = 100 + else: + position = int(100 * self._position / self._total) + + if isinstance(text, string_type): + self._dialog.update(percent=position, message=text) + else: + self._dialog.update(percent=position) + + def is_aborted(self): + raise NotImplementedError() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/info_labels.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py similarity index 71% rename from plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/info_labels.py rename to plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py index 2018d3bb37..191a4379f8 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/impl/xbmc/info_labels.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py @@ -8,15 +8,23 @@ See LICENSES/GPL-2.0-only for more information. """ -from ... import utils -from ...items import * +from __future__ import absolute_import, division, unicode_literals +from ...items import AudioItem, DirectoryItem, ImageItem, VideoItem +from ...utils import current_system_version, datetime_parser -def _process_date(info_labels, param): + +def _process_date_value(info_labels, name, param): if param: - datetime = utils.datetime_parser.parse(param) - datetime = '%02d.%02d.%04d' % (datetime.day, datetime.month, datetime.year) - info_labels['date'] = datetime + info_labels[name] = param.isoformat() + + +def _process_datetime_value(info_labels, name, param): + if not param: + return + info_labels[name] = (param.replace(microsecond=0, tzinfo=None).isoformat() + if current_system_version.compatible(19, 0) else + param.strftime('%d.%m.%Y')) def _process_int_value(info_labels, name, param): @@ -39,17 +47,11 @@ def _process_audio_rating(info_labels, param): rating = int(param) if rating > 5: rating = 5 - if rating < 0: + elif rating < 0: rating = 0 - info_labels['rating'] = rating -def _process_video_dateadded(info_labels, param): - if param is not None and param: - info_labels['dateadded'] = param - - def _process_video_duration(info_labels, param): if param is not None: info_labels['duration'] = '%d' % param @@ -60,16 +62,15 @@ def _process_video_rating(info_labels, param): rating = float(param) if rating > 10.0: rating = 10.0 - if rating < 0.0: + elif rating < 0.0: rating = 0.0 info_labels['rating'] = rating -def _process_date_value(info_labels, name, param): +def _process_date_string(info_labels, name, param): if param: - date = utils.datetime_parser.parse(param) - date = '%04d-%02d-%02d' % (date.year, date.month, date.day) - info_labels[name] = date + date = datetime_parser.parse(param) + info_labels[name] = date.isoformat() def _process_list_value(info_labels, name, param): @@ -81,31 +82,23 @@ def _process_mediatype(info_labels, name, param): info_labels[name] = param -def _process_last_played(info_labels, name, param): - if param: - try: - info_labels[name] = param.strftime('%Y-%m-%d %H:%M:%S') - except AttributeError: - info_labels[name] = param - - def create_from_item(base_item): info_labels = {} - # 'date' = '09.03.1982' - _process_date(info_labels, base_item.get_date()) + # 'date' = '1982-03-09' (string) + _process_datetime_value(info_labels, 'date', base_item.get_date()) # Directory if isinstance(base_item, DirectoryItem): _process_string_value(info_labels, 'plot', base_item.get_plot()) # Image - if isinstance(base_item, ImageItem): + elif isinstance(base_item, ImageItem): # 'title' = 'Blow Your Head Off' (string) _process_string_value(info_labels, 'title', base_item.get_title()) # Audio - if isinstance(base_item, AudioItem): + elif isinstance(base_item, AudioItem): # 'duration' = 79 (int) _process_int_value(info_labels, 'duration', base_item.get_duration()) @@ -119,39 +112,44 @@ def create_from_item(base_item): _process_audio_rating(info_labels, base_item.get_rating()) # Video - if isinstance(base_item, VideoItem): + elif isinstance(base_item, VideoItem): # mediatype _process_mediatype(info_labels, 'mediatype', base_item.get_mediatype()) # play count _process_int_value(info_labels, 'playcount', base_item.get_play_count()) + # 'count' = 12 (integer) + # Can be used to store an id for later, or for sorting purposes + # Used for Youtube video view count + _process_int_value(info_labels, 'count', base_item.get_count()) + # studio _process_studios(info_labels, 'studio', base_item.get_studio()) # 'artist' = [] (list) _process_list_value(info_labels, 'artist', base_item.get_artist()) - # 'dateadded' = '2014-08-11 13:08:56' (string) will be taken from 'date' - _process_video_dateadded(info_labels, base_item.get_date()) + # 'dateadded' = '2014-08-11 13:08:56' (string) will be taken from 'dateadded' + _process_datetime_value(info_labels, 'dateadded', base_item.get_dateadded()) # TODO: starting with Helix this could be seconds # 'duration' = '3:18' (string) _process_video_duration(info_labels, base_item.get_duration()) - _process_last_played(info_labels, 'lastplayed', base_item.get_last_played()) + _process_datetime_value(info_labels, 'lastplayed', base_item.get_last_played()) # 'rating' = 4.5 (float) _process_video_rating(info_labels, base_item.get_rating()) # 'aired' = '2013-12-12' (string) - _process_date_value(info_labels, 'aired', base_item.get_aired()) + _process_date_value(info_labels, 'aired', base_item.get_aired(as_text=False)) # 'director' = 'Steven Spielberg' (string) _process_string_value(info_labels, 'director', base_item.get_director()) # 'premiered' = '2013-12-12' (string) - _process_date_value(info_labels, 'premiered', base_item.get_premiered()) + _process_date_value(info_labels, 'premiered', base_item.get_premiered(as_text=False)) # 'episode' = 12 (int) _process_int_value(info_labels, 'episode', base_item.get_episode()) @@ -162,14 +160,19 @@ def create_from_item(base_item): # 'plot' = '...' (string) _process_string_value(info_labels, 'plot', base_item.get_plot()) - # 'code' = 'tt3458353' (string) - imdb id - _process_string_value(info_labels, 'code', base_item.get_imdb_id()) + # 'imdbnumber' = 'tt3458353' (string) - imdb id + _process_string_value(info_labels, 'imdbnumber', base_item.get_imdb_id()) # 'cast' = [] (list) _process_list_value(info_labels, 'cast', base_item.get_cast()) + # 'code' = '101' (string) + # Production code, currently used to store misc video data for label + # formatting + _process_string_value(info_labels, 'code', base_item.get_code()) + # Audio and Video - if isinstance(base_item, AudioItem) or isinstance(base_item, VideoItem): + if isinstance(base_item, (AudioItem, VideoItem)): # 'title' = 'Blow Your Head Off' (string) _process_string_value(info_labels, 'title', base_item.get_title()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py new file mode 100644 index 0000000000..b6fc6a7e28 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from .xbmc_progress_dialog import XbmcProgressDialog, XbmcProgressDialogBG +from ..abstract_context_ui import AbstractContextUI +from ...compatibility import xbmc, xbmcgui +from ...constants import ADDON_ID +from ...utils import to_unicode + + +class XbmcContextUI(AbstractContextUI): + def __init__(self, xbmc_addon, context): + super(XbmcContextUI, self).__init__() + + self._xbmc_addon = xbmc_addon + + self._context = context + + def create_progress_dialog(self, heading, text=None, background=False): + if background: + return XbmcProgressDialogBG(heading, text) + + return XbmcProgressDialog(heading, text) + + + def on_keyboard_input(self, title, default='', hidden=False): + # Starting with Gotham (13.X > ...) + dialog = xbmcgui.Dialog() + result = dialog.input(title, to_unicode(default), type=xbmcgui.INPUT_ALPHANUM) + if result: + text = to_unicode(result) + return True, text + + return False, '' + + def on_numeric_input(self, title, default=''): + dialog = xbmcgui.Dialog() + result = dialog.input(title, str(default), type=xbmcgui.INPUT_NUMERIC) + if result: + return True, int(result) + + return False, None + + def on_yes_no_input(self, title, text, nolabel='', yeslabel=''): + dialog = xbmcgui.Dialog() + return dialog.yesno(title, text, nolabel=nolabel, yeslabel=yeslabel) + + def on_ok(self, title, text): + dialog = xbmcgui.Dialog() + return dialog.ok(title, text) + + def on_remove_content(self, content_name): + text = self._context.localize('content.remove') % to_unicode(content_name) + return self.on_yes_no_input(self._context.localize('content.remove.confirm'), text) + + def on_delete_content(self, content_name): + text = self._context.localize('content.delete') % to_unicode(content_name) + return self.on_yes_no_input(self._context.localize('content.delete.confirm'), text) + + def on_select(self, title, items=None): + if items is None: + items = [] + + use_details = (isinstance(items[0], tuple) and len(items[0]) == 4) + + _dict = {} + _items = [] + i = 0 + for item in items: + if isinstance(item, tuple): + if use_details: + new_item = xbmcgui.ListItem(label=item[0], label2=item[1]) + new_item.setArt({'icon': item[3], 'thumb': item[3]}) + _items.append(new_item) + _dict[i] = item[2] + else: + _dict[i] = item[1] + _items.append(item[0]) + else: + _dict[i] = i + _items.append(item) + + i += 1 + + dialog = xbmcgui.Dialog() + if use_details: + result = dialog.select(title, _items, useDetails=use_details) + else: + result = dialog.select(title, _items) + + return _dict.get(result, -1) + + def show_notification(self, + message, + header='', + image_uri='', + time_ms=5000, + audible=True): + _header = header + if not _header: + _header = self._context.get_name() + + _image = image_uri + if not _image: + _image = self._context.get_icon() + + _message = message.replace(',', ' ').replace('\n', ' ') + + xbmcgui.Dialog().notification(_header, + _message, + _image, + time_ms, + audible) + + def open_settings(self): + self._xbmc_addon.openSettings() + + def refresh_container(self): + # TODO: find out why the RunScript call is required + # xbmc.executebuiltin("Container.Refresh") + xbmc.executebuiltin('RunScript({addon_id},action/refresh)'.format( + addon_id=ADDON_ID + )) + + @staticmethod + def set_property(property_id, value): + property_id = '-'.join((ADDON_ID, property_id)) + xbmcgui.Window(10000).setProperty(property_id, value) + + @staticmethod + def get_property(property_id): + property_id = '-'.join((ADDON_ID, property_id)) + return xbmcgui.Window(10000).getProperty(property_id) + + @staticmethod + def clear_property(property_id): + property_id = '-'.join((ADDON_ID, property_id)) + xbmcgui.Window(10000).clearProperty(property_id) + + @staticmethod + def bold(value, cr_before=0, cr_after=0): + return ''.join(( + '[CR]' * cr_before, + '[B]', value, '[/B]', + '[CR]' * cr_after, + )) + + @staticmethod + def uppercase(value, cr_before=0, cr_after=0): + return ''.join(( + '[CR]' * cr_before, + '[UPPERCASE]', value, '[/UPPERCASE]', + '[CR]' * cr_after, + )) + + @staticmethod + def color(color, value, cr_before=0, cr_after=0): + return ''.join(( + '[CR]' * cr_before, + '[COLOR=', color.lower(), ']', value, '[/COLOR]', + '[CR]' * cr_after, + )) + + @staticmethod + def light(value, cr_before=0, cr_after=0): + return ''.join(( + '[CR]' * cr_before, + '[LIGHT]', value, '[/LIGHT]', + '[CR]' * cr_after, + )) + + @staticmethod + def italic(value, cr_before=0, cr_after=0): + return ''.join(( + '[CR]' * cr_before, + '[I]', value, '[/I]', + '[CR]' * cr_after, + )) + + @staticmethod + def indent(number=1, value='', cr_before=0, cr_after=0): + return ''.join(( + '[CR]' * cr_before, + '[TABS]', str(number), '[/TABS]', value, + '[CR]' * cr_after, + )) + + @staticmethod + def new_line(value=1, cr_before=0, cr_after=0): + if isinstance(value, int): + return '[CR]' * value + return ''.join(( + '[CR]' * cr_before, + value, + '[CR]' * cr_after, + )) + + def set_focus_next_item(self): + list_id = xbmcgui.Window(xbmcgui.getCurrentWindowId()).getFocusId() + try: + position = self._context.get_infolabel('Container.Position') + next_position = int(position) + 1 + self._context.execute('SetFocus({list_id},{position})'.format( + list_id=list_id, position=next_position + )) + except ValueError: + pass + + @staticmethod + def busy_dialog_active(): + dialog_id = xbmcgui.getCurrentWindowDialogId() + if dialog_id == 10160 or dialog_id == 10138: + return dialog_id + return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py new file mode 100644 index 0000000000..3cf8fdb7ae --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from . import info_labels +from ...compatibility import set_info_tag, xbmcgui +from ...items import AudioItem, UriItem, VideoItem +from ...utils import current_system_version, datetime_parser + + +def video_playback_item(context, video_item, show_fanart=None): + uri = video_item.get_uri() + context.log_debug('Converting VideoItem |%s|' % uri) + + settings = context.get_settings() + headers = video_item.get_headers() + license_key = video_item.get_license_key() + alternative_player = settings.is_support_alternative_player_enabled() + is_strm = context.get_param('strm') + mime_type = None + + if is_strm: + kwargs = { + 'path': uri, + 'offscreen': True, + } + else: + kwargs = { + 'label': video_item.get_title() or video_item.get_name(), + 'label2': video_item.get_short_details(), + 'path': uri, + 'offscreen': True, + } + props = { + 'isPlayable': str(video_item.playable).lower(), + } + + if (alternative_player + and settings.alternative_player_web_urls() + and not license_key): + video_item.set_uri('https://www.youtube.com/watch?v={video_id}'.format( + video_id=video_item.video_id + )) + elif (video_item.use_isa_video() + and context.addon_enabled('inputstream.adaptive')): + if video_item.use_mpd_video(): + manifest_type = 'mpd' + mime_type = 'application/dash+xml' + """ + # MPD manifest update is currently broken + # Following line will force a full update but restart live stream + if video_item.live: + props['inputstream.adaptive.manifest_update_parameter'] = 'full' + """ + if 'auto' in settings.stream_select(): + props['inputstream.adaptive.stream_selection_type'] = 'adaptive' + else: + manifest_type = 'hls' + mime_type = 'application/x-mpegURL' + + inputstream_property = ('inputstream' + if current_system_version.compatible(19, 0) else + 'inputstreamaddon') + props[inputstream_property] = 'inputstream.adaptive' + props['inputstream.adaptive.manifest_type'] = manifest_type + + if headers: + props['inputstream.adaptive.manifest_headers'] = headers + props['inputstream.adaptive.stream_headers'] = headers + + if license_key: + props['inputstream.adaptive.license_type'] = 'com.widevine.alpha' + props['inputstream.adaptive.license_key'] = license_key + + else: + if 'mime=' in uri: + mime_type = uri.split('mime=', 1)[1].split('&', 1)[0] + mime_type = mime_type.replace('%2F', '/') + + if not alternative_player and headers and uri.startswith('http'): + video_item.set_uri('|'.join((uri, headers))) + + list_item = xbmcgui.ListItem(**kwargs) + + if mime_type: + list_item.setContentLookup(False) + list_item.setMimeType(mime_type) + + if is_strm: + return list_item + + if not context.get_param('resume'): + if context.get_param('start'): + prop_value = video_item.get_start_time() + if prop_value: + props['ResumeTime'] = prop_value + elif 'ResumeTime' in props: + del props['ResumeTime'] + + prop_value = video_item.get_duration() + if prop_value: + props['TotalTime'] = prop_value + + if show_fanart is None: + show_fanart = settings.show_fanart() + image = video_item.get_image() or 'DefaultVideo.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and video_item.get_fanart() or '', + 'thumb': image, + }) + + if video_item.subtitles: + list_item.setSubtitles(video_item.subtitles) + + item_info = info_labels.create_from_item(video_item) + info_tag = set_info_tag(list_item, item_info, 'video') + info_tag.set_resume_point(props) + + # This should work for all versions of XBMC/KODI. + if 'duration' in item_info: + info_tag.add_stream_info('video', {'duration': item_info['duration']}) + + list_item.setProperties(props) + + return list_item + + +def audio_listitem(context, audio_item, show_fanart=None): + uri = audio_item.get_uri() + context.log_debug('Converting AudioItem |%s|' % uri) + + kwargs = { + 'label': audio_item.get_title() or audio_item.get_name(), + 'label2': audio_item.get_short_details(), + 'path': uri, + 'offscreen': True, + } + props = { + 'isPlayable': str(audio_item.playable).lower(), + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = audio_item.get_image() or 'DefaultAudio.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and audio_item.get_fanart() or '', + 'thumb': image, + }) + + item_info = info_labels.create_from_item(audio_item) + set_info_tag(list_item, item_info, 'music') + + list_item.setProperties(props) + + context_menu = audio_item.get_context_menu() + if context_menu: + list_item.addContextMenuItems( + context_menu, replaceItems=audio_item.replace_context_menu() + ) + + return uri, list_item, False + + +def directory_listitem(context, directory_item, show_fanart=None): + uri = directory_item.get_uri() + context.log_debug('Converting DirectoryItem |%s|' % uri) + + kwargs = { + 'label': directory_item.get_name(), + 'path': uri, + 'offscreen': True, + } + props = { + 'specialSort': 'bottom' if directory_item.next_page else 'top', + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + + # make channel_subscription_id property available for keymapping + prop_value = directory_item.get_channel_subscription_id() + if prop_value: + props['channel_subscription_id'] = prop_value + + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = directory_item.get_image() or 'DefaultFolder.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and directory_item.get_fanart() or '', + 'thumb': image, + }) + + item_info = info_labels.create_from_item(directory_item) + set_info_tag(list_item, item_info, 'video') + + """ + # ListItems that do not open a lower level list should have the isFolder + # parameter of the xbmcplugin.addDirectoryItem set to False, however this + # now appears to mark the ListItem as playable, even if the IsPlayable + # property is not set or set to "false". + # Set isFolder to True as a workaround, regardless of whether the ListItem + # is actually a folder. + is_folder = not directory_item.is_action() + """ + is_folder = True + + list_item.setProperties(props) + + context_menu = directory_item.get_context_menu() + if context_menu is not None: + list_item.addContextMenuItems( + context_menu, replaceItems=directory_item.replace_context_menu() + ) + + return uri, list_item, is_folder + + +def image_listitem(context, image_item, show_fanart=None): + uri = image_item.get_uri() + context.log_debug('Converting ImageItem |%s|' % uri) + + kwargs = { + 'label': image_item.get_name(), + 'path': uri, + 'offscreen': True, + } + props = { + 'isPlayable': str(image_item.playable).lower(), + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = image_item.get_image() or 'DefaultPicture.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and image_item.get_fanart() or '', + 'thumb': image, + }) + + item_info = info_labels.create_from_item(image_item) + set_info_tag(list_item, item_info, 'picture') + + list_item.setProperties(props) + + context_menu = image_item.get_context_menu() + if context_menu is not None: + list_item.addContextMenuItems( + context_menu, replaceItems=image_item.replace_context_menu() + ) + + return uri, list_item, False + + +def uri_listitem(context, uri_item): + uri = uri_item.get_uri() + context.log_debug('Converting UriItem |%s|' % uri) + + kwargs = { + 'label': uri_item.get_name(), + 'path': uri, + 'offscreen': True, + } + props = { + 'isPlayable': str(uri_item.playable).lower(), + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + list_item.setProperties(props) + return list_item + + +def video_listitem(context, video_item, show_fanart=None): + uri = video_item.get_uri() + context.log_debug('Converting VideoItem |%s|' % uri) + + kwargs = { + 'label': video_item.get_title() or video_item.get_name(), + 'label2': video_item.get_short_details(), + 'path': uri, + 'offscreen': True, + } + props = { + 'isPlayable': str(video_item.playable).lower(), + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + + published_at = video_item.get_added_utc() + scheduled_start = video_item.get_scheduled_start_utc() + datetime = scheduled_start or published_at + local_datetime = None + if datetime: + local_datetime = datetime_parser.utc_to_local(datetime) + props['PublishedLocal'] = str(local_datetime) + if video_item.live: + props['PublishedSince'] = context.localize('live') + elif local_datetime: + props['PublishedSince'] = str(datetime_parser.datetime_to_since( + context, local_datetime + )) + + prop_value = video_item.get_start_time() + if prop_value: + props['ResumeTime'] = prop_value + + prop_value = video_item.get_duration() + if prop_value: + props['TotalTime'] = prop_value + + # make channel_id property available for keymapping + prop_value = video_item.get_channel_id() + if prop_value: + props['channel_id'] = prop_value + + # make subscription_id property available for keymapping + prop_value = video_item.get_subscription_id() + if prop_value: + props['subscription_id'] = prop_value + + # make playlist_id property available for keymapping + prop_value = video_item.get_playlist_id() + if prop_value: + props['playlist_id'] = prop_value + + # make playlist_item_id property available for keymapping + prop_value = video_item.get_playlist_item_id() + if prop_value: + props['playlist_item_id'] = prop_value + + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = video_item.get_image() + list_item.setArt({ + 'icon': image or 'DefaultVideo.png', + 'fanart': show_fanart and video_item.get_fanart() or '', + 'thumb': image, + }) + + if video_item.subtitles: + list_item.setSubtitles(video_item.subtitles) + + item_info = info_labels.create_from_item(video_item) + info_tag = set_info_tag(list_item, item_info, 'video') + info_tag.set_resume_point(props) + + # This should work for all versions of XBMC/KODI. + if 'duration' in item_info: + info_tag.add_stream_info('video', {'duration': item_info['duration']}) + + list_item.setProperties(props) + + context_menu = video_item.get_context_menu() + if context_menu: + list_item.addContextMenuItems( + context_menu, replaceItems=video_item.replace_context_menu() + ) + + return uri, list_item, False + + +def playback_item(context, base_item, show_fanart=None): + if isinstance(base_item, UriItem): + return uri_listitem(context, base_item) + + if isinstance(base_item, AudioItem): + _, item, _ = audio_listitem(context, base_item, show_fanart) + return item + + if isinstance(base_item, VideoItem): + return video_playback_item(context, base_item, show_fanart) + + return None diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_progress_dialog.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_progress_dialog.py new file mode 100644 index 0000000000..596ada365d --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_progress_dialog.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from ..abstract_progress_dialog import AbstractProgressDialog +from ...compatibility import xbmcgui + + +class XbmcProgressDialog(AbstractProgressDialog): + def __init__(self, heading, text): + super(XbmcProgressDialog, self).__init__(xbmcgui.DialogProgress, + heading, + text, + 100) + + def is_aborted(self): + return self._dialog.iscanceled() + + +class XbmcProgressDialogBG(AbstractProgressDialog): + def __init__(self, heading, text): + super(XbmcProgressDialogBG, self).__init__(xbmcgui.DialogProgressBG, + heading, + text, + 100) + + def is_aborted(self): + return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/__init__.py index 717fb4dc29..7861ebf0a6 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/__init__.py @@ -8,24 +8,51 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from . import datetime_parser -from .methods import * -from .search_history import SearchHistory -from .favorite_list import FavoriteList -from .watch_later_list import WatchLaterList -from .function_cache import FunctionCache -from .access_manager import AccessManager -from .http_server import get_http_server, is_httpd_live, get_client_ip_address -from .monitor import YouTubeMonitor -from .player import YouTubePlayer -from .playback_history import PlaybackHistory -from .data_cache import DataCache -from .system_version import SystemVersion -from . import ip_api +from .methods import ( + create_path, + create_uri_path, + duration_to_seconds, + find_best_fit, + find_video_id, + friendly_number, + get_kodi_setting, + loose_version, + make_dirs, + merge_dicts, + rm_dir, + seconds_to_duration, + select_stream, + strip_html_from_text, + to_str, + to_unicode, +) +from .player_monitor import PlayerMonitor +from .service_monitor import ServiceMonitor +from .system_version import current_system_version -__all__ = ['SearchHistory', 'FavoriteList', 'WatchLaterList', 'FunctionCache', 'AccessManager', - 'strip_html_from_text', 'create_path', 'create_uri_path', 'find_best_fit', 'to_unicode', 'to_utf8', - 'datetime_parser', 'select_stream', 'get_http_server', 'is_httpd_live', 'YouTubeMonitor', - 'make_dirs', 'loose_version', 'ip_api', 'PlaybackHistory', 'DataCache', 'get_client_ip_address', - 'SystemVersion', 'find_video_id', 'YouTubePlayer'] +__all__ = ( + 'PlayerMonitor', + 'ServiceMonitor', + 'create_path', + 'create_uri_path', + 'current_system_version', + 'datetime_parser', + 'duration_to_seconds', + 'find_best_fit', + 'find_video_id', + 'friendly_number', + 'get_kodi_setting', + 'loose_version', + 'make_dirs', + 'merge_dicts', + 'rm_dir', + 'seconds_to_duration', + 'select_stream', + 'strip_html_from_text', + 'to_str', + 'to_unicode', +) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/access_manager.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/access_manager.py deleted file mode 100644 index 2e6405bb3f..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/access_manager.py +++ /dev/null @@ -1,363 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import uuid -import time - -from hashlib import md5 - -from ..json_store import LoginTokenStore - -__author__ = 'bromix' - - -class AccessManager(object): - def __init__(self, context): - self._settings = context.get_settings() - self._jstore = LoginTokenStore() - self._json = self._jstore.get_data() - self._user = self._json['access_manager'].get('current_user', '0') - self._last_origin = self._json['access_manager'].get('last_origin', 'plugin.video.youtube') - - def get_current_user_id(self): - """ - - :return: uuid of the current user - """ - self._json = self._jstore.get_data() - return self._json['access_manager']['users'][self.get_user()]['id'] - - def get_new_user(self, user_name=''): - """ - :param user_name: string, users name - :return: a new user dict - """ - uuids = list() - new_uuid = uuid.uuid4().hex - - for k in list(self._json['access_manager']['users'].keys()): - user_uuid = self._json['access_manager']['users'][k].get('id') - if user_uuid: - uuids.append(user_uuid) - - while new_uuid in uuids: - new_uuid = uuid.uuid4().hex - - return {'access_token': '', 'refresh_token': '', 'token_expires': -1, 'last_key_hash': '', - 'name': user_name, 'id': new_uuid, 'watch_later': ' WL', 'watch_history': 'HL'} - - def get_users(self): - """ - Returns users - :return: users - """ - return self._json['access_manager'].get('users', {}) - - def set_users(self, users): - """ - Updates the users - :param users: dict, users - :return: - """ - self._json = self._jstore.get_data() - self._json['access_manager']['users'] = users - self._jstore.save(self._json) - - def set_user(self, user, switch_to=False): - """ - Updates the user - :param user: string, username - :param switch_to: boolean, change current user - :return: - """ - self._user = user - if switch_to: - self._json = self._jstore.get_data() - self._json['access_manager']['current_user'] = user - self._jstore.save(self._json) - - def get_user(self): - """ - Returns the current user - :return: user - """ - return self._user - - def get_watch_later_id(self): - """ - Returns the current users watch later playlist id - :return: the current users watch later playlist id - """ - - self._json = self._jstore.get_data() - current_playlist_id = self._json['access_manager']['users'].get(self._user, {}).get('watch_later', '') - settings_playlist_id = self._settings.get_string('youtube.folder.watch_later.playlist', '').strip() - - if settings_playlist_id.lower().startswith(('wl', ' wl')): - self._settings.set_string('youtube.folder.watch_later.playlist', '') - settings_playlist_id = '' - - if current_playlist_id.lower().startswith(('wl', ' wl')): - self._json['access_manager']['users'][self._user]['watch_later'] = settings_playlist_id - self._jstore.save(self._json) - - self._settings.set_string('youtube.folder.watch_later.playlist', '') - settings_playlist_id = '' - - if settings_playlist_id and current_playlist_id != settings_playlist_id: - self._json['access_manager']['users'][self._user]['watch_later'] = settings_playlist_id - self._jstore.save(self._json) - - self._settings.set_string('youtube.folder.watch_later.playlist', '') - - return self._json['access_manager']['users'].get(self._user, {}).get('watch_later', '') - - def set_watch_later_id(self, playlist_id): - """ - Sets the current users watch later playlist id - :param playlist_id: string, watch later playlist id - :return: - """ - if playlist_id.lower() == 'wl' or playlist_id.lower() == ' wl': - playlist_id = '' - - self._json = self._jstore.get_data() - self._json['access_manager']['users'][self._user]['watch_later'] = playlist_id - self._settings.set_string('youtube.folder.watch_later.playlist', '') - self._jstore.save(self._json) - - def get_watch_history_id(self): - """ - Returns the current users watch history playlist id - :return: the current users watch history playlist id - """ - - self._json = self._jstore.get_data() - current_playlist_id = self._json['access_manager']['users'].get(self._user, {}).get('watch_history', 'HL') - settings_playlist_id = self._settings.get_string('youtube.folder.history.playlist', '').strip() - if settings_playlist_id and (current_playlist_id != settings_playlist_id): - self._json['access_manager']['users'][self._user]['watch_history'] = settings_playlist_id - self._jstore.save(self._json) - self._settings.set_string('youtube.folder.history.playlist', '') - return self._json['access_manager']['users'].get(self._user, {}).get('watch_history', 'HL') - - def set_watch_history_id(self, playlist_id): - """ - Sets the current users watch history playlist id - :param playlist_id: string, watch history playlist id - :return: - """ - - self._json = self._jstore.get_data() - self._json['access_manager']['users'][self._user]['watch_history'] = playlist_id - self._settings.set_string('youtube.folder.history.playlist', '') - self._jstore.save(self._json) - - def set_last_origin(self, origin): - """ - Updates the origin - :param origin: string, origin - :return: - """ - self._last_origin = origin - self._json = self._jstore.get_data() - self._json['access_manager']['last_origin'] = origin - self._jstore.save(self._json) - - def get_last_origin(self): - """ - Returns the last origin - :return: - """ - return self._last_origin - - def get_access_token(self): - """ - Returns the access token for some API - :return: access_token - """ - self._json = self._jstore.get_data() - return self._json['access_manager']['users'].get(self._user, {}).get('access_token', '') - - def get_refresh_token(self): - """ - Returns the refresh token - :return: refresh token - """ - self._json = self._jstore.get_data() - return self._json['access_manager']['users'].get(self._user, {}).get('refresh_token', '') - - def has_refresh_token(self): - return self.get_refresh_token() != '' - - def is_access_token_expired(self): - """ - Returns True if the access_token is expired otherwise False. - If no expiration date was provided and an access_token exists - this method will always return True - :return: - """ - self._json = self._jstore.get_data() - access_token = self._json['access_manager']['users'].get(self._user, {}).get('access_token', '') - expires = int(self._json['access_manager']['users'].get(self._user, {}).get('token_expires', -1)) - - # with no access_token it must be expired - if not access_token: - return True - - # in this case no expiration date was set - if expires == -1: - return False - - now = int(time.time()) - return expires <= now - - def update_access_token(self, access_token, unix_timestamp=None, refresh_token=None): - """ - Updates the old access token with the new one. - :param access_token: - :param unix_timestamp: - :param refresh_token: - :return: - """ - self._json = self._jstore.get_data() - self._json['access_manager']['users'][self._user]['access_token'] = access_token - - if unix_timestamp is not None: - self._json['access_manager']['users'][self._user]['token_expires'] = int(unix_timestamp) - - if refresh_token is not None: - self._json['access_manager']['users'][self._user]['refresh_token'] = refresh_token - - self._jstore.save(self._json) - - @staticmethod - def get_new_developer(): - """ - :return: a new developer dict - """ - - return {'access_token': '', 'refresh_token': '', 'token_expires': -1, 'last_key_hash': ''} - - def get_developers(self): - """ - Returns developers - :return: dict, developers - """ - return self._json['access_manager'].get('developers', {}) - - def set_developers(self, developers): - """ - Updates the users - :param developers: dict, developers - :return: - """ - self._json = self._jstore.get_data() - self._json['access_manager']['developers'] = developers - self._jstore.save(self._json) - - def get_dev_access_token(self, addon_id): - """ - Returns the access token for some API - :param addon_id: addon id - :return: access_token - """ - self._json = self._jstore.get_data() - return self._json['access_manager']['developers'].get(addon_id, {}).get('access_token', '') - - def get_dev_refresh_token(self, addon_id): - """ - Returns the refresh token - :return: refresh token - """ - self._json = self._jstore.get_data() - return self._json['access_manager']['developers'].get(addon_id, {}).get('refresh_token', '') - - def developer_has_refresh_token(self, addon_id): - return self.get_dev_refresh_token(addon_id) != '' - - def is_dev_access_token_expired(self, addon_id): - """ - Returns True if the access_token is expired otherwise False. - If no expiration date was provided and an access_token exists - this method will always return True - :return: - """ - self._json = self._jstore.get_data() - access_token = self._json['access_manager']['developers'].get(addon_id, {}).get('access_token', '') - expires = int(self._json['access_manager']['developers'].get(addon_id, {}).get('token_expires', -1)) - - # with no access_token it must be expired - if not access_token: - return True - - # in this case no expiration date was set - if expires == -1: - return False - - now = int(time.time()) - return expires <= now - - def update_dev_access_token(self, addon_id, access_token, unix_timestamp=None, refresh_token=None): - """ - Updates the old access token with the new one. - :param addon_id: - :param access_token: - :param unix_timestamp: - :param refresh_token: - :return: - """ - self._json = self._jstore.get_data() - self._json['access_manager']['developers'][addon_id]['access_token'] = access_token - - if unix_timestamp is not None: - self._json['access_manager']['developers'][addon_id]['token_expires'] = int(unix_timestamp) - - if refresh_token is not None: - self._json['access_manager']['developers'][addon_id]['refresh_token'] = refresh_token - - self._jstore.save(self._json) - - def get_dev_last_key_hash(self, addon_id): - self._json = self._jstore.get_data() - return self._json['access_manager']['developers'][addon_id]['last_key_hash'] - - def set_dev_last_key_hash(self, addon_id, key_hash): - self._json = self._jstore.get_data() - self._json['access_manager']['developers'][addon_id]['last_key_hash'] = key_hash - self._jstore.save(self._json) - - def dev_keys_changed(self, addon_id, api_key, client_id, client_secret): - self._json = self._jstore.get_data() - last_hash = self._json['access_manager']['developers'][addon_id]['last_key_hash'] - current_hash = self.__calc_key_hash(api_key, client_id, client_secret) - if not last_hash and current_hash: - self.set_dev_last_key_hash(addon_id, current_hash) - return False - if last_hash != current_hash: - self.set_dev_last_key_hash(addon_id, current_hash) - return True - else: - return False - - @staticmethod - def __calc_key_hash(api_key, client_id, client_secret): - - m = md5() - try: - m.update(api_key.encode('utf-8')) - m.update(client_id.encode('utf-8')) - m.update(client_secret.encode('utf-8')) - except: - m.update(api_key) - m.update(client_id) - m.update(client_secret) - - return m.hexdigest() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/data_cache.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/data_cache.py deleted file mode 100644 index 62ced2269e..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/data_cache.py +++ /dev/null @@ -1,124 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2019 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import json -import pickle -import sqlite3 - -from datetime import datetime, timedelta - -from .storage import Storage -from .. import logger - - -class DataCache(Storage): - ONE_MINUTE = 60 - ONE_HOUR = 60 * ONE_MINUTE - ONE_DAY = 24 * ONE_HOUR - ONE_WEEK = 7 * ONE_DAY - ONE_MONTH = 4 * ONE_WEEK - - def __init__(self, filename, max_file_size_mb=5): - max_file_size_kb = max_file_size_mb * 1024 - Storage.__init__(self, filename, max_file_size_kb=max_file_size_kb) - - def is_empty(self): - return self._is_empty() - - def get_items(self, seconds, content_ids): - def _decode(obj): - return pickle.loads(obj) - - current_time = datetime.now() - placeholders = ','.join(['?' for _ in content_ids]) - keys = [str(item) for item in content_ids] - query = 'SELECT * FROM %s WHERE key IN (%s)' % (self._table_name, placeholders) - - self._open() - - query_result = self._execute(False, query, keys) - result = {} - if query_result: - for item in query_result: - cached_time = item[1] - if cached_time is None: - logger.log_error('Data Cache [get_items]: cached_time is None while getting {content_id}'.format(content_id=str(item[0]))) - cached_time = current_time - # this is so stupid, but we have the function 'total_seconds' only starting with python 2.7 - diff_seconds = self.get_seconds_diff(cached_time) - if diff_seconds <= seconds: - result[str(item[0])] = json.loads(_decode(item[2])) - - self._close() - return result - - def get_item(self, seconds, content_id): - content_id = str(content_id) - query_result = self._get(content_id) - result = {} - if query_result: - current_time = datetime.now() - cached_time = query_result[1] - if cached_time is None: - logger.log_error('Data Cache [get]: cached_time is None while getting {content_id}'.format(content_id=content_id)) - cached_time = current_time - # this is so stupid, but we have the function 'total_seconds' only starting with python 2.7 - diff_seconds = self.get_seconds_diff(cached_time) - if diff_seconds <= seconds: - result[content_id] = json.loads(query_result[0]) - - return result - - def set(self, content_id, item): - self._set(content_id, item) - - def set_all(self, items): - self._set_all(items) - - def clear(self): - self._clear() - - def remove(self, content_id): - self._remove(content_id) - - def update(self, content_id, item): - self._set(str(content_id), json.dumps(item)) - - def _optimize_item_count(self): - pass - - def _set(self, content_id, item): - def _encode(obj): - return sqlite3.Binary(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)) - - current_time = datetime.now() + timedelta(microseconds=1) - query = 'REPLACE INTO %s (key,time,value) VALUES(?,?,?)' % self._table_name - - self._open() - self._execute(True, query, values=[content_id, current_time, _encode(item)]) - self._close() - - def _set_all(self, items): - def _encode(obj): - return sqlite3.Binary(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)) - - needs_commit = True - current_time = datetime.now() + timedelta(microseconds=1) - - query = 'REPLACE INTO %s (key,time,value) VALUES(?,?,?)' % self._table_name - - self._open() - - for key in list(items.keys()): - item = items[key] - self._execute(needs_commit, query, values=[key, current_time, _encode(json.dumps(item))]) - needs_commit = False - - self._close() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py index 41b7bf4c1d..df1487afe2 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py @@ -8,179 +8,275 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re -import time -from datetime import date, datetime, timedelta -from datetime import time as dt_time +from datetime import date, datetime, time as dt_time, timedelta +from importlib import import_module +from sys import modules from ..exceptions import KodionException - -__RE_MATCH_TIME_ONLY__ = re.compile(r'^(?P[0-9]{2})([:]?(?P[0-9]{2})([:]?(?P[0-9]{2}))?)?$') -__RE_MATCH_DATE_ONLY__ = re.compile(r'^(?P[0-9]{4})[-]?(?P[0-9]{2})[-]?(?P[0-9]{2})$') -__RE_MATCH_DATETIME__ = re.compile(r'^(?P[0-9]{4})[-]?(?P[0-9]{2})[-]?(?P[0-9]{2})["T ](?P[0-9]{2})[:]?(?P[0-9]{2})[:]?(?P[0-9]{2})') -__RE_MATCH_PERIOD__ = re.compile(r'P((?P\d+)Y)?((?P\d+)M)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?') -__RE_MATCH_ABBREVIATED__ = re.compile(r'(\w+), (?P\d+) (?P\w+) (?P\d+) (?P\d+):(?P\d+):(?P\d+)') +from ..logger import log_error + +try: + from datetime import timezone +except ImportError: + timezone = None + + +__RE_MATCH_TIME_ONLY__ = re.compile( + r'^(?P[0-9]{2})(:?(?P[0-9]{2})(:?(?P[0-9]{2}))?)?$' +) +__RE_MATCH_DATE_ONLY__ = re.compile( + r'^(?P[0-9]{4})[-/.]?(?P[0-9]{2})[-/.]?(?P[0-9]{2})$' +) +__RE_MATCH_DATETIME__ = re.compile( + r'^(?P[0-9]{4})[-/.]?(?P[0-9]{2})[-/.]?(?P[0-9]{2})' + r'["T ](?P[0-9]{2}):?(?P[0-9]{2}):?(?P[0-9]{2})' +) +__RE_MATCH_PERIOD__ = re.compile( + r'P((?P\d+)Y)?((?P\d+)M)?((?P\d+)D)?' + r'(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?' +) +__RE_MATCH_ABBREVIATED__ = re.compile( + r'\w+, (?P\d+) (?P\w+) (?P\d+)' + r' (?P\d+):(?P\d+):(?P\d+)' +) + +__INTERNAL_CONSTANTS__ = { + 'epoch_dt': ( + datetime.fromtimestamp(0, tz=timezone.utc) if timezone + else datetime.fromtimestamp(0) + ), + 'local_offset': None, + 'Jan': 1, + 'Feb': 2, + 'Mar': 3, + 'Apr': 4, + 'May': 5, + 'June': 6, + 'Jun': 6, + 'July': 7, + 'Jul': 7, + 'Aug': 8, + 'Sept': 9, + 'Sep': 9, + 'Oct': 10, + 'Nov': 11, + 'Dec': 12, +} now = datetime.now - - -def py2_utf8(text): - return text - - -def parse(datetime_string, localize=True): - _utc_to_local = utc_to_local if localize else lambda x: x - - def _to_int(value): - if value is None: - return 0 - return int(value) - - # match time only '00:45:10' - time_only_match = __RE_MATCH_TIME_ONLY__.match(datetime_string) - if time_only_match: - return _utc_to_local(datetime.combine(date.today(), - dt_time(hour=_to_int(time_only_match.group('hour')), - minute=_to_int(time_only_match.group('minute')), - second=_to_int(time_only_match.group('second')))) - ).time() +fromtimestamp = datetime.fromtimestamp + + +def parse(datetime_string): + if not datetime_string: + return None + + # match time only "00:45:10" + match = __RE_MATCH_TIME_ONLY__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return datetime.combine( + date=date.today(), + time=dt_time(**match) + ).time() # match date only '2014-11-08' - date_only_match = __RE_MATCH_DATE_ONLY__.match(datetime_string) - if date_only_match: - return _utc_to_local(date(_to_int(date_only_match.group('year')), - _to_int(date_only_match.group('month')), - _to_int(date_only_match.group('day')))) + match = __RE_MATCH_DATE_ONLY__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return datetime(**match) # full date time - date_time_match = __RE_MATCH_DATETIME__.match(datetime_string) - if date_time_match: - return _utc_to_local(datetime(_to_int(date_time_match.group('year')), - _to_int(date_time_match.group('month')), - _to_int(date_time_match.group('day')), - _to_int(date_time_match.group('hour')), - _to_int(date_time_match.group('minute')), - _to_int(date_time_match.group('second')))) - - # period - at the moment we support only hours, minutes and seconds (e.g. videos and audio) - period_match = __RE_MATCH_PERIOD__.match(datetime_string) - if period_match: - return timedelta(hours=_to_int(period_match.group('hours')), - minutes=_to_int(period_match.group('minutes')), - seconds=_to_int(period_match.group('seconds'))) + match = __RE_MATCH_DATETIME__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return datetime(**match) + + # period - at the moment we support only hours, minutes and seconds + # e.g. videos and audio + match = __RE_MATCH_PERIOD__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return timedelta(**match) # abbreviated match - abbreviated_match = __RE_MATCH_ABBREVIATED__.match(datetime_string) - if abbreviated_match: - month = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'June': 6, 'Jun': 6, 'July': 7, 'Jul': 7, 'Aug': 8, - 'Sept': 9, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12} - return _utc_to_local(datetime(year=_to_int(abbreviated_match.group('year')), - month=month[abbreviated_match.group('month')], - day=_to_int(abbreviated_match.group('day')), - hour=_to_int(abbreviated_match.group('hour')), - minute=_to_int(abbreviated_match.group('minute')), - second=_to_int(abbreviated_match.group('second')))) - - raise KodionException("Could not parse iso 8601 timestamp '%s'" % datetime_string) - - -def get_scheduled_start(datetime_object, localize=True): - start_hour = '{:02d}'.format(datetime_object.hour) - start_minute = '{:<02d}'.format(datetime_object.minute) - start_time = ':'.join([start_hour, start_minute]) - start_date = str(datetime_object.date()) - if localize: - now = datetime.now() + match = __RE_MATCH_ABBREVIATED__.match(datetime_string) + if match: + match = { + group: ( + __INTERNAL_CONSTANTS__.get(value, 0) if group == 'month' + else int(value) + ) + for group, value in match.groupdict().items() + if value + } + return datetime(**match) + + raise KodionException('Could not parse |{datetime}| as ISO 8601' + .format(datetime=datetime_string)) + + +def get_scheduled_start(context, datetime_object, local=True): + if timezone: + _now = now(tz=timezone.utc) + if local: + _now = _now.astimezone(None) else: - now = datetime.utcnow() - start_date = start_date.replace(str(now.year), '').lstrip('-') - start_date = start_date.replace('-'.join(['{:02d}'.format(now.month), '{:02d}'.format(now.day)]), '') - return start_date, start_time + _now = now() if local else datetime.utcnow() - -local_timezone_offset = None + if datetime_object.date() == _now.date(): + return '@ {start_time}'.format( + start_time=context.format_time(datetime_object.time()) + ) + return '@ {start_date}, {start_time}'.format( + start_time=context.format_time(datetime_object.time()), + start_date=context.format_date_short(datetime_object.date()) + ) def utc_to_local(dt): - global local_timezone_offset - if local_timezone_offset is None: - now = time.time() - local_timezone_offset = datetime.fromtimestamp(now) - datetime.utcfromtimestamp(now) + if timezone: + return dt.astimezone(None) + + if __INTERNAL_CONSTANTS__['local_offset']: + offset = __INTERNAL_CONSTANTS__['local_offset'] + else: + offset = now() - datetime.utcnow() + __INTERNAL_CONSTANTS__['local_offset'] = offset + + return dt + offset - return dt + local_timezone_offset +def datetime_to_since(context, dt, local=True): + if timezone: + _now = now(tz=timezone.utc) + if local: + _now = _now.astimezone(None) + else: + _now = now() if local else datetime.utcnow() -def datetime_to_since(context, dt): - now = datetime.now() - diff = now - dt - yesterday = now - timedelta(days=1) - yyesterday = now - timedelta(days=2) - use_yesterday = total_seconds(now - yesterday) > 10800 - today = now.date() + diff = _now - dt + yesterday = _now - timedelta(days=1) + yyesterday = _now - timedelta(days=2) + use_yesterday = (_now - yesterday).total_seconds() > 10800 + today = _now.date() tomorrow = today + timedelta(days=1) - seconds = total_seconds(diff) + seconds = diff.total_seconds() if seconds > 0: if seconds < 60: - return py2_utf8(context.localize('30676')) - elif 60 <= seconds < 120: - return py2_utf8(context.localize('30677')) - elif 120 <= seconds < 3600: - return py2_utf8(context.localize('30678')) - elif 3600 <= seconds < 7200: - return py2_utf8(context.localize('30679')) - elif 7200 <= seconds < 10800: - return py2_utf8(context.localize('30680')) - elif 10800 <= seconds < 14400: - return py2_utf8(context.localize('30681')) - elif use_yesterday and dt.date() == yesterday.date(): - return ' '.join([py2_utf8(context.localize('30682')), context.format_time(dt)]) - elif dt.date() == yyesterday.date(): - return py2_utf8(context.localize('30683')) - elif 5400 <= seconds < 86400: - return ' '.join([py2_utf8(context.localize('30684')), context.format_time(dt)]) - elif 86400 <= seconds < 172800: - return ' '.join([py2_utf8(context.localize('30682')), context.format_time(dt)]) + return context.localize('datetime.just_now') + if 60 <= seconds < 120: + return context.localize('datetime.a_minute_ago') + if 120 <= seconds < 3600: + return context.localize('datetime.recently') + if 3600 <= seconds < 7200: + return context.localize('datetime.an_hour_ago') + if 7200 <= seconds < 10800: + return context.localize('datetime.two_hours_ago') + if 10800 <= seconds < 14400: + return context.localize('datetime.three_hours_ago') + if use_yesterday and dt.date() == yesterday.date(): + return ' '.join((context.localize('datetime.yesterday_at'), + context.format_time(dt))) + if dt.date() == yyesterday.date(): + return context.localize('datetime.two_days_ago') + if 5400 <= seconds < 86400: + return ' '.join((context.localize('datetime.today_at'), + context.format_time(dt))) + if 86400 <= seconds < 172800: + return ' '.join((context.localize('datetime.yesterday_at'), + context.format_time(dt))) else: seconds *= -1 if seconds < 60: - return py2_utf8(context.localize('30691')) - elif 60 <= seconds < 120: - return py2_utf8(context.localize('30692')) - elif 120 <= seconds < 3600: - return py2_utf8(context.localize('30693')) - elif 3600 <= seconds < 7200: - return py2_utf8(context.localize('30694')) - elif 7200 <= seconds < 10800: - return py2_utf8(context.localize('30695')) - elif dt.date() == today: - return ' '.join([py2_utf8(context.localize('30696')), context.format_time(dt)]) - elif dt.date() == tomorrow: - return ' '.join([py2_utf8(context.localize('30697')), context.format_time(dt)]) - - return ' '.join([context.format_date_short(dt), context.format_time(dt)]) - - -def strptime(s, fmt='%Y-%m-%dT%H:%M:%S.%fZ'): - # noinspection PyUnresolvedReferences - - ms_precision = '.' in s[-5:-1] - if fmt == '%Y-%m-%dT%H:%M:%S.%fZ' and not ms_precision: - fmt = '%Y-%m-%dT%H:%M:%SZ' - elif fmt == '%Y-%m-%dT%H:%M:%SZ' and ms_precision: - fmt = '%Y-%m-%dT%H:%M:%S.%fZ' - - import _strptime + return context.localize('datetime.airing_now') + if 60 <= seconds < 120: + return context.localize('datetime.in_a_minute') + if 120 <= seconds < 3600: + return context.localize('datetime.airing_soon') + if 3600 <= seconds < 7200: + return context.localize('datetime.in_over_an_hour') + if 7200 <= seconds < 10800: + return context.localize('datetime.in_over_two_hours') + if dt.date() == today: + return ' '.join((context.localize('datetime.airing_today_at'), + context.format_time(dt))) + if dt.date() == tomorrow: + return ' '.join((context.localize('datetime.tomorrow_at'), + context.format_time(dt))) + + return ' '.join((context.format_date_short(dt), context.format_time(dt))) + + +def strptime(datetime_str, fmt=None): + if fmt is None: + fmt = '%Y-%m-%dT%H%M%S' + + if ' ' in datetime_str: + date_part, time_part = datetime_str.split(' ') + elif 'T' in datetime_str: + date_part, time_part = datetime_str.split('T') + + if ':' in time_part: + time_part = time_part.replace(':', '') + + if '+' in time_part: + time_part, offset, timezone_part = time_part.partition('+') + elif '-' in time_part: + time_part, offset, timezone_part = time_part.partition('+') + else: + offset = timezone_part = '' + + if timezone and timezone_part and offset: + fmt = fmt.replace('%S', '%S%z') + else: + fmt = fmt.replace('%S%z', '%S') + + if '.' in time_part: + fmt = fmt.replace('%S', '%S.%f') + else: + fmt = fmt.replace('%S.%f', '%S') + + if timezone and timezone_part and offset: + time_part = offset.join((time_part, timezone_part)) + datetime_str = 'T'.join((date_part, time_part)) + try: - time.strptime('01 01 2012', '%d %m %Y') # dummy call - except: - pass - return datetime(*time.strptime(s, fmt)[:6]) + return datetime.strptime(datetime_str, fmt) + except TypeError: + log_error('Python strptime bug workaround.\n' + 'Refer to https://github.com/python/cpython/issues/71587') + if '_strptime' not in modules: + modules['_strptime'] = import_module('_strptime') + _strptime = modules['_strptime'] -def total_seconds(t_delta): # required for python 2.6 which doesn't have datetime.timedelta.total_seconds - return 24 * 60 * 60 * t_delta.days + t_delta.seconds + (t_delta.microseconds // 1000000.) + if timezone: + return _strptime._strptime_datetime(datetime, datetime_str, fmt) + return datetime(*(_strptime._strptime(datetime_str, fmt)[0][0:6])) -def since_epoch(dt_object): - return total_seconds(dt_object - datetime(1970, 1, 1)) +def since_epoch(dt_object=None): + if dt_object is None: + dt_object = now(tz=timezone.utc) if timezone else datetime.utcnow() + return (dt_object - __INTERNAL_CONSTANTS__['epoch_dt']).total_seconds() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/favorite_list.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/favorite_list.py deleted file mode 100644 index c75c2755c7..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/favorite_list.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -from .storage import Storage -from .. import items - - -class FavoriteList(Storage): - def __init__(self, filename): - Storage.__init__(self, filename) - - def clear(self): - self._clear() - - def list(self): - result = [] - - for key in self._get_ids(): - data = self._get(key) - item = items.from_json(data[0]) - result.append(item) - - def _sort(_item): - return _item.get_name().upper() - - return sorted(result, key=_sort, reverse=False) - - def add(self, base_item): - item_json_data = items.to_json(base_item) - self._set(base_item.get_id(), item_json_data) - - def remove(self, base_item): - self._remove(base_item.get_id()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/function_cache.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/function_cache.py deleted file mode 100644 index cdd4ed4e33..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/function_cache.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2019 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -from functools import partial -import hashlib - -from .storage import Storage - - -class FunctionCache(Storage): - ONE_MINUTE = 60 - ONE_HOUR = 60 * ONE_MINUTE - ONE_DAY = 24 * ONE_HOUR - ONE_WEEK = 7 * ONE_DAY - ONE_MONTH = 4 * ONE_WEEK - - def __init__(self, filename, max_file_size_mb=5): - max_file_size_kb = max_file_size_mb * 1024 - Storage.__init__(self, filename, max_file_size_kb=max_file_size_kb) - - self._enabled = True - - def clear(self): - self._clear() - - def enabled(self): - """ - Enables the caching - :return: - """ - self._enabled = True - - def disable(self): - """ - Disable caching e.g. for tests - :return: - """ - self._enabled = False - - @staticmethod - def _create_id_from_func(partial_func): - """ - Creats an id from the given function - :param partial_func: - :return: id for the given function - """ - m = hashlib.md5() - m.update(partial_func.func.__module__.encode('utf-8')) - m.update(partial_func.func.__name__.encode('utf-8')) - m.update(str(partial_func.args).encode('utf-8')) - m.update(str(partial_func.keywords).encode('utf-8')) - return m.hexdigest() - - def _get_cached_data(self, partial_func): - cache_id = self._create_id_from_func(partial_func) - return self._get(cache_id), cache_id - - def get_cached_only(self, func, *args, **keywords): - partial_func = partial(func, *args, **keywords) - - # if caching is disabled call the function - if not self._enabled: - return partial_func() - - # only return before cached data - data, cache_id = self._get_cached_data(partial_func) - if data is not None: - return data[0] - - return None - - def get(self, seconds, func, *args, **keywords): - """ - Returns the cached data of the given function. - :param partial_func: function to cache - :param seconds: time to live in seconds - :param return_cached_only: return only cached data and don't call the function - :return: - """ - - partial_func = partial(func, *args, **keywords) - - # if caching is disabled call the function - if not self._enabled: - return partial_func() - - cached_data = None - cached_time = None - data, cache_id = self._get_cached_data(partial_func) - if data is not None: - cached_data = data[0] - cached_time = data[1] - - diff_seconds = 0 - - if cached_time is not None: - # this is so stupid, but we have the function 'total_seconds' only starting with python 2.7 - diff_seconds = self.get_seconds_diff(cached_time) - - if cached_data is None or diff_seconds > seconds: - cached_data = partial_func() - self._set(cache_id, cached_data) - - return cached_data - - def _optimize_item_count(self): - # override method from resources/lib/youtube_plugin/kodion/utils/storage.py - # for function cache do not optimize by item count, using database size. - pass diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/http_server.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/http_server.py deleted file mode 100644 index 522e5a8300..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/http_server.py +++ /dev/null @@ -1,511 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import json -import os -import re -import requests -import socket -from http import server as BaseHTTPServer -from urllib.parse import parse_qs -from urllib.parse import urlparse - -import xbmc -import xbmcaddon -import xbmcgui -import xbmcvfs - -from .. import logger - -try: - xbmc.translatePath = xbmcvfs.translatePath -except AttributeError: - pass - -class YouTubeRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): - - def __init__(self, request, client_address, server): - self.addon_id = 'plugin.video.youtube' - addon = xbmcaddon.Addon(self.addon_id) - whitelist_ips = addon.getSetting('kodion.http.ip.whitelist') - whitelist_ips = ''.join(whitelist_ips.split()) - self.whitelist_ips = whitelist_ips.split(',') - self.local_ranges = ('10.', '172.16.', '192.168.', '127.0.0.1', 'localhost', '::1') - self.chunk_size = 1024 * 64 - try: - self.base_path = xbmc.translatePath('special://temp/%s' % self.addon_id).decode('utf-8') - except AttributeError: - self.base_path = xbmc.translatePath('special://temp/%s' % self.addon_id) - BaseHTTPServer.BaseHTTPRequestHandler.__init__(self, request, client_address, server) - - def connection_allowed(self): - client_ip = self.client_address[0] - log_lines = ['HTTPServer: Connection from |%s|' % client_ip] - conn_allowed = client_ip.startswith(self.local_ranges) - log_lines.append('Local range: |%s|' % str(conn_allowed)) - if not conn_allowed: - conn_allowed = client_ip in self.whitelist_ips - log_lines.append('Whitelisted: |%s|' % str(conn_allowed)) - - if not conn_allowed: - logger.log_debug('HTTPServer: Connection from |%s| not allowed' % client_ip) - else: - if self.path != '/ping': - logger.log_debug(' '.join(log_lines)) - return conn_allowed - - # noinspection PyPep8Naming - def do_GET(self): - addon = xbmcaddon.Addon('plugin.video.youtube') - mpd_proxy_enabled = addon.getSetting('kodion.mpd.videos') == 'true' and addon.getSetting('kodion.video.quality.mpd') == 'true' - api_config_enabled = addon.getSetting('youtube.api.config.page') == 'true' - - # Strip trailing slash if present - stripped_path = self.path.rstrip('/') - - if stripped_path == '/client_ip': - client_json = json.dumps({"ip": "{ip}".format(ip=self.client_address[0])}) - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.send_header('Content-Length', len(client_json)) - self.end_headers() - self.wfile.write(client_json.encode('utf-8')) - - if stripped_path != '/ping': - logger.log_debug('HTTPServer: GET Request uri path |{proxy_path}|'.format(proxy_path=self.path)) - - if not self.connection_allowed(): - self.send_error(403) - else: - if mpd_proxy_enabled and self.path.endswith('.mpd'): - file_path = os.path.join(self.base_path, self.path.strip('/').strip('\\')) - file_chunk = True - logger.log_debug('HTTPServer: Request file path |{file_path}|'.format(file_path=file_path.encode('utf-8'))) - try: - with open(file_path, 'rb') as f: - self.send_response(200) - self.send_header('Content-Type', 'application/xml+dash') - self.send_header('Content-Length', os.path.getsize(file_path)) - self.end_headers() - while file_chunk: - file_chunk = f.read(self.chunk_size) - if file_chunk: - self.wfile.write(file_chunk) - except IOError: - response = 'File Not Found: |{proxy_path}| -> |{file_path}|'.format(proxy_path=self.path, file_path=file_path.encode('utf-8')) - self.send_error(404, response) - elif api_config_enabled and stripped_path.lower() == '/api': - html = self.api_config_page() - html = html.encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.send_header('Content-Length', len(html)) - self.end_headers() - for chunk in self.get_chunks(html): - self.wfile.write(chunk) - elif api_config_enabled and stripped_path.startswith('/api_submit'): - addon = xbmcaddon.Addon('plugin.video.youtube') - i18n = addon.getLocalizedString - xbmc.executebuiltin('Dialog.Close(addonsettings,true)') - old_api_key = addon.getSetting('youtube.api.key') - old_api_id = addon.getSetting('youtube.api.id') - old_api_secret = addon.getSetting('youtube.api.secret') - query = urlparse(self.path).query - params = parse_qs(query) - api_key = params.get('api_key', [None])[0] - api_id = params.get('api_id', [None])[0] - api_secret = params.get('api_secret', [None])[0] - if api_key and api_id and api_secret: - footer = i18n(30638) - else: - footer = u'' - if re.search(r'api_key=(?:&|$)', query): - api_key = '' - if re.search(r'api_id=(?:&|$)', query): - api_id = '' - if re.search(r'api_secret=(?:&|$)', query): - api_secret = '' - updated = [] - if api_key is not None and api_key != old_api_key: - addon.setSetting('youtube.api.key', api_key) - updated.append(i18n(30201)) - if api_id is not None and api_id != old_api_id: - addon.setSetting('youtube.api.id', api_id) - updated.append(i18n(30202)) - if api_secret is not None and api_secret != old_api_secret: - updated.append(i18n(30203)) - addon.setSetting('youtube.api.secret', api_secret) - if addon.getSetting('youtube.api.key') and addon.getSetting('youtube.api.id') and \ - addon.getSetting('youtube.api.secret'): - enabled = i18n(30636) - else: - enabled = i18n(30637) - if not updated: - updated = i18n(30635) - else: - updated = i18n(30631) % u', '.join(updated) - html = self.api_submit_page(updated, enabled, footer) - html = html.encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.send_header('Content-Length', len(html)) - self.end_headers() - for chunk in self.get_chunks(html): - self.wfile.write(chunk) - elif stripped_path == '/ping': - self.send_error(204) - else: - self.send_error(501) - - # noinspection PyPep8Naming - def do_HEAD(self): - logger.log_debug('HTTPServer: HEAD Request uri path |{proxy_path}|'.format(proxy_path=self.path)) - - if not self.connection_allowed(): - self.send_error(403) - else: - addon = xbmcaddon.Addon('plugin.video.youtube') - mpd_proxy_enabled = addon.getSetting('kodion.mpd.videos') == 'true' and addon.getSetting('kodion.video.quality.mpd') == 'true' - if mpd_proxy_enabled and self.path.endswith('.mpd'): - file_path = os.path.join(self.base_path, self.path.strip('/').strip('\\')) - if not os.path.isfile(file_path): - response = 'File Not Found: |{proxy_path}| -> |{file_path}|'.format(proxy_path=self.path, file_path=file_path.encode('utf-8')) - self.send_error(404, response) - else: - self.send_response(200) - self.send_header('Content-Type', 'application/xml+dash') - self.send_header('Content-Length', os.path.getsize(file_path)) - self.end_headers() - else: - self.send_error(501) - - # noinspection PyPep8Naming - def do_POST(self): - logger.log_debug('HTTPServer: Request uri path |{proxy_path}|'.format(proxy_path=self.path)) - - if not self.connection_allowed(): - self.send_error(403) - elif self.path.startswith('/widevine'): - license_url = xbmcgui.Window(10000).getProperty('plugin.video.youtube-license_url') - license_token = xbmcgui.Window(10000).getProperty('plugin.video.youtube-license_token') - - if not license_url: - self.send_error(404) - return - if not license_token: - self.send_error(403) - return - - size_limit = None - - length = int(self.headers['Content-Length']) - post_data = self.rfile.read(length) - - li_headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'Bearer %s' % license_token - } - - result = requests.post(url=license_url, headers=li_headers, data=post_data, stream=True) - - response_length = int(result.headers.get('content-length')) - content = result.raw.read(response_length) - - content_split = content.split('\r\n\r\n'.encode('utf-8')) - response_header = content_split[0].decode('utf-8', 'ignore') - response_body = content_split[1] - response_length = len(response_body) - - match = re.search(r'^Authorized-Format-Types:\s*(?P.+?)\r*$', response_header, re.MULTILINE) - if match: - authorized_types = match.group('authorized_types').split(',') - logger.log_debug('HTTPServer: Found authorized formats |{authorized_fmts}|'.format(authorized_fmts=authorized_types)) - - fmt_to_px = {'SD': (1280 * 528) - 1, 'HD720': 1280 * 720, 'HD': 7680 * 4320} - if 'HD' in authorized_types: - size_limit = fmt_to_px['HD'] - elif 'HD720' in authorized_types: - if xbmc.getCondVisibility('system.platform.android') == 1: - size_limit = fmt_to_px['HD720'] - else: - size_limit = fmt_to_px['SD'] - elif 'SD' in authorized_types: - size_limit = fmt_to_px['SD'] - - self.send_response(200) - - if size_limit: - self.send_header('X-Limit-Video', 'max={size_limit}px'.format(size_limit=str(size_limit))) - for d in list(result.headers.items()): - if re.match('^[Cc]ontent-[Ll]ength$', d[0]): - self.send_header(d[0], response_length) - else: - self.send_header(d[0], d[1]) - self.end_headers() - - for chunk in self.get_chunks(response_body): - self.wfile.write(chunk) - else: - self.send_error(501) - - # noinspection PyShadowingBuiltins - def log_message(self, format, *args): - return - - def get_chunks(self, data): - for i in range(0, len(data), self.chunk_size): - yield data[i:i + self.chunk_size] - - @staticmethod - def api_config_page(): - addon = xbmcaddon.Addon('plugin.video.youtube') - i18n = addon.getLocalizedString - api_key = addon.getSetting('youtube.api.key') - api_id = addon.getSetting('youtube.api.id') - api_secret = addon.getSetting('youtube.api.secret') - html = Pages().api_configuration.get('html') - css = Pages().api_configuration.get('css') - html = html.format(css=css, title=i18n(30634), api_key_head=i18n(30201), api_id_head=i18n(30202), - api_secret_head=i18n(30203), api_id_value=api_id, api_key_value=api_key, - api_secret_value=api_secret, submit=i18n(30630), header=i18n(30634)) - return html - - @staticmethod - def api_submit_page(updated_keys, enabled, footer): - addon = xbmcaddon.Addon('plugin.video.youtube') - i18n = addon.getLocalizedString - html = Pages().api_submit.get('html') - css = Pages().api_submit.get('css') - html = html.format(css=css, title=i18n(30634), updated=updated_keys, enabled=enabled, footer=footer, header=i18n(30634)) - return html - - -class Pages(object): - api_configuration = { - 'html': - u'\n\n' - u'\n\t\n' - u'\t{title}\n' - u'\t\n' - u'\n\n' - u'\t
\n' - u'\t
{header}
\n' - u'\t
\n' - u'\t\t\n' - u'\t\t\n' - u'\t\t\n' - u'\t\t\n' - u'\t
\n' - u'\t
\n' - u'\n', - - 'css': - u'body {\n' - u' background: #141718;\n' - u'}\n' - u'.center {\n' - u' margin: auto;\n' - u' width: 600px;\n' - u' padding: 10px;\n' - u'}\n' - u'.config_form {\n' - u' width: 575px;\n' - u' height: 145px;\n' - u' font-size: 16px;\n' - u' background: #1a2123;\n' - u' padding: 30px 30px 15px 30px;\n' - u' border: 5px solid #1a2123;\n' - u'}\n' - u'h5 {\n' - u' font-family: Arial, Helvetica, sans-serif;\n' - u' font-size: 16px;\n' - u' color: #fff;\n' - u' font-weight: 600;\n' - u' width: 575px;\n' - u' height: 20px;\n' - u' background: #0f84a5;\n' - u' padding: 5px 30px 5px 30px;\n' - u' border: 5px solid #0f84a5;\n' - u' margin: 0px;\n' - u'}\n' - u'.config_form input[type=submit],\n' - u'.config_form input[type=button],\n' - u'.config_form input[type=text],\n' - u'.config_form textarea,\n' - u'.config_form label {\n' - u' font-family: Arial, Helvetica, sans-serif;\n' - u' font-size: 16px;\n' - u' color: #fff;\n' - u'}\n' - u'.config_form label {\n' - u' display:block;\n' - u' margin-bottom: 10px;\n' - u'}\n' - u'.config_form label > span {\n' - u' display: inline-block;\n' - u' float: left;\n' - u' width: 150px;\n' - u'}\n' - u'.config_form input[type=text] {\n' - u' background: transparent;\n' - u' border: none;\n' - u' border-bottom: 1px solid #147a96;\n' - u' width: 400px;\n' - u' outline: none;\n' - u' padding: 0px 0px 0px 0px;\n' - u'}\n' - u'.config_form input[type=text]:focus {\n' - u' border-bottom: 1px dashed #0f84a5;\n' - u'}\n' - u'.config_form input[type=submit],\n' - u'.config_form input[type=button] {\n' - u' width: 150px;\n' - u' background: #141718;\n' - u' border: none;\n' - u' padding: 8px 0px 8px 10px;\n' - u' border-radius: 5px;\n' - u' color: #fff;\n' - u' margin-top: 10px\n' - u'}\n' - u'.config_form input[type=submit]:hover,\n' - u'.config_form input[type=button]:hover {\n' - u' background: #0f84a5;\n' - u'}\n' - } - - api_submit = { - 'html': - u'\n\n' - u'\n\t\n' - u'\t{title}\n' - u'\t\n' - u'\n\n' - u'\t
\n' - u'\t
{header}
\n' - u'\t
\n' - u'\t\t{updated}\n' - u'\t\t{enabled}\n' - u'\t\t \n' - u'\t\t \n' - u'\t\t \n' - u'\t\t \n' - u'\t\t
\n' - u'\t\t\t{footer}\n' - u'\t\t
\n' - u'\t
\n' - u'\t
\n' - u'\n', - - 'css': - u'body {\n' - u' background: #141718;\n' - u'}\n' - u'.center {\n' - u' margin: auto;\n' - u' width: 600px;\n' - u' padding: 10px;\n' - u'}\n' - u'.textcenter {\n' - u' margin: auto;\n' - u' width: 600px;\n' - u' padding: 10px;\n' - u' text-align: center;\n' - u'}\n' - u'.content {\n' - u' width: 575px;\n' - u' height: 145px;\n' - u' background: #1a2123;\n' - u' padding: 30px 30px 15px 30px;\n' - u' border: 5px solid #1a2123;\n' - u'}\n' - u'h5 {\n' - u' font-family: Arial, Helvetica, sans-serif;\n' - u' font-size: 16px;\n' - u' color: #fff;\n' - u' font-weight: 600;\n' - u' width: 575px;\n' - u' height: 20px;\n' - u' background: #0f84a5;\n' - u' padding: 5px 30px 5px 30px;\n' - u' border: 5px solid #0f84a5;\n' - u' margin: 0px;\n' - u'}\n' - u'span {\n' - u' font-family: Arial, Helvetica, sans-serif;\n' - u' font-size: 16px;\n' - u' color: #fff;\n' - u' display: block;\n' - u' float: left;\n' - u' width: 575px;\n' - u'}\n' - u'small {\n' - u' font-family: Arial, Helvetica, sans-serif;\n' - u' font-size: 12px;\n' - u' color: #fff;\n' - u'}\n' - } - - -def get_http_server(address=None, port=None): - addon_id = 'plugin.video.youtube' - addon = xbmcaddon.Addon(addon_id) - address = address if address else addon.getSetting('kodion.http.listen') - address = address if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', address) else '0.0.0.0' - port = int(port) if port else 50152 - try: - server = BaseHTTPServer.HTTPServer((address, port), YouTubeRequestHandler) - return server - except socket.error as e: - logger.log_debug('HTTPServer: Failed to start |{address}:{port}| |{response}|'.format(address=address, port=port, response=str(e))) - xbmcgui.Dialog().notification(addon.getAddonInfo('name'), str(e), - addon.getAddonInfo('icon'), - 5000, False) - return None - - -def is_httpd_live(address=None, port=None): - addon_id = 'plugin.video.youtube' - addon = xbmcaddon.Addon(addon_id) - address = address if address else addon.getSetting('kodion.http.listen') - address = '127.0.0.1' if address == '0.0.0.0' else address - address = address if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', address) else '127.0.0.1' - port = int(port) if port else 50152 - url = 'http://{address}:{port}/ping'.format(address=address, port=port) - try: - response = requests.get(url) - result = response.status_code == 204 - if not result: - logger.log_debug('HTTPServer: Ping |{address}:{port}| |{response}|'.format(address=address, port=port, response=response.status_code)) - return result - except: - logger.log_debug('HTTPServer: Ping |{address}:{port}| |{response}|'.format(address=address, port=port, response='failed')) - return False - - -def get_client_ip_address(address=None, port=None): - addon_id = 'plugin.video.youtube' - addon = xbmcaddon.Addon(addon_id) - address = address if address else addon.getSetting('kodion.http.listen') - address = '127.0.0.1' if address == '0.0.0.0' else address - address = address if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', address) else '127.0.0.1' - port = int(port) if port else 50152 - url = 'http://{address}:{port}/client_ip'.format(address=address, port=port) - response = requests.get(url) - ip_address = None - if response.status_code == 200: - response_json = response.json() - if response_json: - ip_address = response_json.get('ip') - return ip_address diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/ip_api.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/ip_api.py deleted file mode 100644 index afb3a7595d..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/ip_api.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import requests - - -class Locator: - - def __init__(self, context): - self._base_url = 'http://ip-api.com' - self._response = dict() - self._context = context - - def response(self): - return self._response - - def locate_requester(self): - request_url = '/'.join([self._base_url, 'json']) - response = requests.get(request_url) - self._response = response.json() - - def success(self): - successful = self.response().get('status', 'fail') == 'success' - if successful: - self._context.log_debug('Location request was successful') - else: - self._context.log_error(self.response().get('message', 'Location request failed with no error message')) - return successful - - def coordinates(self): - lat = None - lon = None - if self.success(): - lat = self._response.get('lat') - lon = self._response.get('lon') - if lat is None or lon is None: - self._context.log_error('No coordinates returned') - return None - else: - self._context.log_debug('Coordinates found') - return lat, lon diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/methods.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/methods.py index 5a0738c639..41c040515e 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/methods.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/methods.py @@ -8,24 +8,39 @@ See LICENSES/GPL-2.0-only for more information. """ -import os +from __future__ import absolute_import, division, unicode_literals + import copy +import json +import os import re -from urllib.parse import quote - -from ..constants import localize - -import xbmc -import xbmcvfs - - -__all__ = ['create_path', 'create_uri_path', 'strip_html_from_text', 'print_items', 'find_best_fit', 'to_utf8', - 'to_str', 'to_unicode', 'select_stream', 'make_dirs', 'loose_version', 'find_video_id'] - -try: - xbmc.translatePath = xbmcvfs.translatePath -except AttributeError: - pass +import shutil +from datetime import timedelta +from math import floor, log + +from ..compatibility import quote, string_type, xbmc, xbmcvfs +from ..logger import log_error + + +__all__ = ( + 'create_path', + 'create_uri_path', + 'duration_to_seconds', + 'find_best_fit', + 'find_video_id', + 'friendly_number', + 'get_kodi_setting', + 'loose_version', + 'make_dirs', + 'merge_dicts', + 'print_items', + 'rm_dir', + 'seconds_to_duration', + 'select_stream', + 'strip_html_from_text', + 'to_str', + 'to_unicode', +) def loose_version(v): @@ -41,50 +56,36 @@ def to_str(text): return text -def to_utf8(text): - result = text - if isinstance(text, str): - try: - result = text.encode('utf-8', 'ignore') - except UnicodeDecodeError: - pass - - return result - - def to_unicode(text): result = text - if isinstance(text, str) or isinstance(text, bytes): + if isinstance(text, (bytes, str)): try: result = text.decode('utf-8', 'ignore') - except (AttributeError, UnicodeEncodeError): + except (AttributeError, UnicodeError): pass return result def find_best_fit(data, compare_method=None): + if isinstance(data, dict): + data = data.values() + try: - return next(item for item in data if item['container'] == 'mpd') + return next(item for item in data if item.get('container') == 'mpd') except StopIteration: pass - result = None + if not compare_method: + return None + result = None last_fit = -1 - if isinstance(data, dict): - for key in list(data.keys()): - item = data[key] - fit = abs(compare_method(item)) - if last_fit == -1 or fit < last_fit: - last_fit = fit - result = item - elif isinstance(data, list): - for item in data: - fit = abs(compare_method(item)) - if last_fit == -1 or fit < last_fit: - last_fit = fit - result = item + for item in data: + fit = abs(compare_method(item)) + if last_fit == -1 or fit < last_fit: + last_fit = fit + result = item return result @@ -99,7 +100,7 @@ def _sort_stream_data(_stream_data): ask_for_quality = context.get_settings().ask_for_video_quality() if ask_for_quality is None else ask_for_quality video_quality = settings.get_video_quality(quality_map_override=quality_map_override) audio_only = audio_only if audio_only is not None else settings.audio_only() - adaptive_live = settings.use_adaptive_live_streams() and context.inputstream_adaptive_capabilities('live') + adaptive_live = settings.use_isa_live_streams() and context.inputstream_adaptive_capabilities('live') if not ask_for_quality: stream_data_list = [item for item in stream_data_list @@ -138,13 +139,12 @@ def _sort_stream_data(_stream_data): def _find_best_fit_video(_stream_data): if audio_only: return video_quality - _stream_data.get('sort', (0, 0))[0] - else: - return video_quality - _stream_data.get('video', {}).get('height', 0) + return video_quality - _stream_data.get('video', {}).get('height', 0) sorted_stream_data_list = sorted(stream_data_list, key=_sort_stream_data) context.log_debug('selectable streams: %d' % len(sorted_stream_data_list)) - log_streams = list() + log_streams = [] for sorted_stream_data in sorted_stream_data_list: log_data = copy.deepcopy(sorted_stream_data) if 'license_info' in log_data: @@ -157,11 +157,12 @@ def _find_best_fit_video(_stream_data): selected_stream_data = None if ask_for_quality and len(sorted_stream_data_list) > 1: - items = list() - for sorted_stream_data in sorted_stream_data_list: - items.append((sorted_stream_data['title'], sorted_stream_data)) + items = [ + (sorted_stream_data['title'], sorted_stream_data) + for sorted_stream_data in sorted_stream_data_list + ] - result = context.get_ui().on_select(context.localize(localize.SELECT_VIDEO_QUALITY), items) + result = context.get_ui().on_select(context.localize('select_video_quality'), items) if result != -1: selected_stream_data = result else: @@ -180,14 +181,14 @@ def _find_best_fit_video(_stream_data): def create_path(*args): comps = [] for arg in args: - if isinstance(arg, list): + if isinstance(arg, (list, tuple)): return create_path(*arg) comps.append(str(arg.strip('/').replace('\\', '/').replace('//', '/'))) uri_path = '/'.join(comps) if uri_path: - return u'/%s/' % uri_path + return '/%s/' % uri_path return '/' @@ -195,7 +196,7 @@ def create_path(*args): def create_uri_path(*args): comps = [] for arg in args: - if isinstance(arg, list): + if isinstance(arg, (list, tuple)): return create_uri_path(*arg) comps.append(str(arg.strip('/').replace('\\', '/').replace('//', '/'))) @@ -231,25 +232,109 @@ def print_items(items): def make_dirs(path): if not path.endswith('/'): - path = ''.join([path, '/']) - path = xbmc.translatePath(path) - if not xbmcvfs.exists(path): + path = ''.join((path, '/')) + + succeeded = xbmcvfs.exists(path) or xbmcvfs.mkdirs(path) + if succeeded: + return xbmcvfs.translatePath(path) + + path = xbmcvfs.translatePath(path) + try: + os.makedirs(path) + succeeded = True + except OSError: + pass + + if succeeded: + return path + log_error('Failed to create directory: |{0}|'.format(path)) + return False + + +def rm_dir(path): + succeeded = (not xbmcvfs.exists(path) + or xbmcvfs.rmdir(path, force=True)) + if not succeeded: + path = xbmcvfs.translatePath(path) try: - _ = xbmcvfs.mkdirs(path) - except: + shutil.rmtree(path) + succeeded = not xbmcvfs.exists(path) + except OSError: pass - if not xbmcvfs.exists(path): - try: - os.makedirs(path) - except: - pass - return xbmcvfs.exists(path) - - return True + if succeeded: + return True + log_error('Failed to remove directory: {0}'.format(path)) + return False def find_video_id(plugin_path): match = re.search(r'.*video_id=(?P[a-zA-Z0-9_\-]{11}).*', plugin_path) if match: return match.group('video_id') return '' + + +def friendly_number(input, precision=3, scale=('', 'K', 'M', 'B'), as_str=True): + _input = float('{input:.{precision}g}'.format( + input=float(input), precision=precision + )) + _abs_input = abs(_input) + magnitude = 0 if _abs_input < 1000 else int(log(floor(_abs_input), 1000)) + output = '{output:f}'.format( + output=_input / 1000 ** magnitude + ).rstrip('0').rstrip('.') + scale[magnitude] + return output if as_str else (output, _input) + + +_RE_PERIODS = re.compile(r'([\d.]+)(d|h|m|s|$)') +_SECONDS_IN_PERIODS = { + '': 1, # 1 second for unitless period + 's': 1, # 1 second + 'm': 60, # 1 minute + 'h': 3600, # 1 hour + 'd': 86400, # 1 day +} + + +def duration_to_seconds(duration): + if ':' in duration: + seconds = 0 + for part in duration.split(':'): + seconds = seconds * 60 + (float(part) if '.' in part else int(part)) + return seconds + return sum( + (float(number) if '.' in number else int(number)) + * _SECONDS_IN_PERIODS.get(period, 1) + for number, period in re.findall(_RE_PERIODS, duration.lower()) + ) + + +def seconds_to_duration(seconds): + return str(timedelta(seconds=seconds)) + + +def merge_dicts(item1, item2, templates=None, _=Ellipsis): + if not isinstance(item1, dict) or not isinstance(item2, dict): + return item1 if item2 is _ else _ if item2 is KeyError else item2 + new = {} + keys = set(item1) + keys.update(item2) + for key in keys: + value = merge_dicts(item1.get(key, _), item2.get(key, _), templates) + if value is _: + continue + if (templates is not None + and isinstance(value, string_type) and '{' in value): + templates['{0}.{1}'.format(id(new), key)] = (new, key, value) + new[key] = value + return new or _ + +def get_kodi_setting(setting): + json_query = xbmc.executeJSONRPC(json.dumps({ + 'jsonrpc': '2.0', + 'method': 'Settings.GetSettingValue', + 'params': {'setting': setting}, + 'id': 1, + })) + json_query = json.loads(json_query) + return json_query.get('result', {}).get('value') diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/monitor.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/monitor.py deleted file mode 100644 index 9ca023e79b..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/monitor.py +++ /dev/null @@ -1,164 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import json -import os -import shutil -import threading -from urllib.parse import unquote - -import xbmc -import xbmcaddon -import xbmcvfs - -from ..utils import get_http_server, is_httpd_live -from .. import logger - -try: - xbmc.translatePath = xbmcvfs.translatePath -except AttributeError: - pass - - -class YouTubeMonitor(xbmc.Monitor): - - # noinspection PyUnusedLocal,PyMissingConstructor - def __init__(self, *args, **kwargs): - self.addon_id = 'plugin.video.youtube' - addon = xbmcaddon.Addon(self.addon_id) - self._whitelist = addon.getSetting('kodion.http.ip.whitelist') - self._httpd_port = int(addon.getSetting('kodion.mpd.proxy.port')) - self._old_httpd_port = self._httpd_port - self._use_httpd = (addon.getSetting('kodion.mpd.videos') == 'true' and addon.getSetting('kodion.video.quality.mpd') == 'true') or \ - (addon.getSetting('youtube.api.config.page') == 'true') - self._httpd_address = addon.getSetting('kodion.http.listen') - self._old_httpd_address = self._httpd_address - self.httpd = None - self.httpd_thread = None - if self.use_httpd(): - self.start_httpd() - del addon - - def onNotification(self, sender, method, data): - if sender == 'plugin.video.youtube' and method.endswith('.check_settings'): - data = json.loads(data) - data = json.loads(unquote(data[0])) - logger.log_debug('onNotification: |check_settings| -> |%s|' % json.dumps(data)) - - _use_httpd = data.get('use_httpd') - _httpd_port = data.get('httpd_port') - _whitelist = data.get('whitelist') - _httpd_address = data.get('httpd_address') - - whitelist_changed = _whitelist != self._whitelist - port_changed = self._httpd_port != _httpd_port - address_changed = self._httpd_address != _httpd_address - - if _whitelist != self._whitelist: - self._whitelist = _whitelist - - if self._use_httpd != _use_httpd: - self._use_httpd = _use_httpd - - if self._httpd_port != _httpd_port: - self._old_httpd_port = self._httpd_port - self._httpd_port = _httpd_port - - if self._httpd_address != _httpd_address: - self._old_httpd_address = self._httpd_address - self._httpd_address = _httpd_address - - if self.use_httpd() and not self.httpd: - self.start_httpd() - elif self.use_httpd() and (port_changed or whitelist_changed or address_changed): - if self.httpd: - self.restart_httpd() - else: - self.start_httpd() - elif not self.use_httpd() and self.httpd: - self.shutdown_httpd() - - elif sender == 'plugin.video.youtube': - logger.log_debug('onNotification: |unknown method|') - - def use_httpd(self): - return self._use_httpd - - def httpd_port(self): - return int(self._httpd_port) - - def httpd_address(self): - return self._httpd_address - - def old_httpd_address(self): - return self._old_httpd_address - - def old_httpd_port(self): - return int(self._old_httpd_port) - - def httpd_port_sync(self): - self._old_httpd_port = self._httpd_port - - def start_httpd(self): - if not self.httpd: - logger.log_debug('HTTPServer: Starting |{ip}:{port}|'.format(ip=self.httpd_address(), - port=str(self.httpd_port()))) - self.httpd_port_sync() - self.httpd = get_http_server(address=self.httpd_address(), port=self.httpd_port()) - if self.httpd: - self.httpd_thread = threading.Thread(target=self.httpd.serve_forever) - self.httpd_thread.daemon = True - self.httpd_thread.start() - sock_name = self.httpd.socket.getsockname() - logger.log_debug('HTTPServer: Serving on |{ip}:{port}|'.format(ip=str(sock_name[0]), - port=str(sock_name[1]))) - - def shutdown_httpd(self): - if self.httpd: - logger.log_debug('HTTPServer: Shutting down |{ip}:{port}|'.format(ip=self.old_httpd_address(), - port=str(self.old_httpd_port()))) - self.httpd_port_sync() - self.httpd.shutdown() - self.httpd.socket.close() - self.httpd_thread.join() - self.httpd_thread = None - self.httpd = None - - def restart_httpd(self): - logger.log_debug('HTTPServer: Restarting... |{old_ip}:{old_port}| -> |{ip}:{port}|' - .format(old_ip=self.old_httpd_address(), old_port=str(self.old_httpd_port()), - ip=self.httpd_address(), port=str(self.httpd_port()))) - self.shutdown_httpd() - self.start_httpd() - - def ping_httpd(self): - return is_httpd_live(port=self.httpd_port()) - - def remove_temp_dir(self): - try: - path = xbmc.translatePath('special://temp/%s' % self.addon_id).decode('utf-8') - except AttributeError: - path = xbmc.translatePath('special://temp/%s' % self.addon_id) - - if os.path.isdir(path): - try: - xbmcvfs.rmdir(path, force=True) - except: - pass - if os.path.isdir(path): - try: - shutil.rmtree(path) - except: - pass - - if os.path.isdir(path): - logger.log_debug('Failed to remove directory: {dir}'.format(dir=path.encode('utf-8'))) - return False - else: - return True diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/playback_history.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/playback_history.py deleted file mode 100644 index 723f3b1607..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/playback_history.py +++ /dev/null @@ -1,79 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import datetime -import pickle -import sqlite3 - -from .storage import Storage - - -class PlaybackHistory(Storage): - def __init__(self, filename): - Storage.__init__(self, filename) - - def is_empty(self): - return self._is_empty() - - def get_items(self, keys): - def _decode(obj): - return pickle.loads(obj) - - self._open() - placeholders = ','.join(['?' for _ in keys]) - keys = [str(item) for item in keys] - query = 'SELECT * FROM %s WHERE key IN (%s)' % (self._table_name, placeholders) - query_result = self._execute(False, query, keys) - result = {} - if query_result: - for item in query_result: - values = _decode(item[2]).split(',') - result[str(item[0])] = {'play_count': values[0], 'total_time': values[1], - 'played_time': values[2], 'played_percent': values[3], - 'last_played': item[1]} - - self._close() - return result - - def get_item(self, key): - key = str(key) - query_result = self._get(key) - result = {} - if query_result: - values = query_result[0].split(',') - result[key] = {'play_count': values[0], 'total_time': values[1], - 'played_time': values[2], 'played_percent': values[3], - 'last_played': query_result[1]} - return result - - def clear(self): - self._clear() - - def remove(self, video_id): - self._remove(video_id) - - def update(self, video_id, play_count, total_time, played_time, played_percent): - item = ','.join([str(play_count), str(total_time), str(played_time), str(played_percent)]) - self._set(str(video_id), item) - - def _set(self, item_id, item): - def _encode(obj): - return sqlite3.Binary(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)) - - self._open() - now = datetime.datetime.now() + datetime.timedelta(microseconds=1) # add 1 microsecond, required for dbapi2 - query = 'REPLACE INTO %s (key,time,value) VALUES(?,?,?)' % self._table_name - self._execute(True, query, values=[item_id, now, _encode(item)]) - self._close() - - def _optimize_item_count(self): - pass - - def _optimize_file_size(self): - pass diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/player.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/player.py deleted file mode 100644 index c8ce052263..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/player.py +++ /dev/null @@ -1,420 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import json -import re -import threading - -import xbmc - - -class PlaybackMonitorThread(threading.Thread): - def __init__(self, provider, context, playback_json): - super(PlaybackMonitorThread, self).__init__() - - self._stopped = threading.Event() - self._ended = threading.Event() - - self.context = context - self.provider = provider - self.ui = self.context.get_ui() - - self.player = xbmc.Player() - - self.playback_json = playback_json - self.video_id = self.playback_json.get('video_id') - self.channel_id = self.playback_json.get('channel_id') - self.video_status = self.playback_json.get('video_status') - - self.total_time = 0.0 - self.current_time = 0.0 - self.segment_start = 0.0 - self.percent_complete = 0 - - self.daemon = True - self.start() - - def update_times(self, total_time, current_time, segment_start, percent_complete): - self.total_time = total_time - self.current_time = current_time - self.segment_start = segment_start - self.percent_complete = percent_complete - - def abort_now(self): - return not self.player.isPlaying() or self.context.abort_requested() or self.stopped() - - def run(self): - playing_file = self.playback_json.get('playing_file') - play_count = self.playback_json.get('play_count', 0) - use_remote_history = self.playback_json.get('use_remote_history', False) - use_local_history = self.playback_json.get('use_local_history', False) - playback_stats = self.playback_json.get('playback_stats') - refresh_only = self.playback_json.get('refresh_only', False) - try: - seek_time = float(self.playback_json.get('seek_time')) - except (ValueError, TypeError): - seek_time = None - - player = self.player - - self.context.log_debug('PlaybackMonitorThread[%s]: Starting...' % self.video_id) - access_manager = self.context.get_access_manager() - - settings = self.context.get_settings() - - if playback_stats is None: - playback_stats = {} - - play_count = str(play_count) - - played_time = -1.0 - - state = 'playing' - last_state = 'playing' - - np_wait_time = 0.2 - np_waited = 0.0 - p_wait_time = 0.5 - p_waited = 0.0 - - report_interval = 10.0 - first_report = True - - report_url = playback_stats.get('playback_url', '') - - while not player.isPlaying() and not self.context.abort_requested(): - self.context.log_debug('Waiting for playback to start') - - xbmc.sleep(int(np_wait_time * 1000)) - if np_waited >= 5: - self.end() - return - - np_waited += np_wait_time - - client = self.provider.get_client(self.context) - is_logged_in = self.provider.is_logged_in() - - if is_logged_in and report_url and use_remote_history: - client.update_watch_history(self.video_id, report_url) - self.context.log_debug('Playback start reported: |%s|' % self.video_id) - - report_url = playback_stats.get('watchtime_url', '') - - plugin_play_path = 'plugin://plugin.video.youtube/play/' - video_id_param = 'video_id=%s' % self.video_id - - notification_sent = False - - while player.isPlaying() and not self.context.abort_requested() and not self.stopped(): - if not notification_sent: - notification_sent = True - self.context.send_notification('PlaybackStarted', { - 'video_id': self.video_id, - 'channel_id': self.channel_id, - 'status': self.video_status, - }) - - last_total_time = self.total_time - last_current_time = self.current_time - last_segment_start = self.segment_start - last_percent_complete = self.percent_complete - - try: - current_file = player.getPlayingFile() - if (current_file != playing_file and - not (current_file.startswith(plugin_play_path) and - video_id_param in current_file)) or self.stopped(): - self.stop() - break - except RuntimeError: - pass - - if self.abort_now(): - self.update_times(last_total_time, last_current_time, last_segment_start, last_percent_complete) - break - - try: - self.current_time = float(player.getTime()) - self.total_time = float(player.getTotalTime()) - except RuntimeError: - pass - - if self.current_time < 0.0: - self.current_time = 0.0 - - if self.abort_now(): - self.update_times(last_total_time, last_current_time, last_segment_start, last_percent_complete) - break - - try: - self.percent_complete = int(float(self.current_time) / float(self.total_time) * 100) - except ZeroDivisionError: - self.percent_complete = 0 - - if self.abort_now(): - self.update_times(last_total_time, last_current_time, last_segment_start, last_percent_complete) - break - - if seek_time and seek_time != 0.0: - player.seekTime(seek_time) - try: - self.current_time = float(player.getTime()) - except RuntimeError: - pass - if self.current_time >= seek_time: - seek_time = None - - if self.abort_now(): - self.update_times(last_total_time, last_current_time, last_segment_start, last_percent_complete) - break - - if p_waited >= report_interval: - if is_logged_in: - self.provider.reset_client() # refresh client, tokens may need refreshing - client = self.provider.get_client(self.context) - is_logged_in = self.provider.is_logged_in() - - if self.current_time == played_time: - last_state = state - state = 'paused' - else: - last_state = state - state = 'playing' - - played_time = self.current_time - - if self.abort_now(): - self.update_times(last_total_time, last_current_time, last_segment_start, last_percent_complete) - break - - if is_logged_in and report_url and use_remote_history: - if first_report or (p_waited >= report_interval): - if first_report: - first_report = False - self.segment_start = 0.0 - self.current_time = 0.0 - self.percent_complete = 0 - - p_waited = 0.0 - - if self.segment_start < 0.0: - self.segment_start = 0.0 - - if state == 'playing': - segment_end = self.current_time - else: - segment_end = self.segment_start - - if segment_end > float(self.total_time): - segment_end = float(self.total_time) - - if self.segment_start > segment_end: - segment_end = self.segment_start + 10.0 - - if state == 'playing' or last_state == 'playing': # only report state='paused' once - client.update_watch_history(self.video_id, report_url - .format(st=format(self.segment_start, '.3f'), - et=format(segment_end, '.3f'), - state=state)) - self.context.log_debug( - 'Playback reported [%s]: %s segment start, %s segment end @ %s%% state=%s' % - (self.video_id, - format(self.segment_start, '.3f'), - format(segment_end, '.3f'), - self.percent_complete, state)) - - self.segment_start = segment_end - - if self.abort_now(): - break - - xbmc.sleep(int(p_wait_time * 1000)) - - p_waited += p_wait_time - - if is_logged_in and report_url and use_remote_history: - client.update_watch_history(self.video_id, report_url - .format(st=format(self.segment_start, '.3f'), - et=format(self.current_time, '.3f'), - state=state)) - self.context.log_debug('Playback reported [%s]: %s segment start, %s segment end @ %s%% state=%s' % - (self.video_id, - format(self.segment_start, '.3f'), - format(self.current_time, '.3f'), - self.percent_complete, state)) - - self.context.send_notification('PlaybackStopped', { - 'video_id': self.video_id, - 'channel_id': self.channel_id, - 'status': self.video_status, - }) - self.context.log_debug('Playback stopped [%s]: %s secs of %s @ %s%%' % - (self.video_id, format(self.current_time, '.3f'), - format(self.total_time, '.3f'), self.percent_complete)) - - state = 'stopped' - if is_logged_in: - self.provider.reset_client() # refresh client, tokens may need refreshing - client = self.provider.get_client(self.context) - is_logged_in = self.provider.is_logged_in() - - if self.percent_complete >= settings.get_play_count_min_percent(): - play_count = '1' - self.current_time = 0.0 - if is_logged_in and report_url and use_remote_history: - client.update_watch_history(self.video_id, report_url - .format(st=format(self.total_time, '.3f'), - et=format(self.total_time, '.3f'), - state=state)) - self.context.log_debug('Playback reported [%s] @ 100%% state=%s' % (self.video_id, state)) - - else: - if is_logged_in and report_url and use_remote_history: - client.update_watch_history(self.video_id, report_url - .format(st=format(self.current_time, '.3f'), - et=format(self.current_time, '.3f'), - state=state)) - self.context.log_debug('Playback reported [%s]: %s segment start, %s segment end @ %s%% state=%s' % - (self.video_id, format(self.current_time, '.3f'), - format(self.current_time, '.3f'), - self.percent_complete, state)) - - refresh_only = True - - if use_local_history: - self.context.get_playback_history().update(self.video_id, play_count, self.total_time, - self.current_time, self.percent_complete) - - if not refresh_only: - if is_logged_in: - - if settings.get_bool('youtube.playlist.watchlater.autoremove', True): - watch_later_id = access_manager.get_watch_later_id() - - if watch_later_id: - playlist_item_id = \ - client.get_playlist_item_id_of_video_id(playlist_id=watch_later_id, video_id=self.video_id) - if playlist_item_id: - json_data = client.remove_video_from_playlist(watch_later_id, playlist_item_id) - _ = self.provider.v3_handle_error(self.provider, self.context, json_data) - - history_playlist_id = access_manager.get_watch_history_id() - if history_playlist_id and history_playlist_id != 'HL': - json_data = client.add_video_to_playlist(history_playlist_id, self.video_id) - _ = self.provider.v3_handle_error(self.provider, self.context, json_data) - - # rate video - if settings.get_bool('youtube.post.play.rate', False): - do_rating = True - if not settings.get_bool('youtube.post.play.rate.playlists', False): - playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - do_rating = int(playlist.size()) < 2 - - if do_rating: - json_data = client.get_video_rating(self.video_id) - success = self.provider.v3_handle_error(self.provider, self.context, json_data) - if success: - items = json_data.get('items', [{'rating': 'none'}]) - rating = items[0].get('rating', 'none') - if rating == 'none': - rating_match = \ - re.search('/(?P[^/]+)/(?P[^/]+)', '/%s/%s/' % - (self.video_id, rating)) - self.provider.yt_video.process('rate', self.provider, self.context, rating_match) - - playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - do_refresh = (int(playlist.size()) < 2) or (playlist.getposition() == -1) - - if do_refresh and settings.get_bool('youtube.post.play.refresh', False) and \ - not xbmc.getInfoLabel('Container.FolderPath') \ - .startswith(self.context.create_uri(['kodion', 'search', 'input'])): - # don't refresh search input it causes request for new input, - # (Container.Update in abstract_provider /kodion/search/input/ - # would resolve this but doesn't work with Remotes(Yatse)) - self.ui.refresh_container() - - self.end() - - def stop(self): - self.context.log_debug('PlaybackMonitorThread[%s]: Stop event set...' % self.video_id) - self._stopped.set() - - def stopped(self): - return self._stopped.is_set() - - def end(self): - self.context.log_debug('PlaybackMonitorThread[%s]: End event set...' % self.video_id) - self._ended.set() - - def ended(self): - return self._ended.is_set() - - -class YouTubePlayer(xbmc.Player): - def __init__(self, *args, **kwargs): - self.context = kwargs.get('context') - self.provider = kwargs.get('provider') - self.ui = self.context.get_ui() - self.threads = [] - - def stop_threads(self): - for thread in self.threads: - if thread.ended(): - continue - - if not thread.stopped(): - self.context.log_debug('PlaybackMonitorThread[%s]: stopping...' % thread.video_id) - thread.stop() - - for thread in self.threads: - if thread.stopped() and not thread.ended(): - try: - thread.join() - except RuntimeError: - pass - - def cleanup_threads(self, only_ended=True): - active_threads = [] - for thread in self.threads: - if only_ended and not thread.ended(): - active_threads.append(thread) - continue - - if thread.ended(): - self.context.log_debug('PlaybackMonitorThread[%s]: clean up...' % thread.video_id) - else: - self.context.log_debug('PlaybackMonitorThread[%s]: stopping...' % thread.video_id) - if not thread.stopped(): - thread.stop() - try: - thread.join() - except RuntimeError: - pass - - self.context.log_debug('PlaybackMonitor active threads: |%s|' % - ', '.join([thread.video_id for thread in active_threads])) - self.threads = active_threads - - def onPlayBackStarted(self): - if self.ui.get_home_window_property('playback_json'): - playback_json = json.loads(self.ui.get_home_window_property('playback_json')) - self.ui.clear_home_window_property('playback_json') - self.cleanup_threads() - self.threads.append(PlaybackMonitorThread(self.provider, self.context, playback_json)) - - def onPlayBackEnded(self): - self.stop_threads() - self.cleanup_threads() - - def onPlayBackStopped(self): - self.onPlayBackEnded() - - def onPlayBackError(self): - self.onPlayBackEnded() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/player_monitor.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/player_monitor.py new file mode 100644 index 0000000000..d2e5fafa8f --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/player_monitor.py @@ -0,0 +1,498 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import json +import re +import threading + +from ..compatibility import xbmc + + +class PlayerMonitorThread(threading.Thread): + def __init__(self, player, provider, context, playback_json): + super(PlayerMonitorThread, self).__init__() + + self._stopped = threading.Event() + self._ended = threading.Event() + + self._context = context + self.provider = provider + self.ui = self._context.get_ui() + + self.player = player + + self.playback_json = playback_json + self.video_id = self.playback_json.get('video_id') + self.channel_id = self.playback_json.get('channel_id') + self.video_status = self.playback_json.get('video_status') + + self.total_time = 0.0 + self.current_time = 0.0 + self.segment_start = 0.0 + self.percent_complete = 0 + + self.daemon = True + self.start() + + def update_times(self, + total_time, + current_time, + segment_start, + percent_complete): + self.total_time = total_time + self.current_time = current_time + self.segment_start = segment_start + self.percent_complete = percent_complete + + def abort_now(self): + return (not self.player.isPlaying() + or self._context.abort_requested() + or self.stopped()) + + def run(self): + playing_file = self.playback_json.get('playing_file') + play_count = self.playback_json.get('play_count', 0) + use_remote_history = self.playback_json.get('use_remote_history', False) + use_local_history = self.playback_json.get('use_local_history', False) + playback_stats = self.playback_json.get('playback_stats') + refresh_only = self.playback_json.get('refresh_only', False) + clip = self.playback_json.get('clip', False) + + self._context.log_debug('PlayerMonitorThread[{0}]: Starting' + .format(self.video_id)) + + player = self.player + + wait_time = 0.2 + waited = 0.0 + while not player.isPlaying(): + if self._context.abort_requested(): + break + if waited >= 5: + self.end() + return + + self._context.log_debug('Waiting for playback to start') + xbmc.sleep(int(wait_time * 1000)) + waited += wait_time + else: + self._context.send_notification('PlaybackStarted', { + 'video_id': self.video_id, + 'channel_id': self.channel_id, + 'status': self.video_status, + }) + + client = self.provider.get_client(self._context) + logged_in = self.provider.is_logged_in() + report_url = playback_stats.get('playback_url', '') + if playback_stats is None: + playback_stats = {} + state = 'playing' + last_state = 'playing' + + if logged_in and report_url and use_remote_history: + client.update_watch_history( + self._context, + self.video_id, + report_url, + st=0, + et='N/A', + state=state + ) + + report_url = playback_stats.get('watchtime_url', '') + report_interval = 10.0 + first_report = True + + access_manager = self._context.get_access_manager() + settings = self._context.get_settings() + + video_id_param = 'video_id=%s' % self.video_id + + played_time = -1.0 + wait_time = 0.5 + waited = 0.0 + while not self.abort_now(): + last_total_time = self.total_time + last_current_time = self.current_time + last_segment_start = self.segment_start + last_percent_complete = self.percent_complete + + try: + current_file = player.getPlayingFile() + except RuntimeError: + current_file = None + + if (not current_file + or (current_file != playing_file and not ( + self._context.is_plugin_path(current_file, 'play/') + and video_id_param in current_file)) + or self.stopped()): + self.stop() + break + + if self.abort_now(): + self.update_times(last_total_time, + last_current_time, + last_segment_start, + last_percent_complete) + break + + try: + self.current_time = float(player.getTime()) + self.total_time = float(player.getTotalTime()) + except RuntimeError: + pass + + if self.current_time < 0: + self.current_time = 0.0 + + if self.abort_now(): + self.update_times(last_total_time, + last_current_time, + last_segment_start, + last_percent_complete) + break + + try: + self.percent_complete = int(100 * self.current_time + / self.total_time) + except ZeroDivisionError: + self.percent_complete = 0 + + if self.abort_now(): + self.update_times(last_total_time, + last_current_time, + last_segment_start, + last_percent_complete) + break + + if player.start_time or player.seek_time: + _seek_time = player.start_time or player.seek_time + if self.current_time < _seek_time: + player.seekTime(_seek_time) + try: + self.current_time = float(player.getTime()) + except RuntimeError: + pass + + if player.end_time and self.current_time >= player.end_time: + if clip and player.start_time: + player.seekTime(player.start_time) + else: + player.stop() + + if self.abort_now(): + self.update_times(last_total_time, + last_current_time, + last_segment_start, + last_percent_complete) + break + + if waited >= report_interval: + # refresh client, tokens may need refreshing + if logged_in: + self.provider.reset_client() + client = self.provider.get_client(self._context) + logged_in = self.provider.is_logged_in() + + if self.current_time == played_time: + last_state = state + state = 'paused' + else: + last_state = state + state = 'playing' + + played_time = self.current_time + + if self.abort_now(): + self.update_times(last_total_time, + last_current_time, + last_segment_start, + last_percent_complete) + break + + if (logged_in and report_url and use_remote_history + and (first_report or waited >= report_interval)): + if first_report: + first_report = False + self.segment_start = 0.0 + self.current_time = 0.0 + self.percent_complete = 0 + + waited = 0.0 + + if self.segment_start < 0: + self.segment_start = 0.0 + + if state == 'playing': + segment_end = self.current_time + else: + segment_end = self.segment_start + + if segment_end > float(self.total_time): + segment_end = float(self.total_time) + + if self.segment_start > segment_end: + segment_end = self.segment_start + 10.0 + + # only report state='paused' once + if state == 'playing' or last_state == 'playing': + client.update_watch_history( + self._context, + self.video_id, + report_url, + st=format(self.segment_start, '.3f'), + et=format(segment_end, '.3f'), + state=state + ) + + self.segment_start = segment_end + + if self.abort_now(): + break + + xbmc.sleep(int(wait_time * 1000)) + + waited += wait_time + + if logged_in and report_url and use_remote_history: + client.update_watch_history( + self._context, + self.video_id, + report_url, + st=format(self.segment_start, '.3f'), + et=format(self.current_time, '.3f'), + state=state + ) + + self._context.send_notification('PlaybackStopped', { + 'video_id': self.video_id, + 'channel_id': self.channel_id, + 'status': self.video_status, + }) + self._context.log_debug('Playback stopped [{video_id}]:' + ' {current:.3f} secs of {total:.3f}' + ' @ {percent}%' + .format(video_id=self.video_id, + current=self.current_time, + total=self.total_time, + percent=self.percent_complete)) + + state = 'stopped' + # refresh client, tokens may need refreshing + if logged_in: + self.provider.reset_client() + client = self.provider.get_client(self._context) + logged_in = self.provider.is_logged_in() + + if self.percent_complete >= settings.get_play_count_min_percent(): + play_count += 1 + self.current_time = 0.0 + if logged_in and report_url and use_remote_history: + client.update_watch_history( + self._context, + self.video_id, + report_url, + st=format(self.total_time, '.3f'), + et=format(self.total_time, '.3f'), + state=state + ) + + else: + if logged_in and report_url and use_remote_history: + client.update_watch_history( + self._context, + self.video_id, + report_url, + st=format(self.current_time, '.3f'), + et=format(self.current_time, '.3f'), + state=state + ) + + refresh_only = True + + if use_local_history: + play_data = { + 'play_count': play_count, + 'total_time': self.total_time, + 'played_time': self.current_time, + 'played_percent': self.percent_complete, + } + self._context.get_playback_history().update(self.video_id, + play_data) + + if refresh_only: + pass + elif settings.get_bool('youtube.playlist.watchlater.autoremove', True): + watch_later_id = logged_in and access_manager.get_watch_later_id() + if watch_later_id: + playlist_item_id = client.get_playlist_item_id_of_video_id( + playlist_id=watch_later_id, video_id=self.video_id + ) + if playlist_item_id: + _ = client.remove_video_from_playlist( + watch_later_id, playlist_item_id + ) + else: + self._context.get_watch_later_list().remove(self.video_id) + + if logged_in and not refresh_only: + history_id = access_manager.get_watch_history_id() + if history_id: + _ = client.add_video_to_playlist(history_id, self.video_id) + + # rate video + if settings.get_bool('youtube.post.play.rate', False): + do_rating = True + if not settings.get_bool('youtube.post.play.rate.playlists', + False): + playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + do_rating = int(playlist.size()) < 2 + + if do_rating: + json_data = client.get_video_rating(self.video_id) + if json_data: + items = json_data.get('items', [{'rating': 'none'}]) + rating = items[0].get('rating', 'none') + if rating == 'none': + rating_match = \ + re.search(r'/(?P[^/]+)' + r'/(?P[^/]+)', + '/{0}/{1}/'.format(self.video_id, + rating)) + self.provider.yt_video.process('rate', + self.provider, + self._context, + rating_match) + + playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + do_refresh = playlist.size() < 2 or playlist.getposition() == -1 + if do_refresh and settings.get_bool('youtube.post.play.refresh', False): + self.ui.refresh_container() + + self.end() + + def stop(self): + self._context.log_debug('PlayerMonitorThread[{0}]: Stop event set' + .format(self.video_id)) + self._stopped.set() + + def stopped(self): + return self._stopped.is_set() + + def end(self): + self._context.log_debug('PlayerMonitorThread[{0}]: End event set' + .format(self.video_id)) + self._ended.set() + + def ended(self): + return self._ended.is_set() + + +class PlayerMonitor(xbmc.Player): + def __init__(self, *_args, **kwargs): + super(PlayerMonitor, self).__init__() + self._context = kwargs.get('context') + self.provider = kwargs.get('provider') + self.ui = self._context.get_ui() + self.threads = [] + self.seek_time = None + self.start_time = None + self.end_time = None + + def stop_threads(self): + for thread in self.threads: + if thread.ended(): + continue + + if not thread.stopped(): + self._context.log_debug('PlayerMonitorThread[{0}]: stopping' + .format(thread.video_id)) + thread.stop() + + for thread in self.threads: + if thread.stopped() and not thread.ended(): + try: + thread.join(5) + except RuntimeError: + pass + + def cleanup_threads(self, only_ended=True): + active_threads = [] + for thread in self.threads: + if only_ended and not thread.ended(): + active_threads.append(thread) + continue + + if thread.ended(): + self._context.log_debug('PlayerMonitorThread[{0}]: clean up' + .format(thread.video_id)) + else: + self._context.log_debug('PlayerMonitorThread[{0}]: stopping' + .format(thread.video_id)) + if not thread.stopped(): + thread.stop() + try: + thread.join(5) + except RuntimeError: + pass + + self._context.log_debug('PlayerMonitor active threads: |{0}|'.format( + ', '.join([thread.video_id for thread in active_threads]) + )) + self.threads = active_threads + + def onAVStarted(self): + if not self.ui.busy_dialog_active(): + self.ui.clear_property('busy') + + playback_json = self.ui.get_property('playback_json') + if not playback_json: + return + + playback_json = json.loads(playback_json) + try: + self.seek_time = float(playback_json.get('seek_time')) + self.start_time = float(playback_json.get('start_time')) + self.end_time = float(playback_json.get('end_time')) + except (ValueError, TypeError): + self.seek_time = None + self.start_time = None + self.end_time = None + + self.ui.clear_property('playback_json') + self.cleanup_threads() + self.threads.append(PlayerMonitorThread(self, + self.provider, + self._context, + playback_json)) + + def onPlayBackEnded(self): + if not self.ui.busy_dialog_active(): + self.ui.clear_property('busy') + + self.stop_threads() + self.cleanup_threads() + + def onPlayBackStopped(self): + self.onPlayBackEnded() + + def onPlayBackError(self): + self.onPlayBackEnded() + + def onPlayBackSeek(self, time, seekOffset): + time_s = time / 1000 + self.seek_time = None + if ((self.end_time and time_s > self.end_time + 1) + or (self.start_time and time_s < self.start_time - 1)): + self.start_time = None + self.end_time = None diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/service_monitor.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/service_monitor.py new file mode 100644 index 0000000000..cddab114fa --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/service_monitor.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import json +import threading + +from ..compatibility import unquote, xbmc, xbmcaddon +from ..constants import ADDON_ID +from ..logger import log_debug +from ..network import get_http_server, is_httpd_live +from ..settings import Settings + + +class ServiceMonitor(xbmc.Monitor): + _settings = Settings(xbmcaddon.Addon(ADDON_ID)) + + def __init__(self): + settings = self._settings + self._whitelist = settings.httpd_whitelist() + self._old_httpd_port = self._httpd_port = int(settings.httpd_port()) + self._use_httpd = (settings.use_mpd_videos() + or settings.api_config_page()) + self._old_httpd_address = self._httpd_address = settings.httpd_listen() + self.httpd = None + self.httpd_thread = None + if self.use_httpd(): + self.start_httpd() + super(ServiceMonitor, self).__init__() + + def onNotification(self, sender, method, data): + if sender == ADDON_ID and method.endswith('.check_settings'): + if not isinstance(data, dict): + data = json.loads(data) + data = json.loads(unquote(data[0])) + log_debug('onNotification: |check_settings| -> |{data}|' + .format(data=data)) + + _use_httpd = data.get('use_httpd') + _httpd_port = data.get('httpd_port') + _whitelist = data.get('whitelist') + _httpd_address = data.get('httpd_address') + + whitelist_changed = _whitelist != self._whitelist + port_changed = self._httpd_port != _httpd_port + address_changed = self._httpd_address != _httpd_address + + if whitelist_changed: + self._whitelist = _whitelist + + if self._use_httpd != _use_httpd: + self._use_httpd = _use_httpd + + if port_changed: + self._old_httpd_port = self._httpd_port + self._httpd_port = _httpd_port + + if address_changed: + self._old_httpd_address = self._httpd_address + self._httpd_address = _httpd_address + + if not _use_httpd: + if self.httpd: + self.shutdown_httpd() + elif not self.httpd: + self.start_httpd() + elif port_changed or whitelist_changed or address_changed: + if self.httpd: + self.restart_httpd() + else: + self.start_httpd() + + elif sender == ADDON_ID: + log_debug('onNotification: |unhandled method| -> |{method}|' + .format(method=method)) + + def onSettingsChanged(self): + self._settings.flush(xbmcaddon.Addon(ADDON_ID)) + + data = { + 'use_httpd': (self._settings.use_mpd_videos() + or self._settings.api_config_page()), + 'httpd_port': self._settings.httpd_port(), + 'whitelist': self._settings.httpd_whitelist(), + 'httpd_address': self._settings.httpd_listen() + } + self.onNotification(ADDON_ID, 'Other.check_settings', data) + + def use_httpd(self): + return self._use_httpd + + def httpd_port(self): + return int(self._httpd_port) + + def httpd_address(self): + return self._httpd_address + + def old_httpd_address(self): + return self._old_httpd_address + + def old_httpd_port(self): + return int(self._old_httpd_port) + + def httpd_port_sync(self): + self._old_httpd_port = self._httpd_port + + def start_httpd(self): + if self.httpd: + return + + log_debug('HTTPServer: Starting |{ip}:{port}|' + .format(ip=self.httpd_address(), port=str(self.httpd_port()))) + self.httpd_port_sync() + self.httpd = get_http_server(address=self.httpd_address(), + port=self.httpd_port()) + if not self.httpd: + return + + self.httpd_thread = threading.Thread(target=self.httpd.serve_forever) + self.httpd_thread.daemon = True + self.httpd_thread.start() + sock_name = self.httpd.socket.getsockname() + log_debug('HTTPServer: Serving on |{ip}:{port}|'.format( + ip=str(sock_name[0]), + port=str(sock_name[1]) + )) + + def shutdown_httpd(self): + if self.httpd: + log_debug('HTTPServer: Shutting down |{ip}:{port}|' + .format(ip=self.old_httpd_address(), + port=self.old_httpd_port())) + self.httpd_port_sync() + self.httpd.shutdown() + self.httpd.socket.close() + self.httpd_thread.join() + self.httpd_thread = None + self.httpd = None + + def restart_httpd(self): + log_debug('HTTPServer: Restarting |{old_ip}:{old_port}| > |{ip}:{port}|' + .format(old_ip=self.old_httpd_address(), + old_port=self.old_httpd_port(), + ip=self.httpd_address(), + port=self.httpd_port())) + self.shutdown_httpd() + self.start_httpd() + + def ping_httpd(self): + return is_httpd_live(port=self.httpd_port()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/storage.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/storage.py deleted file mode 100644 index 17ba64062e..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/storage.py +++ /dev/null @@ -1,249 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2019 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import datetime -import os -import pickle -import sqlite3 -import time -import traceback - -from .. import logger - - -class Storage(object): - def __init__(self, filename, max_item_count=0, max_file_size_kb=-1): - self._table_name = 'storage' - self._filename = filename - if not self._filename.endswith('.sqlite'): - self._filename = ''.join([self._filename, '.sqlite']) - self._file = None - self._cursor = None - self._max_item_count = max_item_count - self._max_file_size_kb = max_file_size_kb - - self._table_created = False - self._needs_commit = False - - def set_max_item_count(self, max_item_count): - self._max_item_count = max_item_count - - def set_max_file_size_kb(self, max_file_size_kb): - self._max_file_size_kb = max_file_size_kb - - def __del__(self): - self._close() - - def _open(self): - if self._file is None: - self._optimize_file_size() - - path = os.path.dirname(self._filename) - if not os.path.exists(path): - os.makedirs(path) - - self._file = sqlite3.connect(self._filename, check_same_thread=False, - detect_types=0, timeout=1) - - self._file.isolation_level = None - self._cursor = self._file.cursor() - self._cursor.execute('PRAGMA journal_mode=MEMORY') - self._cursor.execute('PRAGMA busy_timeout=20000') - # self._cursor.execute('PRAGMA synchronous=OFF') - self._create_table() - - def _execute(self, needs_commit, query, values=None): - if values is None: - values = [] - if not self._needs_commit and needs_commit: - self._needs_commit = True - self._cursor.execute('BEGIN') - - """ - Tests revealed that sqlite has problems to release the database in time. This happens no so often, but just to - be sure, we try at least 3 times to execute out statement. - """ - for tries in range(3): - try: - return self._cursor.execute(query, values) - except TypeError: - return None - except: - time.sleep(0.1) - else: - return None - - def _close(self): - if self._file is not None: - self.sync() - self._file.commit() - self._cursor.close() - self._cursor = None - self._file.close() - self._file = None - - def _optimize_file_size(self): - # do nothing - only we have given a size - if self._max_file_size_kb <= 0: - return - - # do nothing - only if this folder exists - path = os.path.dirname(self._filename) - if not os.path.exists(path): - return - - if not os.path.exists(self._filename): - return - - try: - file_size_kb = (os.path.getsize(self._filename) // 1024) - if file_size_kb >= self._max_file_size_kb: - os.remove(self._filename) - except OSError: - pass - - def _create_table(self): - self._open() - if not self._table_created: - query = 'CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, time TIMESTAMP, value BLOB)' % self._table_name - self._execute(True, query) - self._table_created = True - - def sync(self): - if self._cursor is not None and self._needs_commit: - self._needs_commit = False - return self._execute(False, 'COMMIT') - - def _set(self, item_id, item): - def _encode(obj): - return sqlite3.Binary(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)) - - if self._max_file_size_kb < 1 and self._max_item_count < 1: - self._optimize_item_count() - else: - self._open() - now = datetime.datetime.now() + datetime.timedelta(microseconds=1) # add 1 microsecond, required for dbapi2 - query = 'REPLACE INTO %s (key,time,value) VALUES(?,?,?)' % self._table_name - self._execute(True, query, values=[item_id, now, _encode(item)]) - self._close() - self._optimize_item_count() - - def _optimize_item_count(self): - if self._max_item_count < 1: - if not self._is_empty(): - self._clear() - else: - self._open() - query = 'SELECT key FROM %s ORDER BY time DESC LIMIT -1 OFFSET %d' % (self._table_name, self._max_item_count) - result = self._execute(False, query) - if result is not None: - for item in result: - self._remove(item[0]) - self._close() - - def _clear(self): - self._open() - query = 'DELETE FROM %s' % self._table_name - self._execute(True, query) - self._create_table() - self._close() - self._open() - self._execute(False, 'VACUUM') - self._close() - - def _is_empty(self): - self._open() - query = 'SELECT exists(SELECT 1 FROM %s LIMIT 1);' % self._table_name - result = self._execute(False, query) - is_empty = True - if result is not None: - for item in result: - is_empty = item[0] == 0 - break - self._close() - return is_empty - - def _get_ids(self, oldest_first=True): - self._open() - # self.sync() - query = 'SELECT key FROM %s' % self._table_name - if oldest_first: - query = '%s ORDER BY time ASC' % query - else: - query = '%s ORDER BY time DESC' % query - - query_result = self._execute(False, query) - - result = [] - if query_result: - for item in query_result: - result.append(item[0]) - - self._close() - return result - - def _get(self, item_id): - def _decode(obj): - return pickle.loads(obj, encoding='utf-8') - - self._open() - query = 'SELECT time, value FROM %s WHERE key=?' % self._table_name - result = self._execute(False, query, [item_id]) - if result is None: - self._close() - return None - - item = result.fetchone() - if item is None: - self._close() - return None - - self._close() - return _decode(item[1]), item[0] - - def _remove(self, item_id): - self._open() - query = 'DELETE FROM %s WHERE key = ?' % self._table_name - self._execute(True, query, [item_id]) - - @staticmethod - def strptime(stamp, stamp_fmt): - # noinspection PyUnresolvedReferences - import _strptime - try: - time.strptime('01 01 2012', '%d %m %Y') # dummy call - except: - pass - return time.strptime(stamp, stamp_fmt) - - def get_seconds_diff(self, current_stamp): - stamp_format = '%Y-%m-%d %H:%M:%S.%f' - current_datetime = datetime.datetime.now() - if not current_stamp: - return 86400 # 24 hrs - try: - stamp_datetime = datetime.datetime(*(self.strptime(current_stamp, stamp_format)[0:6])) - except ValueError: # current_stamp has no microseconds - stamp_format = '%Y-%m-%d %H:%M:%S' - stamp_datetime = datetime.datetime(*(self.strptime(current_stamp, stamp_format)[0:6])) - except TypeError: - logger.log_error('Exception while calculating timestamp difference: ' - 'current_stamp |{cs}|{cst}| stamp_format |{sf}|{sft}| \n{tb}' - .format(cs=current_stamp, cst=type(current_stamp), - sf=stamp_format, sft=type(stamp_format), - tb=traceback.print_exc()) - ) - return 604800 # one week - - time_delta = current_datetime - stamp_datetime - total_seconds = 0 - if time_delta: - total_seconds = ((time_delta.seconds + time_delta.days * 24 * 3600) * 10 ** 6) // (10 ** 6) - return total_seconds diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/system_version.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/system_version.py index 50e24b86a1..b38e3fa9ee 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/system_version.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/system_version.py @@ -8,46 +8,54 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import json -import xbmc +from ..compatibility import string_type, xbmc class SystemVersion(object): - def __init__(self, version, releasename, appname): - if not isinstance(version, tuple): - self._version = (0, 0, 0, 0) - else: - self._version = version + def __init__(self, version=None, releasename=None, appname=None): + self._version = ( + version if version and isinstance(version, tuple) + else (0, 0, 0, 0) + ) - if not releasename or not isinstance(releasename, str): - self._releasename = 'UNKNOWN' - else: - self._releasename = releasename + self._releasename = ( + releasename if releasename and isinstance(releasename, string_type) + else 'UNKNOWN' + ) - if not appname or not isinstance(appname, str): - self._appname = 'UNKNOWN' - else: - self._appname = appname + self._appname = ( + appname if appname and isinstance(appname, string_type) + else 'UNKNOWN' + ) try: - json_query = xbmc.executeJSONRPC('{ "jsonrpc": "2.0", "method": "Application.GetProperties", ' - '"params": {"properties": ["version", "name"]}, "id": 1 }') + json_query = xbmc.executeJSONRPC(json.dumps({ + 'jsonrpc': '2.0', + 'method': 'Application.GetProperties', + 'params': { + 'properties': ['version', 'name'] + }, + 'id': 1, + })) json_query = str(json_query) json_query = json.loads(json_query) version_installed = json_query['result']['version'] - self._version = (version_installed.get('major', 1), version_installed.get('minor', 0)) + self._version = (version_installed.get('major', 1), + version_installed.get('minor', 0)) self._appname = json_query['result']['name'] except: self._version = (1, 0) # Frodo self._appname = 'Unknown Application' - self._releasename = 'Unknown Release' if self._version >= (21, 0): - self._releasename = 'O*****' + self._releasename = 'Omega' elif self._version >= (20, 0): - self._releasename = 'N*****' + self._releasename = 'Nexus' elif self._version >= (19, 0): self._releasename = 'Matrix' elif self._version >= (18, 0): @@ -64,9 +72,15 @@ def __init__(self, version, releasename, appname): self._releasename = 'Gotham' elif self._version >= (12, 0): self._releasename = 'Frodo' + else: + self._releasename = 'Unknown Release' def __str__(self): - obj_str = "%s (%s-%s)" % (self._releasename, self._appname, '.'.join(map(str, self._version))) + obj_str = '{releasename} ({appname}-{version[0]}.{version[1]})'.format( + releasename=self._releasename, + appname=self._appname, + version=self._version + ) return obj_str def get_release_name(self): @@ -77,3 +91,9 @@ def get_version(self): def get_app_name(self): return self._appname + + def compatible(self, *version): + return self._version >= version + + +current_system_version = SystemVersion() diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/watch_later_list.py b/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/watch_later_list.py deleted file mode 100644 index 38fc222cb3..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/kodion/utils/watch_later_list.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import datetime - -from .storage import Storage -from .. import items - - -class WatchLaterList(Storage): - def __init__(self, filename): - Storage.__init__(self, filename) - - def clear(self): - self._clear() - - def list(self): - result = [] - - for key in self._get_ids(): - data = self._get(key) - item = items.from_json(data[0]) - result.append(item) - - def _sort(video_item): - return video_item.get_date() - - self.sync() - - sorted_list = sorted(result, key=_sort, reverse=False) - return sorted_list - - def add(self, base_item): - now = datetime.datetime.now() - base_item.set_date(now.year, now.month, now.day, now.hour, now.minute, now.second) - - item_json_data = items.to_json(base_item) - self._set(base_item.get_id(), item_json_data) - - def remove(self, base_item): - self._remove(base_item.get_id()) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/refresh.py b/plugin.video.youtube/resources/lib/youtube_plugin/refresh.py deleted file mode 100644 index 15789ab5f5..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/refresh.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -import xbmc - -if __name__ == '__main__': - xbmc.executebuiltin("Container.Refresh") diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/script.py b/plugin.video.youtube/resources/lib/youtube_plugin/script.py new file mode 100644 index 0000000000..3ff79f8dfd --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/script.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +import os +import socket +import sys + +from kodion.compatibility import parse_qsl, xbmc, xbmcaddon, xbmcvfs +from kodion.constants import DATA_PATH, TEMP_PATH +from kodion.context import Context +from kodion.network import get_client_ip_address, is_httpd_live +from kodion.utils import rm_dir + + +def _config_actions(action, *_args): + context = Context() + localize = context.localize + settings = context.get_settings() + ui = context.get_ui() + + if action == 'youtube': + xbmcaddon.Addon().openSettings() + xbmc.executebuiltin('Container.Refresh') + + elif action == 'isa': + if context.use_inputstream_adaptive(): + xbmcaddon.Addon(id='inputstream.adaptive').openSettings() + else: + settings.set_bool('kodion.video.quality.isa', False) + + elif action == 'inputstreamhelper': + try: + xbmcaddon.Addon('script.module.inputstreamhelper') + ui.show_notification(localize('inputstreamhelper.is_installed')) + except RuntimeError: + xbmc.executebuiltin('InstallAddon(script.module.inputstreamhelper)') + + elif action == 'subtitles': + language = settings.get_string('youtube.language', 'en-US') + sub_setting = settings.subtitle_languages() + + sub_opts = [ + localize('none'), + localize('prompt'), + (localize('subtitles.with_fallback') % ( + ('en', 'en-US/en-GB') if language.startswith('en') else + (language, 'en') + )), + language, + '%s (%s)' % (language, localize('subtitles.no_auto_generated')) + ] + sub_opts[sub_setting] = ui.bold(sub_opts[sub_setting]) + + result = ui.on_select(localize('subtitles.language'), sub_opts) + if result > -1: + settings.set_subtitle_languages(result) + + result = ui.on_yes_no_input( + localize('subtitles.download'), + localize('subtitles.download.pre') + ) + if result > -1: + settings.set_subtitle_download(result == 1) + + elif action == 'listen_ip': + local_ranges = ('10.', '172.16.', '192.168.') + addresses = [iface[4][0] + for iface in socket.getaddrinfo(socket.gethostname(), None) + if iface[4][0].startswith(local_ranges)] + addresses += ['127.0.0.1', '0.0.0.0'] + selected_address = ui.on_select(localize('select.listen.ip'), addresses) + if selected_address != -1: + settings.set_httpd_listen(addresses[selected_address]) + + elif action == 'show_client_ip': + port = settings.httpd_port() + + if is_httpd_live(port=port): + client_ip = get_client_ip_address(port=port) + if client_ip: + ui.on_ok(context.get_name(), + context.localize('client.ip') % client_ip) + else: + ui.show_notification(context.localize('client.ip.failed')) + else: + ui.show_notification(context.localize('httpd.not.running')) + + +def _maintenance_actions(action, target): + context = Context() + ui = context.get_ui() + localize = context.localize + + if action == 'clear': + if target == 'function_cache': + if ui.on_remove_content(localize('cache.function')): + context.get_function_cache().clear() + ui.show_notification(localize('succeeded')) + elif target == 'data_cache': + if ui.on_remove_content(localize('cache.data')): + context.get_data_cache().clear() + ui.show_notification(localize('succeeded')) + elif target == 'search_cache': + if ui.on_remove_content(localize('search.history')): + context.get_search_history().clear() + ui.show_notification(localize('succeeded')) + elif (target == 'playback_history' and ui.on_remove_content( + localize('playback.history') + )): + context.get_playback_history().clear() + ui.show_notification(localize('succeeded')) + + elif action == 'delete': + _maint_files = {'function_cache': 'cache.sqlite', + 'search_cache': 'search.sqlite', + 'data_cache': 'data_cache.sqlite', + 'playback_history': 'playback_history', + 'settings_xml': 'settings.xml', + 'api_keys': 'api_keys.json', + 'access_manager': 'access_manager.json', + 'temp_files': TEMP_PATH} + _file = _maint_files.get(target) + succeeded = False + + if not _file: + return + + data_path = xbmcvfs.translatePath(DATA_PATH) + if 'sqlite' in _file: + _file_w_path = os.path.join(data_path, 'kodion', _file) + elif target == 'temp_files': + _file_w_path = _file + elif target == 'playback_history': + _file = ''.join(( + context.get_access_manager().get_current_user_id(), + '.sqlite' + )) + _file_w_path = os.path.join(data_path, 'playback', _file) + else: + _file_w_path = os.path.join(data_path, _file) + + if not ui.on_delete_content(_file): + return + + if target == 'temp_files': + succeeded = rm_dir(_file_w_path) + + elif _file_w_path: + succeeded = xbmcvfs.delete(_file_w_path) + + if succeeded: + ui.show_notification(localize('succeeded')) + else: + ui.show_notification(localize('failed')) + + +def _user_actions(action, params): + context = Context() + if params: + context.parse_params(dict(parse_qsl(params))) + localize = context.localize + access_manager = context.get_access_manager() + ui = context.get_ui() + + def select_user(reason, new_user=False): + current_users = access_manager.get_users() + current_user = access_manager.get_current_user() + usernames = [] + for user, details in sorted(current_users.items()): + username = details.get('name') or localize('user.unnamed') + if user == current_user: + username = '> ' + ui.bold(username) + if details.get('access_token') or details.get('refresh_token'): + username = ui.color('limegreen', username) + usernames.append(username) + if new_user: + usernames.append(ui.italic(localize('user.new'))) + return ui.on_select(reason, usernames), sorted(current_users.keys()) + + def add_user(): + results = ui.on_keyboard_input(localize('user.enter_name')) + if results[0] is False: + return None, None + new_username = results[1].strip() + if not new_username: + new_username = localize('user.unnamed') + return access_manager.add_user(new_username) + + def switch_to_user(user): + access_manager.set_user(user, switch_to=True) + ui.show_notification( + localize('user.changed') % access_manager.get_username(user), + localize('user.switch') + ) + context.get_data_cache().clear() + context.get_function_cache().clear() + if context.get_param('refresh') is not False: + ui.refresh_container() + + if action == 'switch': + result, user_index_map = select_user(localize('user.switch'), + new_user=True) + if result == -1: + return False + if result == len(user_index_map): + user, _ = add_user() + else: + user = user_index_map[result] + + if user is not None and user != access_manager.get_current_user(): + switch_to_user(user) + + elif action == 'add': + user, details = add_user() + if user is not None: + result = ui.on_yes_no_input( + localize('user.switch'), + localize('user.switch.now') % details.get('name') + ) + if result: + switch_to_user(user) + + elif action == 'remove': + result, user_index_map = select_user(localize('user.remove')) + if result == -1: + return False + + user = user_index_map[result] + username = access_manager.get_username(user) + if ui.on_remove_content(username): + access_manager.remove_user(user) + if user == 0: + access_manager.add_user(username=localize('user.default'), + user=0) + if user == access_manager.get_current_user(): + access_manager.set_user(0, switch_to=True) + ui.show_notification(localize('removed') % username, + localize('remove')) + + elif action == 'rename': + result, user_index_map = select_user(localize('user.rename')) + if result == -1: + return False + + user = user_index_map[result] + old_username = access_manager.get_username(user) + results = ui.on_keyboard_input(localize('user.enter_name'), + default=old_username) + if results[0] is False: + return False + new_username = results[1].strip() + if not new_username: + new_username = localize('user.unnamed') + if old_username == new_username: + return False + + if access_manager.set_username(user, new_username): + ui.show_notification( + localize('renamed') % (old_username, new_username), + localize('rename') + ) + + return True + + +if __name__ == '__main__': + args = sys.argv[1:] + if args: + args = args[0].split('/') + num_args = len(args) + category = args[0] if num_args else None + action = args[1] if num_args > 1 else None + params = args[2] if num_args > 2 else None + + if not category: + xbmcaddon.Addon().openSettings() + elif action == 'refresh': + xbmc.executebuiltin('Container.Refresh') + elif category == 'config': + _config_actions(action, params) + elif category == 'maintenance': + _maintenance_actions(action, params) + elif category == 'users': + _user_actions(action, params) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/__init__.py index 76a20e1cf8..f1edbe0f98 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/__init__.py @@ -8,6 +8,9 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .provider import Provider -__all__ = ['Provider'] + +__all__ = ('Provider',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__config__.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__config__.py index 7df58a97bd..545becf4e2 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__config__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__config__.py @@ -7,28 +7,33 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from base64 import b64decode -from hashlib import md5 -from ...kodion.json_store import APIKeyStore, LoginTokenStore -from ...kodion import Context as __Context + from ... import key_sets +from ...kodion import Context +from ...kodion.json_store import APIKeyStore, AccessManager -DEFAULT_SWITCH = 1 -__context = __Context(plugin_id='plugin.video.youtube') -__settings = __context.get_settings() +DEFAULT_SWITCH = 1 class APICheck(object): - - def __init__(self, context, settings): + def __init__(self, context): + settings = context.get_settings() + context.send_notification('check_settings', { + 'use_httpd': settings.use_mpd_videos() or settings.api_config_page(), + 'httpd_port': settings.httpd_port(), + 'whitelist': settings.httpd_whitelist(), + 'httpd_address': settings.httpd_listen() + }) self._context = context self._settings = settings self._ui = context.get_ui() self._api_jstore = APIKeyStore() self._json_api = self._api_jstore.get_data() - self._am_jstore = LoginTokenStore() - self._json_am = self._am_jstore.get_data() + self._access_manager = AccessManager(context) self.changed = False self._on_init() @@ -44,21 +49,21 @@ def _on_init(self): # users are now pasting keys into api_keys.json # try stripping whitespace and .apps.googleusercontent.com from keys and saving the result if they differ stripped_key, stripped_id, stripped_secret = self._strip_api_keys(j_key, j_id, j_secret) - if stripped_key and stripped_id and stripped_secret: - if (j_key != stripped_key) or (j_id != stripped_id) or (j_secret != stripped_secret): - self._json_api['keys']['personal'] = {'api_key': stripped_key, 'client_id': stripped_id, 'client_secret': stripped_secret} - self._api_jstore.save(self._json_api) - - original_key = self._settings.get_string('youtube.api.key') - original_id = self._settings.get_string('youtube.api.id') - original_secret = self._settings.get_string('youtube.api.secret') + if (stripped_key and stripped_id and stripped_secret + and (j_key != stripped_key or j_id != stripped_id or j_secret != stripped_secret)): + self._json_api['keys']['personal'] = {'api_key': stripped_key, 'client_id': stripped_id, 'client_secret': stripped_secret} + self._api_jstore.save(self._json_api) + + original_key = self._settings.api_key() + original_id = self._settings.api_id() + original_secret = self._settings.api_secret() if original_key and original_id and original_secret: own_key, own_id, own_secret = self._strip_api_keys(original_key, original_id, original_secret) if own_key and own_id and own_secret: if (original_key != own_key) or (original_id != own_id) or (original_secret != own_secret): - self._settings.set_string('youtube.api.key', own_key) - self._settings.set_string('youtube.api.id', own_id) - self._settings.set_string('youtube.api.secret', own_secret) + self._settings.api_key(own_key) + self._settings.api_id(own_id) + self._settings.api_secret(own_secret) if (j_key != own_key) or (j_id != own_id) or (j_secret != own_secret): self._json_api['keys']['personal'] = {'api_key': own_key, 'client_id': own_id, 'client_secret': own_secret} @@ -69,103 +74,81 @@ def _on_init(self): j_id = self._json_api['keys']['personal'].get('client_id', '') j_secret = self._json_api['keys']['personal'].get('client_secret', '') - if not original_key or not original_id or not original_secret and (j_key and j_secret and j_id): - self._settings.set_string('youtube.api.key', j_key) - self._settings.set_string('youtube.api.id', j_id) - self._settings.set_string('youtube.api.secret', j_secret) + if (not original_key or not original_id or not original_secret + and j_key and j_secret and j_id): + self._settings.api_key(j_key) + self._settings.api_id(j_id) + self._settings.api_secret(j_secret) switch = self.get_current_switch() - user = self.get_current_user() - - access_token = self._settings.get_string('kodion.access_token', '') - refresh_token = self._settings.get_string('kodion.refresh_token', '') - token_expires = self._settings.get_int('kodion.access_token.expires', -1) - last_hash = self._settings.get_string('youtube.api.last.hash', '') - if not self._json_am['access_manager']['users'].get(user, {}).get('access_token') or \ - not self._json_am['access_manager']['users'].get(user, {}).get('refresh_token'): - if access_token and refresh_token: - self._json_am['access_manager']['users'][user]['access_token'] = access_token - self._json_am['access_manager']['users'][user]['refresh_token'] = refresh_token - self._json_am['access_manager']['users'][user]['token_expires'] = token_expires - if switch == 'own': - own_key_hash = self._get_key_set_hash('own') - if last_hash == self._get_key_set_hash('own', True) or \ - last_hash == own_key_hash: - self._json_am['access_manager']['users'][user]['last_key_hash'] = own_key_hash - self._am_jstore.save(self._json_am) - if access_token or refresh_token or last_hash: - self._settings.set_string('kodion.access_token', '') - self._settings.set_string('kodion.refresh_token', '') - self._settings.set_int('kodion.access_token.expires', -1) - self._settings.set_string('youtube.api.last.hash', '') - - updated_hash = self._api_keys_changed(switch) - if updated_hash: - self._context.log_warning('User: |%s| Switching API key set to |%s|' % (user, switch)) - self._json_am['access_manager']['users'][user]['last_key_hash'] = updated_hash - self._am_jstore.save(self._json_am) + user_details = self._access_manager.get_current_user_details() + last_hash = user_details.get('last_key_hash', '') + current_set_hash = self._get_key_set_hash(switch) + self.changed = current_set_hash != last_hash + + self._context.log_debug('User: |{user}|, ' + 'Using API key set: |{switch}|' + .format(user=self.get_current_user(), + switch=switch)) + if self.changed: self._context.log_debug('API key set changed: Signing out') - self._context.execute('RunPlugin(plugin://plugin.video.youtube/sign/out/?confirmed=true)') - else: - self._context.log_debug('User: |%s| Using API key set: |%s|' % (user, switch)) + self._context.execute('RunPlugin(plugin://plugin.video.youtube/' + 'sign/out/?confirmed=true)') + self._access_manager.set_last_key_hash(current_set_hash) - def get_current_switch(self): + @staticmethod + def get_current_switch(): return 'own' def get_current_user(self): - self._json_am = self._am_jstore.get_data() - return self._json_am['access_manager'].get('current_user', '0') + return self._access_manager.get_current_user() def has_own_api_keys(self): self._json_api = self._api_jstore.get_data() own_key = self._json_api['keys']['personal']['api_key'] own_id = self._json_api['keys']['personal']['client_id'] own_secret = self._json_api['keys']['personal']['client_secret'] - return False if not own_key or \ - not own_id or \ - not own_secret else True + return own_key and own_id and own_secret def get_api_keys(self, switch): self._json_api = self._api_jstore.get_data() + if switch == 'developer': + return self._json_api['keys'][switch] + + decode = True if switch == 'youtube-tv': - api_key = b64decode(key_sets['youtube-tv']['key']).decode('utf-8'), - client_id = u''.join([b64decode(key_sets['youtube-tv']['id']).decode('utf-8'), u'.apps.googleusercontent.com']) - client_secret = b64decode(key_sets['youtube-tv']['secret']).decode('utf-8') - elif switch == 'developer': - self._json_api = self._api_jstore.get_data() - return self._json_api['keys']['developer'] + api_key = key_sets[switch]['key'] + client_id = key_sets[switch]['id'] + client_secret = key_sets[switch]['secret'] + elif switch == 'own': + decode = False api_key = self._json_api['keys']['personal']['api_key'] - client_id = u''.join([self._json_api['keys']['personal']['client_id'], u'.apps.googleusercontent.com']) + client_id = self._json_api['keys']['personal']['client_id'] client_secret = self._json_api['keys']['personal']['client_secret'] - else: - api_key = b64decode(key_sets['provided'][switch]['key']).decode('utf-8') - client_id = u''.join([b64decode(key_sets['provided'][switch]['id']).decode('utf-8'), u'.apps.googleusercontent.com']) - client_secret = b64decode(key_sets['provided'][switch]['secret']).decode('utf-8') - return api_key, client_id, client_secret - - def _api_keys_changed(self, switch): - self._json_am = self._am_jstore.get_data() - user = self.get_current_user() - last_set_hash = self._json_am['access_manager']['users'].get(user, {}).get('last_key_hash', '') - current_set_hash = self._get_key_set_hash(switch) - if last_set_hash != current_set_hash: - self.changed = True - return current_set_hash - else: - self.changed = False - return None - def _get_key_set_hash(self, switch, old=False): - api_key, client_id, client_secret = self.get_api_keys(switch) - if old and switch == 'own': - client_id = client_id.replace(u'.apps.googleusercontent.com', u'') - m = md5() - m.update(api_key.encode('utf-8')) - m.update(client_id.encode('utf-8')) - m.update(client_secret.encode('utf-8')) - - return m.hexdigest() + else: + api_key = key_sets['provided'][switch]['key'] + client_id = key_sets['provided'][switch]['id'] + client_secret = key_sets['provided'][switch]['secret'] + + if decode: + api_key = b64decode(api_key).decode('utf-8') + client_id = b64decode(client_id).decode('utf-8') + client_secret = b64decode(client_secret).decode('utf-8') + + client_id += '.apps.googleusercontent.com' + return {'key': api_key, + 'id': client_id, + 'secret': client_secret} + + def _get_key_set_hash(self, switch): + key_set = self.get_api_keys(switch) + if switch == 'own': + client_id = key_set['id'].replace('.apps.googleusercontent.com', + '') + key_set['id'] = client_id + return self._access_manager.calc_key_hash(**key_set) def _strip_api_keys(self, api_key, client_id, client_secret): @@ -209,28 +192,11 @@ def _strip_api_keys(self, api_key, client_id, client_secret): return return_key, return_id, return_secret -notification_data = {'use_httpd': (__settings.use_mpd_videos() or - __settings.use_mpd_live_streams()) or - (__settings.api_config_page()), - 'httpd_port': __settings.httpd_port(), - 'whitelist': __settings.httpd_whitelist(), - 'httpd_address': __settings.httpd_listen() - } - -__context.send_notification('check_settings', notification_data) - -_api_check = APICheck(__context, __settings) +_api_check = APICheck(Context()) keys_changed = _api_check.changed current_user = _api_check.get_current_user() -api = dict() -youtube_tv = dict() - -_current_switch = _api_check.get_current_switch() - -api['key'], api['id'], api['secret'] = _api_check.get_api_keys(_current_switch) - -youtube_tv['key'], youtube_tv['id'], youtube_tv['secret'] = _api_check.get_api_keys('youtube-tv') - +api = _api_check.get_api_keys(_api_check.get_current_switch()) +youtube_tv = _api_check.get_api_keys('youtube-tv') developer_keys = _api_check.get_api_keys('developer') diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__init__.py index 804d1f7ab8..8a0c95b2dd 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/__init__.py @@ -8,6 +8,9 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .youtube import YouTube -__all__ = ['YouTube'] + +__all__ = ('YouTube',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/login_client.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/login_client.py index 2948c4992f..f23853ddaf 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/login_client.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/login_client.py @@ -8,21 +8,44 @@ See LICENSES/GPL-2.0-only for more information. """ -import time -from urllib.parse import parse_qsl - -import requests - -from ...youtube.youtube_exceptions import InvalidGrant, LoginException -from ...kodion import Context -from .__config__ import api, youtube_tv, developer_keys, keys_changed - -context = Context(plugin_id='plugin.video.youtube') +from __future__ import absolute_import, division, unicode_literals +import time -class LoginClient(object): +from .__config__ import ( + api, + developer_keys, + keys_changed, + youtube_tv, +) +from .request_client import YouTubeRequestClient +from ..youtube_exceptions import ( + InvalidGrant, + InvalidJSON, + LoginException, +) +from ...kodion.compatibility import parse_qsl +from ...kodion.logger import log_debug + + +class LoginClient(YouTubeRequestClient): api_keys_changed = keys_changed + ANDROID_CLIENT_AUTH_URL = 'https://android.clients.google.com/auth' + DEVICE_CODE_URL = 'https://accounts.google.com/o/oauth2/device/code' + REVOKE_URL = 'https://accounts.google.com/o/oauth2/revoke' + SERVICE_URLS = 'oauth2:' + 'https://www.googleapis.com/auth/'.join(( + 'youtube ' + 'youtube.force-ssl ' + 'plus.me ' + 'emeraldsea.mobileapps.doritos.cookie ' + 'plus.stream.read ' + 'plus.stream.write ' + 'plus.pages.manage ' + 'identity.plus.page.impersonation', + )) + TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' + CONFIGS = { 'youtube-tv': { 'system': 'YouTube TV', @@ -39,30 +62,53 @@ class LoginClient(object): 'developer': developer_keys } - def __init__(self, config=None, language='en-US', region='', access_token='', access_token_tv=''): + def __init__(self, + config=None, + language='en_US', + region='', + access_token='', + access_token_tv=''): self._config = self.CONFIGS['main'] if config is None else config self._config_tv = self.CONFIGS['youtube-tv'] - self._verify = context.get_settings().verify_ssl() # the default language is always en_US (like YouTube on the WEB) if not language: language = 'en_US' - - language = language.replace('-', '_') - + else: + language = language.replace('-', '_') self._language = language self._region = region + self._access_token = access_token self._access_token_tv = access_token_tv - self._log_error_callback = None - def set_log_error(self, callback): - self._log_error_callback = callback + super(LoginClient, self).__init__(exc_type=LoginException) - def log_error(self, text): - if self._log_error_callback: - self._log_error_callback(text) - else: - print(text) + @staticmethod + def _response_hook(**kwargs): + response = kwargs['response'] + try: + json_data = response.json() + if 'error' in json_data: + json_data.setdefault('code', response.status_code) + raise LoginException('"error" in response JSON data', + json_data=json_data, + response=response) + except ValueError as exc: + raise InvalidJSON(exc, response=response) + response.raise_for_status() + return json_data + + @staticmethod + def _error_hook(**kwargs): + json_data = getattr(kwargs['exc'], 'json_data', None) + if not json_data or 'error' not in json_data: + return None, None, None, None, None, LoginException + if json_data['error'] == 'authorization_pending': + return None, None, None, json_data, False, False + if (json_data['error'] == 'invalid_grant' + and json_data.get('code') == '400'): + return None, None, None, json_data, False, InvalidGrant(json_data) + return None, None, None, json_data, False, LoginException(json_data) def verify(self): return self._verify @@ -76,141 +122,115 @@ def set_access_token_tv(self, access_token_tv=''): def revoke(self, refresh_token): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'accounts.google.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} post_data = {'token': refresh_token} - # url - url = 'https://accounts.google.com/o/oauth2/revoke' - - result = requests.post(url, data=post_data, headers=headers, verify=self._verify) - - try: - json_data = result.json() - if 'error' in json_data: - context.log_error('Revoke failed: Code: |%s| JSON: |%s|' % (str(result.status_code), json_data)) - json_data.update({'code': str(result.status_code)}) - raise LoginException(json_data) - except ValueError: - json_data = None - - if result.status_code != requests.codes.ok: - response_dump = self._get_response_dump(result, json_data) - context.log_error('Revoke failed: Code: |%s| Response dump: |%s|' % (str(result.status_code), response_dump)) - raise LoginException('Logout Failed') + self.request(self.REVOKE_URL, + method='POST', + data=post_data, + headers=headers, + response_hook=LoginClient._response_hook, + error_hook=LoginClient._error_hook, + error_title='Logout Failed', + error_info='Revoke failed: {exc}', + raise_exc=True) def refresh_token_tv(self, refresh_token): client_id = str(self.CONFIGS['youtube-tv']['id']) client_secret = str(self.CONFIGS['youtube-tv']['secret']) - return self.refresh_token(refresh_token, client_id=client_id, client_secret=client_secret) + return self.refresh_token(refresh_token, + client_id=client_id, + client_secret=client_secret) def refresh_token(self, refresh_token, client_id='', client_secret=''): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'www.googleapis.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} client_id = client_id or self._config['id'] client_secret = client_secret or self._config['secret'] - post_data = {'client_id': client_id, 'client_secret': client_secret, 'refresh_token': refresh_token, 'grant_type': 'refresh_token'} - # url - url = 'https://www.googleapis.com/oauth2/v4/token' - config_type = self._get_config_type(client_id, client_secret) - context.log_debug('Refresh token: Config: |%s| Client id [:5]: |%s| Client secret [:5]: |%s|' % - (config_type, client_id[:5], client_secret[:5])) - - result = requests.post(url, data=post_data, headers=headers, verify=self._verify) + client = ''.join([ + '(config_type: |', config_type, + '| client_id: |', client_id[:5], '...', client_id[-5:], + '| client_secret: |', client_secret[:5], '...', client_secret[-5:], + '|)' + ]) + log_debug('Refresh token for {0}'.format(client)) + + json_data = self.request(self.TOKEN_URL, + method='POST', + data=post_data, + headers=headers, + response_hook=LoginClient._response_hook, + error_hook=LoginClient._error_hook, + error_title='Login Failed', + error_info=('Refresh token failed' + ' {client}:\n{{exc}}' + .format(client=client)), + raise_exc=True) - try: - json_data = result.json() - if 'error' in json_data: - context.log_error('Refresh Failed: Code: |%s| JSON: |%s|' % (str(result.status_code), json_data)) - json_data.update({'code': str(result.status_code)}) - if json_data['error'] == 'invalid_grant' and json_data['code'] == '400': - raise InvalidGrant(json_data) - raise LoginException(json_data) - except ValueError: - json_data = None - - if result.status_code != requests.codes.ok: - response_dump = self._get_response_dump(result, json_data) - context.log_error('Refresh failed: Config: |%s| Client id [:5]: |%s| Client secret [:5]: |%s| Code: |%s| Response dump |%s|' % - (config_type, client_id[:5], client_secret[:5], str(result.status_code), response_dump)) - raise LoginException('Login Failed') - - if result.headers.get('content-type', '').startswith('application/json'): - if not json_data: - json_data = result.json() + if json_data: access_token = json_data['access_token'] expires_in = time.time() + int(json_data.get('expires_in', 3600)) return access_token, expires_in - return '', '' def request_access_token_tv(self, code, client_id='', client_secret=''): client_id = client_id or self.CONFIGS['youtube-tv']['id'] client_secret = client_secret or self.CONFIGS['youtube-tv']['secret'] - return self.request_access_token(code, client_id=client_id, client_secret=client_secret) + return self.request_access_token(code, + client_id=client_id, + client_secret=client_secret) def request_access_token(self, code, client_id='', client_secret=''): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'www.googleapis.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} client_id = client_id or self._config['id'] client_secret = client_secret or self._config['secret'] - post_data = {'client_id': client_id, 'client_secret': client_secret, 'code': code, 'grant_type': 'http://oauth.net/grant_type/device/1.0'} - # url - url = 'https://www.googleapis.com/oauth2/v4/token' - config_type = self._get_config_type(client_id, client_secret) - context.log_debug('Requesting access token: Config: |%s| Client id [:5]: |%s| Client secret [:5]: |%s|' % - (config_type, client_id[:5], client_secret[:5])) - - result = requests.post(url, data=post_data, headers=headers, verify=self._verify) - - authorization_pending = False - try: - json_data = result.json() - if 'error' in json_data: - if json_data['error'] != u'authorization_pending': - context.log_error('Requesting access token: Code: |%s| JSON: |%s|' % (str(result.status_code), json_data)) - json_data.update({'code': str(result.status_code)}) - raise LoginException(json_data) - else: - authorization_pending = True - except ValueError: - json_data = None - - if (result.status_code != requests.codes.ok) and not authorization_pending: - response_dump = self._get_response_dump(result, json_data) - context.log_error('Requesting access token: Config: |%s| Client id [:5]: |%s| Client secret [:5]: |%s| Code: |%s| Response dump |%s|' % - (config_type, client_id[:5], client_secret[:5], str(result.status_code), response_dump)) - raise LoginException('Login Failed: Code %s' % str(result.status_code)) - - if result.headers.get('content-type', '').startswith('application/json'): - if json_data: - return json_data - else: - return result.json() - else: - response_dump = self._get_response_dump(result, json_data) - context.log_error('Requesting access token: Config: |%s| Client id [:5]: |%s| Client secret [:5]: |%s| Code: |%s| Response dump |%s|' % - (config_type, client_id[:5], client_secret[:5], str(result.status_code), response_dump)) - raise LoginException('Login Failed: Unknown response') + client = ''.join([ + '(config_type: |', config_type, + '| client_id: |', client_id[:5], '...', client_id[-5:], + '| client_secret: |', client_secret[:5], '...', client_secret[-5:], + '|)' + ]) + log_debug('Requesting access token for {0}'.format(client)) + + json_data = self.request(self.TOKEN_URL, + method='POST', + data=post_data, + headers=headers, + response_hook=LoginClient._response_hook, + error_hook=LoginClient._error_hook, + error_title='Login Failed: Unknown response', + error_info=('Access token request failed' + ' {client}:\n{{exc}}' + .format(client=client)), + raise_exc=True) + return json_data def request_device_and_user_code_tv(self): client_id = str(self.CONFIGS['youtube-tv']['id']) @@ -219,48 +239,34 @@ def request_device_and_user_code_tv(self): def request_device_and_user_code(self, client_id=''): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'accounts.google.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} client_id = client_id or self._config['id'] - post_data = {'client_id': client_id, 'scope': 'https://www.googleapis.com/auth/youtube'} - # url - url = 'https://accounts.google.com/o/oauth2/device/code' - config_type = self._get_config_type(client_id) - context.log_debug('Requesting device and user code: Config: |%s| Client id [:5]: |%s|' % - (config_type, client_id[:5])) - - result = requests.post(url, data=post_data, headers=headers, verify=self._verify) - - try: - json_data = result.json() - if 'error' in json_data: - context.log_error('Requesting device and user code failed: Code: |%s| JSON: |%s|' % (str(result.status_code), json_data)) - json_data.update({'code': str(result.status_code)}) - raise LoginException(json_data) - except ValueError: - json_data = None - - if result.status_code != requests.codes.ok: - response_dump = self._get_response_dump(result, json_data) - context.log_error('Requesting device and user code failed: Config: |%s| Client id [:5]: |%s| Code: |%s| Response dump |%s|' % - (config_type, client_id[:5], str(result.status_code), response_dump)) - raise LoginException('Login Failed') - - if result.headers.get('content-type', '').startswith('application/json'): - if json_data: - return json_data - else: - return result.json() - else: - response_dump = self._get_response_dump(result, json_data) - context.log_error('Requesting access token: Config: |%s| Client id [:5]: |%s| Code: |%s| Response dump |%s|' % - (config_type, client_id[:5], str(result.status_code), response_dump)) - raise LoginException('Login Failed: Unknown response') + client = ''.join([ + '(config_type: |', config_type, '|', + ' client_id: |', client_id[:5], '...|)', + ]) + log_debug('Requesting device and user code for {0}'.format(client)) + + json_data = self.request(self.DEVICE_CODE_URL, + method='POST', + data=post_data, + headers=headers, + response_hook=LoginClient._response_hook, + error_hook=LoginClient._error_hook, + error_title='Login Failed: Unknown response', + error_info=('Device/user code request failed' + ' {client}:\n{{exc}}' + .format(client=client)), + raise_exc=True) + return json_data def get_access_token(self): return self._access_token @@ -274,35 +280,30 @@ def authenticate(self, username, password): 'Connection': 'Keep-Alive', 'Accept-Encoding': 'gzip'} - post_data = {'device_country': self._region.lower(), - 'operatorCountry': self._region.lower(), - 'lang': self._language.replace('-', '_'), - 'sdk_version': '19', - # 'google_play_services_version': '6188034', - 'accountType': 'HOSTED_OR_GOOGLE', - 'Email': username.encode('utf-8'), - 'service': 'oauth2:https://www.googleapis.com/auth/youtube ' - 'https://www.googleapis.com/auth/youtube.force-ssl ' - 'https://www.googleapis.com/auth/plus.me ' - 'https://www.googleapis.com/auth/emeraldsea.mobileapps.doritos.cookie ' - 'https://www.googleapis.com/auth/plus.stream.read ' - 'https://www.googleapis.com/auth/plus.stream.write ' - 'https://www.googleapis.com/auth/plus.pages.manage ' - 'https://www.googleapis.com/auth/identity.plus.page.impersonation', - 'source': 'android', - 'androidId': '38c6ee9a82b8b10a', - 'app': 'com.google.android.youtube', - # 'client_sig': '24bb24c05e47e0aefa68a58a766179d9b613a600', - 'callerPkg': 'com.google.android.youtube', - # 'callerSig': '24bb24c05e47e0aefa68a58a766179d9b613a600', - 'Passwd': password.encode('utf-8')} - - # url - url = 'https://android.clients.google.com/auth' - - result = requests.post(url, data=post_data, headers=headers, verify=self._verify) - if result.status_code != requests.codes.ok: - raise LoginException('Login Failed') + post_data = { + 'device_country': self._region.lower(), + 'operatorCountry': self._region.lower(), + 'lang': self._language, + 'sdk_version': '19', + # 'google_play_services_version': '6188034', + 'accountType': 'HOSTED_OR_GOOGLE', + 'Email': username.encode('utf-8'), + 'service': self.SERVICE_URLS, + 'source': 'android', + 'androidId': '38c6ee9a82b8b10a', + 'app': 'com.google.android.youtube', + # 'client_sig': '24bb24c05e47e0aefa68a58a766179d9b613a600', + 'callerPkg': 'com.google.android.youtube', + # 'callerSig': '24bb24c05e47e0aefa68a58a766179d9b613a600', + 'Passwd': password.encode('utf-8') + } + + result = self.request(self.ANDROID_CLIENT_AUTH_URL, + method='POST', + data=post_data, + headers=headers, + error_title='Login Failed', + raise_exc=True) lines = result.text.replace('\n', '&') params = dict(parse_qsl(lines)) @@ -316,29 +317,21 @@ def authenticate(self, username, password): def _get_config_type(self, client_id, client_secret=None): """used for logging""" if client_secret is None: - using_conf_tv = (client_id == self.CONFIGS['youtube-tv'].get('id')) - using_conf_main = (client_id == self.CONFIGS['main'].get('id')) + using_conf_tv = client_id == self.CONFIGS['youtube-tv'].get('id') + using_conf_main = client_id == self.CONFIGS['main'].get('id') else: - using_conf_tv = ((client_id == self.CONFIGS['youtube-tv'].get('id')) and (client_secret == self.CONFIGS['youtube-tv'].get('secret'))) - using_conf_main = ((client_id == self.CONFIGS['main'].get('id')) and (client_secret == self.CONFIGS['main'].get('secret'))) + using_conf_tv = ( + client_secret == self.CONFIGS['youtube-tv'].get('secret') + and client_id == self.CONFIGS['youtube-tv'].get('id') + ) + using_conf_main = ( + client_secret == self.CONFIGS['main'].get('secret') + and client_id == self.CONFIGS['main'].get('id') + ) if not using_conf_main and not using_conf_tv: return 'None' - elif using_conf_tv: + if using_conf_tv: return 'YouTube-TV' - elif using_conf_main: + if using_conf_main: return 'YouTube-Kodi' - else: - return 'Unknown' - - @staticmethod - def _get_response_dump(response, json_data=None): - if json_data: - return json_data - else: - try: - return response.json() - except ValueError: - try: - return response.text - except: - return 'None' + return 'Unknown' diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/request_client.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/request_client.py new file mode 100644 index 0000000000..442178cef9 --- /dev/null +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/request_client.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2023-present plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from ..youtube_exceptions import YouTubeException +from ...kodion.network import BaseRequestsClass +from ...kodion.utils import merge_dicts + + +class YouTubeRequestClient(BaseRequestsClass): + CLIENTS = { + # 4k no VP9 HDR + # Limited subtitle availability + 'android_testsuite': { + '_id': 30, + '_query_subtitles': True, + 'json': { + 'params': '2AMBCgIQBg', + 'context': { + 'client': { + 'clientName': 'ANDROID_TESTSUITE', + 'clientVersion': '1.9', + 'androidSdkVersion': '29', + 'osName': 'Android', + 'osVersion': '10', + 'platform': 'MOBILE', + }, + }, + }, + 'headers': { + 'User-Agent': ('com.google.android.youtube/' + '{json[context][client][clientVersion]}' + ' (Linux; U; {json[context][client][osName]}' + ' {json[context][client][osVersion]};' + ' {json[context][client][gl]}) gzip'), + 'X-YouTube-Client-Name': '{_id}', + 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', + }, + 'params': { + 'key': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', + }, + }, + 'android': { + '_id': 3, + 'json': { + 'params': '2AMBCgIQBg', + 'context': { + 'client': { + 'clientName': 'ANDROID', + 'clientVersion': '17.31.35', + 'androidSdkVersion': '30', + 'osName': 'Android', + 'osVersion': '11', + 'platform': 'MOBILE', + }, + }, + }, + 'headers': { + 'User-Agent': ('com.google.android.youtube/' + '{json[context][client][clientVersion]}' + ' (Linux; U; {json[context][client][osName]}' + ' {json[context][client][osVersion]};' + ' {json[context][client][gl]}) gzip'), + 'X-YouTube-Client-Name': '{_id}', + 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', + }, + 'params': { + 'key': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', + }, + }, + # Only for videos that allow embedding + # Limited to 720p on some videos + 'android_embedded': { + '_id': 55, + 'json': { + 'params': '2AMBCgIQBg', + 'context': { + 'client': { + 'clientName': 'ANDROID_EMBEDDED_PLAYER', + 'clientVersion': '17.36.4', + 'clientScreen': 'EMBED', + 'androidSdkVersion': '29', + 'osName': 'Android', + 'osVersion': '10', + 'platform': 'MOBILE', + }, + }, + 'thirdParty': { + 'embedUrl': 'https://www.youtube.com/embed/{json[videoId]}', + }, + }, + 'headers': { + 'User-Agent': ('com.google.android.youtube/' + '{json[context][client][clientVersion]}' + ' (Linux; U; {json[context][client][osName]}' + ' {json[context][client][osVersion]};' + ' {json[context][client][gl]}) gzip'), + 'X-YouTube-Client-Name': '{_id}', + 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', + }, + 'params': { + 'key': 'AIzaSyCjc_pVEDi4qsv5MtC2dMXzpIaDoRFLsxw', + }, + }, + # 4k with HDR + # Some videos block this client, may also require embedding enabled + # Limited subtitle availability + 'android_youtube_tv': { + '_id': 29, + '_query_subtitles': True, + 'json': { + 'params': '2AMBCgIQBg', + 'context': { + 'client': { + 'clientName': 'ANDROID_UNPLUGGED', + 'clientVersion': '6.36', + 'androidSdkVersion': '29', + 'osName': 'Android', + 'osVersion': '10', + 'platform': 'MOBILE', + }, + }, + }, + 'headers': { + 'User-Agent': ('com.google.android.apps.youtube.unplugged/' + '{json[context][client][clientVersion]}' + ' (Linux; U; {json[context][client][osName]}' + ' {json[context][client][osVersion]};' + ' {json[context][client][gl]}) gzip'), + 'X-YouTube-Client-Name': '{_id}', + 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', + }, + 'params': { + 'key': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', + }, + }, + 'ios': { + '_id': 5, + 'json': { + 'context': { + 'client': { + 'clientName': 'IOS', + 'clientVersion': '17.33.2', + 'deviceModel': 'iPhone14,3', + 'osName': 'iOS', + 'osVersion': '15_6', + 'platform': 'MOBILE', + }, + }, + }, + 'headers': { + 'User-Agent': ('com.google.ios.youtube/' + '{json[context][client][clientVersion]}' + ' ({json[context][client][deviceModel]};' + ' U; CPU {json[context][client][osName]}' + ' {json[context][client][osVersion]}' + ' like Mac OS X)'), + 'X-YouTube-Client-Name': '{_id}', + 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', + }, + 'params': { + 'key': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc', + }, + }, + # Used to requests captions for clients that don't provide them + # Requires handling of nsig to overcome throttling (TODO) + 'smarttv_embedded': { + '_id': 85, + 'json': { + 'params': '2AMBCgIQBg', + 'context': { + 'client': { + 'clientName': 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', + 'clientScreen': 'WATCH', + 'clientVersion': '2.0', + }, + }, + 'thirdParty': { + 'embedUrl': 'https://www.youtube.com', + }, + }, + # Headers from a 2022 Samsung Tizen 6.5 based Smart TV + 'headers': { + 'User-Agent': ('Mozilla/5.0 (SMART-TV; LINUX; Tizen 6.5)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' 85.0.4183.93/6.5 TV Safari/537.36'), + }, + 'params': { + 'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8', + }, + }, + # Used for misc api requests by default + # Requires handling of nsig to overcome throttling (TODO) + 'web': { + '_id': 1, + 'json': { + 'context': { + 'client': { + 'clientName': 'WEB', + 'clientVersion': '2.20220801.00.00', + }, + }, + }, + # Headers for a "Galaxy S20 Ultra" from Chrome dev tools device + # emulation + 'headers': { + 'User-Agent': ('Mozilla/5.0 (Linux; Android 10; SM-G981B)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/80.0.3987.162 Mobile Safari/537.36'), + 'Referer': 'https://www.youtube.com/watch?v={json[videoId]}' + }, + 'params': { + 'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8', + }, + }, + '_common': { + '_access_token': None, + 'json': { + 'contentCheckOk': True, + 'context': { + 'client': { + 'gl': None, + 'hl': None, + }, + }, + 'playbackContext': { + 'contentPlaybackContext': { + 'html5Preference': 'HTML5_PREF_WANTS', + }, + }, + 'racyCheckOk': True, + 'thirdParty': {}, + 'user': { + 'lockedSafetyMode': False + }, + 'videoId': None, + }, + 'headers': { + 'Origin': 'https://www.youtube.com', + 'Referer': 'https://www.youtube.com/watch?v={json[videoId]}', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + 'Authorization': 'Bearer {_access_token}', + }, + 'params': { + 'key': None, + 'prettyPrint': 'false' + }, + }, + } + + def __init__(self, exc_type=None): + if isinstance(exc_type, tuple): + exc_type = (YouTubeException,) + exc_type + elif exc_type: + exc_type = (YouTubeException, exc_type) + else: + exc_type = (YouTubeException,) + super(YouTubeRequestClient, self).__init__(exc_type=exc_type) + + @classmethod + def json_traverse(cls, json_data, path): + if not json_data or not path: + return None + + result = json_data + for idx, keys in enumerate(path): + if not isinstance(result, (dict, list, tuple)): + return None + + if isinstance(keys, slice): + return [ + cls.json_traverse(part, path[idx + 1:]) + for part in result[keys] + if part + ] + + if not isinstance(keys, (list, tuple)): + keys = [keys] + + for key in keys: + if isinstance(key, (list, tuple)): + new_result = cls.json_traverse(result, key) + if new_result: + result = new_result + break + continue + + try: + result = result[key] + except (KeyError, IndexError): + continue + break + else: + return None + + if result == json_data: + return None + return result + + @classmethod + def build_client(cls, client_name, data=None): + templates = {} + + client = (cls.CLIENTS.get(client_name) + or YouTubeRequestClient.CLIENTS['web']).copy() + if data: + client = merge_dicts(client, data) + client = merge_dicts(cls.CLIENTS['_common'], client, templates) + + if data and '_access_token' in data: + del client['params']['key'] + elif 'Authorization' in client['headers']: + del client['headers']['Authorization'] + + for values, template_id, template in templates.values(): + if template_id in values: + values[template_id] = template.format(**client) + + return client diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/youtube.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/youtube.py index 3657cef021..ccb49226f8 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/youtube.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/client/youtube.py @@ -8,33 +8,76 @@ See LICENSES/GPL-2.0-only for more information. """ -import copy -import json -import re +from __future__ import absolute_import, division, unicode_literals + import threading -import traceback import xml.etree.ElementTree as ET - -import requests +from copy import deepcopy +from itertools import chain, islice +from random import randint from .login_client import LoginClient -from ..youtube_exceptions import YouTubeException from ..helper.video_info import VideoInfo -from ...kodion import Context -from ...kodion.utils import datetime_parser -from ...kodion.utils import to_unicode - -_context = Context(plugin_id='plugin.video.youtube') +from ..youtube_exceptions import InvalidJSON, YouTubeException +from ...kodion.compatibility import string_type +from ...kodion.utils import datetime_parser, strip_html_from_text, to_unicode class YouTube(LoginClient): - def __init__(self, config=None, language='en-US', region='US', items_per_page=50, access_token='', access_token_tv=''): - if config is None: - config = {} - LoginClient.__init__(self, config=config, language=language, region=region, access_token=access_token, - access_token_tv=access_token_tv) + CLIENTS = { + 1: { + 'url': 'https://www.youtube.com/youtubei/v1/{_endpoint}', + 'method': None, + 'json': { + 'context': { + 'client': { + 'clientName': 'WEB', + 'clientVersion': '2.20220801.00.00', + }, + }, + }, + 'headers': { + 'Host': 'www.youtube.com', + }, + 'params': { + 'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8', + }, + }, + 3: { + 'url': 'https://www.googleapis.com/youtube/v3/{_endpoint}', + 'method': None, + 'headers': { + 'Host': 'www.googleapis.com', + }, + }, + '_common': { + '_access_token': None, + 'headers': { + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + 'Authorization': 'Bearer {_access_token}', + 'DNT': '1', + 'User-Agent': ('Mozilla/5.0 (Linux; Android 10; SM-G981B)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/80.0.3987.162 Mobile Safari/537.36'), + }, + 'params': { + 'key': None, + 'prettyPrint': 'false' + }, + }, + } + + def __init__(self, context, **kwargs): + self._context = context + if not kwargs.get('config'): + kwargs['config'] = {} + if 'items_per_page' in kwargs: + self._max_results = kwargs.pop('items_per_page') - self._max_results = items_per_page + super(YouTube, self).__init__(**kwargs) def get_max_results(self): return self._max_results @@ -69,7 +112,25 @@ def calculate_next_page_token(page, max_result): return 'C%s%s%sAA' % (high[high_iteration], low[low_iteration], overflow_token) - def update_watch_history(self, video_id, url): + def update_watch_history(self, + context, + video_id, + url, + st=None, + et=None, + state=None): + if None not in (st, et, state): + url.format(st=st, et=et, state=state) + else: + st = et = state = 'N/A' + + context.log_debug('Playback reported [{video_id}]:' + ' {st} segment start,' + ' {et} segment end,' + ' state={state}'.format( + video_id=video_id, st=st, et=et, state=state + )) + headers = {'Host': 'www.youtube.com', 'Connection': 'keep-alive', 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.36 Safari/537.36', @@ -90,10 +151,8 @@ def update_watch_history(self, video_id, url): if self._access_token: params['access_token'] = self._access_token - try: - _ = requests.get(url, params=params, headers=headers, verify=self._verify, allow_redirects=True) - except: - _context.log_error('Failed to update watch history |%s|' % traceback.print_exc()) + self.request(url, params=params, headers=headers, + error_msg='Failed to update watch history') def get_video_streams(self, context, video_id): video_info = VideoInfo(context, access_token=self._access_token_tv, @@ -139,52 +198,76 @@ def get_video_streams(self, context, video_id): return video_streams - def remove_playlist(self, playlist_id): + def remove_playlist(self, playlist_id, **kwargs): params = {'id': playlist_id, 'mine': 'true'} - return self.perform_v3_request(method='DELETE', path='playlists', params=params) + return self.api_request(method='DELETE', + path='playlists', + params=params, + **kwargs) - def get_supported_languages(self, language=None): + def get_supported_languages(self, language=None, **kwargs): _language = language if not _language: _language = self._language _language = _language.replace('-', '_') params = {'part': 'snippet', 'hl': _language} - return self.perform_v3_request(method='GET', path='i18nLanguages', params=params) + return self.api_request(method='GET', + path='i18nLanguages', + params=params, + **kwargs) - def get_supported_regions(self, language=None): + def get_supported_regions(self, language=None, **kwargs): _language = language if not _language: _language = self._language _language = _language.replace('-', '_') params = {'part': 'snippet', 'hl': _language} - return self.perform_v3_request(method='GET', path='i18nRegions', params=params) - - def rename_playlist(self, playlist_id, new_title, privacy_status='private'): + return self.api_request(method='GET', + path='i18nRegions', + params=params, + **kwargs) + + def rename_playlist(self, + playlist_id, + new_title, + privacy_status='private', + **kwargs): params = {'part': 'snippet,id,status'} post_data = {'kind': 'youtube#playlist', 'id': playlist_id, 'snippet': {'title': new_title}, 'status': {'privacyStatus': privacy_status}} - return self.perform_v3_request(method='PUT', path='playlists', params=params, post_data=post_data) + return self.api_request(method='PUT', + path='playlists', + params=params, + post_data=post_data, + **kwargs) - def create_playlist(self, title, privacy_status='private'): + def create_playlist(self, title, privacy_status='private', **kwargs): params = {'part': 'snippet,status'} post_data = {'kind': 'youtube#playlist', 'snippet': {'title': title}, 'status': {'privacyStatus': privacy_status}} - return self.perform_v3_request(method='POST', path='playlists', params=params, post_data=post_data) - - def get_video_rating(self, video_id): - if isinstance(video_id, list): + return self.api_request(method='POST', + path='playlists', + params=params, + post_data=post_data, + **kwargs) + + def get_video_rating(self, video_id, **kwargs): + if not isinstance(video_id, string_type): video_id = ','.join(video_id) params = {'id': video_id} - return self.perform_v3_request(method='GET', path='videos/getRating', params=params) + return self.api_request(method='GET', + path='videos/getRating', + params=params, + **kwargs) - def rate_video(self, video_id, rating='like'): + def rate_video(self, video_id, rating='like', **kwargs): """ Rate a video :param video_id: if of the video @@ -193,34 +276,58 @@ def rate_video(self, video_id, rating='like'): """ params = {'id': video_id, 'rating': rating} - return self.perform_v3_request(method='POST', path='videos/rate', params=params) + return self.api_request(method='POST', + path='videos/rate', + params=params, + **kwargs) - def add_video_to_playlist(self, playlist_id, video_id): + def add_video_to_playlist(self, playlist_id, video_id, **kwargs): params = {'part': 'snippet', 'mine': 'true'} post_data = {'kind': 'youtube#playlistItem', 'snippet': {'playlistId': playlist_id, 'resourceId': {'kind': 'youtube#video', 'videoId': video_id}}} - return self.perform_v3_request(method='POST', path='playlistItems', params=params, post_data=post_data) + return self.api_request(method='POST', + path='playlistItems', + params=params, + post_data=post_data, + **kwargs) # noinspection PyUnusedLocal - def remove_video_from_playlist(self, playlist_id, playlist_item_id): + def remove_video_from_playlist(self, + playlist_id, + playlist_item_id, + **kwargs): params = {'id': playlist_item_id} - return self.perform_v3_request(method='DELETE', path='playlistItems', params=params) + return self.api_request(method='DELETE', + path='playlistItems', + params=params, + **kwargs) - def unsubscribe(self, subscription_id): + def unsubscribe(self, subscription_id, **kwargs): params = {'id': subscription_id} - return self.perform_v3_request(method='DELETE', path='subscriptions', params=params) + return self.api_request(method='DELETE', + path='subscriptions', + params=params, + **kwargs) - def subscribe(self, channel_id): + def subscribe(self, channel_id, **kwargs): params = {'part': 'snippet'} post_data = {'kind': 'youtube#subscription', 'snippet': {'resourceId': {'kind': 'youtube#channel', 'channelId': channel_id}}} - return self.perform_v3_request(method='POST', path='subscriptions', params=params, post_data=post_data) - - def get_subscription(self, channel_id, order='alphabetical', page_token=''): + return self.api_request(method='POST', + path='subscriptions', + params=params, + post_data=post_data, + **kwargs) + + def get_subscription(self, + channel_id, + order='alphabetical', + page_token='', + **kwargs): """ :param channel_id: [channel-id|'mine'] @@ -238,9 +345,12 @@ def get_subscription(self, channel_id, order='alphabetical', page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='subscriptions', params=params) + return self.api_request(method='GET', + path='subscriptions', + params=params, + **kwargs) - def get_guide_category(self, guide_category_id, page_token=''): + def get_guide_category(self, guide_category_id, page_token='', **kwargs): params = {'part': 'snippet,contentDetails,brandingSettings', 'maxResults': str(self._max_results), 'categoryId': guide_category_id, @@ -248,9 +358,12 @@ def get_guide_category(self, guide_category_id, page_token=''): 'hl': self._language} if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='channels', params=params) + return self.api_request(method='GET', + path='channels', + params=params, + **kwargs) - def get_guide_categories(self, page_token=''): + def get_guide_categories(self, page_token='', **kwargs): params = {'part': 'snippet', 'maxResults': str(self._max_results), 'regionCode': self._region, @@ -258,9 +371,12 @@ def get_guide_categories(self, page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='guideCategories', params=params) + return self.api_request(method='GET', + path='guideCategories', + params=params, + **kwargs) - def get_popular_videos(self, page_token=''): + def get_trending_videos(self, page_token='', **kwargs): params = {'part': 'snippet,status', 'maxResults': str(self._max_results), 'regionCode': self._region, @@ -268,9 +384,12 @@ def get_popular_videos(self, page_token=''): 'chart': 'mostPopular'} if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='videos', params=params) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) - def get_video_category(self, video_category_id, page_token=''): + def get_video_category(self, video_category_id, page_token='', **kwargs): params = {'part': 'snippet,contentDetails,status', 'maxResults': str(self._max_results), 'videoCategoryId': video_category_id, @@ -279,9 +398,12 @@ def get_video_category(self, video_category_id, page_token=''): 'hl': self._language} if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='videos', params=params) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) - def get_video_categories(self, page_token=''): + def get_video_categories(self, page_token='', **kwargs): params = {'part': 'snippet', 'maxResults': str(self._max_results), 'regionCode': self._region, @@ -289,135 +411,381 @@ def get_video_categories(self, page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='videoCategories', params=params) + return self.api_request(method='GET', + path='videoCategories', + params=params, + **kwargs) - def _get_recommendations_for_home(self): - # YouTube has deprecated this API, so use history and related items to form - # a recommended set. We cache aggressively because searches incur a high - # quota cost of 100 on the YouTube API. - # Note this is a first stab attempt and can be refined a lot more. + def get_recommended_for_home(self, + visitor='', + page_token='', + click_tracking=''): payload = { 'kind': 'youtube#activityListResponse', 'items': [] } - watch_history_id = _context.get_access_manager().get_watch_history_id() - if not watch_history_id or watch_history_id == 'HL': + post_data = {'browseId': 'FEwhat_to_watch'} + if page_token: + post_data['continuation'] = page_token + if click_tracking or visitor: + context = {} + if click_tracking: + context['clickTracking'] = { + 'clickTrackingParams': click_tracking, + } + if visitor: + context['client'] = { + 'visitorData': visitor, + } + post_data['context'] = context + + result = self.api_request(version=1, + method='POST', + path='browse', + post_data=post_data) + if not result: return payload - cache = _context.get_data_cache() + recommended_videos = self.json_traverse( + result, + path=( + ( + ( + 'onResponseReceivedEndpoints', + 'onResponseReceivedActions', + ), + 0, + 'appendContinuationItemsAction', + 'continuationItems', + ) if page_token else ( + 'contents', + 'twoColumnBrowseResultsRenderer', + 'tabs', + 0, + 'tabRenderer', + 'content', + 'richGridRenderer', + 'contents', + ) + ) + ( + slice(None), + ( + ( + 'richItemRenderer', + 'content', + 'videoRenderer', + # 'videoId', + ), + ( + 'richSectionRenderer', + 'content', + 'richShelfRenderer', + 'contents', + slice(None), + 'richItemRenderer', + 'content', + ( + 'videoRenderer', + 'reelItemRenderer' + ), + # 'videoId', + ), + ( + 'continuationItemRenderer', + 'continuationEndpoint', + ), + ), + ) + ) + if not recommended_videos: + return payload - # Do we have a cached result? - cache_home_key = 'get-activities-home' - cached = cache.get_item(cache.ONE_HOUR * 4, cache_home_key) - if cache_home_key in cached and cached[cache_home_key].get('items'): - return cached[cache_home_key] + v3_response = { + 'kind': 'youtube#activityListResponse', + 'items': [ + { + 'kind': "youtube#video", + 'id': video['videoId'], + 'partial': True, + 'snippet': { + 'title': self.json_traverse(video, ( + ('title', 'runs', 0, 'text'), + ('headline', 'simpleText'), + )), + 'thumbnails': dict(zip( + ('default', 'high'), + video['thumbnail']['thumbnails'], + )), + 'channelId': self.json_traverse(video, ( + ('longBylineText', 'shortBylineText'), + 'runs', + 0, + 'navigationEndpoint', + 'browseEndpoint', + 'browseId', + )), + } + } + for videos in recommended_videos + for video in + (videos if isinstance(videos, list) else (videos,)) + if video and 'videoId' in video + ] + } - # Fetch existing list of items, if any - items = [] - cache_items_key = 'get-activities-home-items' - cached = cache.get_item(cache.ONE_WEEK * 2, cache_items_key) - if cache_items_key in cached: - items = cached[cache_items_key] - - # Fetch history and recommended items. Use threads for faster execution. - def helper(video_id, responses): - _context.log_debug( - 'Method get_activities: doing expensive API fetch for related' - 'items for video %s' % video_id - ) - di = self.get_related_videos(video_id, max_results=10) - if 'items' in di: - # Record for which video we fetched the items - for item in di['items']: - item['plugin_fetched_for'] = video_id - responses.extend(di['items']) + last_item = recommended_videos[-1] + if last_item and 'continuationCommand' in last_item: + if 'clickTrackingParams' in last_item: + v3_response['clickTracking'] = last_item['clickTrackingParams'] + token = last_item['continuationCommand'].get('token') + if token: + v3_response['nextPageToken'] = token + visitor = self.json_traverse(result, ( + 'responseContext', + 'visitorData', + )) or visitor + if visitor: + v3_response['visitorData'] = visitor + + return v3_response + + def get_related_for_home(self, page_token=''): + """ + YouTube has deprecated this API, so we use history and related items to + form a recommended set. + We cache aggressively because searches can be slow. + Note this is a naive implementation and can be refined a lot more. + """ - history = self.get_playlist_items(watch_history_id, max_results=50) + payload = { + 'kind': 'youtube#activityListResponse', + 'items': [] + } - if not history.get('items'): - return payload + # Related videos are retrieved for the following num_items from history + num_items = 10 + local_history = self._context.get_settings().use_local_history() + history_id = self._context.get_access_manager().get_watch_history_id() + if not history_id: + if local_history: + history = self._context.get_playback_history() + video_ids = history.get_items(limit=num_items) + else: + return payload + else: + history = self.get_playlist_items(history_id, max_results=num_items) + if history and 'items' in history: + history_items = history['items'] or [] + video_ids = [] + else: + return payload - threads = [] - candidates = [] - already_fetched_for_video_ids = [item['plugin_fetched_for'] for item in items] - history_items = [item for item in history['items'] - if re.match(r'(?P[\w-]{11})', - item['snippet']['resourceId']['videoId'])] + for item in history_items: + try: + video_ids.append(item['snippet']['resourceId']['videoId']) + except KeyError: + continue - # TODO: - # It would be nice to make this 8 user configurable - for item in history_items[:8]: - video_id = item['snippet']['resourceId']['videoId'] - if video_id not in already_fetched_for_video_ids: - thread = threading.Thread(target=helper, args=(video_id, candidates)) + # Fetch existing list of items, if any + cache = self._context.get_data_cache() + cache_items_key = 'get-activities-home-items' + cached = cache.get_item(cache_items_key, None) or [] + + # Increase value to recursively retrieve recommendations for the first + # recommended video, up to the set maximum recursion depth + max_depth = 2 + items_per_page = self._max_results + diversity_limits = items_per_page // (num_items * max_depth) + items = [[] for _ in range(max_depth * len(video_ids))] + counts = { + '_counter': 0, + '_pages': {}, + '_related': {}, + } + + def index_items(items, index, + item_store=None, + original_ids=None, + group=None, + depth=1, + original_related=None, + original_channel=None): + if original_ids is not None: + original_ids = list(original_ids) + + running = 0 + threads = [] + + for idx, item in enumerate(items): + if original_related is not None: + related = item['related_video_id'] = original_related + else: + related = item['related_video_id'] + if original_channel is not None: + channel = item['related_channel_id'] = original_channel + else: + channel = item['related_channel_id'] + video_id = item['id'] + + index['_related'].setdefault(related, 0) + index['_related'][related] += 1 + + if video_id in index: + item_count = index[video_id] + item_count['related'].setdefault(related, 0) + item_count['related'][related] += 1 + item_count['channels'].setdefault(channel, 0) + item_count['channels'][channel] += 1 + continue + + index[video_id] = { + 'related': {related: 1}, + 'channels': {channel: 1} + } + + if item_store is None: + if original_ids and related not in original_ids: + items[idx] = None + continue + + if group is not None: + pass + elif original_ids and related in original_ids: + group = max_depth * original_ids.index(related) + else: + group = 0 + + num_stored = len(item_store[group]) + item['order'] = items_per_page * group + num_stored + item_store[group].append(item) + + if num_stored or depth <= 1: + continue + + running += 1 + thread = threading.Thread( + target=threaded_get_related, + args=(video_id, index_items, counts), + kwargs={'item_store': item_store, + 'group': (group + 1), + 'depth': (depth - 1), + 'original_related': related, + 'original_channel': channel}, + ) + thread.daemon = True threads.append(thread) thread.start() - for thread in threads: - thread.join() + while running: + for thread in threads: + thread.join(5) + if not thread.is_alive(): + running -= 1 - # Prepend new candidates to items - seen = [item['id']['videoId'] for item in items] - for candidate in candidates: - vid = candidate['id']['videoId'] - if vid not in seen: - seen.append(vid) - candidate['plugin_created_date'] = datetime_parser.now().strftime('%Y-%m-%dT%H:%M:%SZ') - items.insert(0, candidate) + index_items(cached, counts, original_ids=video_ids) - # Truncate items to keep it manageable, and cache - items = items[:500] - cache.set(cache_items_key, json.dumps(items)) + # Fetch related videos. Use threads for faster execution. + def threaded_get_related(video_id, func, *args, **kwargs): + related_videos = self.get_related_videos(video_id).get('items') + if related_videos: + func(related_videos[:items_per_page], *args, **kwargs) - # Build the result set - items.sort( - key=lambda a: datetime_parser.parse(a['plugin_created_date']), - reverse=True - ) - sorted_items = [] - counter = 0 - channel_counts = {} - while items: - counter += 1 - - # Hard stop on iteration. Good enough for our purposes. - if counter >= 1000: - break - - # Reset channel counts on a new page - if counter % 50 == 0: - channel_counts = {} + running = 0 + threads = [] + candidates = [] + for video_id in video_ids: + if video_id in counts['_related']: + continue + running += 1 + thread = threading.Thread( + target=threaded_get_related, + args=(video_id, candidates.extend), + ) + thread.daemon = True + threads.append(thread) + thread.start() + + while running: + for thread in threads: + thread.join(5) + if not thread.is_alive(): + running -= 1 + + num_items = items_per_page * num_items * max_depth + index_items(candidates[:num_items], counts, + item_store=items, + original_ids=video_ids, + depth=max_depth) - # Ensure a single channel isn't hogging the page - item = items.pop() + # Truncate items to keep it manageable, and cache + items = list(chain.from_iterable(items)) + counts['_counter'] = len(items) + remaining = num_items - counts['_counter'] + if remaining > 0: + items.extend(islice(filter(None, cached), remaining)) + elif remaining: + items = items[:num_items] + + # Finally sort items per page by rank and date for a better distribution + def rank_and_sort(item): + if 'order' not in item: + counts['_counter'] += 1 + item['order'] = counts['_counter'] + + page = 1 + item['order'] // (items_per_page * max_depth) + page_count = counts['_pages'].setdefault(page, {'_counter': 0}) + while page_count['_counter'] < items_per_page and page > 1: + page -= 1 + page_count = counts['_pages'].setdefault(page, {'_counter': 0}) + + related_video = item['related_video_id'] + related_channel = item['related_channel_id'] channel_id = item.get('snippet', {}).get('channelId') - if not channel_id: - continue + """ + # Video channel and related channel can be the same which can double + # up the channel count. Checking for this allows more similar videos + # in the recommendation, ignoring it allows for more variety. + # Currently prefer not to check for this to allow more variety. + if channel_id == related_channel: + channel_id = None + """ + while (page_count['_counter'] >= items_per_page + or (related_video in page_count + and page_count[related_video] >= diversity_limits) + or (related_channel and related_channel in page_count + and page_count[related_channel] >= diversity_limits) + or (channel_id and channel_id in page_count + and page_count[channel_id] >= diversity_limits) + ): + page += 1 + page_count = counts['_pages'].setdefault(page, {'_counter': 0}) + + page_count.setdefault(related_video, 0) + page_count[related_video] += 1 + if related_channel: + page_count.setdefault(related_channel, 0) + page_count[related_channel] += 1 + if channel_id: + page_count.setdefault(channel_id, 0) + page_count[channel_id] += 1 + page_count['_counter'] += 1 + item['page'] = page + + item_count = counts[item['id']] + item['rank'] = (2 * sum(item_count['channels'].values()) + + sum(item_count['related'].values())) + + return ( + -item['page'], + item['rank'], + -randint(0, item['order']) + ) - channel_counts.setdefault(channel_id, 0) - if channel_counts[channel_id] <= 3: - # Use the item - channel_counts[channel_id] = channel_counts[channel_id] + 1 - item["page_number"] = counter // 50 - sorted_items.append(item) - else: - # Move the item to the end of the list - items.append(item) - - # Finally sort items per page by date for a better distribution - now = datetime_parser.now() - sorted_items.sort( - key=lambda a: ( - a['page_number'], - datetime_parser.total_seconds( - now - datetime_parser.parse(a['snippet']['publishedAt']) - ) - ), - ) + items.sort(key=rank_and_sort, reverse=True) # Finalize result - payload['items'] = sorted_items + payload['items'] = items """ # TODO: # Enable pagination @@ -426,22 +794,18 @@ def helper(video_id, responses): 'totalResults': len(sorted_items) } """ + # Update cache - cache.set(cache_home_key, json.dumps(payload)) + cache.set_item(cache_items_key, items) - # If there are no sorted_items we fall back to default API behaviour return payload - def get_activities(self, channel_id, page_token=''): + def get_activities(self, channel_id, page_token='', **kwargs): params = {'part': 'snippet,contentDetails', 'maxResults': str(self._max_results), 'regionCode': self._region, 'hl': self._language} - if channel_id == 'home': - recommended = self._get_recommendations_for_home() - if 'items' in recommended and recommended.get('items'): - return recommended if channel_id == 'home': params['home'] = 'true' elif channel_id == 'mine': @@ -451,9 +815,12 @@ def get_activities(self, channel_id, page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='activities', params=params) + return self.api_request(method='GET', + path='activities', + params=params, + **kwargs) - def get_channel_sections(self, channel_id): + def get_channel_sections(self, channel_id, **kwargs): params = {'part': 'snippet,contentDetails', 'regionCode': self._region, 'hl': self._language} @@ -461,9 +828,12 @@ def get_channel_sections(self, channel_id): params['mine'] = 'true' else: params['channelId'] = channel_id - return self.perform_v3_request(method='GET', path='channelSections', params=params) + return self.api_request(method='GET', + path='channelSections', + params=params, + **kwargs) - def get_playlists_of_channel(self, channel_id, page_token=''): + def get_playlists_of_channel(self, channel_id, page_token='', **kwargs): params = {'part': 'snippet', 'maxResults': str(self._max_results)} if channel_id != 'mine': @@ -473,7 +843,10 @@ def get_playlists_of_channel(self, channel_id, page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='playlists', params=params) + return self.api_request(method='GET', + path='playlists', + params=params, + **kwargs) def get_playlist_item_id_of_video_id(self, playlist_id, video_id, page_token=''): old_max_results = self._max_results @@ -495,7 +868,11 @@ def get_playlist_item_id_of_video_id(self, playlist_id, video_id, page_token='') return None - def get_playlist_items(self, playlist_id, page_token='', max_results=None): + def get_playlist_items(self, + playlist_id, + page_token='', + max_results=None, + **kwargs): # prepare params max_results = str(self._max_results) if max_results is None else str(max_results) params = {'part': 'snippet', @@ -504,9 +881,12 @@ def get_playlist_items(self, playlist_id, page_token='', max_results=None): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='playlistItems', params=params) + return self.api_request(method='GET', + path='playlistItems', + params=params, + **kwargs) - def get_channel_by_username(self, username): + def get_channel_by_username(self, username, **kwargs): """ Returns a collection of zero or more channel resources that match the request criteria. :param username: retrieve channel_id for username @@ -514,19 +894,22 @@ def get_channel_by_username(self, username): """ params = {'part': 'id'} if username == 'mine': - params.update({'mine': 'true'}) + params['mine'] = True else: - params.update({'forUsername': username}) + params['forUsername'] = username - return self.perform_v3_request(method='GET', path='channels', params=params) + return self.api_request(method='GET', + path='channels', + params=params, + **kwargs) - def get_channels(self, channel_id): + def get_channels(self, channel_id, **kwargs): """ Returns a collection of zero or more channel resources that match the request criteria. :param channel_id: list or comma-separated list of the YouTube channel ID(s) :return: """ - if isinstance(channel_id, list): + if not isinstance(channel_id, string_type): channel_id = ','.join(channel_id) params = {'part': 'snippet,contentDetails,brandingSettings'} @@ -534,9 +917,12 @@ def get_channels(self, channel_id): params['id'] = channel_id else: params['mine'] = 'true' - return self.perform_v3_request(method='GET', path='channels', params=params) + return self.api_request(method='GET', + path='channels', + params=params, + **kwargs) - def get_disliked_videos(self, page_token=''): + def get_disliked_videos(self, page_token='', **kwargs): # prepare page token if not page_token: page_token = '' @@ -548,37 +934,50 @@ def get_disliked_videos(self, page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='videos', params=params) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) - def get_videos(self, video_id, live_details=False): + def get_videos(self, video_id, live_details=False, **kwargs): """ Returns a list of videos that match the API request parameters :param video_id: list of video ids :param live_details: also retrieve liveStreamingDetails :return: """ - if isinstance(video_id, list): + if not isinstance(video_id, string_type): video_id = ','.join(video_id) - parts = ['snippet,contentDetails,status'] + parts = ['snippet', 'contentDetails', 'status', 'statistics'] if live_details: - parts.append(',liveStreamingDetails') + parts.append('liveStreamingDetails') - params = {'part': ''.join(parts), + params = {'part': ','.join(parts), 'id': video_id} - return self.perform_v3_request(method='GET', path='videos', params=params) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) - def get_playlists(self, playlist_id): - if isinstance(playlist_id, list): + def get_playlists(self, playlist_id, **kwargs): + if not isinstance(playlist_id, string_type): playlist_id = ','.join(playlist_id) params = {'part': 'snippet,contentDetails', 'id': playlist_id} - return self.perform_v3_request(method='GET', path='playlists', params=params) - - def get_live_events(self, event_type='live', order='relevance', page_token='', location=False): + return self.api_request(method='GET', + path='playlists', + params=params, + **kwargs) + + def get_live_events(self, + event_type='live', + order='relevance', + page_token='', + location=False, + **kwargs): """ - :param event_type: one of: 'live', 'completed', 'upcoming' :param order: one of: 'date', 'rating', 'relevance', 'title', 'videoCount', 'viewCount' :param page_token: @@ -600,36 +999,138 @@ def get_live_events(self, event_type='live', order='relevance', page_token='', l 'maxResults': str(self._max_results)} if location: - location = _context.get_settings().get_location() + settings = self._context.get_settings() + location = settings.get_location() if location: params['location'] = location - params['locationRadius'] = _context.get_settings().get_location_radius() + params['locationRadius'] = settings.get_location_radius() if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='search', params=params) - - def get_related_videos(self, video_id, page_token='', max_results=0): - # prepare page token - if not page_token: - page_token = '' - + return self.api_request(method='GET', + path='search', + params=params, + **kwargs) + + def get_related_videos(self, + video_id, + page_token='', + max_results=0, + **kwargs): + # TODO: Improve handling of InnerTube requests, including automatic + # continuation processing to retrieve max_results number of results + # See Youtube.get_saved_playlists for existing implementation max_results = self._max_results if max_results <= 0 else max_results - # prepare params - params = {'relatedToVideoId': video_id, - 'part': 'snippet', - 'type': 'video', - 'regionCode': self._region, - 'hl': self._language, - 'maxResults': str(max_results)} + post_data = {'videoId': video_id} if page_token: - params['pageToken'] = page_token + post_data['continuation'] = page_token + + result = self.api_request(version=1, + method='POST', + path='next', + post_data=post_data, + no_login=True) + if not result: + return [] + + related_videos = self.json_traverse( + result, + path=( + ( + 'onResponseReceivedEndpoints', + 0, + 'appendContinuationItemsAction', + 'continuationItems', + ) if page_token else ( + 'contents', + 'twoColumnWatchNextResults', + 'secondaryResults', + 'secondaryResults', + 'results', + ) + ) + ( + slice(None), + ( + ( + 'compactVideoRenderer', + # 'videoId', + ), + ( + 'continuationItemRenderer', + 'continuationEndpoint', + 'continuationCommand', + # 'token', + ), + ), + ) + ) + if not related_videos: + return [] + + channel_id = self.json_traverse( + result, + path=( + 'contents', + 'twoColumnWatchNextResults', + 'results', + 'results', + 'contents', + 1, + 'videoSecondaryInfoRenderer', + 'owner', + 'videoOwnerRenderer', + 'title', + 'runs', + 0, + 'navigationEndpoint', + 'browseEndpoint', + 'browseId' + ) + ) + + v3_response = { + 'kind': 'youtube#videoListResponse', + 'items': [ + { + 'kind': "youtube#video", + 'id': video['videoId'], + 'related_video_id': video_id, + 'related_channel_id': channel_id, + 'partial': True, + 'snippet': { + 'title': video['title']['simpleText'], + 'thumbnails': dict(zip( + ('default', 'high'), + video['thumbnail']['thumbnails'], + )), + 'channelId': self.json_traverse(video, ( + ('longBylineText', 'shortBylineText'), + 'runs', + 0, + 'navigationEndpoint', + 'browseEndpoint', + 'browseId', + )), + } + } + for video in related_videos + if video and 'videoId' in video + ] + } - return self.perform_v3_request(method='GET', path='search', params=params) + last_item = related_videos[-1] + if last_item and 'token' in last_item: + v3_response['nextPageToken'] = last_item['token'] - def get_parent_comments(self, video_id, page_token='', max_results=0): + return v3_response + + def get_parent_comments(self, + video_id, + page_token='', + max_results=0, + **kwargs): max_results = self._max_results if max_results <= 0 else max_results # prepare params @@ -641,9 +1142,17 @@ def get_parent_comments(self, video_id, page_token='', max_results=0): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='commentThreads', params=params, no_login=True) - - def get_child_comments(self, parent_id, page_token='', max_results=0): + return self.api_request(method='GET', + path='commentThreads', + params=params, + no_login=True, + **kwargs) + + def get_child_comments(self, + parent_id, + page_token='', + max_results=0, + **kwargs): max_results = self._max_results if max_results <= 0 else max_results # prepare params @@ -654,9 +1163,13 @@ def get_child_comments(self, parent_id, page_token='', max_results=0): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='comments', params=params, no_login=True) + return self.api_request(method='GET', + path='comments', + params=params, + no_login=True, + **kwargs) - def get_channel_videos(self, channel_id, page_token=''): + def get_channel_videos(self, channel_id, page_token='', **kwargs): """ Returns a collection of video search results for the specified channel_id """ @@ -676,10 +1189,21 @@ def get_channel_videos(self, channel_id, page_token=''): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', path='search', params=params) - - def search(self, q, search_type=None, event_type='', channel_id='', - order='relevance', safe_search='moderate', page_token='', location=False): + return self.api_request(method='GET', + path='search', + params=params, + **kwargs) + + def search(self, + q, + search_type=None, + event_type='', + channel_id='', + order='relevance', + safe_search='moderate', + page_token='', + location=False, + **kwargs): """ Returns a collection of search results that match the query parameters specified in the API request. By default, a search result set identifies matching video, channel, and playlist resources, but you can also configure @@ -701,7 +1225,7 @@ def search(self, q, search_type=None, event_type='', channel_id='', # prepare search type if not search_type: search_type = '' - if isinstance(search_type, list): + if not isinstance(search_type, string_type): search_type = ','.join(search_type) # prepare page token @@ -738,14 +1262,18 @@ def search(self, q, search_type=None, event_type='', channel_id='', break if params['type'] == 'video' and location: - location = _context.get_settings().get_location() + settings = self._context.get_settings() + location = settings.get_location() if location: params['location'] = location - params['locationRadius'] = _context.get_settings().get_location_radius() + params['locationRadius'] = settings.get_location_radius() - return self.perform_v3_request(method='GET', path='search', params=params) + return self.api_request(method='GET', + path='search', + params=params, + **kwargs) - def get_my_subscriptions(self, page_token=None, offset=0): + def get_my_subscriptions(self, page_token=None, offset=0, **kwargs): """ modified by PureHemp, using YouTube RSS for fetching latest videos """ @@ -766,16 +1294,16 @@ def _perform(_page_token, _offset, _result): 'items': [] } - cache = _context.get_data_cache() + cache = self._context.get_data_cache() # if new uploads is cached cache_items_key = 'my-subscriptions-items' - cached = cache.get_item(cache.ONE_HOUR, cache_items_key) - if cache_items_key in cached: - _result['items'] = cached[cache_items_key] + cached = cache.get_item(cache_items_key, cache.ONE_HOUR) or [] + if cached: + _result['items'] = cached """ no cache, get uploads data from web """ - if len(_result['items']) == 0: + if not _result['items']: # get all subscriptions channel ids sub_page_token = True sub_channel_ids = [] @@ -794,19 +1322,22 @@ def _perform(_page_token, _offset, _result): if sub_page_token: params['pageToken'] = sub_page_token - sub_json_data = self.perform_v3_request(method='GET', path='subscriptions', params=params) + json_data = self.api_request(method='GET', + path='subscriptions', + params=params, + **kwargs) - if not sub_json_data: - sub_json_data = {} + if not json_data: + json_data = {} - items = sub_json_data.get('items', []) + items = json_data.get('items', []) for item in items: item = item.get('snippet', {}).get('resourceId', {}).get('channelId', '') sub_channel_ids.append(item) # get next token if exists - sub_page_token = sub_json_data.get('nextPageToken', '') + sub_page_token = json_data.get('nextPageToken', '') # terminate loop when last page if not sub_page_token: @@ -822,22 +1353,12 @@ def _perform(_page_token, _offset, _result): 'Accept-Language': 'en-US,en;q=0.7,de;q=0.3' } - session = requests.Session() - session.headers = headers - session.verify = self._verify - adapter = requests.adapters.HTTPAdapter(pool_maxsize=5, pool_block=True) - session.mount("https://", adapter) responses = [] def fetch_xml(_url, _responses): - try: - _response = session.get(_url, timeout=(3.05, 27)) - _response.raise_for_status() - except requests.exceptions.RequestException as error: - _context.log_debug('Response: {0}'.format(error.response and error.response.text)) - _context.log_error('Failed |%s|' % traceback.print_exc()) - return - _responses.append(_response) + _response = self.request(_url, headers=headers) + if _response: + _responses.append(_response) threads = [] for channel_id in sub_channel_ids: @@ -851,7 +1372,6 @@ def fetch_xml(_url, _responses): for thread in threads: thread.join(30) - session.close() for response in responses: if response: @@ -876,15 +1396,15 @@ def fetch_xml(_url, _responses): _result['items'].append(entry_data) # sorting by publish date - def _sort_by_date_time(e): + def _sort_by_date_time(item): return datetime_parser.since_epoch( - datetime_parser.strptime(e["published"][0:19], "%Y-%m-%dT%H:%M:%S") + datetime_parser.strptime(item['published']) ) _result['items'].sort(reverse=True, key=_sort_by_date_time) # Update cache - cache.set(cache_items_key, json.dumps(_result['items'])) + cache.set_item(cache_items_key, _result['items']) """ no cache, get uploads data from web """ # trim result @@ -944,7 +1464,10 @@ def _perform(_playlist_idx, _page_token, _offset, _result): else: _post_data['browseId'] = 'FEmy_youtube' - _json_data = self.perform_v1_tv_request(method='POST', path='browse', post_data=_post_data) + _json_data = self.api_request(version=1, + method='POST', + path='browse', + post_data=_post_data) _data = {} if 'continuationContents' in _json_data: _data = _json_data.get('continuationContents', {}).get('horizontalListContinuation', {}) @@ -1029,7 +1552,10 @@ def _perform(_playlist_idx, _page_token, _offset, _result): } playlist_index = None - json_data = self.perform_v1_tv_request(method='POST', path='browse', post_data=_en_post_data) + json_data = self.api_request(version=1, + method='POST', + path='browse', + post_data=_en_post_data) contents = json_data.get('contents', {}).get('sectionListRenderer', {}).get('contents', [{}]) for idx, shelf in enumerate(contents): @@ -1045,126 +1571,130 @@ def _perform(_playlist_idx, _page_token, _offset, _result): return result - def _request(self, url, method='GET', - cookies=None, data=None, headers=None, json=None, params=None, - error_msg=None, raise_error=False, timeout=(3.05, 27), **_): + def _response_hook(self, **kwargs): + response = kwargs['response'] + self._context.log_debug('API response: |{0.status_code}|\n' + 'headers: |{0.headers}|'.format(response)) try: - result = requests.request(method, url, - verify=self._verify, - allow_redirects=True, - timeout=timeout, - cookies=cookies, - data=data, - headers=headers, - json=json, - params=params) - result.raise_for_status() - except requests.exceptions.RequestException as error: - response = error.response and error.response.text - _context.log_debug('Response: {0}'.format(response)) - _context.log_error('{0}\n{1}'.format( - error_msg or 'Request failed', traceback.format_exc() - )) - if raise_error: - raise YouTubeException(error_msg) from error - return None - return result - - def perform_v3_request(self, method='GET', headers=None, path=None, - post_data=None, params=None, no_login=False): - - # params - _params = {} - - # headers - _headers = {'Host': 'www.googleapis.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.36 Safari/537.36', - 'Accept-Encoding': 'gzip, deflate'} - - # a config can decide if a token is allowed - if self._access_token and self._config.get('token-allowed', True) and not no_login: - _headers['Authorization'] = 'Bearer %s' % self._access_token - else: - _params['key'] = self._config_tv['key'] - - # url - _url = 'https://www.googleapis.com/youtube/v3/%s' % path.strip('/') + json_data = response.json() + if 'error' in json_data: + raise YouTubeException('"error" in response JSON data', + json_data=json_data, + **kwargs) + except ValueError as exc: + raise InvalidJSON(exc, **kwargs) + response.raise_for_status() + return json_data + + def _error_hook(self, **kwargs): + exc = kwargs['exc'] + json_data = getattr(exc, 'json_data', None) + data = getattr(exc, 'pass_data', None) and json_data + exception = getattr(exc, 'raise_exc', None) and YouTubeException + + if not json_data or 'error' not in json_data: + return None, None, None, data, None, exception + + details = json_data['error'] + reason = details.get('errors', [{}])[0].get('reason', 'Unknown') + message = strip_html_from_text(details.get('message', 'Unknown error')) + + notify = getattr(exc, 'notify', True) + if notify: + ok_dialog = False + timeout = 5000 + if reason == 'accessNotConfigured': + notification = self._context.localize('key.requirement') + ok_dialog = True + elif reason == 'keyInvalid' and message == 'Bad Request': + notification = self._context.localize('api.key.incorrect') + timeout = 7000 + elif reason in ('quotaExceeded', 'dailyLimitExceeded'): + notification = message + timeout = 7000 + else: + notification = message + title = '{0}: {1}'.format(self._context.get_name(), reason) + if ok_dialog: + self._context.get_ui().on_ok(title, notification) + else: + self._context.get_ui().show_notification(notification, + title, + time_ms=timeout) + + info = ('API error: {reason}\n' + 'exc: |{exc}|\n' + 'message: |{message}|') + details = {'reason': reason, 'message': message} + return '', info, details, data, False, exception + + def api_request(self, + version=3, + method='GET', + path=None, + params=None, + post_data=None, + headers=None, + no_login=False, + **kwargs): + client_data = { + '_endpoint': path.strip('/'), + 'method': method, + } if headers: - _headers.update(headers) + client_data['headers'] = headers + if post_data: + client_data['json'] = post_data if params: - _params.update(params) - log_params = copy.deepcopy(params) - if 'location' in log_params: - log_params['location'] = 'xx.xxxx,xx.xxxx' - else: - log_params = None - _context.log_debug('[data] v3 request: |{0}| path: |{1}| params: |{2}| post_data: |{3}|'.format(method, path, log_params, post_data)) - - result = self._request(_url, method=method, headers=_headers, json=post_data, params=_params) - if result is None: - return {} + client_data['params'] = params - _context.log_debug('[data] v3 response: |{0}| headers: |{1}|'.format(result.status_code, result.headers)) + # a config can decide if a token is allowed + if (not no_login and self._access_token + and self._config.get('token-allowed', True)): + client_data['_access_token'] = self._access_token - if result.headers.get('content-type', '').startswith('application/json'): - try: - return result.json() - except ValueError: - return { - 'status_code': result.status_code, - 'payload': result.text - } - return {} - - def perform_v1_tv_request(self, method='GET', headers=None, path=None, - post_data=None, params=None, no_login=False): - - # params - _params = {} - - # headers - _headers = { - 'User-Agent': ('Mozilla/5.0 (Linux; Android 7.0; SM-G892A Build/NRD90M;' - ' wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0' - ' Chrome/67.0.3396.87 Mobile Safari/537.36'), - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'DNT': '1', - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Language': 'en-US,en;q=0.5', - } + client = self.build_client(version, client_data) - if self._access_token and self._config.get('token-allowed', True) and not no_login: - _headers['Authorization'] = 'Bearer %s' % self._access_token - else: - _params = {'key': self._config_tv['key']} + if 'key' in client['params'] and not client['params']['key']: + client['params']['key'] = self._config_tv['key'] - # url - _url = 'https://www.googleapis.com/youtubei/v1/%s' % path.strip('/') - - if headers: - _headers.update(headers) + params = client.get('params') if params: - _params.update(params) - log_params = copy.deepcopy(params) + log_params = deepcopy(params) if 'location' in log_params: log_params['location'] = 'xx.xxxx,xx.xxxx' + if 'key' in log_params: + key = list(log_params['key']) + key[5:-5] = '...' + log_params['key'] = ''.join(key) else: log_params = None - _context.log_debug('[data] v1 request: |{0}| path: |{1}| params: |{2}| post_data: |{3}|'.format(method, path, log_params, post_data)) - - result = self._request(_url, method=method, headers=_headers, json=post_data, params=_params) - if result is None: - return {} - - _context.log_debug('[data] v1 response: |{0}| headers: |{1}|'.format(result.status_code, result.headers)) - if result.headers.get('content-type', '').startswith('application/json'): - try: - return result.json() - except ValueError: - return { - 'status_code': result.status_code, - 'payload': result.text - } - return {} + headers = client.get('headers') + if headers: + log_headers = deepcopy(headers) + if 'Authorization' in log_headers: + log_headers['Authorization'] = 'logged in' + else: + log_headers = None + + self._context.log_debug('API request:\n' + 'version: |{version}|\n' + 'method: |{method}|\n' + 'path: |{path}|\n' + 'params: |{params}|\n' + 'post_data: |{data}|\n' + 'headers: |{headers}|' + .format(version=version, + method=method, + path=path, + params=log_params, + data=client.get('json'), + headers=log_headers)) + + json_data = self.request(response_hook=self._response_hook, + response_hook_kwargs=kwargs, + error_hook=self._error_hook, + **client) + return json_data diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/__init__.py index 6e400683d9..12b93382cf 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/__init__.py @@ -8,6 +8,8 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from .resource_manager import ResourceManager from .url_resolver import UrlResolver from .url_to_item_converter import UrlToItemConverter diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/__init__.py index ecbe3188e6..3da983ffd6 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/__init__.py @@ -7,6 +7,9 @@ See LICENSES/GPL-2.0-only for more information. """ -from ....youtube.helper.ratebypass import ratebypass +from __future__ import absolute_import, division, unicode_literals -__all__ = ['ratebypass'] +from . import ratebypass + + +__all__ = ('ratebypass',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/ratebypass.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/ratebypass.py index baa8669be5..bd924f1a9d 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/ratebypass.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/ratebypass/ratebypass.py @@ -9,12 +9,14 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re try: from ....kodion import logger except: - class logger: + class logger(object): @staticmethod def log_debug(txt): print(txt) @@ -24,7 +26,7 @@ def throttling_reverse(arr): """Reverses the input list. Needs to do an in-place reversal so that the passed list gets changed. To accomplish this, we create a reversed copy, and then change each - indvidual element. + individual element. """ reverse_copy = arr[::-1] for i in range(len(reverse_copy)): @@ -221,7 +223,7 @@ def throttling_splice(d, e): js_splice(d, e, 1) -class CalculateN: +class CalculateN(object): # References: # https://github.com/ytdl-org/youtube-dl/issues/29326#issuecomment-894619419 # https://github.com/pytube/pytube/blob/fc9aec5c35829f2ebb4ef8dd599b14a666850d20/pytube/cipher.py diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/resource_manager.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/resource_manager.py index 81afc78abd..01f52ee4bb 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/resource_manager.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/resource_manager.py @@ -8,270 +8,307 @@ See LICENSES/GPL-2.0-only for more information. """ -from ..youtube_exceptions import YouTubeException -from ...kodion.utils import FunctionCache, DataCache, strip_html_from_text +from __future__ import absolute_import, division, unicode_literals class ResourceManager(object): - def __init__(self, context, youtube_client): + def __init__(self, context, client): self._context = context - self._youtube_client = youtube_client - self._channel_data = {} - self._video_data = {} - self._playlist_data = {} - self._enable_channel_fanart = context.get_settings().get_bool('youtube.channel.fanart.show', True) + self._client = client + self._data_cache = context.get_data_cache() + self._func_cache = context.get_function_cache() + self._show_fanart = context.get_settings().get_bool( + 'youtube.channel.fanart.show', True + ) + self.new_data = {} - def clear(self): - self._context.get_function_cache().clear() - self._context.get_data_cache().clear() - - def _get_channel_data(self, channel_id): - return self._channel_data.get(channel_id, {}) - - def _get_video_data(self, video_id): - return self._video_data.get(video_id, {}) - - def _get_playlist_data(self, playlist_id): - return self._playlist_data.get(playlist_id, {}) - - def _update_channels(self, channel_ids): - result = dict() - json_data = dict() - channel_ids_to_update = list() - channel_ids_cached = list() - updated_channel_ids = list() - - data_cache = self._context.get_data_cache() - function_cache = self._context.get_function_cache() - - for channel_id in channel_ids: - if channel_id == 'mine': - json_data = function_cache.get(FunctionCache.ONE_DAY, self._youtube_client.get_channel_by_username, channel_id) - items = json_data.get('items', [{'id': 'mine'}]) - - try: - channel_id = items[0]['id'] - except IndexError: - self._context.log_debug('Channel "mine" not found: %s' % json_data) - channel_id = None + @staticmethod + def _list_batch(input_list, n=50): + if not isinstance(input_list, (list, tuple)): + input_list = list(input_list) + for i in range(0, len(input_list), n): + yield input_list[i:i + n] - json_data = dict() + def clear(self): + self._func_cache.clear() + self._data_cache.clear() + + def get_channels(self, ids, defer_cache=False): + updated = [] + for channel_id in ids: + if not channel_id: + continue + + if channel_id != 'mine': + updated.append(channel_id) + continue + + data = self._func_cache.get(self._client.get_channel_by_username, + self._func_cache.ONE_DAY, + channel_id) + items = data.get('items', [{'id': 'mine'}]) + + try: + channel_id = items[0]['id'] + updated.append(channel_id) + except IndexError: + self._context.log_error('Channel not found:\n{data}' + .format(data=data)) + + ids = updated + result = self._data_cache.get_items(ids, self._data_cache.ONE_MONTH) + to_update = [id_ for id_ in ids + if id_ not in result or result[id_].get('partial')] + + if result: + self._context.log_debug('Found cached data for channels:\n|{ids}|' + .format(ids=list(result))) + + if to_update: + new_data = [self._client.get_channels(list_of_50) + for list_of_50 in self._list_batch(to_update, n=50)] + if not any(new_data): + new_data = None + else: + new_data = None + + if new_data: + self._context.log_debug('Got data for channels:\n|{ids}|' + .format(ids=to_update)) + new_data = { + yt_item['id']: yt_item + for batch in new_data + for yt_item in batch.get('items', []) + if yt_item + } + result.update(new_data) + self.cache_data(new_data, defer=defer_cache) + + # Re-sort result to match order of requested IDs + # Will only work in Python v3.7+ + if list(result) != ids[:len(result)]: + result = { + id_: result[id_] + for id_ in ids + if id_ in result + } - if channel_id: - updated_channel_ids.append(channel_id) + return result - channel_ids = updated_channel_ids + def get_fanarts(self, channel_ids, defer_cache=False): + if not self._show_fanart: + return {} - channel_data = data_cache.get_items(DataCache.ONE_MONTH, channel_ids) - for channel_id in channel_ids: - if not channel_data.get(channel_id): - channel_ids_to_update.append(channel_id) + result = self.get_channels(channel_ids, defer_cache=defer_cache) + banners = ['bannerTvMediumImageUrl', 'bannerTvLowImageUrl', + 'bannerTvImageUrl', 'bannerExternalUrl'] + # transform + for key, item in result.items(): + images = item.get('brandingSettings', {}).get('image', {}) + for banner in banners: + image = images.get(banner) + if not image: + continue + result[key] = image + break else: - channel_ids_cached.append(channel_id) - result.update(channel_data) - if len(channel_ids_cached) > 0: - self._context.log_debug('Found cached data for channels |%s|' % ', '.join(channel_ids_cached)) - - if len(channel_ids_to_update) > 0: - self._context.log_debug('No data for channels |%s| cached' % ', '.join(channel_ids_to_update)) - - data = [] - list_of_50s = self._make_list_of_50(channel_ids_to_update) - for list_of_50 in list_of_50s: - data.append(self._youtube_client.get_channels(list_of_50)) - - channel_data = dict() - yt_items = [] - for response in data: - yt_items += response.get('items', []) - - for yt_item in yt_items: - channel_id = str(yt_item['id']) - channel_data[channel_id] = yt_item - result[channel_id] = yt_item - - data_cache.set_all(channel_data) - self._context.log_debug('Cached data for channels |%s|' % ', '.join(list(channel_data.keys()))) - - if self.handle_error(json_data): - return result + # set an empty url + result[key] = '' return result - def _update_videos(self, video_ids, live_details=False, suppress_errors=False): - result = dict() - json_data = dict() - video_ids_to_update = list() - video_ids_cached = list() - - data_cache = self._context.get_data_cache() - - video_data = data_cache.get_items(DataCache.ONE_MONTH, video_ids) - for video_id in video_ids: - if not video_data.get(video_id): - video_ids_to_update.append(video_id) - else: - video_ids_cached.append(video_id) - result.update(video_data) - if len(video_ids_cached) > 0: - self._context.log_debug('Found cached data for videos |%s|' % ', '.join(video_ids_cached)) - - if len(video_ids_to_update) > 0: - self._context.log_debug('No data for videos |%s| cached' % ', '.join(video_ids_to_update)) - json_data = self._youtube_client.get_videos(video_ids_to_update, live_details) - video_data = dict() - yt_items = json_data.get('items', []) - for yt_item in yt_items: - video_id = str(yt_item['id']) - video_data[video_id] = yt_item - result[video_id] = yt_item - data_cache.set_all(video_data) - self._context.log_debug('Cached data for videos |%s|' % ', '.join(list(video_data.keys()))) - - played_items = dict() - if self._context.get_settings().use_local_history(): - playback_history = self._context.get_playback_history() - played_items = playback_history.get_items(video_ids) - - for k in list(result.keys()): - result[k]['play_data'] = played_items.get(k, dict()) + def get_playlists(self, ids, defer_cache=False): + ids = tuple(ids) + result = self._data_cache.get_items(ids, self._data_cache.ONE_MONTH) + to_update = [id_ for id_ in ids + if id_ not in result or result[id_].get('partial')] + + if result: + self._context.log_debug('Found cached data for playlists:\n|{ids}|' + .format(ids=list(result))) + + if to_update: + new_data = [self._client.get_playlists(list_of_50) + for list_of_50 in self._list_batch(to_update, n=50)] + if not any(new_data): + new_data = None + else: + new_data = None + + if new_data: + self._context.log_debug('Got data for playlists:\n|{ids}|' + .format(ids=to_update)) + new_data = { + yt_item['id']: yt_item + for batch in new_data + for yt_item in batch.get('items', []) + if yt_item + } + result.update(new_data) + self.cache_data(new_data, defer=defer_cache) + + # Re-sort result to match order of requested IDs + # Will only work in Python v3.7+ + if list(result) != ids[:len(result)]: + result = { + id_: result[id_] + for id_ in ids + if id_ in result + } - if self.handle_error(json_data, suppress_errors) or suppress_errors: - return result + return result - @staticmethod - def _make_list_of_50(list_of_ids): - list_of_50 = [] - pos = 0 - while pos < len(list_of_ids): - list_of_50.append(list_of_ids[pos:pos + 50]) - pos += 50 - return list_of_50 + def get_playlist_items(self, ids=None, batch_id=None, defer_cache=False): + if not ids and not batch_id: + return None - def get_videos(self, video_ids, live_details=False, suppress_errors=False): - list_of_50s = self._make_list_of_50(video_ids) + if batch_id: + ids = [batch_id[0]] + page_token = batch_id[1] + fetch_next = False + else: + page_token = None + fetch_next = True + batch_ids = [] + to_update = [] result = {} - for list_of_50 in list_of_50s: - result.update(self._update_videos(list_of_50, live_details, suppress_errors)) - return result - - def _update_playlists(self, playlists_ids): - result = dict() - json_data = dict() - playlist_ids_to_update = list() - playlists_ids_cached = list() + for playlist_id in ids: + page_token = page_token or 0 + while 1: + batch_id = (playlist_id, page_token) + batch_ids.append(batch_id) + batch = self._data_cache.get_item(batch_id, + self._data_cache.ONE_HOUR) + if not batch: + to_update.append(batch_id) + break + result[batch_id] = batch + page_token = batch.get('nextPageToken') if fetch_next else None + if page_token is None: + break - data_cache = self._context.get_data_cache() + if result: + self._context.log_debug('Found cached items for playlists:\n|{ids}|' + .format(ids=list(result))) + + new_data = {} + insert_point = 0 + for playlist_id, page_token in to_update: + new_batch_ids = [] + batch_id = (playlist_id, page_token) + insert_point = batch_ids.index(batch_id, insert_point) + while 1: + batch_id = (playlist_id, page_token) + new_batch_ids.append(batch_id) + batch = self._client.get_playlist_items(*batch_id) + new_data[batch_id] = batch + page_token = batch.get('nextPageToken') if fetch_next else None + if page_token is None: + batch_ids[insert_point:insert_point] = new_batch_ids + insert_point += len(new_batch_ids) + break - playlist_data = data_cache.get_items(DataCache.ONE_MONTH, playlists_ids) - for playlist_id in playlists_ids: - if not playlist_data.get(playlist_id): - playlist_ids_to_update.append(playlist_id) - else: - playlists_ids_cached.append(playlist_id) - result.update(playlist_data) - if len(playlists_ids_cached) > 0: - self._context.log_debug('Found cached data for playlists |%s|' % ', '.join(playlists_ids_cached)) - - if len(playlist_ids_to_update) > 0: - self._context.log_debug('No data for playlists |%s| cached' % ', '.join(playlist_ids_to_update)) - json_data = self._youtube_client.get_playlists(playlist_ids_to_update) - playlist_data = dict() - yt_items = json_data.get('items', []) - for yt_item in yt_items: - playlist_id = str(yt_item['id']) - playlist_data[playlist_id] = yt_item - result[playlist_id] = yt_item - data_cache.set_all(playlist_data) - self._context.log_debug('Cached data for playlists |%s|' % ', '.join(list(playlist_data.keys()))) - - if self.handle_error(json_data): - return result - - def get_playlists(self, playlists_ids): - list_of_50s = self._make_list_of_50(playlists_ids) + if new_data: + to_update = list(new_data) + self._context.log_debug('Got items for playlists:\n|{ids}|' + .format(ids=to_update)) + result.update(new_data) + self.cache_data(new_data, defer=defer_cache) + + # Re-sort result to match order of requested IDs + # Will only work in Python v3.7+ + if list(result) != batch_ids[:len(result)]: + result = { + id_: result[id_] + for id_ in batch_ids + if id_ in result + } - result = {} - for list_of_50 in list_of_50s: - result.update(self._update_playlists(list_of_50)) return result - def get_related_playlists(self, channel_id): - result = self._update_channels([channel_id]) + def get_related_playlists(self, channel_id, defer_cache=False): + result = self.get_channels([channel_id], defer_cache=defer_cache) # transform item = None if channel_id != 'mine': item = result.get(channel_id, {}) else: - for key in list(result.keys()): - item = result[key] + for item in result.values(): + if item: + break if item is None: return {} return item.get('contentDetails', {}).get('relatedPlaylists', {}) - def get_channels(self, channel_ids): - list_of_50s = self._make_list_of_50(channel_ids) - - result = {} - for list_of_50 in list_of_50s: - result.update(self._update_channels(list_of_50)) - return result - - def get_fanarts(self, channel_ids): - if not self._enable_channel_fanart: - return {} - - result = self._update_channels(channel_ids) - - # transform - for key in list(result.keys()): - item = result[key] + def get_videos(self, + ids, + live_details=False, + suppress_errors=False, + defer_cache=False): + ids = tuple(ids) + result = self._data_cache.get_items(ids, self._data_cache.ONE_MONTH) + to_update = [id_ for id_ in ids + if id_ not in result or result[id_].get('partial')] + + if result: + self._context.log_debug('Found cached data for videos:\n|{ids}|' + .format(ids=list(result))) + + if to_update: + notify_and_raise = not suppress_errors + new_data = [self._client.get_videos(list_of_50, + live_details, + notify=notify_and_raise, + raise_exc=notify_and_raise) + for list_of_50 in self._list_batch(to_update, n=50)] + if not any(new_data): + new_data = None + else: + new_data = None + + if new_data: + self._context.log_debug('Got data for videos:\n|{ids}|' + .format(ids=to_update)) + new_data = { + yt_item['id']: yt_item + for batch in new_data + for yt_item in batch.get('items', []) + if yt_item + } + new_data = dict(dict.fromkeys(to_update, {}), **new_data) + result.update(new_data) + self.cache_data(new_data, defer=defer_cache) + + # Re-sort result to match order of requested IDs + # Will only work in Python v3.7+ + if list(result) != ids[:len(result)]: + result = { + id_: result[id_] + for id_ in ids + if id_ in result + } - # set an empty url - result[key] = u'' - images = item.get('brandingSettings', {}).get('image', {}) - banners = ['bannerTvMediumImageUrl', 'bannerTvLowImageUrl', 'bannerTvImageUrl', 'bannerExternalUrl'] - for banner in banners: - image = images.get(banner, '') - if image: - result[key] = image - break + if self._context.get_settings().use_local_history(): + playback_history = self._context.get_playback_history() + played_items = playback_history.get_items(ids) + for video_id, play_data in played_items.items(): + if video_id in result: + result[video_id]['play_data'] = play_data return result - def handle_error(self, json_data, suppress_errors=False): - context = self._context - if json_data and 'error' in json_data: - ok_dialog = False - message_timeout = 5000 - message = json_data['error'].get('message', '') - message = strip_html_from_text(message) - reason = json_data['error']['errors'][0].get('reason', '') - title = '%s: %s' % (context.get_name(), reason) - error_message = 'Error reason: |%s| with message: |%s|' % (reason, message) - - context.log_error(error_message) - - if reason == 'accessNotConfigured': - message = context.localize(30731) - ok_dialog = True - - if reason == 'quotaExceeded' or reason == 'dailyLimitExceeded': - message_timeout = 7000 - - if not suppress_errors: - if ok_dialog: - context.get_ui().on_ok(title, message) - else: - context.get_ui().show_notification(message, title, - time_milliseconds=message_timeout) - - raise YouTubeException(error_message) - - return False - - return True + def cache_data(self, data=None, defer=False): + if defer: + if data: + self.new_data.update(data) + return + + data = data or self.new_data + if data: + self._data_cache.set_items(data) + self._context.log_debug('Cached data for items:\n|{ids}|' + .format(ids=list(data))) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/__init__.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/__init__.py index a5d8eca2fa..c3cf86311c 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/__init__.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/__init__.py @@ -8,8 +8,9 @@ See LICENSES/GPL-2.0-only for more information. """ -from ....youtube.helper.signature.cipher import Cipher +from __future__ import absolute_import, division, unicode_literals -__all__ = ['Cipher'] +from .cipher import Cipher +__all__ = ('Cipher',) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/cipher.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/cipher.py index 8db38e1ddb..a8549afcd7 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/cipher.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/cipher.py @@ -8,9 +8,10 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re -from ....kodion.utils import FunctionCache from .json_script_engine import JsonScriptEngine @@ -24,15 +25,18 @@ def __init__(self, context, javascript): def get_signature(self, signature): function_cache = self._context.get_function_cache() - json_script = function_cache.get_cached_only(self._load_javascript, self._javascript) + json_script = function_cache.get_cached_only(self._load_javascript, + self._javascript) if not json_script: - json_script = function_cache.get(FunctionCache.ONE_DAY, self._load_javascript, self._javascript) + json_script = function_cache.get(self._load_javascript, + function_cache.ONE_DAY, + self._javascript) if json_script: json_script_engine = JsonScriptEngine(json_script) return json_script_engine.execute(signature) - return u'' + return '' def _load_javascript(self, javascript): function_name = self._find_signature_function_name(javascript) @@ -121,7 +125,7 @@ def _find_signature_function_name(javascript): r'yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*(?P[a-zA-Z0-9$]+)\(', r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?P[a-zA-Z0-9$]+)\(', r'\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*(?P[a-zA-Z0-9$]+)\(', - r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P[a-zA-Z0-9$]+)\(' + r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P[a-zA-Z0-9$]+)\(' ) for pattern in match_patterns: @@ -160,7 +164,7 @@ def _get_object_function(self, object_name, function_name, javascript): _object_body = _object_body.split('},') for _function in _object_body: if not _function.endswith('}'): - _function = ''.join([_function, '}']) + _function = ''.join((_function, '}')) _function = _function.strip() match = re.match(r'(?P[^:]*):function\((?P[^)]*)\){(?P[^}]+)}', _function) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/json_script_engine.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/json_script_engine.py index eac6dc8f7b..90fb3a9d29 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/json_script_engine.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/signature/json_script_engine.py @@ -18,7 +18,7 @@ def execute(self, signature): _actions = self._json_script['actions'] for action in _actions: - func = ''.join(['_', action['func']]) + func = ''.join(('_', action['func'])) params = action['params'] if func == '_return': diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/subtitles.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/subtitles.py index b6e0779856..8effeada9d 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/subtitles.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/subtitles.py @@ -6,11 +6,20 @@ See LICENSES/GPL-2.0-only for more information. """ -from html import unescape -from urllib.parse import (parse_qs, urlsplit, urlunsplit, urlencode, urljoin) - -import xbmcvfs -import requests +from __future__ import absolute_import, division, unicode_literals + +import os + +from ...kodion.compatibility import ( + parse_qs, + unescape, + urlencode, + urljoin, + urlsplit, + xbmcvfs, +) +from ...kodion.constants import TEMP_PATH +from ...kodion.network import BaseRequestsClass from ...kodion.utils import make_dirs @@ -21,16 +30,16 @@ class Subtitles(object): LANG_CURR = 3 LANG_CURR_NO_ASR = 4 - BASE_PATH = 'special://temp/plugin.video.youtube/' - SRT_FILE = ''.join([BASE_PATH, '%s.%s.srt']) + BASE_PATH = make_dirs(TEMP_PATH) def __init__(self, context, video_id, captions, headers=None): - self.context = context - self._verify = context.get_settings().verify_ssl() self.video_id = video_id - self.language = (context.get_settings() - .get_string('youtube.language', 'en_US') - .replace('_', '-')) + self._context = context + + settings = context.get_settings() + self.language = settings.get_language() + self.pre_download = settings.subtitle_download() + self.subtitle_languages = settings.subtitle_languages() if not headers and 'headers' in captions: headers = captions['headers'] @@ -39,11 +48,10 @@ def __init__(self, context, video_id, captions, headers=None): headers.pop('Content-Type', None) self.headers = headers - ui = self.context.get_ui() - self.prompt_override = ( - ui.get_home_window_property('prompt_for_subtitles') == video_id - ) - ui.clear_home_window_property('prompt_for_subtitles') + ui = self._context.get_ui() + self.prompt_override = (ui.get_property('prompt_for_subtitles') + == video_id) + ui.clear_property('prompt_for_subtitles') self.renderer = captions.get('playerCaptionsTracklistRenderer', {}) self.caption_tracks = self.renderer.get('captionTracks', []) @@ -67,11 +75,8 @@ def __init__(self, context, video_id, captions, headers=None): 'defaultTranslationSourceTrackIndices', [None] )[0] - if default_caption is None: - default_caption = ( - default_audio.get('hasDefaultTrack') - and default_audio.get('defaultCaptionTrackIndex') - ) + if default_caption is None and default_audio.get('hasDefaultTrack'): + default_caption = default_audio.get('defaultCaptionTrackIndex') if default_caption is None: try: @@ -90,32 +95,11 @@ def __init__(self, context, video_id, captions, headers=None): 'is_asr': default_caption.get('kind') == 'asr', } - def srt_filename(self, sub_language): - return self.SRT_FILE % (self.video_id, sub_language) - - def _write_file(self, _file, contents): - if not make_dirs(self.BASE_PATH): - self.context.log_debug('Failed to create directories: %s' % self.BASE_PATH) - return False - self.context.log_debug('Writing subtitle file: %s' % _file) - try: - f = xbmcvfs.File(_file, 'w') - f.write(contents) - f.close() - return True - except: - self.context.log_debug('File write failed for: %s' % _file) - return False - def _unescape(self, text): - try: - text = text.decode('utf8', 'ignore') - except: - self.context.log_debug('Subtitle unescape: failed to decode utf-8') try: text = unescape(text) except: - self.context.log_debug('Subtitle unescape: failed to unescape text') + self._context.log_debug('Subtitle unescape: failed to unescape text') return text def get_default_lang(self): @@ -128,8 +112,8 @@ def get_subtitles(self): if self.prompt_override: languages = self.LANG_PROMPT else: - languages = self.context.get_settings().subtitle_languages() - self.context.log_debug('Subtitle get_subtitles: for setting |%s|' % str(languages)) + languages = self.subtitle_languages + self._context.log_debug('Subtitle get_subtitles: for setting |%s|' % str(languages)) if languages == self.LANG_NONE: return [] if languages == self.LANG_CURR: @@ -152,33 +136,70 @@ def get_subtitles(self): list_of_subs.extend(self._get('en-US')) list_of_subs.extend(self._get('en-GB')) return list(set(list_of_subs)) - self.context.log_debug('Unknown language_enum: %s for subtitles' % str(languages)) + self._context.log_debug('Unknown language_enum: %s for subtitles' % str(languages)) return [] - def _get_all(self): + def _get_all(self, download=False): list_of_subs = [] - for language in self.translation_langs: - list_of_subs.extend(self._get(language=language.get('languageCode'))) + for track in self.caption_tracks: + list_of_subs.extend(self._get(track.get('languageCode'), + self._get_language_name(track), + download=download)) + for track in self.translation_langs: + list_of_subs.extend(self._get(track.get('languageCode'), + self._get_language_name(track), + download=download)) return list(set(list_of_subs)) def _prompt(self): - tracks = [(track.get('languageCode'), self._get_language_name(track)) for track in self.caption_tracks] - translations = [(track.get('languageCode'), self._get_language_name(track)) for track in self.translation_langs] - languages = tracks + translations - if languages: - choice = self.context.get_ui().on_select(self.context.localize(30560), [language_name for language, language_name in languages]) - if choice != -1: - return self._get(language=languages[choice][0], language_name=languages[choice][1]) - self.context.log_debug('Subtitle selection cancelled') - return [] - self.context.log_debug('No subtitles found for prompt') + captions = [(track.get('languageCode'), + self._get_language_name(track)) + for track in self.caption_tracks] + translations = [(track.get('languageCode'), + self._get_language_name(track)) + for track in self.translation_langs] + num_captions = len(captions) + num_translations = len(translations) + num_total = num_captions + num_translations + + if num_total: + choice = self._context.get_ui().on_select( + self._context.localize('subtitles.language'), + [name for _, name in captions] + + [name + ' *' for _, name in translations] + ) + if choice == -1: + self._context.log_debug('Subtitle selection cancelled') + return [] + + subtitle = None + if 0 <= choice < num_captions: + choice = captions[choice] + subtitle = self._get(lang_code=choice[0], language=choice[1]) + elif num_captions <= choice < num_total: + choice = translations[choice - num_captions] + subtitle = self._get(lang_code=choice[0], language=choice[1]) + + if subtitle: + return subtitle + self._context.log_debug('No subtitles found for prompt') return [] - def _get(self, lang_code='en', language=None, no_asr=False): - fname = self.srt_filename(lang_code) - if xbmcvfs.exists(fname): - self.context.log_debug('Subtitle exists for: %s, filename: %s' % (lang_code, fname)) - return [fname] + def _get(self, lang_code='en', language=None, no_asr=False, download=None): + filename = '.'.join((self.video_id, lang_code, 'srt')) + if not self.BASE_PATH: + self._context.log_error('Subtitles._get - ' + 'unable to access temp directory') + return [] + + filepath = os.path.join(self.BASE_PATH, filename) + if xbmcvfs.exists(filepath): + self._context.log_debug('Subtitle exists for |{lang}| - |{file}|' + .format(lang=lang_code, file=filepath)) + return [filepath] + + if download is None: + download = self.pre_download caption_track = None asr_track = None @@ -206,7 +227,7 @@ def _get(self, lang_code='en', language=None, no_asr=False): if (lang_code != self.defaults['lang_code'] and not has_translation and caption_track is None): - self.context.log_debug('No subtitles found for: %s' % lang_code) + self._context.log_debug('No subtitles found for: %s' % lang_code) return [] subtitle_url = None @@ -221,45 +242,57 @@ def _get(self, lang_code='en', language=None, no_asr=False): base_url = None if base_url: - subtitle_url = self._set_query_param(base_url, + subtitle_url = self._set_query_param( + base_url, ('type', 'track'), ('fmt', 'vtt'), ('tlang', lang_code) if has_translation else (None, None), ) - if subtitle_url: - self.context.log_debug('Subtitle url: %s' % subtitle_url) - if not self.context.get_settings().subtitle_download(): - return [subtitle_url] - result_auto = requests.get(subtitle_url, headers=self.headers, - verify=self._verify, allow_redirects=True) - - if result_auto.text: - self.context.log_debug('Subtitle found for: %s' % lang_code) - self._write_file(fname, bytearray(self._unescape(result_auto.text), encoding='utf8', errors='ignore')) - return [fname] - self.context.log_debug('Failed to retrieve subtitles for: %s' % lang_code) + if not subtitle_url: + self._context.log_debug('No subtitles found for: %s' % lang_code) + return [] + + if not download: + return [subtitle_url] + + response = BaseRequestsClass().request( + subtitle_url, + headers=self.headers, + error_info=('Failed to retrieve subtitles for: {lang}: {{exc}}' + .format(lang=lang_code)) + ) + response = response and response.text + if not response: return [] - self.context.log_debug('No subtitles found for: %s' % lang_code) + + output = bytearray(self._unescape(response), + encoding='utf8', + errors='ignore') + try: + with xbmcvfs.File(filepath, 'w') as srt_file: + success = srt_file.write(output) + except (IOError, OSError): + self._context.log_error('Subtitles._get - ' + 'file write failed for: {file}' + .format(file=filepath)) + if success: + return [filepath] return [] - def _get_language_name(self, track): + @staticmethod + def _get_language_name(track): key = 'languageName' if 'languageName' in track else 'name' lang_name = track.get(key, {}).get('simpleText') if not lang_name: track_name = track.get(key, {}).get('runs', [{}]) - if isinstance(track_name, list) and len(track_name) >= 1: + if isinstance(track_name, (list, tuple)) and len(track_name) >= 1: lang_name = track_name[0].get('text') if lang_name: - return self._recode_language_name(lang_name) - + return lang_name return None - @staticmethod - def _recode_language_name(language_name): - return language_name - @staticmethod def _set_query_param(url, *pairs): if not url or not pairs: @@ -274,18 +307,16 @@ def _set_query_param(url, *pairs): else: return url - scheme, netloc, path, query_string, fragment = urlsplit(url) - query_params = parse_qs(query_string) + components = urlsplit(url) + query_params = parse_qs(components.query) for name, value in pairs: if name: query_params[name] = [value] - new_query_string = urlencode(query_params, doseq=True) - if isinstance(scheme, bytes): - new_query_string = new_query_string.encode('utf-8') - - return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + return components._replace( + query=urlencode(query_params, doseq=True) + ).geturl() @staticmethod def _normalize_url(url): diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/tv.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/tv.py index c9818f924d..6c532038c6 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/tv.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/tv.py @@ -8,17 +8,16 @@ See LICENSES/GPL-2.0-only for more information. """ -from ... import kodion -from ...youtube.helper import utils -from ...kodion.items.video_item import VideoItem +from __future__ import absolute_import, division, unicode_literals + +from ..helper import utils +from ...kodion.items import DirectoryItem, NextPageItem, VideoItem def my_subscriptions_to_items(provider, context, json_data, do_filter=False): result = [] video_id_dict = {} - incognito = str(context.get_param('incognito', False)).lower() == 'true' - filter_list = [] black_list = False if do_filter: @@ -28,16 +27,20 @@ def my_subscriptions_to_items(provider, context, json_data, do_filter=False): filter_list = filter_list.split(',') filter_list = [x.lower() for x in filter_list] + item_params = {'video_id': None} + incognito = context.get_param('incognito', False) + if incognito: + item_params['incognito'] = incognito + items = json_data.get('items', []) for item in items: channel = item['channel'].lower() channel = channel.replace(',', '') - if not do_filter or (do_filter and (not black_list) and (channel in filter_list)) or \ - (do_filter and black_list and (channel not in filter_list)): + if (not do_filter + or (black_list and channel not in filter_list) + or (not black_list and channel in filter_list)): video_id = item['id'] - item_params = {'video_id': video_id} - if incognito: - item_params.update({'incognito': incognito}) + item_params['video_id'] = video_id item_uri = context.create_uri(['play'], item_params) video_item = VideoItem(item['title'], uri=item_uri) if incognito: @@ -49,23 +52,25 @@ def my_subscriptions_to_items(provider, context, json_data, do_filter=False): use_play_data = not incognito and context.get_settings().use_local_history() channel_item_dict = {} - utils.update_video_infos(provider, context, video_id_dict, channel_items_dict=channel_item_dict, use_play_data=use_play_data) + utils.update_video_infos(provider, + context, + video_id_dict, + channel_items_dict=channel_item_dict, + use_play_data=use_play_data) utils.update_fanarts(provider, context, channel_item_dict) - result = utils.filter_short_videos(context, result) + if context.get_settings().hide_short_videos(): + result = utils.filter_short_videos(result) # next page next_page_token = json_data.get('next_page_token', '') if next_page_token or json_data.get('continue', False): - new_params = {} - new_params.update(context.get_params()) - new_params['next_page_token'] = next_page_token - new_params['offset'] = int(json_data.get('offset', 0)) - + new_params = dict(context.get_params(), + next_page_token=next_page_token, + offset=int(json_data.get('offset', 0))) new_context = context.clone(new_params=new_params) - - current_page = int(new_context.get_param('page', 1)) - next_page_item = kodion.items.NextPageItem(new_context, current_page, fanart=provider.get_fanart(new_context)) + current_page = new_context.get_param('page', 1) + next_page_item = NextPageItem(new_context, current_page) result.append(next_page_item) return result @@ -75,14 +80,15 @@ def tv_videos_to_items(provider, context, json_data): result = [] video_id_dict = {} - incognito = str(context.get_param('incognito', False)).lower() == 'true' + item_params = {'video_id': None} + incognito = context.get_param('incognito', False) + if incognito: + item_params['incognito'] = incognito items = json_data.get('items', []) for item in items: video_id = item['id'] - item_params = {'video_id': video_id} - if incognito: - item_params.update({'incognito': incognito}) + item_params['video_id'] = video_id item_uri = context.create_uri(['play'], item_params) video_item = VideoItem(item['title'], uri=item_uri) if incognito: @@ -95,23 +101,25 @@ def tv_videos_to_items(provider, context, json_data): use_play_data = not incognito and context.get_settings().use_local_history() channel_item_dict = {} - utils.update_video_infos(provider, context, video_id_dict, channel_items_dict=channel_item_dict, use_play_data=use_play_data) + utils.update_video_infos(provider, + context, + video_id_dict, + channel_items_dict=channel_item_dict, + use_play_data=use_play_data) utils.update_fanarts(provider, context, channel_item_dict) - result = utils.filter_short_videos(context, result) + if context.get_settings().hide_short_videos(): + result = utils.filter_short_videos(result) # next page next_page_token = json_data.get('next_page_token', '') if next_page_token or json_data.get('continue', False): - new_params = {} - new_params.update(context.get_params()) - new_params['next_page_token'] = next_page_token - new_params['offset'] = int(json_data.get('offset', 0)) - + new_params = dict(context.get_params(), + next_page_token=next_page_token, + offset=int(json_data.get('offset', 0))) new_context = context.clone(new_params=new_params) - - current_page = int(new_context.get_param('page', 1)) - next_page_item = kodion.items.NextPageItem(new_context, current_page, fanart=provider.get_fanart(new_context)) + current_page = new_context.get_param('page', 1) + next_page_item = NextPageItem(new_context, current_page) result.append(next_page_item) return result @@ -121,8 +129,11 @@ def saved_playlists_to_items(provider, context, json_data): result = [] playlist_id_dict = {} - incognito = str(context.get_param('incognito', False)).lower() == 'true' thumb_size = context.get_settings().use_thumbnail_size() + incognito = context.get_param('incognito', False) + item_params = {} + if incognito: + item_params['incognito'] = incognito items = json_data.get('items', []) for item in items: @@ -131,38 +142,37 @@ def saved_playlists_to_items(provider, context, json_data): playlist_id = item['id'] image = utils.get_thumbnail(thumb_size, item.get('thumbnails', {})) - item_params = {} - if incognito: - item_params.update({'incognito': incognito}) - if channel_id: - item_uri = context.create_uri(['channel', channel_id, 'playlist', playlist_id], item_params) + item_uri = context.create_uri( + ['channel', channel_id, 'playlist', playlist_id], + item_params, + ) else: - item_uri = context.create_uri(['playlist', playlist_id], item_params) + item_uri = context.create_uri( + ['playlist', playlist_id], + item_params, + ) - playlist_item = kodion.items.DirectoryItem(title, item_uri, image=image) - playlist_item.set_fanart(provider.get_fanart(context)) + playlist_item = DirectoryItem(title, item_uri, image=image) result.append(playlist_item) playlist_id_dict[playlist_id] = playlist_item channel_items_dict = {} - utils.update_playlist_infos(provider, context, playlist_id_dict, channel_items_dict) + utils.update_playlist_infos(provider, + context, + playlist_id_dict, + channel_items_dict) utils.update_fanarts(provider, context, channel_items_dict) - result = utils.filter_short_videos(context, result) - # next page next_page_token = json_data.get('next_page_token', '') if next_page_token or json_data.get('continue', False): - new_params = {} - new_params.update(context.get_params()) - new_params['next_page_token'] = next_page_token - new_params['offset'] = int(json_data.get('offset', 0)) - + new_params = dict(context.get_params(), + next_page_token=next_page_token, + offset=int(json_data.get('offset', 0))) new_context = context.clone(new_params=new_params) - - current_page = int(new_context.get_param('page', 1)) - next_page_item = kodion.items.NextPageItem(new_context, current_page, fanart=provider.get_fanart(new_context)) + current_page = new_context.get_param('page', 1) + next_page_item = NextPageItem(new_context, current_page) result.append(next_page_item) return result diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_resolver.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_resolver.py index 2d09a41392..73b4eeb4ae 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_resolver.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_resolver.py @@ -8,21 +8,42 @@ See LICENSES/GPL-2.0-only for more information. """ -import re -from urllib.parse import parse_qsl -from urllib.parse import urlparse -from urllib.parse import parse_qs -from urllib.parse import urlunsplit -from urllib.parse import urlencode +from __future__ import absolute_import, division, unicode_literals -from ...kodion.utils import FunctionCache -from ...kodion import Context as _Context -import requests +import re +from ...kodion.compatibility import parse_qsl, unescape, urlencode, urlsplit +from ...kodion.network import BaseRequestsClass + + +class AbstractResolver(BaseRequestsClass): + _HEADERS = { + 'Cache-Control': 'max-age=0', + 'Accept': ('text/html,' + 'application/xhtml+xml,' + 'application/xml;q=0.9,' + 'image/webp,' + '*/*;q=0.8'), + # Desktop user agent + 'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/119.0.0.0 Safari/537.36'), + # Mobile user agent - for testing m.youtube.com redirect + # 'User-Agent': ('Mozilla/5.0 (Linux; Android 10; SM-G981B)' + # ' AppleWebKit/537.36 (KHTML, like Gecko)' + # ' Chrome/80.0.3987.162 Mobile Safari/537.36'), + # Old desktop user agent - for testing /supported_browsers redirect + # 'User-Agent': ('Mozilla/5.0 (Windows NT 6.1; WOW64)' + # ' AppleWebKit/537.36 (KHTML, like Gecko)' + # ' Chrome/41.0.2272.118 Safari/537.36'), + 'DNT': '1', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-US,en;q=0.8,de;q=0.6' + } -class AbstractResolver(object): - def __init__(self): - self._verify = _Context(plugin_id='plugin.video.youtube').get_settings().verify_ssl() + def __init__(self, context): + self._context = context + super(AbstractResolver, self).__init__() def supports_url(self, url, url_components): raise NotImplementedError() @@ -32,165 +53,193 @@ def resolve(self, url, url_components): class YouTubeResolver(AbstractResolver): - RE_USER_NAME = re.compile(r'http(s)?://(www.)?youtube.com/(?P[a-zA-Z0-9]+)$') - - def __init__(self): - AbstractResolver.__init__(self) + _RE_CHANNEL_URL = re.compile(r'') + _RE_CLIP_DETAILS = re.compile(r'()' + r'|("startTimeMs":"(?P\d+)")' + r'|("endTimeMs":"(?P\d+)")') + + def __init__(self, *args, **kwargs): + super(YouTubeResolver, self).__init__(*args, **kwargs) def supports_url(self, url, url_components): - if url_components.hostname == 'www.youtube.com' or url_components.hostname == 'youtube.com': - if url_components.path.lower() in ['/redirect', '/user']: - return True - - if url_components.path.lower().startswith('/user'): - return True - - re_match = self.RE_USER_NAME.match(url) - if re_match: - return True - - return False - - def resolve(self, url, url_components): - def _load_page(_url): - # we try to extract the channel id from the html content. With the channel id we can construct a url we - # already work with. - # https://www.youtube.com/channel/ - try: - headers = {'Cache-Control': 'max-age=0', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36', - 'DNT': '1', - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Language': 'en-US,en;q=0.8,de;q=0.6'} - response = requests.get(url, headers=headers, verify=self._verify) - if response.status_code == 200: - match = re.search(r'', response.text) - if match: - channel_id = match.group('channel_id') - return 'https://www.youtube.com/channel/%s' % channel_id - except: - # do nothing - pass - - return _url - - if url_components.path.lower() == '/redirect': + if url_components.hostname not in ( + 'www.youtube.com', + 'youtube.com', + 'm.youtube.com', + ): + return False + + path = url_components.path.lower() + if path.startswith(( + '/@', + '/c/', + '/channel/', + '/clip', + '/user/', + )): + return 'GET' + + if path.startswith(( + '/embed', + '/live', + '/redirect', + '/shorts', + '/supported_browsers', + '/watch', + )): + return 'HEAD' + + # user channel in the form of youtube.com/username + path = path.strip('/').split('/', 1) + return 'GET' if len(path) == 1 and path[0] else False + + def resolve(self, url, url_components, method='HEAD'): + path = url_components.path.rstrip('/').lower() + if path == '/redirect': params = dict(parse_qsl(url_components.query)) - return params['q'] - - if url_components.path.lower().startswith('/user'): - return _load_page(url) - - re_match = self.RE_USER_NAME.match(url) - if re_match: - return _load_page(url) - - return url - + url = params['q'] + + # "sometimes", we get a redirect through a URL of the form + # https://.../supported_browsers?next_url=&further=parameters&stuck=here + # put together query string from both what's encoded inside + # next_url and the remaining parameters of this URL... + elif path == '/supported_browsers': + # top-level query string + params = dict(parse_qsl(url_components.query)) + # components of next_url + next_components = urlsplit(params.pop('next_url', '')) + if not next_components.scheme or not next_components.netloc: + return url + # query string encoded inside next_url + next_params = dict(parse_qsl(next_components.query)) + # add/overwrite all other params from top level query string + next_params.update(params) + # build new URL from these components + return next_components._replace( + query=urlencode(next_params) + ).geturl() + + response = self.request(url, + method=method, + headers=self._HEADERS, + allow_redirects=True) + if not response or not response.ok: + return url -class CommonResolver(AbstractResolver, list): - def __init__(self): - AbstractResolver.__init__(self) + if path.startswith('/clip'): + all_matches = self._RE_CLIP_DETAILS.finditer(response.text) + num_matched = 0 + url_components = params = start_time = end_time = None + for matches in all_matches: + matches = matches.groupdict() + + if not num_matched & 1: + url = matches['video_url'] + if url: + num_matched += 1 + url_components = urlsplit(unescape(url)) + params = dict(parse_qsl(url_components.query)) + + if not num_matched & 2: + start_time = matches['start_time'] + if start_time: + start_time = int(start_time) / 1000 + num_matched += 2 + + if not num_matched & 4: + end_time = matches['end_time'] + if end_time: + end_time = int(end_time) / 1000 + num_matched += 4 + + if num_matched != 7: + continue + + params.update({ + 'clip': True, + 'start': start_time, + 'end': end_time, + }) + return url_components._replace(query=urlencode(params)).geturl() + + # we try to extract the channel id from the html content + # With the channel id we can construct a URL we already work with + # https://www.youtube.com/channel/ + elif method == 'GET': + match = self._RE_CHANNEL_URL.search(response.text) + if match: + url = match.group('channel_url') + if path.endswith(('/live', '/streams')): + url_components = urlsplit(unescape(url)) + params = dict(parse_qsl(url_components.query)) + params['live'] = 1 + return url_components._replace( + query=urlencode(params) + ).geturl() + return url + + return response.url + + +class CommonResolver(AbstractResolver): + def __init__(self, *args, **kwargs): + super(CommonResolver, self).__init__(*args, **kwargs) def supports_url(self, url, url_components): - return True - - def resolve(self, url, url_components): - def _loop(_url, tries=5): - if tries == 0: - return _url - - try: - headers = {'Cache-Control': 'max-age=0', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36', - 'DNT': '1', - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Language': 'en-US,en;q=0.8,de;q=0.6'} - response = requests.head(_url, headers=headers, verify=self._verify, allow_redirects=False) - if response.status_code == 304: - return url - - if response.status_code in [301, 302, 303]: - headers = response.headers - location = headers.get('location', '') - - # validate the location - some server returned garbage - _url_components = urlparse(location) - if not _url_components.scheme and not _url_components.hostname: - return url - - # some server return 301 for HEAD requests - # we just compare the new location - if it's equal we can return the url - if location == _url or ''.join([location, '/']) == _url or location == ''.join([_url, '/']): - return _url - - if location: - return _loop(location, tries=tries - 1) - - # just to be sure ;) - location = headers.get('Location', '') - if location: - return _loop(location, tries=tries - 1) - - if response.status_code == 200: - _url_components = urlparse(_url) - if _url_components.path == '/supported_browsers': - # "sometimes", we get a redirect through an URL of the form https://.../supported_browsers?next_url=&further=paramaters&stuck=here - # put together query string from both what's encoded inside next_url and the remaining paramaters of this URL... - _query = parse_qs(_url_components.query) # top-level query string - _nc = urlparse(_query['next_url'][0]) # components of next_url - _next_query = parse_qs(_nc.query) # query string encoded inside next_url - del _query['next_url'] # remove next_url from top level query string - _next_query.update(_query) # add/overwrite all other params from top level query string - _next_query = dict(map(lambda kv : (kv[0], kv[1][0]), _next_query.items())) # flatten to only use first argument of each param - _next_url = urlunsplit((_nc.scheme, _nc.netloc, _nc.path, urlencode(_next_query), _nc.fragment)) # build new URL from these components - return _next_url - - except: - # do nothing - pass - - return _url - - resolved_url = _loop(url) - - return resolved_url + return 'HEAD' + + def resolve(self, url, url_components, method='HEAD'): + response = self.request(url, + method=method, + headers=self._HEADERS, + allow_redirects=True) + if not response or not response.ok: + return url + return response.url class UrlResolver(object): def __init__(self, context): self._context = context - self._cache = {} - self._youtube_resolver = YouTubeResolver() - self._resolver = [ - self._youtube_resolver, - CommonResolver() + self._cache = context.get_function_cache() + self._resolver_map = { + 'common_resolver': CommonResolver(context), + 'youtube_resolver': YouTubeResolver(context), + } + self._resolvers = [ + 'common_resolver', + 'youtube_resolver', ] def clear(self): - self._context.get_function_cache().clear() + self._cache.clear() def _resolve(self, url): - # try one of the resolver - url_components = urlparse(url) - for resolver in self._resolver: - if resolver.supports_url(url, url_components): - resolved_url = resolver.resolve(url, url_components) - self._cache[url] = resolved_url - - # one last check...sometimes the resolved url is YouTube-specific and can be resolved again or - # simplified. - url_components = urlparse(resolved_url) - if resolver is not self._youtube_resolver and self._youtube_resolver.supports_url(resolved_url, url_components): - return self._youtube_resolver.resolve(resolved_url, url_components) - - return resolved_url + # try one of the resolvers + resolved_url = url + for resolver_name in self._resolvers: + resolver = self._resolver_map[resolver_name] + url_components = urlsplit(resolved_url) + method = resolver.supports_url(resolved_url, url_components) + if not method: + continue + + self._context.log_debug('Resolving |{uri}| using |{name} {method}|' + .format(uri=resolved_url, + name=resolver_name, + method=method)) + resolved_url = resolver.resolve(resolved_url, + url_components, + method) + self._context.log_debug('Resolved to |{0}|'.format(resolved_url)) + return resolved_url def resolve(self, url): - function_cache = self._context.get_function_cache() - resolved_url = function_cache.get(FunctionCache.ONE_DAY, self._resolve, url) + resolved_url = self._cache.get(self._resolve, self._cache.ONE_DAY, url) if not resolved_url or resolved_url == '/': return url diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py index b8859d7cb4..0bde8abe24 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py @@ -8,17 +8,23 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re -from urllib.parse import urlparse -from urllib.parse import parse_qsl -from ...kodion.items import VideoItem, DirectoryItem from . import utils +from ...kodion.compatibility import parse_qsl, urlsplit +from ...kodion.items import DirectoryItem, UriItem, VideoItem +from ...kodion.utils import duration_to_seconds class UrlToItemConverter(object): - RE_CHANNEL_ID = re.compile(r'^/channel/(?P.+)$') - RE_SHORTS_VID = re.compile(r'^/shorts/(?P.+)$') + RE_PATH_ID = re.compile(r'/[^/]+/(?P[^/?#]+)', re.I) + VALID_HOSTNAMES = { + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + } def __init__(self, flatten=True): self._flatten = flatten @@ -34,129 +40,181 @@ def __init__(self, flatten=True): self._channel_items = [] self._channel_ids = [] - def add_url(self, url, provider, context): - url_components = urlparse(url) - if url_components.hostname.lower() in ('youtube.com', 'www.youtube.com', 'm.youtube.com'): - params = dict(parse_qsl(url_components.query)) - if url_components.path.lower() == '/watch': - video_id = params.get('v', '') - if video_id: - plugin_uri = context.create_uri(['play'], {'video_id': video_id}) - video_item = VideoItem('', plugin_uri) - self._video_id_dict[video_id] = video_item - - playlist_id = params.get('list', '') - if playlist_id: - if self._flatten: - self._playlist_ids.append(playlist_id) - else: - playlist_item = DirectoryItem('', context.create_uri(['playlist', playlist_id])) - playlist_item.set_fanart(provider.get_fanart(context)) - self._playlist_id_dict[playlist_id] = playlist_item - elif url_components.path.lower() == '/playlist': - playlist_id = params.get('list', '') - if playlist_id: - if self._flatten: - self._playlist_ids.append(playlist_id) - else: - playlist_item = DirectoryItem('', context.create_uri(['playlist', playlist_id])) - playlist_item.set_fanart(provider.get_fanart(context)) - self._playlist_id_dict[playlist_id] = playlist_item - elif self.RE_SHORTS_VID.match(url_components.path): - re_match = self.RE_SHORTS_VID.match(url_components.path) - video_id = re_match.group('video_id') - plugin_uri = context.create_uri(['play'], {'video_id': video_id}) - video_item = VideoItem('', plugin_uri) - self._video_id_dict[video_id] = video_item - elif self.RE_CHANNEL_ID.match(url_components.path): - re_match = self.RE_CHANNEL_ID.match(url_components.path) - channel_id = re_match.group('channel_id') - if self._flatten: - self._channel_ids.append(channel_id) - else: - channel_item = DirectoryItem('', context.create_uri(['channel', channel_id])) - channel_item.set_fanart(provider.get_fanart(context)) - self._channel_id_dict[channel_id] = channel_item - else: - context.log_debug('Unknown path "%s"' % url_components.path) - - def add_urls(self, urls, provider, context): + def add_url(self, url, context): + parsed_url = urlsplit(url) + if parsed_url.hostname.lower() not in self.VALID_HOSTNAMES: + context.log_debug('Unknown hostname "{0}" in url "{1}"'.format( + parsed_url.hostname, url + )) + return + + url_params = dict(parse_qsl(parsed_url.query)) + new_params = { + new: process(url_params[old]) if process else url_params[old] + for old, new, process in ( + ('end', 'end', duration_to_seconds), + ('start', 'start', duration_to_seconds), + ('t', 'seek', duration_to_seconds), + ('list', 'playlist_id', False), + ('v', 'video_id', False), + ('live', 'live', False), + ('clip', 'clip', False), + ) + if old in url_params + } + + path = parsed_url.path.rstrip('/').lower() + if path.startswith(('/playlist', '/watch')): + pass + elif path.startswith('/channel/'): + re_match = self.RE_PATH_ID.match(parsed_url.path) + new_params['channel_id'] = re_match.group('id') + if ('live' not in new_params + and path.endswith(('/live', '/streams'))): + new_params['live'] = 1 + elif path.startswith(('/clip/', '/embed/', '/live/', '/shorts/')): + re_match = self.RE_PATH_ID.match(parsed_url.path) + new_params['video_id'] = re_match.group('id') + else: + context.log_debug('Unknown path "{0}" in url "{1}"'.format( + parsed_url.path, url + )) + return + + if 'video_id' in new_params: + video_id = new_params['video_id'] + + video_item = VideoItem( + '', context.create_uri(['play'], new_params) + ) + self._video_id_dict[video_id] = video_item + + elif 'playlist_id' in new_params: + playlist_id = new_params['playlist_id'] + + if self._flatten: + self._playlist_ids.append(playlist_id) + return + + playlist_item = DirectoryItem( + '', context.create_uri(['playlist', playlist_id], new_params), + ) + self._playlist_id_dict[playlist_id] = playlist_item + + elif 'channel_id' in new_params: + channel_id = new_params['channel_id'] + live = new_params.get('live') + + if not live and self._flatten: + self._channel_ids.append(channel_id) + return + + channel_item = VideoItem( + '', context.create_uri(['play'], new_params) + ) if live else DirectoryItem( + '', context.create_uri(['channel', channel_id], new_params) + ) + self._channel_id_dict[channel_id] = channel_item + + else: + context.log_debug('No items found in url "{0}"'.format(url)) + + def add_urls(self, urls, context): for url in urls: - self.add_url(url, provider, context) + self.add_url(url, context) - def get_items(self, provider, context, title_required=True): + def get_items(self, provider, context, skip_title=False): result = [] - if self._flatten and len(self._channel_ids) > 0: + if self._channel_ids: # remove duplicates self._channel_ids = list(set(self._channel_ids)) - channels_item = DirectoryItem(context.get_ui().bold(context.localize(provider.LOCAL_MAP['youtube.channels'])), - context.create_uri(['special', 'description_links'], - {'channel_ids': ','.join(self._channel_ids)}), - context.create_resource_path('media', 'playlist.png')) - channels_item.set_fanart(provider.get_fanart(context)) + channels_item = DirectoryItem( + context.get_ui().bold(context.localize('channels')), + context.create_uri(['special', 'description_links'], { + 'channel_ids': ','.join(self._channel_ids), + }), + image='{media}/playlist.png' + ) result.append(channels_item) - if self._flatten and len(self._playlist_ids) > 0: + if self._playlist_ids: # remove duplicates self._playlist_ids = list(set(self._playlist_ids)) - playlists_item = DirectoryItem(context.get_ui().bold(context.localize(provider.LOCAL_MAP['youtube.playlists'])), - context.create_uri(['special', 'description_links'], - {'playlist_ids': ','.join(self._playlist_ids)}), - context.create_resource_path('media', 'playlist.png')) - playlists_item.set_fanart(provider.get_fanart(context)) + playlists_item = UriItem( + context.create_uri(['play'], { + 'playlist_ids': ','.join(self._playlist_ids), + 'play': True, + }), + playable=True + ) if context.get_param('uri') else DirectoryItem( + context.get_ui().bold(context.localize('playlists')), + context.create_uri(['special', 'description_links'], { + 'playlist_ids': ','.join(self._playlist_ids), + }), + image='{media}/playlist.png' + ) result.append(playlists_item) - if not self._flatten: - result.extend(self.get_channel_items(provider, context)) + if self._channel_id_dict: + result += self.get_channel_items(provider, context, skip_title) - if not self._flatten: - result.extend(self.get_playlist_items(provider, context)) + if self._playlist_id_dict: + result += self.get_playlist_items(provider, context, skip_title) - # add videos - result.extend(self.get_video_items(provider, context, title_required)) + if self._video_id_dict: + result += self.get_video_items(provider, context, skip_title) return result - def get_video_items(self, provider, context, title_required=True): - incognito = str(context.get_param('incognito', False)).lower() == 'true' - use_play_data = not incognito + def get_video_items(self, provider, context, skip_title=False): + if self._video_items: + return self._video_items - if len(self._video_items) == 0: - channel_id_dict = {} - utils.update_video_infos(provider, context, self._video_id_dict, None, channel_id_dict, use_play_data=use_play_data) - utils.update_fanarts(provider, context, channel_id_dict) + use_play_data = not context.get_param('incognito', False) - for key in self._video_id_dict: - video_item = self._video_id_dict[key] - if not title_required or (title_required and video_item.get_title()): - self._video_items.append(video_item) + channel_id_dict = {} + utils.update_video_infos(provider, context, self._video_id_dict, + channel_items_dict=channel_id_dict, + use_play_data=use_play_data) + utils.update_fanarts(provider, context, channel_id_dict) + self._video_items = [ + video_item + for video_item in self._video_id_dict.values() + if skip_title or video_item.get_title() + ] return self._video_items - def get_playlist_items(self, provider, context): - if len(self._playlist_items) == 0: - channel_id_dict = {} - utils.update_playlist_infos(provider, context, self._playlist_id_dict, channel_id_dict) - utils.update_fanarts(provider, context, channel_id_dict) - - for key in self._playlist_id_dict: - playlist_item = self._playlist_id_dict[key] - if playlist_item.get_name(): - self._playlist_items.append(playlist_item) - + def get_playlist_items(self, provider, context, skip_title=False): + if self._playlist_items: + return self._playlist_items + + channel_id_dict = {} + utils.update_playlist_infos(provider, context, + self._playlist_id_dict, + channel_items_dict=channel_id_dict) + utils.update_fanarts(provider, context, channel_id_dict) + + self._playlist_items = [ + playlist_item + for playlist_item in self._playlist_id_dict.values() + if skip_title or playlist_item.get_title() + ] return self._playlist_items - def get_channel_items(self, provider, context): - if len(self._channel_items) == 0: - channel_id_dict = {} - utils.update_fanarts(provider, context, channel_id_dict) + def get_channel_items(self, provider, context, skip_title=False): + if self._channel_items: + return self._channel_items - for key in self._channel_id_dict: - channel_item = self._channel_id_dict[key] - if channel_item.get_name(): - self._channel_items.append(channel_item) + channel_id_dict = {} + utils.update_fanarts(provider, context, channel_id_dict) + self._channel_items = [ + channel_item + for channel_item in self._channel_id_dict.values() + if skip_title or channel_item.get_title() + ] return self._channel_items diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/utils.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/utils.py index 734e0a12fa..29c4ddba08 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/utils.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/utils.py @@ -8,113 +8,186 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re import time +from math import log10 -from ... import kodion -from ...kodion import utils -from ...youtube.helper import yt_context_menu +from ...kodion.constants import content +from ...kodion.items import DirectoryItem, menu_items +from ...kodion.utils import ( + create_path, + datetime_parser, + friendly_number, + strip_html_from_text, +) try: - import inputstreamhelper + from inputstreamhelper import Helper as ISHelper except ImportError: - inputstreamhelper = None + ISHelper = None -__RE_SEASON_EPISODE_MATCHES__ = [re.compile(r'Part (?P\d+)'), - re.compile(r'#(?P\d+)'), - re.compile(r'Ep.[^\w]?(?P\d+)'), - re.compile(r'\[(?P\d+)\]'), - re.compile(r'S(?P\d+)E(?P\d+)'), - re.compile(r'Season (?P\d+)(.+)Episode (?P\d+)'), - re.compile(r'Episode (?P\d+)')] +__COLOR_MAP = { + 'commentCount': 'cyan', + 'favoriteCount': 'gold', + 'likeCount': 'lime', + 'viewCount': 'lightblue', +} -def extract_urls(text): - result = [] +__RE_PLAYLIST_MATCH = re.compile( + r'^(/channel/(?P[^/]+))/playlist/(?P[^/]+)/$' +) + +__RE_SEASON_EPISODE_MATCHES__ = [ + re.compile(r'Part (?P\d+)'), + re.compile(r'#(?P\d+)'), + re.compile(r'Ep.\W?(?P\d+)'), + re.compile(r'\[(?P\d+)]'), + re.compile(r'S(?P\d+)E(?P\d+)'), + re.compile(r'Season (?P\d+)(.+)Episode (?P\d+)'), + re.compile(r'Episode (?P\d+)'), +] + +__RE_URL = re.compile(r'(https?://\S+)') - re_url = re.compile(r'(https?://[^\s]+)') - matches = re_url.findall(text) - result = matches or result - return result +def extract_urls(text): + return __RE_URL.findall(text) def get_thumb_timestamp(minutes=15): - return str(time.mktime(time.gmtime(minutes * 60 * (round(time.time() / (minutes * 60)))))) - - -def make_comment_item(context, provider, snippet, uri, total_replies=0): - author = '[B]{}[/B]'.format(kodion.utils.to_str(snippet['authorDisplayName'])) - body = kodion.utils.to_str(snippet['textOriginal']) - - label_props = None - plot_props = None - is_edited = (snippet['publishedAt'] != snippet['updatedAt']) - - str_likes = ('%.1fK' % (snippet['likeCount'] / 1000.0)) if snippet['likeCount'] > 1000 else str(snippet['likeCount']) - str_replies = ('%.1fK' % (total_replies / 1000.0)) if total_replies > 1000 else str(total_replies) - - if snippet['likeCount'] and total_replies: - label_props = '[COLOR lime][B]+%s[/B][/COLOR]|[COLOR cyan][B]%s[/B][/COLOR]' % (str_likes, str_replies) - plot_props = '[COLOR lime][B]%s %s[/B][/COLOR]|[COLOR cyan][B]%s %s[/B][/COLOR]' % (str_likes, - context.localize(provider.LOCAL_MAP['youtube.video.comments.likes']), str_replies, - context.localize(provider.LOCAL_MAP['youtube.video.comments.replies'])) - elif snippet['likeCount']: - label_props = '[COLOR lime][B]+%s[/B][/COLOR]' % str_likes - plot_props = '[COLOR lime][B]%s %s[/B][/COLOR]' % (str_likes, - context.localize(provider.LOCAL_MAP['youtube.video.comments.likes'])) - elif total_replies: - label_props = '[COLOR cyan][B]%s[/B][/COLOR]' % str_replies - plot_props = '[COLOR cyan][B]%s %s[/B][/COLOR]' % (str_replies, - context.localize(provider.LOCAL_MAP['youtube.video.comments.replies'])) - else: - pass # The comment has no likes or replies. + seconds = minutes * 60 + return str(time.mktime(time.gmtime( + seconds * (round(time.time() / seconds)) + ))) + + +def make_comment_item(context, snippet, uri, total_replies=0): + ui = context.get_ui() + + author = ui.bold(snippet['authorDisplayName']) + body = snippet['textOriginal'] + + label_props = [] + plot_props = [] + + like_count = snippet['likeCount'] + if like_count: + like_count = friendly_number(like_count) + color = __COLOR_MAP['likeCount'] + label_likes = ui.color(color, ui.bold(like_count)) + plot_likes = ui.color(color, ui.bold(' '.join(( + like_count, context.localize('video.comments.likes') + )))) + label_props.append(label_likes) + plot_props.append(plot_likes) + + if total_replies: + total_replies = friendly_number(total_replies) + color = __COLOR_MAP['commentCount'] + label_replies = ui.color(color, ui.bold(total_replies)) + plot_replies = ui.color(color, ui.bold(' '.join(( + total_replies, context.localize('video.comments.replies') + )))) + label_props.append(label_replies) + plot_props.append(plot_replies) + + published_at = snippet['publishedAt'] + updated_at = snippet['updatedAt'] + edited = published_at != updated_at + if edited: + label_props.append('*') + plot_props.append(context.localize('video.comments.edited')) # Format the label of the comment item. - edited = '[B]*[/B]' if is_edited else '' if label_props: - label = '{author} ({props}){edited} {body}'.format(author=author, props=label_props, edited=edited, - body=body.replace('\n', ' ')) + label = '{author} ({props}) {body}'.format( + author=author, + props='|'.join(label_props), + body=body.replace('\n', ' ') + ) else: - label = '{author}{edited} {body}'.format(author=author, edited=edited, body=body.replace('\n', ' ')) + label = '{author} {body}'.format( + author=author, body=body.replace('\n', ' ') + ) # Format the plot of the comment item. - edited = ' (%s)' % context.localize(provider.LOCAL_MAP['youtube.video.comments.edited']) if is_edited else '' if plot_props: - plot = '{author} ({props}){edited}[CR][CR]{body}'.format(author=author, props=plot_props, - edited=edited, body=body) + plot = '{author} ({props}){body}'.format( + author=author, + props='|'.join(plot_props), + body=ui.new_line(body, cr_before=2) + ) else: - plot = '{author}{edited}[CR][CR]{body}'.format(author=author, edited=edited, body=body) + plot = '{author}{body}'.format( + author=author, body=ui.new_line(body, cr_before=2) + ) - comment_item = kodion.items.DirectoryItem(label, uri) + comment_item = DirectoryItem(label, uri) comment_item.set_plot(plot) - comment_item.set_date_from_datetime(utils.datetime_parser.parse(snippet['publishedAt'])) + + datetime = datetime_parser.parse(published_at) + comment_item.set_added_utc(datetime) + local_datetime = datetime_parser.utc_to_local(datetime) + comment_item.set_dateadded_from_datetime(local_datetime) + if edited: + datetime = datetime_parser.parse(updated_at) + local_datetime = datetime_parser.utc_to_local(datetime) + comment_item.set_date_from_datetime(local_datetime) + if not uri: - comment_item.set_action(True) # Cosmetic, makes the item not a folder. + # Cosmetic, makes the item not a folder. + comment_item.set_action(True) + return comment_item -def update_channel_infos(provider, context, channel_id_dict, subscription_id_dict=None, channel_items_dict=None): - if subscription_id_dict is None: - subscription_id_dict = {} +def update_channel_infos(provider, context, channel_id_dict, + subscription_id_dict=None, + channel_items_dict=None, + data=None): + channel_ids = list(channel_id_dict) + if not channel_ids and not data: + return + + if not data: + resource_manager = provider.get_resource_manager(context) + data = resource_manager.get_channels(channel_ids) - channel_ids = list(channel_id_dict.keys()) - if len(channel_ids) == 0: + if not data: return - resource_manager = provider.get_resource_manager(context) - channel_data = resource_manager.get_channels(channel_ids) + if subscription_id_dict is None: + subscription_id_dict = {} - filter_list = [] - if context.get_path() == '/subscriptions/list/': - filter_string = context.get_settings().get_string('youtube.filter.my_subscriptions_filtered.list', '') - filter_string = filter_string.replace(', ', ',') - filter_list = filter_string.split(',') - filter_list = [x.lower() for x in filter_list] + settings = context.get_settings() + logged_in = provider.is_logged_in() + path = context.get_path() + + filter_list = None + if path == '/subscriptions/list/': + in_subscription_list = True + if settings.get_bool('youtube.folder.my_subscriptions_filtered.show', + False): + filter_string = settings.get_string( + 'youtube.filter.my_subscriptions_filtered.list', '' + ) + filter_string = filter_string.replace(', ', ',') + filter_list = filter_string.split(',') + filter_list = [x.lower() for x in filter_list] + else: + in_subscription_list = False - thumb_size = context.get_settings().use_thumbnail_size() - for channel_id in list(channel_data.keys()): - yt_item = channel_data[channel_id] + thumb_size = settings.use_thumbnail_size + banners = [ + 'bannerTvMediumImageUrl', + 'bannerTvLowImageUrl', + 'bannerTvImageUrl' + ] + + for channel_id, yt_item in data.items(): channel_item = channel_id_dict[channel_id] snippet = yt_item['snippet'] @@ -129,35 +202,47 @@ def update_channel_infos(provider, context, channel_id_dict, subscription_id_dic # - update context menu context_menu = [] + # -- unsubscribe from channel subscription_id = subscription_id_dict.get(channel_id, '') if subscription_id: channel_item.set_channel_subscription_id(subscription_id) - yt_context_menu.append_unsubscribe_from_channel(context_menu, provider, context, subscription_id) - # -- subscribe to the channel - if provider.is_logged_in() and context.get_path() != '/subscriptions/list/': - yt_context_menu.append_subscribe_to_channel(context_menu, provider, context, channel_id) - - if context.get_path() == '/subscriptions/list/': - channel = title.lower() - channel = channel.replace(',', '') - if channel in filter_list: - yt_context_menu.append_remove_my_subscriptions_filter(context_menu, provider, context, title) - else: - yt_context_menu.append_add_my_subscriptions_filter(context_menu, provider, context, title) + context_menu.append( + menu_items.unsubscribe_from_channel( + context, subscription_id + ) + ) - channel_item.set_context_menu(context_menu) + # -- subscribe to the channel + if logged_in and not in_subscription_list: + context_menu.append( + menu_items.subscribe_to_channel( + context, channel_id + ) + ) + + # add/remove from filter list + if in_subscription_list and filter_list is not None: + channel = title.lower().replace(',', '') + context_menu.append( + menu_items.remove_my_subscriptions_filter( + context, title + ) if channel in filter_list else + menu_items.add_my_subscriptions_filter( + context, title + ) + ) + + if context_menu: + channel_item.set_context_menu(context_menu) - fanart = u'' fanart_images = yt_item.get('brandingSettings', {}).get('image', {}) - banners = ['bannerTvMediumImageUrl', 'bannerTvLowImageUrl', 'bannerTvImageUrl'] for banner in banners: - fanart = fanart_images.get(banner, u'') + fanart = fanart_images.get(banner) if fanart: + channel_item.set_fanart(fanart) break - channel_item.set_fanart(fanart) - # update channel mapping if channel_items_dict is not None: if channel_id not in channel_items_dict: @@ -165,21 +250,28 @@ def update_channel_infos(provider, context, channel_id_dict, subscription_id_dic channel_items_dict[channel_id].append(channel_item) -def update_playlist_infos(provider, context, playlist_id_dict, channel_items_dict=None): - playlist_ids = list(playlist_id_dict.keys()) - if len(playlist_ids) == 0: +def update_playlist_infos(provider, context, playlist_id_dict, + channel_items_dict=None, + data=None): + playlist_ids = list(playlist_id_dict) + if not playlist_ids and not data: return - resource_manager = provider.get_resource_manager(context) - access_manager = context.get_access_manager() - playlist_data = resource_manager.get_playlists(playlist_ids) + if not data: + resource_manager = provider.get_resource_manager(context) + data = resource_manager.get_playlists(playlist_ids) + + if not data: + return + access_manager = context.get_access_manager() custom_watch_later_id = access_manager.get_watch_later_id() custom_history_id = access_manager.get_watch_history_id() - + logged_in = provider.is_logged_in() + path = context.get_path() thumb_size = context.get_settings().use_thumbnail_size() - for playlist_id in list(playlist_data.keys()): - yt_item = playlist_data[playlist_id] + + for playlist_id, yt_item in data.items(): playlist_item = playlist_id_dict[playlist_id] snippet = yt_item['snippet'] @@ -189,40 +281,55 @@ def update_playlist_infos(provider, context, playlist_id_dict, channel_items_dic playlist_item.set_image(image) channel_id = snippet['channelId'] - # if the path directs to a playlist of our own, we correct the channel id to 'mine' - if context.get_path() == '/channel/mine/playlists/': + # if the path directs to a playlist of our own, set channel id to 'mine' + if path == '/channel/mine/playlists/': channel_id = 'mine' channel_name = snippet.get('channelTitle', '') - context_menu = [] + # play all videos of the playlist - yt_context_menu.append_play_all_from_playlist(context_menu, provider, context, playlist_id) + context_menu = [ + menu_items.play_all_from_playlist( + context, playlist_id + ) + ] - if provider.is_logged_in(): + if logged_in: if channel_id != 'mine': # subscribe to the channel via the playlist item - yt_context_menu.append_subscribe_to_channel(context_menu, provider, context, channel_id, - channel_name) + context_menu.append( + menu_items.subscribe_to_channel( + context, channel_id, channel_name + ) + ) else: - # remove my playlist - yt_context_menu.append_delete_playlist(context_menu, provider, context, playlist_id, title) - - # rename playlist - yt_context_menu.append_rename_playlist(context_menu, provider, context, playlist_id, title) - - # remove as my custom watch later playlist - if playlist_id == custom_watch_later_id: - yt_context_menu.append_remove_as_watchlater(context_menu, provider, context, playlist_id, title) - # set as my custom watch later playlist - else: - yt_context_menu.append_set_as_watchlater(context_menu, provider, context, playlist_id, title) - # remove as custom history playlist - if playlist_id == custom_history_id: - yt_context_menu.append_remove_as_history(context_menu, provider, context, playlist_id, title) - # set as custom history playlist - else: - yt_context_menu.append_set_as_history(context_menu, provider, context, playlist_id, title) - - if len(context_menu) > 0: + context_menu.extend(( + # remove my playlist + menu_items.delete_playlist( + context, playlist_id, title + ), + # rename playlist + menu_items.rename_playlist( + context, playlist_id, title + ), + # remove as my custom watch later playlist + menu_items.remove_as_watch_later( + context, playlist_id, title + ) if playlist_id == custom_watch_later_id else + # set as my custom watch later playlist + menu_items.set_as_watch_later( + context, playlist_id, title + ), + # remove as custom history playlist + menu_items.remove_as_history( + context, playlist_id, title + ) if playlist_id == custom_history_id else + # set as custom history playlist + menu_items.set_as_history( + context, playlist_id, title + ), + )) + + if context_menu: playlist_item.set_context_menu(context_menu) # update channel mapping @@ -232,79 +339,162 @@ def update_playlist_infos(provider, context, playlist_id_dict, channel_items_dic channel_items_dict[channel_id].append(playlist_item) -def update_video_infos(provider, context, video_id_dict, playlist_item_id_dict=None, channel_items_dict=None, live_details=False, use_play_data=True): - settings = context.get_settings() - ui = context.get_ui() +def update_video_infos(provider, context, video_id_dict, + playlist_item_id_dict=None, + channel_items_dict=None, + live_details=True, + use_play_data=True, + data=None): + video_ids = list(video_id_dict) + if not video_ids and not data: + return + + if not data: + resource_manager = provider.get_resource_manager(context) + data = resource_manager.get_videos(video_ids, + live_details=live_details, + suppress_errors=True) - video_ids = list(video_id_dict.keys()) - if len(video_ids) == 0: + if not data: return if not playlist_item_id_dict: playlist_item_id_dict = {} - resource_manager = provider.get_resource_manager(context) - video_data = resource_manager.get_videos(video_ids, live_details=live_details, - suppress_errors=True) + logged_in = provider.is_logged_in() + if logged_in: + watch_later_id = context.get_access_manager().get_watch_later_id() + else: + watch_later_id = None + + settings = context.get_settings() + hide_shorts = settings.hide_short_videos() + alternate_player = settings.is_support_alternative_player_enabled() + show_details = settings.show_detailed_description() thumb_size = settings.use_thumbnail_size() thumb_stamp = get_thumb_timestamp() - for video_id in list(video_data.keys()): - datetime = None - yt_item = video_data.get(video_id) + + path = context.get_path() + ui = context.get_ui() + + for video_id, yt_item in data.items(): video_item = video_id_dict[video_id] # set mediatype - video_item.set_mediatype('video') # using video + video_item.set_mediatype(content.VIDEO_TYPE) - if not yt_item: + if not yt_item or 'snippet' not in yt_item: continue - snippet = yt_item['snippet'] # crash if not conform - play_data = yt_item['play_data'] - video_item.live = snippet.get('liveBroadcastContent') == 'live' + snippet = yt_item['snippet'] + play_data = use_play_data and yt_item.get('play_data') + broadcast_type = snippet.get('liveBroadcastContent') + video_item.live = broadcast_type == 'live' + video_item.upcoming = broadcast_type == 'upcoming' # duration - if not video_item.live and use_play_data and play_data.get('total_time'): - video_item.set_duration_from_seconds(float(play_data.get('total_time'))) + if (not (video_item.live or video_item.upcoming) + and play_data and 'total_time' in play_data): + duration = play_data['total_time'] else: - duration = yt_item.get('contentDetails', {}).get('duration', '') + duration = yt_item.get('contentDetails', {}).get('duration') if duration: - duration = utils.datetime_parser.parse(duration) - # we subtract 1 seconds because YouTube returns +1 second to much - video_item.set_duration_from_seconds(duration.seconds - 1) + duration = datetime_parser.parse(duration) + # subtract 1s because YouTube duration is +1s too long + duration = (duration.seconds - 1) if duration.seconds else None + if duration: + video_item.set_duration_from_seconds(duration) + if hide_shorts and duration <= 60: + continue - if not video_item.live and use_play_data: - # play count - if play_data.get('play_count'): - video_item.set_play_count(int(play_data.get('play_count'))) + if not video_item.live and play_data: + if 'play_count' in play_data: + video_item.set_play_count(play_data['play_count']) - if play_data.get('played_percent'): - video_item.set_start_percent(play_data.get('played_percent')) + if 'played_percent' in play_data: + video_item.set_start_percent(play_data['played_percent']) - if play_data.get('played_time'): - video_item.set_start_time(play_data.get('played_time')) + if 'played_time' in play_data: + video_item.set_start_time(play_data['played_time']) - if play_data.get('last_played'): - video_item.set_last_played(play_data.get('last_played')) + if 'last_played' in play_data: + video_item.set_last_played(play_data['last_played']) elif video_item.live: video_item.set_play_count(0) - scheduled_start = video_data[video_id].get('liveStreamingDetails', {}).get('scheduledStartTime') - if scheduled_start: - datetime = utils.datetime_parser.parse(scheduled_start) - video_item.set_scheduled_start_utc(datetime) - start_date, start_time = utils.datetime_parser.get_scheduled_start(datetime) - if start_date: - title = u'({live} {date}@{time}) {title}' \ - .format(live=context.localize(provider.LOCAL_MAP['youtube.live']), date=start_date, time=start_time, title=snippet['title']) - else: - title = u'({live} @ {time}) {title}' \ - .format(live=context.localize(provider.LOCAL_MAP['youtube.live']), date=start_date, time=start_time, title=snippet['title']) - video_item.set_title(title) + if ((video_item.live or video_item.upcoming) + and 'liveStreamingDetails' in yt_item): + start_at = yt_item['liveStreamingDetails'].get('scheduledStartTime') else: - # set the title - if not video_item.get_title(): - video_item.set_title(snippet['title']) + start_at = None + if start_at: + datetime = datetime_parser.parse(start_at) + video_item.set_scheduled_start_utc(datetime) + local_datetime = datetime_parser.utc_to_local(datetime) + video_item.set_year_from_datetime(local_datetime) + video_item.set_aired_from_datetime(local_datetime) + video_item.set_premiered_from_datetime(local_datetime) + video_item.set_date_from_datetime(local_datetime) + type_label = context.localize('live' if video_item.live + else 'upcoming') + start_at = '{type_label} {start_at}'.format( + type_label=type_label, + start_at=datetime_parser.get_scheduled_start( + context, local_datetime + ) + ) + + label_stats = [] + stats = [] + rating = [0, 0] + if 'statistics' in yt_item: + for stat, value in yt_item['statistics'].items(): + label = context.LOCAL_MAP.get('stats.' + stat) + if not label: + continue + + str_value, value = friendly_number(value, as_str=False) + if not value: + continue + + color = __COLOR_MAP.get(stat, 'white') + label = context.localize(label) + if value == 1: + label = label.rstrip('s') + + label_stats.append(ui.color(color, str_value)) + stats.append(ui.color(color, ui.bold(' '.join(( + str_value, label + ))))) + + if stat == 'likeCount': + rating[0] = value + elif stat == 'viewCount': + rating[1] = value + video_item.set_count(value) + + label_stats = ' | '.join(label_stats) + stats = ' | '.join(stats) + + if 0 < rating[0] <= rating[1]: + if rating[0] == rating[1]: + rating = 10 + else: + # This is a completely made up, arbitrary ranking score + rating = (10 * (log10(rating[1]) * log10(rating[0])) + / (log10(rating[0] + rating[1]) ** 2)) + video_item.set_rating(rating) + + # Used for label2, but is poorly supported in skins + video_item.set_short_details(label_stats) + # Hack to force a custom label mask containing production code, + # activated on sort order selection, to display details + # Refer XbmcContext.set_content for usage + video_item.set_code(label_stats) + + # update and set the title + title = video_item.get_title() or snippet['title'] or '' + video_item.set_title(ui.italic(title) if video_item.upcoming else title) """ This is experimental. We try to get the most information out of the title of a video. @@ -325,279 +515,239 @@ def update_video_infos(provider, context, video_id_dict, playlist_item_id_dict=N # plot channel_name = snippet.get('channelTitle', '') - description = kodion.utils.strip_html_from_text(snippet['description']) - if channel_name and settings.get_bool('youtube.view.description.show_channel_name', True): - description = '%s[CR][CR]%s' % (ui.uppercase(ui.bold(channel_name)), description) + description = strip_html_from_text(snippet['description']) + if show_details: + description = ''.join(( + ui.bold(channel_name, cr_after=1) if channel_name else '', + ui.new_line(stats, cr_after=1) if stats else '', + (ui.italic(start_at, cr_after=1) if video_item.upcoming + else ui.new_line(start_at, cr_after=1)) if start_at else '', + description, + )) video_item.set_studio(channel_name) # video_item.add_cast(channel_name) video_item.add_artist(channel_name) video_item.set_plot(description) # date time - if not datetime and 'publishedAt' in snippet and snippet['publishedAt']: - datetime = utils.datetime_parser.parse(snippet['publishedAt']) - video_item.set_aired_utc(utils.datetime_parser.strptime(snippet['publishedAt'])) - - if datetime: - video_item.set_year_from_datetime(datetime) - video_item.set_aired_from_datetime(datetime) - video_item.set_premiered_from_datetime(datetime) - video_item.set_date_from_datetime(datetime) + published_at = snippet.get('publishedAt') + if published_at: + datetime = datetime_parser.parse(published_at) + video_item.set_added_utc(datetime) + local_datetime = datetime_parser.utc_to_local(datetime) + video_item.set_dateadded_from_datetime(local_datetime) + if not start_at: + video_item.set_year_from_datetime(local_datetime) + video_item.set_aired_from_datetime(local_datetime) + video_item.set_premiered_from_datetime(local_datetime) + video_item.set_date_from_datetime(local_datetime) # try to find a better resolution for the image image = video_item.get_image() if not image: image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) if image.endswith('_live.jpg'): - image = ''.join([image, '?ct=', thumb_stamp]) + image = ''.join((image, '?ct=', thumb_stamp)) video_item.set_image(image) - # set fanart - video_item.set_fanart(provider.get_fanart(context)) - # update channel mapping channel_id = snippet.get('channelId', '') - if channel_items_dict is not None: + video_item.set_subscription_id(channel_id) + if channel_id and channel_items_dict is not None: if channel_id not in channel_items_dict: channel_items_dict[channel_id] = [] channel_items_dict[channel_id].append(video_item) - context_menu = [] + context_menu = [ + # Refresh + menu_items.refresh(context), + # Queue Video + menu_items.queue_video(context), + ] replace_context_menu = False - # Refresh - yt_context_menu.append_refresh(context_menu, provider, context) - - # Queue Video - yt_context_menu.append_queue_video(context_menu, provider, context) - """ Play all videos of the playlist. /channel/[CHANNEL_ID]/playlist/[PLAYLIST_ID]/ /playlist/[PLAYLIST_ID]/ """ - some_playlist_match = re.match(r'^(/channel/([^/]+))/playlist/(?P[^/]+)/$', context.get_path()) - if some_playlist_match: + playlist_match = __RE_PLAYLIST_MATCH.match(path) + playlist_id = playlist_channel_id = '' + if playlist_match: replace_context_menu = True - playlist_id = some_playlist_match.group('playlist_id') - - yt_context_menu.append_play_all_from_playlist(context_menu, provider, context, playlist_id, video_id) - yt_context_menu.append_play_all_from_playlist(context_menu, provider, context, playlist_id) + playlist_id = playlist_match.group('playlist_id') + playlist_channel_id = playlist_match.group('channel_id') + + context_menu.extend(( + menu_items.play_all_from_playlist( + context, playlist_id, video_id + ), + menu_items.play_all_from_playlist( + context, playlist_id + ) + )) # 'play with...' (external player) - if settings.is_support_alternative_player_enabled(): - yt_context_menu.append_play_with(context_menu, provider, context) - - if provider.is_logged_in(): - # add 'Watch Later' only if we are not in my 'Watch Later' list - watch_later_playlist_id = context.get_access_manager().get_watch_later_id() - if watch_later_playlist_id: - yt_context_menu.append_watch_later(context_menu, provider, context, watch_later_playlist_id, video_id) + if alternate_player: + context_menu.append(menu_items.play_with(context)) + + # add 'Watch Later' only if we are not in my 'Watch Later' list + if watch_later_id: + if not playlist_id or watch_later_id != playlist_id: + context_menu.append( + menu_items.watch_later_add( + context, watch_later_id, video_id + ) + ) + else: + context_menu.append( + menu_items.watch_later_local_add( + context, video_item + ) + ) # provide 'remove' for videos in my playlists - if video_id in playlist_item_id_dict: - playlist_match = re.match('^/channel/mine/playlist/(?P[^/]+)/$', context.get_path()) - if playlist_match: - playlist_id = playlist_match.group('playlist_id') - # we support all playlist except 'Watch History' - if playlist_id: - if playlist_id != 'HL' and playlist_id.strip().lower() != 'wl': - playlist_item_id = playlist_item_id_dict[video_id] - video_item.set_playlist_id(playlist_id) - video_item.set_playlist_item_id(playlist_item_id) - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.remove']), - 'RunPlugin(%s)' % context.create_uri( - ['playlist', 'remove', 'video'], - {'playlist_id': playlist_id, 'video_id': playlist_item_id, - 'video_name': video_item.get_name()}))) - - is_history = re.match('^/special/watch_history_tv/$', context.get_path()) - if is_history: - yt_context_menu.append_clear_watch_history(context_menu, provider, context) - - # got to [CHANNEL] - if channel_id and channel_name: - # only if we are not directly in the channel provide a jump to the channel - if kodion.utils.create_path('channel', channel_id) != context.get_path(): - video_item.set_channel_id(channel_id) - yt_context_menu.append_go_to_channel(context_menu, provider, context, channel_id, channel_name) - - if provider.is_logged_in(): + # we support all playlist except 'Watch History' + if (logged_in and video_id in playlist_item_id_dict and playlist_id + and playlist_channel_id == 'mine' + and playlist_id.strip().lower() not in ('hl', 'wl')): + playlist_item_id = playlist_item_id_dict[video_id] + video_item.set_playlist_id(playlist_id) + video_item.set_playlist_item_id(playlist_item_id) + context_menu.append( + menu_items.remove_video_from_playlist( + context, playlist_id, video_id, video_item.get_name() + ) + ) + + + # got to [CHANNEL] only if we are not directly in the channel + if (channel_id and channel_name and + create_path('channel', channel_id) != path): + video_item.set_channel_id(channel_id) + context_menu.append( + menu_items.go_to_channel( + context, channel_id, channel_name + ) + ) + + if logged_in: # subscribe to the channel of the video - video_item.set_subscription_id(channel_id) - yt_context_menu.append_subscribe_to_channel(context_menu, provider, context, channel_id, channel_name) - - if not video_item.live and use_play_data: - if play_data.get('play_count') is None or int(play_data.get('play_count')) == 0: - yt_context_menu.append_mark_watched(context_menu, provider, context, video_id) - else: - yt_context_menu.append_mark_unwatched(context_menu, provider, context, video_id) - - if int(play_data.get('played_percent', '0')) > 0 or float(play_data.get('played_time', '0.0')) > 0.0: - yt_context_menu.append_reset_resume_point(context_menu, provider, context, video_id) + context_menu.append( + menu_items.subscribe_to_channel( + context, channel_id, channel_name + ) + ) + + if not video_item.live and play_data: + context_menu.append( + menu_items.history_mark_unwatched( + context, video_id + ) if play_data.get('play_count') else + menu_items.history_mark_watched( + context, video_id + ) + ) + + if (play_data.get('played_percent', 0) > 0 + or play_data.get('played_time', 0) > 0): + context_menu.append( + menu_items.history_reset_resume( + context, video_id + ) + ) # more... - refresh_container = \ - context.get_path().startswith('/channel/mine/playlist/LL') or \ - context.get_path() == '/special/disliked_videos/' - yt_context_menu.append_more_for_video(context_menu, provider, context, video_id, - is_logged_in=provider.is_logged_in(), - refresh_container=refresh_container) - - if not video_item.live: - yt_context_menu.append_play_with_subtitles(context_menu, provider, context, video_id) - yt_context_menu.append_play_audio_only(context_menu, provider, context, video_id) - - yt_context_menu.append_play_ask_for_quality(context_menu, provider, context, video_id) - - if len(context_menu) > 0: - video_item.set_context_menu(context_menu, replace=replace_context_menu) - + refresh_container = (path.startswith('/channel/mine/playlist/LL') + or path == '/special/disliked_videos/') + context_menu.extend(( + menu_items.more_for_video( + context, + video_id, + logged_in=logged_in, + refresh_container=refresh_container, + ), + menu_items.play_with_subtitles( + context, video_id + ), + menu_items.play_audio_only( + context, video_id + ), + menu_items.play_ask_for_quality( + context, video_id + ), + )) + + if context_menu: + video_item.set_context_menu( + context_menu, replace=replace_context_menu + ) + + +def update_play_info(provider, context, video_id, video_item, video_stream, + use_play_data=True): + video_item.video_id = video_id + update_video_infos(provider, + context, + {video_id: video_item}, + use_play_data=use_play_data) -def update_play_info(provider, context, video_id, video_item, video_stream, use_play_data=True): settings = context.get_settings() ui = context.get_ui() - resource_manager = provider.get_resource_manager(context) - - video_data = resource_manager.get_videos([video_id], suppress_errors=True) meta_data = video_stream.get('meta', None) - thumb_size = settings.use_thumbnail_size() - image = None - - video_item.video_id = video_id - if meta_data: + video_item.live = meta_data.get('status', {}).get('live', False) video_item.set_subtitles(meta_data.get('subtitles', None)) - image = get_thumbnail(thumb_size, meta_data.get('images', {})) + image = get_thumbnail(settings.use_thumbnail_size(), + meta_data.get('images', {})) + if image: + if video_item.live: + image = ''.join((image, '?ct=', get_thumb_timestamp())) + video_item.set_image(image) if 'headers' in video_stream: video_item.set_headers(video_stream['headers']) - # set uses_mpd - video_item.set_use_mpd_video(settings.use_mpd_videos()) - - license_info = video_stream.get('license_info', {}) - - if inputstreamhelper and \ - license_info.get('proxy') and \ - license_info.get('url') and \ - license_info.get('token'): - ishelper = inputstreamhelper.Helper('mpd', drm='com.widevine.alpha') - ishelper.check_inputstream() - - video_item.set_license_key(license_info.get('proxy')) - ui.set_home_window_property('license_url', license_info.get('url')) - ui.set_home_window_property('license_token', license_info.get('token')) - - """ - This is experimental. We try to get the most information out of the title of a video. - This is not based on any language. In some cases this won't work at all. - TODO: via language and settings provide the regex for matching episode and season. - """ - - for regex in __RE_SEASON_EPISODE_MATCHES__: - re_match = regex.search(video_item.get_name()) - if re_match: - if 'season' in re_match.groupdict(): - video_item.set_season(int(re_match.group('season'))) - - if 'episode' in re_match.groupdict(): - video_item.set_episode(int(re_match.group('episode'))) - break - + # set _uses_isa if video_item.live: - video_item.set_play_count(0) - - if image: - if video_item.live: - image = ''.join([image, '?ct=', get_thumb_timestamp()]) - video_item.set_image(image) - - # set fanart - video_item.set_fanart(provider.get_fanart(context)) + video_item.set_isa_video(settings.use_isa_live_streams()) + elif video_item.use_hls_video() or video_item.use_mpd_video(): + video_item.set_isa_video(settings.use_isa()) - if not video_data: - return video_item + if video_item.use_isa_video(): + license_info = video_stream.get('license_info', {}) + license_proxy = license_info.get('proxy', '') + license_url = license_info.get('url', '') + license_token = license_info.get('token', '') - # requires API - # =============== - yt_item = video_data[video_id] + if ISHelper and license_proxy and license_url and license_token: + ISHelper('mpd' if video_item.use_mpd_video() else 'hls', + drm='com.widevine.alpha').check_inputstream() - snippet = yt_item['snippet'] # crash if not conform - play_data = yt_item['play_data'] - video_item.live = snippet.get('liveBroadcastContent') == 'live' + video_item.set_license_key(license_proxy) + ui.set_property('license_url', license_url) + ui.set_property('license_token', license_token) - # set the title - if not video_item.get_title(): - video_item.set_title(snippet['title']) - # duration - if not video_item.live and use_play_data and play_data.get('total_time'): - video_item.set_duration_from_seconds(float(play_data.get('total_time'))) - else: - duration = yt_item.get('contentDetails', {}).get('duration', '') - if duration: - duration = utils.datetime_parser.parse(duration) - # we subtract 1 seconds because YouTube returns +1 second to much - video_item.set_duration_from_seconds(duration.seconds - 1) - - if not video_item.live and use_play_data: - # play count - if play_data.get('play_count'): - video_item.set_play_count(int(play_data.get('play_count'))) - - if play_data.get('played_percent'): - video_item.set_start_percent(play_data.get('played_percent')) - - if play_data.get('played_time'): - video_item.set_start_time(play_data.get('played_time')) - - if play_data.get('last_played'): - video_item.set_last_played(play_data.get('last_played')) - - # plot - channel_name = snippet.get('channelTitle', '') - description = kodion.utils.strip_html_from_text(snippet['description']) - if channel_name and settings.get_bool('youtube.view.description.show_channel_name', True): - description = '%s[CR][CR]%s' % (ui.uppercase(ui.bold(channel_name)), description) - video_item.set_studio(channel_name) - # video_item.add_cast(channel_name) - video_item.add_artist(channel_name) - video_item.set_plot(description) - - # date time - if 'publishedAt' in snippet and snippet['publishedAt']: - date_time = utils.datetime_parser.parse(snippet['publishedAt']) - video_item.set_year_from_datetime(date_time) - video_item.set_aired_from_datetime(date_time) - video_item.set_premiered_from_datetime(date_time) - video_item.set_date_from_datetime(date_time) - - if not image: - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - - if video_item.live and image: - image = ''.join([image, '?ct=', get_thumb_timestamp()]) - video_item.set_image(image) - - return video_item - - -def update_fanarts(provider, context, channel_items_dict): +def update_fanarts(provider, context, channel_items_dict, data=None): # at least we need one channel id - channel_ids = list(channel_items_dict.keys()) - if len(channel_ids) == 0: + channel_ids = list(channel_items_dict) + if not channel_ids and not data: return - fanarts = provider.get_resource_manager(context).get_fanarts(channel_ids) + if not data: + resource_manager = provider.get_resource_manager(context) + data = resource_manager.get_fanarts(channel_ids) + + if not data: + return - for channel_id in channel_ids: - channel_items = channel_items_dict[channel_id] + for channel_id, channel_items in channel_items_dict.items(): for channel_item in channel_items: # only set not empty fanarts - fanart = fanarts.get(channel_id, '') + fanart = data.get(channel_id, '') if fanart: channel_item.set_fanart(fanart) @@ -627,13 +777,16 @@ def get_shelf_index_by_title(context, json_data, shelf_title): title = shelf.get('shelfRenderer', {}).get('title', {}).get('runs', [{}])[0].get('text', '') if title.lower() == shelf_title.lower(): shelf_index = idx - context.log_debug('Found shelf index |{index}| for |{title}|'.format(index=str(shelf_index), title=shelf_title)) + context.log_debug('Found shelf index |{index}| for |{title}|'.format( + index=shelf_index, title=shelf_title + )) break - if shelf_index is not None: - if 0 > shelf_index >= len(contents): - context.log_debug('Shelf index |{index}| out of range |0-{content_length}|'.format(index=str(shelf_index), content_length=str(len(contents)))) - shelf_index = None + if shelf_index is not None and 0 > shelf_index >= len(contents): + context.log_debug('Shelf index |{index}| out of range |0-{content_length}|'.format( + index=shelf_index, content_length=len(contents) + )) + shelf_index = None return shelf_index @@ -656,7 +809,7 @@ def add_related_video_to_playlist(provider, context, client, v3, video_id): result_items = v3.response_to_items(provider, context, json_data, process_next_page=False) page_token = json_data.get('nextPageToken', '') except: - context.get_ui().show_notification('Failed to add a suggested video.', time_milliseconds=5000) + context.get_ui().show_notification('Failed to add a suggested video.', time_ms=5000) if result_items: add_item = next(( @@ -677,17 +830,9 @@ def add_related_video_to_playlist(provider, context, client, v3, video_id): break -def filter_short_videos(context, items): - if context.get_settings().hide_short_videos(): - shorts_filtered = [] - - for item in items: - if hasattr(item, '_duration'): - item_duration = 0 if item.get_duration() is None else item.get_duration() - if 0 < item_duration <= 60: - continue - shorts_filtered += [item] - - return shorts_filtered - - return items +def filter_short_videos(items): + return [ + item + for item in items + if item.playable and not 0 <= item.get_duration() <= 60 + ] diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/v3.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/v3.py index b0427088fc..439ce29c99 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/v3.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/v3.py @@ -8,13 +8,29 @@ See LICENSES/GPL-2.0-only for more information. """ -from ...youtube.helper import yt_context_menu -from ... import kodion -from ...kodion import items -from . import utils +from __future__ import absolute_import, division, unicode_literals + +from threading import Thread + +from .utils import ( + filter_short_videos, + get_thumbnail, + make_comment_item, + update_channel_infos, + update_fanarts, + update_playlist_infos, + update_video_infos, +) +from ...kodion import KodionException +from ...kodion.items import DirectoryItem, NextPageItem, VideoItem, menu_items def _process_list_response(provider, context, json_data): + yt_items = json_data.get('items', []) + if not yt_items: + context.log_warning('List of search result is empty') + return [] + video_id_dict = {} channel_id_dict = {} playlist_id_dict = {} @@ -23,282 +39,332 @@ def _process_list_response(provider, context, json_data): result = [] - is_upcoming = False - - thumb_size = context.get_settings().use_thumbnail_size() - yt_items = json_data.get('items', []) - if len(yt_items) == 0: - context.log_warning('List of search result is empty') - return result - - incognito = str(context.get_param('incognito', False)).lower() == 'true' + item_params = {} + incognito = context.get_param('incognito', False) + if incognito: + item_params['incognito'] = incognito addon_id = context.get_param('addon_id', '') + if addon_id: + item_params['addon_id'] = addon_id - for yt_item in yt_items: + settings = context.get_settings() + thumb_size = settings.use_thumbnail_size() + use_play_data = not incognito and settings.use_local_history() + for yt_item in yt_items: is_youtube, kind = _parse_kind(yt_item) if not is_youtube or not kind: context.log_debug('v3 response: Item discarded, is_youtube=False') continue + item_id = yt_item.get('id') + snippet = yt_item.get('snippet', {}) + title = snippet.get('title', context.localize('untitled')) + image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) + + if kind == 'searchresult': + _, kind = _parse_kind(item_id) + if kind == 'video': + item_id = item_id['videoId'] + elif kind == 'playlist': + item_id = item_id['playlistId'] + elif kind == 'channel': + item_id = item_id['channelId'] + if kind == 'video': - video_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['play'], item_params) - video_item = items.VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - video_item.set_fanart(provider.get_fanart(context)) - result.append(video_item) - video_id_dict[video_id] = video_item - elif kind == 'channel': - channel_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = items.DirectoryItem(title, item_uri, image=image) - channel_item.set_fanart(provider.get_fanart(context)) + item_uri = context.create_uri( + ('play',), + dict(item_params, video_id=item_id), + ) + item = VideoItem(title, item_uri, image=image) + video_id_dict[item_id] = item + elif kind == 'channel': + item_uri = context.create_uri( + ('channel', item_id), + item_params, + ) + item = DirectoryItem(title, item_uri, image=image) + channel_id_dict[item_id] = item # if logged in => provide subscribing to the channel if provider.is_logged_in(): - context_menu = [] - yt_context_menu.append_subscribe_to_channel(context_menu, provider, context, channel_id) - channel_item.set_context_menu(context_menu) - result.append(channel_item) - channel_id_dict[channel_id] = channel_item + context_menu = ( + menu_items.subscribe_to_channel( + context, item_id + ), + ) + item.set_context_menu(context_menu) + elif kind == 'guidecategory': - guide_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - item_params = {'guide_id': guide_id} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['special', 'browse_channels'], item_params) - guide_item = items.DirectoryItem(title, item_uri) - guide_item.set_fanart(provider.get_fanart(context)) - result.append(guide_item) - elif kind == 'subscription': - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - channel_id = snippet['resourceId']['channelId'] - item_params = {} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = items.DirectoryItem(title, item_uri, image=image) - channel_item.set_fanart(provider.get_fanart(context)) - channel_item.set_channel_id(channel_id) - # map channel id with subscription id - we need it for the unsubscription - subscription_id_dict[channel_id] = yt_item['id'] - - result.append(channel_item) - channel_id_dict[channel_id] = channel_item - elif kind == 'playlist': - playlist_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) + item_uri = context.create_uri( + ('special', 'browse_channels'), + dict(item_params, guide_id=item_id), + ) + item = DirectoryItem(title, item_uri) - channel_id = snippet['channelId'] + elif kind == 'subscription': + subscription_id = item_id + item_id = snippet['resourceId']['channelId'] + # map channel id with subscription id - needed to unsubscribe + subscription_id_dict[item_id] = subscription_id + + item_uri = context.create_uri( + ('channel', item_id), + item_params + ) + item = DirectoryItem(title, item_uri, image=image) + channel_id_dict[item_id] = item + item.set_channel_id(item_id) - # if the path directs to a playlist of our own, we correct the channel id to 'mine' + elif kind == 'playlist': + # set channel id to 'mine' if the path is for a playlist of our own if context.get_path() == '/channel/mine/playlists/': channel_id = 'mine' - item_params = {} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['channel', channel_id, 'playlist', playlist_id], item_params) - playlist_item = items.DirectoryItem(title, item_uri, image=image) - playlist_item.set_fanart(provider.get_fanart(context)) - result.append(playlist_item) - playlist_id_dict[playlist_id] = playlist_item - elif kind == 'playlistitem': - snippet = yt_item['snippet'] - video_id = snippet['resourceId']['videoId'] + else: + channel_id = snippet['channelId'] + item_uri = context.create_uri( + ('channel', channel_id, 'playlist', item_id), + item_params, + ) + item = DirectoryItem(title, item_uri, image=image) + playlist_id_dict[item_id] = item - # store the id of the playlistItem - for deleting this item we need this item - playlist_item_id_dict[video_id] = yt_item['id'] + elif kind == 'playlistitem': + playlistitem_id = item_id + item_id = snippet['resourceId']['videoId'] + # store the id of the playlistItem - needed for deleting item + playlist_item_id_dict[item_id] = playlistitem_id + + item_uri = context.create_uri( + ('play',), + dict(item_params, video_id=item_id), + ) + item = VideoItem(title, item_uri, image=image) + video_id_dict[item_id] = item - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['play'], item_params) - video_item = items.VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - video_item.set_fanart(provider.get_fanart(context)) - # Get Track-ID from Playlist - video_item.set_track_number(snippet['position'] + 1) - result.append(video_item) - video_id_dict[video_id] = video_item - elif kind == 'activity': - snippet = yt_item['snippet'] details = yt_item['contentDetails'] activity_type = snippet['type'] - - # recommendations if activity_type == 'recommendation': - video_id = details['recommendation']['resourceId']['videoId'] + item_id = details['recommendation']['resourceId']['videoId'] elif activity_type == 'upload': - video_id = details['upload']['videoId'] + item_id = details['upload']['videoId'] else: continue - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['play'], item_params) - video_item = items.VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - video_item.set_fanart(provider.get_fanart(context)) - result.append(video_item) - video_id_dict[video_id] = video_item - + item_uri = context.create_uri( + ('play',), + dict(item_params, video_id=item_id), + ) + item = VideoItem(title, item_uri, image=image) + video_id_dict[item_id] = item + elif kind == 'commentthread': - thread_snippet = yt_item['snippet'] - total_replies = thread_snippet['totalReplyCount'] - snippet = thread_snippet['topLevelComment']['snippet'] - item_params = {'parent_id': yt_item['id']} + total_replies = snippet['totalReplyCount'] + snippet = snippet['topLevelComment']['snippet'] if total_replies: - item_uri = context.create_uri(['special', 'child_comments'], item_params) + item_uri = context.create_uri( + ('special', 'child_comments'), + {'parent_id': item_id} + ) else: item_uri = '' - result.append(utils.make_comment_item(context, provider, snippet, item_uri, total_replies)) - - elif kind == 'comment': - result.append(utils.make_comment_item(context, provider, yt_item['snippet'], uri='')) - - elif kind == 'searchresult': - _, kind = _parse_kind(yt_item.get('id', {})) + item = make_comment_item(context, snippet, item_uri, total_replies) - # video - if kind == 'video': - video_id = yt_item['id']['videoId'] - snippet = yt_item.get('snippet', {}) - is_upcoming = snippet.get('liveBroadcastContent', '').lower() == 'upcoming' - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['play'], item_params) - video_item = items.VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - video_item.set_fanart(provider.get_fanart(context)) - result.append(video_item) - video_id_dict[video_id] = video_item - # playlist - elif kind == 'playlist': - playlist_id = yt_item['id']['playlistId'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) + elif kind == 'comment': + item = make_comment_item(context, snippet, uri='') - channel_id = snippet['channelId'] - # if the path directs to a playlist of our own, we correct the channel id to 'mine' - if context.get_path() == '/channel/mine/playlists/': - channel_id = 'mine' - # channel_name = snippet.get('channelTitle', '') - item_params = {} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['channel', channel_id, 'playlist', playlist_id], item_params) - playlist_item = items.DirectoryItem(title, item_uri, image=image) - playlist_item.set_fanart(provider.get_fanart(context)) - result.append(playlist_item) - playlist_id_dict[playlist_id] = playlist_item - elif kind == 'channel': - channel_id = yt_item['id']['channelId'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize(provider.LOCAL_MAP['youtube.untitled'])) - image = utils.get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {} - if incognito: - item_params.update({'incognito': incognito}) - if addon_id: - item_params.update({'addon_id': addon_id}) - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = items.DirectoryItem(title, item_uri, image=image) - channel_item.set_fanart(provider.get_fanart(context)) - result.append(channel_item) - channel_id_dict[channel_id] = channel_item - else: - raise kodion.KodionException("Unknown kind '%s'" % kind) else: - raise kodion.KodionException("Unknown kind '%s'" % kind) - - use_play_data = not incognito and context.get_settings().use_local_history() + raise KodionException("Unknown kind '%s'" % kind) - # this will also update the channel_id_dict with the correct channel id for each video. + if not item: + continue + if isinstance(item, VideoItem): + item.video_id = item_id + if incognito: + item.set_play_count(0) + # Set track number from playlist, or set to current list length to + # match "Default" (unsorted) sort order + position = snippet.get('position') or len(result) + item.set_track_number(position + 1) + result.append(item) + + # this will also update the channel_id_dict with the correct channel_id + # for each video. channel_items_dict = {} - utils.update_video_infos(provider, context, video_id_dict, playlist_item_id_dict, channel_items_dict, - live_details=is_upcoming, use_play_data=use_play_data) - utils.update_playlist_infos(provider, context, playlist_id_dict, channel_items_dict) - utils.update_channel_infos(provider, context, channel_id_dict, subscription_id_dict, channel_items_dict) - if video_id_dict or playlist_id_dict: - utils.update_fanarts(provider, context, channel_items_dict) - return result + resource_manager = provider.get_resource_manager(context) + resources = { + 1: { + 'fetcher': resource_manager.get_videos, + 'args': (video_id_dict,), + 'kwargs': { + 'live_details': True, + 'suppress_errors': True, + 'defer_cache': True, + }, + 'thread': None, + 'updater': update_video_infos, + 'upd_args': ( + provider, + context, + video_id_dict, + playlist_item_id_dict, + channel_items_dict, + ), + 'upd_kwargs': { + 'data': None, + 'live_details': True, + 'use_play_data': use_play_data + }, + 'complete': False, + 'defer': False, + }, + 2: { + 'fetcher': resource_manager.get_playlists, + 'args': (playlist_id_dict,), + 'kwargs': {'defer_cache': True}, + 'thread': None, + 'updater': update_playlist_infos, + 'upd_args': ( + provider, + context, + playlist_id_dict, + channel_items_dict, + ), + 'upd_kwargs': {'data': None}, + 'complete': False, + 'defer': False, + }, + 3: { + 'fetcher': resource_manager.get_channels, + 'args': (channel_id_dict,), + 'kwargs': {'defer_cache': True}, + 'thread': None, + 'updater': update_channel_infos, + 'upd_args': ( + provider, + context, + channel_id_dict, + subscription_id_dict, + channel_items_dict, + ), + 'upd_kwargs': {'data': None}, + 'complete': False, + 'defer': False, + }, + 4: { + 'fetcher': resource_manager.get_fanarts, + 'args': (channel_items_dict,), + 'kwargs': {'defer_cache': True}, + 'thread': None, + 'updater': update_fanarts, + 'upd_args': ( + provider, + context, + channel_items_dict, + ), + 'upd_kwargs': {'data': None}, + 'complete': False, + 'defer': True, + }, + 5: { + 'fetcher': resource_manager.cache_data, + 'args': (), + 'kwargs': {}, + 'thread': None, + 'updater': None, + 'upd_args': (), + 'upd_kwargs': {}, + 'complete': False, + 'defer': 4, + }, + } + + def _fetch(resource): + data = resource['fetcher']( + *resource['args'], **resource['kwargs'] + ) + if not data or not resource['updater']: + return + resource['upd_kwargs']['data'] = data + resource['updater'](*resource['upd_args'], **resource['upd_kwargs']) + + remaining = len(resources) + deferred = sum(1 for resource in resources.values() if resource['defer']) + iterator = iter(resources.values()) + while remaining: + try: + resource = next(iterator) + except StopIteration: + iterator = iter(resources.values()) + resource = next(iterator) -def response_to_items(provider, context, json_data, sort=None, reverse_sort=False, process_next_page=True): - result = [] + if resource['complete']: + continue + + defer = resource['defer'] + if defer: + if remaining > deferred: + continue + if defer in resources and not resources[defer]['complete']: + continue + resource['defer'] = False + + args = resource['args'] + if args and not args[0]: + resource['complete'] = True + remaining -= 1 + continue + + thread = resource['thread'] + if thread: + thread.join(5) + if not thread.is_alive(): + resource['thread'] = None + resource['complete'] = True + remaining -= 1 + else: + thread = Thread(target=_fetch, args=(resource,)) + thread.daemon = True + thread.start() + resource['thread'] = thread + + return result + +def response_to_items(provider, + context, + json_data, + sort=None, + reverse=False, + process_next_page=True): is_youtube, kind = _parse_kind(json_data) if not is_youtube: context.log_debug('v3 response: Response discarded, is_youtube=False') - return result - - if kind in ['searchlistresponse', 'playlistitemlistresponse', 'playlistlistresponse', - 'subscriptionlistresponse', 'guidecategorylistresponse', 'channellistresponse', - 'videolistresponse', 'activitylistresponse', 'commentthreadlistresponse', - 'commentlistresponse']: - result.extend(_process_list_response(provider, context, json_data)) + return [] + + if kind in ( + 'activitylistresponse', + 'channellistresponse', + 'commentlistresponse', + 'commentthreadlistresponse', + 'guidecategorylistresponse', + 'playlistitemlistresponse', + 'playlistlistresponse', + 'searchlistresponse', + 'subscriptionlistresponse', + 'videolistresponse', + ): + result = _process_list_response(provider, context, json_data) else: - raise kodion.KodionException("Unknown kind '%s'" % kind) + raise KodionException("Unknown kind '%s'" % kind) if sort is not None: - result = sorted(result, key=sort, reverse=reverse_sort) + result.sort(key=sort, reverse=reverse) - result = utils.filter_short_videos(context, result) + if context.get_settings().hide_short_videos(): + result = filter_short_videos(result) # no processing of next page item if not process_next_page: @@ -306,83 +372,42 @@ def response_to_items(provider, context, json_data, sort=None, reverse_sort=Fals # next page """ - This will try to prevent the issue 7163 (https://code.google.com/p/gdata-issues/issues/detail?id=7163). - Somehow the APIv3 is missing the token for the next page. We implemented our own calculation for the token - into the YouTube client...this should work for up to ~2000 entries. + This will try to prevent the issue 7163 + https://code.google.com/p/gdata-issues/issues/detail?id=7163 + Somehow the APIv3 is missing the token for the next page. + We implemented our own calculation for the token into the YouTube client + This should work for up to ~2000 entries. """ - yt_total_results = int(json_data.get('pageInfo', {}).get('totalResults', 0)) - yt_results_per_page = int(json_data.get('pageInfo', {}).get('resultsPerPage', 0)) + page_info = json_data.get('pageInfo', {}) + yt_total_results = int(page_info.get('totalResults', 0)) + yt_results_per_page = int(page_info.get('resultsPerPage', 0)) page = int(context.get_param('page', 1)) + yt_visitor_data = json_data.get('visitorData', '') yt_next_page_token = json_data.get('nextPageToken', '') + yt_click_tracking = json_data.get('clickTracking', '') if yt_next_page_token or (page * yt_results_per_page < yt_total_results): if not yt_next_page_token: client = provider.get_client(context) - yt_next_page_token = client.calculate_next_page_token(page + 1, yt_results_per_page) - - new_params = {} - new_params.update(context.get_params()) - new_params['page_token'] = yt_next_page_token - + yt_next_page_token = client.calculate_next_page_token( + page + 1, yt_results_per_page + ) + + new_params = dict(context.get_params(), + page_token=yt_next_page_token) + if yt_click_tracking: + new_params['visitor'] = yt_visitor_data + if yt_click_tracking: + new_params['click_tracking'] = yt_click_tracking new_context = context.clone(new_params=new_params) - - current_page = int(new_context.get_param('page', 1)) - next_page_item = items.NextPageItem(new_context, current_page, fanart=provider.get_fanart(new_context)) + current_page = new_context.get_param('page', 1) + next_page_item = NextPageItem(new_context, current_page) result.append(next_page_item) return result -def handle_error(provider, context, json_data): - if json_data and 'error' in json_data: - ok_dialog = False - message_timeout = 5000 - - message = kodion.utils.strip_html_from_text(json_data['error'].get('message', '')) - log_message = kodion.utils.strip_html_from_text(json_data['error'].get('message', '')) - reason = json_data['error']['errors'][0].get('reason', '') - title = '%s: %s' % (context.get_name(), reason) - - context.log_error('Error reason: |%s| with message: |%s|' % (reason, log_message)) - - if reason == 'accessNotConfigured': - message = context.localize(provider.LOCAL_MAP['youtube.key.requirement.notification']) - ok_dialog = True - - if reason == 'keyInvalid' and message == 'Bad Request': - message = context.localize(provider.LOCAL_MAP['youtube.api.key.incorrect']) - message_timeout = 7000 - - if reason == 'quotaExceeded' or reason == 'dailyLimitExceeded': - message_timeout = 7000 - - if ok_dialog: - context.get_ui().on_ok(title, message) - else: - context.get_ui().show_notification(message, title, time_milliseconds=message_timeout) - - return False - - return True - - def _parse_kind(item): - kind = item.get('kind', '').split('#') - - if len(kind) < 1: - return False, '' - - if len(kind) < 2: - try: - _ = kind.index('youtube') - return True, '' - except ValueError: - return False, str(kind[0]).lower() - - try: - idx = kind.index('youtube') - if idx == 0: - return True, str(kind[1]).lower() - except ValueError: - pass - - return False, str(kind[1]).lower() + parts = item.get('kind', '').split('#') + is_youtube = parts[0] == 'youtube' + kind = parts[1 if len(parts) > 1 else 0].lower() + return is_youtube, kind diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/video_info.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/video_info.py index 8bdbc6e632..64088ff27b 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/video_info.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/video_info.py @@ -2,32 +2,43 @@ """ Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube + Copyright (C) 2016-present plugin.video.youtube SPDX-License-Identifier: GPL-2.0-only See LICENSES/GPL-2.0-only for more information. """ -import re -import random -import traceback +from __future__ import absolute_import, division, unicode_literals -from json import dumps as json_dumps, loads as json_loads -from html import unescape -from urllib.parse import (parse_qs, urlsplit, urlunsplit, urlencode, urljoin, - quote, unquote) - -import requests -import xbmcvfs +import json +import os +import random +import re +from traceback import format_stack -from ...kodion.utils import is_httpd_live, make_dirs, DataCache -from ..youtube_exceptions import YouTubeException -from .subtitles import Subtitles from .ratebypass import ratebypass from .signature.cipher import Cipher +from .subtitles import Subtitles +from ..client.request_client import YouTubeRequestClient +from ..youtube_exceptions import YouTubeException +from ...kodion.compatibility import ( + parse_qs, + quote, + unescape, + unquote, + urlencode, + urljoin, + urlsplit, + xbmcvfs, +) +from ...kodion.constants import TEMP_PATH, paths +from ...kodion.network import is_httpd_live +from ...kodion.utils import make_dirs + + +class VideoInfo(YouTubeRequestClient): + BASE_PATH = make_dirs(TEMP_PATH) - -class VideoInfo(object): FORMAT = { # === Non-DASH === '5': {'container': 'flv', @@ -526,6 +537,14 @@ class VideoInfo(object): 'title': 'ac-3@384', 'dash/audio': True, 'audio': {'bitrate': 384, 'encoding': 'ac-3'}}, + # === HLS + '9994': {'container': 'hls', + 'sort': [-1080, -1], + 'title': 'HLS', + 'hls/audio': True, + 'hls/video': True, + 'audio': {'bitrate': 0, 'encoding': 'aac'}, + 'video': {'height': 0, 'encoding': 'h.264'}}, # === Live HLS '9995': {'container': 'hls', 'Live': True, @@ -569,248 +588,12 @@ class VideoInfo(object): 'video': {'height': 0, 'encoding': ''}} } - CLIENTS = { - # 4k no VP9 HDR - # Limited subtitle availability - 'android_testsuite': { - '_id': 30, - '_query_subtitles': True, - 'json': { - 'context': { - 'client': { - 'clientName': 'ANDROID_TESTSUITE', - 'clientVersion': '1.9', - 'androidSdkVersion': '29', - 'osName': 'Android', - 'osVersion': '10', - 'platform': 'MOBILE', - }, - }, - }, - 'headers': { - 'User-Agent': ('com.google.android.youtube/' - '{json[context][client][clientVersion]}' - ' (Linux; U; {json[context][client][osName]}' - ' {json[context][client][osVersion]};' - ' {json[context][client][gl]}) gzip'), - 'X-YouTube-Client-Name': '{_id}', - 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', - }, - 'params': { - 'key': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', - }, - }, - 'android': { - '_id': 3, - 'json': { - 'params': 'CgIQBg==', - 'context': { - 'client': { - 'clientName': 'ANDROID', - 'clientVersion': '17.31.35', - 'androidSdkVersion': '30', - 'osName': 'Android', - 'osVersion': '11', - 'platform': 'MOBILE', - }, - }, - }, - 'headers': { - 'User-Agent': ('com.google.android.youtube/' - '{json[context][client][clientVersion]}' - ' (Linux; U; {json[context][client][osName]}' - ' {json[context][client][osVersion]};' - ' {json[context][client][gl]}) gzip'), - 'X-YouTube-Client-Name': '{_id}', - 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', - }, - 'params': { - 'key': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', - }, - }, - # Only for videos that allow embedding - # Limited to 720p on some videos - 'android_embedded': { - '_id': 55, - 'json': { - 'context': { - 'client': { - 'clientName': 'ANDROID_EMBEDDED_PLAYER', - 'clientVersion': '17.36.4', - 'clientScreen': 'EMBED', - 'androidSdkVersion': '29', - 'osName': 'Android', - 'osVersion': '10', - 'platform': 'MOBILE', - }, - }, - 'thirdParty': { - 'embedUrl': 'https://www.youtube.com/embed/{json[videoId]}', - }, - }, - 'headers': { - 'User-Agent': ('com.google.android.youtube/' - '{json[context][client][clientVersion]}' - ' (Linux; U; {json[context][client][osName]}' - ' {json[context][client][osVersion]};' - ' {json[context][client][gl]}) gzip'), - 'X-YouTube-Client-Name': '{_id}', - 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', - }, - 'params': { - 'key': 'AIzaSyCjc_pVEDi4qsv5MtC2dMXzpIaDoRFLsxw', - }, - }, - # 4k with HDR - # Some videos block this client, may also require embedding enabled - # Limited subtitle availability - 'android_youtube_tv': { - '_id': 29, - '_query_subtitles': True, - 'json': { - 'context': { - 'client': { - 'clientName': 'ANDROID_UNPLUGGED', - 'clientVersion': '6.36', - 'androidSdkVersion': '29', - 'osName': 'Android', - 'osVersion': '10', - 'platform': 'MOBILE', - }, - }, - }, - 'headers': { - 'User-Agent': ('com.google.android.apps.youtube.unplugged/' - '{json[context][client][clientVersion]}' - ' (Linux; U; {json[context][client][osName]}' - ' {json[context][client][osVersion]};' - ' {json[context][client][gl]}) gzip'), - 'X-YouTube-Client-Name': '{_id}', - 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', - }, - 'params': { - 'key': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', - }, - }, - 'ios': { - '_id': 5, - 'json': { - 'context': { - 'client': { - 'clientName': 'IOS', - 'clientVersion': '17.33.2', - 'deviceModel': 'iPhone14,3', - 'osName': 'iOS', - 'osVersion': '15_6', - 'platform': 'MOBILE', - }, - }, - }, - 'headers': { - 'User-Agent': ('com.google.ios.youtube/' - '{json[context][client][clientVersion]}' - ' ({json[context][client][deviceModel]};' - ' U; CPU {json[context][client][osName]}' - ' {json[context][client][osVersion]}' - ' like Mac OS X)'), - 'X-YouTube-Client-Name': '{_id}', - 'X-YouTube-Client-Version': '{json[context][client][clientVersion]}', - }, - 'params': { - 'key': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc', - }, - }, - # Used to requests captions for clients that don't provide them - # Requires handling of nsig to overcome throttling (TODO) - 'smarttv': { - '_id': 75, - 'json': { - 'context': { - 'client': { - 'clientName': 'TVHTML5_SIMPLY', - 'clientVersion': '1.0', - }, - }, - }, - # Headers from a 2022 Samsung Tizen 6.5 based Smart TV - 'headers': { - 'User-Agent': ('Mozilla/5.0 (SMART-TV; LINUX; Tizen 6.5)' - ' AppleWebKit/537.36 (KHTML, like Gecko)' - ' 85.0.4183.93/6.5 TV Safari/537.36'), - }, - 'params': { - 'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8', - }, - }, - # Used for misc api requests by default - # Requires handling of nsig to overcome throttling (TODO) - 'web': { - '_id': 1, - 'json': { - 'context': { - 'client': { - 'clientName': 'WEB', - 'clientVersion': '2.20220801.00.00', - }, - }, - }, - # Headers for a "Galaxy S20 Ultra" from Chrome dev tools device - # emulation - 'headers': { - 'User-Agent': ('Mozilla/5.0 (Linux; Android 10; SM-G981B)' - ' AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/80.0.3987.162 Mobile Safari/537.36'), - 'Referer': 'https://www.youtube.com/watch?v={json[videoId]}' - }, - 'params': { - 'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8', - }, - }, - '_common': { - '_access_token': None, - 'json': { - 'contentCheckOk': True, - 'context': { - 'client': { - 'gl': None, - 'hl': None, - }, - }, - 'playbackContext': { - 'contentPlaybackContext': { - 'html5Preference': 'HTML5_PREF_WANTS', - }, - }, - 'racyCheckOk': True, - 'thirdParty': {}, - 'user': { - 'lockedSafetyMode': False - }, - 'videoId': None, - }, - 'headers': { - 'Origin': 'https://www.youtube.com', - 'Referer': 'https://www.youtube.com/watch?v={json[videoId]}', - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.5', - 'Authorization': 'Bearer {_access_token}', - }, - 'params': { - 'key': None, - 'prettyPrint': 'false' - }, - }, - } - def __init__(self, context, access_token='', language='en-US'): settings = context.get_settings() self.video_id = None self._context = context self._data_cache = self._context.get_data_cache() - self._verify = settings.verify_ssl() self._language = (settings.get_string('youtube.language', language) .replace('-', '_')) self._language_base = self._language[0:2] @@ -822,30 +605,30 @@ def __init__(self, context, access_token='', language='en-US'): self._selected_client = None client_selection = settings.client_selection() - # All client selections use the Android client as the first option to - # ensure that the age gate setting is enforced, regardless of login - # status + # Default client selection uses the Android or iOS client as the first + # option to ensure that the age gate setting is enforced, regardless of + # login status # Alternate #1 - # Will play most videos with subtitles at full resolution with HDR - # Some restricted videos may only play at 720p - # Some restricted videos require additional requests for subtitles + # Enable iOS client to access premium streams, however other stream + # types are limited if client_selection == 1: self._prioritised_clients = ( + 'ios', 'android', - 'android_embedded', 'android_youtube_tv', 'android_testsuite', + 'android_embedded', ) # Alternate #2 - # Will play most videos at full resolution with HDR - # Most videos wont show subtitles - # Useful for testing AV1 HDR + # Used to bypass age restriction, however streams are obfuscated and + # throttled. Useful for testing n-sig de-obfuscation. elif client_selection == 2: self._prioritised_clients = ( + 'smarttv_embedded', 'android', - 'android_testsuite', 'android_youtube_tv', + 'android_testsuite', 'android_embedded', ) # Default @@ -864,6 +647,8 @@ def __init__(self, context, access_token='', language='en-US'): 'gl': settings.get_string('youtube.region', 'US'), } + super(VideoInfo, self).__init__() + @staticmethod def _generate_cpn(): # https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L1381 @@ -873,81 +658,21 @@ def _generate_cpn(): cpn_alphabet = ('abcdefghijklmnopqrstuvwxyz' 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' '0123456789-_') - # Python 2 compatible method - # cpn = ''.join(cpn_alphabet[random.randint(0, 63)] for _ in range(16)) - # return cpn - return ''.join(random.choices(cpn_alphabet, k=16)) + return ''.join(random.choice(cpn_alphabet) for _ in range(16)) def load_stream_infos(self, video_id): self.video_id = video_id return self._get_video_info() - def _build_client(self, client_name, auth_header=False): - def _merge_dicts(item1, item2): - if not isinstance(item1, dict) or not isinstance(item2, dict): - return item1 if item2 is ... else item2 - new = {} - for key in (item1.keys() | item2.keys()): - value = _merge_dicts(item1.get(key, ...), item2.get(key, ...)) - if value is ...: - continue - if isinstance(value, str) and '{' in value: - _format['{0}.{1}'.format(id(new), key)] = (new, key, value) - new[key] = value - return new or ... - _format = {} - - client = (self.CLIENTS.get(client_name) or self.CLIENTS['web']).copy() - client = _merge_dicts(self.CLIENTS['_common'], client) - - client['json']['videoId'] = self.video_id - if auth_header and self._access_token: - client['_access_token'] = self._access_token - client['params'] = None - elif 'Authorization' in client['headers']: - del client['headers']['Authorization'] - - for values, key, value in _format.values(): - if key in values: - values[key] = value.format(**client) - - return client - - def _request(self, url, method='GET', - cookies=None, data=None, headers=None, json=None, params=None, - error_msg=None, raise_error=False, timeout=(3.05, 27), **_): - try: - result = requests.request(method, url, - verify=self._verify, - allow_redirects=True, - timeout=timeout, - cookies=cookies, - data=data, - headers=headers, - json=json, - params=params) - result.raise_for_status() - except requests.exceptions.RequestException as error: - response = error.response and error.response.text - self._context.log_debug('Response: {0}'.format(response)) - self._context.log_error('{0}\n{1}'.format( - error_msg or 'Request failed', traceback.format_exc() - )) - if raise_error: - raise YouTubeException(error_msg) from error - return None - return result - def _get_player_page(self, client='web', embed=False): - client = self._build_client(client) if embed: url = 'https://www.youtube.com/embed/{0}'.format(self.video_id) else: url = 'https://www.youtube.com/watch?v={0}'.format(self.video_id) cookies = {'CONSENT': 'YES+cb.20210615-14-p0.en+FX+294'} - result = self._request( - url, cookies=cookies, headers=client['headers'], + result = self.request( + url, cookies=cookies, headers=self.build_client(client)['headers'], error_msg=('Failed to get player html for video_id: {0}' .format(self.video_id)) ) @@ -984,17 +709,14 @@ def _get_player_config(page): found = re.search(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', page.text) if found: - return json_loads(found.group(1)) + return json.loads(found.group(1)) return None def _get_player_js(self): - cached_url = self._data_cache.get_item( - DataCache.ONE_HOUR * 4, 'player_js_url' - ).get('url', '') - if cached_url not in {'', 'http://', 'https://'}: - js_url = cached_url - else: - js_url = None + cached = self._data_cache.get_item('player_js_url', + self._data_cache.ONE_HOUR * 4) + cached = cached and cached.get('url', '') + js_url = cached if cached not in {'', 'http://', 'https://'} else None if not js_url: player_page = self._get_player_page() @@ -1014,27 +736,26 @@ def _get_player_js(self): return '' js_url = self._normalize_url(js_url) - self._data_cache.set('player_js_url', json_dumps({'url': js_url})) - - cache_key = quote(js_url) - cached_js = self._data_cache.get_item( - DataCache.ONE_HOUR * 4, cache_key - ).get('js') - if cached_js: - return cached_js - - client = self._build_client('web') - result = self._request( - js_url, headers=client['headers'], + self._data_cache.set_item('player_js_url', {'url': js_url}) + + js_cache_key = quote(js_url) + cached = self._data_cache.get_item(js_cache_key, + self._data_cache.ONE_HOUR * 4) + cached = cached and cached.get('js') + if cached: + return cached + + result = self.request( + js_url, headers=self.build_client('web')['headers'], error_msg=('Failed to get player js for video_id: {0}' .format(self.video_id)) ) + result = result and result.text if not result: return '' - javascript = result.text - self._data_cache.set(cache_key, json_dumps({'js': javascript})) - return javascript + self._data_cache.set_item(js_cache_key, {'js': result}) + return result @staticmethod def _make_curl_headers(headers, cookies=None): @@ -1061,7 +782,8 @@ def _normalize_url(url): url = urljoin('https://www.youtube.com', url) return url - def _load_hls_manifest(self, url, live_type=None, meta_info=None, headers=None, playback_stats=None): + def _load_hls_manifest(self, url, live_type=None, meta_info=None, + headers=None, playback_stats=None): if not url: return [] @@ -1070,10 +792,10 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, headers=None, if 'Authorization' in headers: del headers['Authorization'] else: - headers = self._build_client('web')['headers'] + headers = self.build_client('web')['headers'] curl_headers = self._make_curl_headers(headers, cookies=None) - result = self._request( + result = self.request( url, headers=headers, error_msg=('Failed to get manifest for video_id: {0}' .format(self.video_id)) @@ -1090,14 +812,15 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, headers=None, if playback_stats is None: playback_stats = {} - if live_type is None: - live_type = self._context.get_settings().get_live_stream_type() + yt_format = None + if not live_type: + yt_format = self.FORMAT['9994'] + elif live_type == 'hls': + yt_format = self.FORMAT['9995'] + elif live_type == 'isa_hls': + yt_format = self.FORMAT['9996'] - if 'hls' in live_type: - if live_type == 'hls': - yt_format = self.FORMAT['9995'] - else: - yt_format = self.FORMAT['9996'] + if yt_format: stream = {'url': url, 'meta': meta_info, 'headers': curl_headers, @@ -1112,7 +835,7 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, headers=None, # Capture the URL of a .m3u8 playlist and the itag value from that URL. re_playlist_data = re.compile( r'#EXT-X-STREAM-INF[^#]+' - r'(?Phttp[^\s]+/itag/(?P\d+)[^\s]+)' + r'(?Phttp\S+/itag/(?P\d+)\S+)' ) for match in re_playlist_data.finditer(result.text): playlist_url = match.group('url') @@ -1131,13 +854,17 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, headers=None, stream_list.append(stream) return stream_list - def _create_stream_list(self, streams, meta_info=None, headers=None, playback_stats=None): + def _create_stream_list(self, + streams, + meta_info=None, + headers=None, + playback_stats=None): if not headers and self._selected_client: headers = self._selected_client['headers'].copy() if 'Authorization' in headers: del headers['Authorization'] else: - headers = self._build_client('web')['headers'] + headers = self.build_client('web')['headers'] curl_headers = self._make_curl_headers(headers, cookies=None) if meta_info is None: @@ -1203,23 +930,22 @@ def _process_signature_cipher(self, stream_map): if not url or not encrypted_signature: return None - signature = self._data_cache.get_item( - DataCache.ONE_HOUR * 4, encrypted_signature - ).get('sig') + signature = self._data_cache.get_item(encrypted_signature, + self._data_cache.ONE_HOUR * 4) + signature = signature and signature.get('sig') if not signature: try: signature = self._cipher.get_signature(encrypted_signature) - except Exception as error: - self._context.log_debug('{0}: {1}\n{2}'.format( - error, encrypted_signature, traceback.format_exc() + except Exception as exc: + self._context.log_error('VideoInfo._process_signature_cipher - ' + 'failed to extract URL from |{sig}|\n' + '{exc}:\n{details}'.format( + sig=encrypted_signature, + exc=exc, + details=''.join(format_stack()) )) - self._context.log_error( - 'Failed to extract URL from signatureCipher' - ) return None - self._data_cache.set( - encrypted_signature, json_dumps({'sig': signature}) - ) + self._data_cache.set_item(encrypted_signature, {'sig': signature}) if signature: url = '{0}&{1}={2}'.format(url, query_var, signature) @@ -1235,8 +961,7 @@ def _process_url_params(self, url): new_query = {} update_url = False - if (self._calculate_n and 'n' in query - and query.get('ratebypass', [None])[0] != 'yes'): + if self._calculate_n and 'n' in query: self._player_js = self._player_js or self._get_player_js() if self._calculate_n is True: self._context.log_debug('nsig detected') @@ -1260,43 +985,29 @@ def _process_url_params(self, url): elif not update_url: return url - return urlunsplit((parts.scheme, - parts.netloc, - parts.path, - urlencode(query, doseq=True), - parts.fragment)) + return parts._replace(query=urlencode(query, doseq=True)).geturl() - @staticmethod - def _get_error_details(playability_status, details=None): + def _get_error_details(self, playability_status, details=None): if not playability_status: return None if not details: details = ( 'errorScreen', - ('playerErrorMessageRenderer', 'confirmDialogRenderer'), - ('reason', 'title') + ( + ( + 'playerErrorMessageRenderer', + 'reason', + ), + ( + 'confirmDialogRenderer', + 'title', + ), + ) ) - result = playability_status - for keys in details: - is_dict = isinstance(result, dict) - if not is_dict and not isinstance(result, list): - return None - - if not isinstance(keys, (list, tuple)): - keys = [keys] - for key in keys: - if is_dict: - if key not in result: - continue - elif not isinstance(key, int) or len(result) <= key: - continue - result = result[key] - break - else: - return None + result = self.json_traverse(playability_status, details) - if 'runs' not in result: + if not result or 'runs' not in result: return result detail_texts = [ @@ -1311,25 +1022,31 @@ def _get_error_details(playability_status, details=None): return None def _get_video_info(self): - auth_header = bool(self._access_token) video_info_url = 'https://www.youtube.com/youtubei/v1/player' _settings = self._context.get_settings() playability_status = status = reason = None + + client_data = {'json': {'videoId': self.video_id}} + if self._access_token: + client_data['_access_token'] = self._access_token + for _ in range(2): for client_name in self._prioritised_clients: - client = self._build_client(client_name, auth_header) + client = self.build_client(client_name, client_data) - result = self._request( - video_info_url, 'POST', **client, + result = self.request( + video_info_url, 'POST', error_msg=( 'Player response failed for video_id: {0},' ' using {1} client ({2})' .format(self.video_id, client_name, - 'logged in' if auth_header else 'logged out') + 'logged in' if '_access_token' in client_data + else 'logged out') ), - raise_error=True + raise_error=True, + **client ) response = result.json() @@ -1353,7 +1070,7 @@ def _get_video_info(self): # This is used to check for error like: # "The following content is not available on this app." # Text will vary depending on Accept-Language and client hl - # Youtube support url is checked instead + # YouTube support url is checked instead url = self._get_error_details( playability_status, details=( @@ -1372,8 +1089,8 @@ def _get_video_info(self): # Only attempt to remove Authorization header if clients iterable # was exhausted i.e. request attempted using all clients else: - if auth_header: - auth_header = False + if '_access_token' in client_data: + del client_data['_access_token'] continue # Otherwise skip retrying clients without Authorization header break @@ -1397,9 +1114,9 @@ def _get_video_info(self): self._context.log_debug( 'Retrieved video info for video_id: {0}, using {1} client ({2})' - .format(self.video_id, - client['json']['context']['client']['clientName'], - 'logged in' if auth_header else 'logged out') + .format(self.video_id, client_name, + 'logged in' if '_access_token' in client_data + else 'logged out') ) self._selected_client = client.copy() @@ -1421,13 +1138,13 @@ def _get_video_info(self): if captions: captions['headers'] = client['headers'] elif client.get('_query_subtitles'): - result = self._request( - video_info_url, 'POST', **self._build_client('smarttv', True), + result = self.request( + video_info_url, 'POST', error_msg=('Caption request failed to get player response for' 'video_id: {0}'.format(self.video_id)), + **self.build_client('smarttv_embedded', client_data) ) - - response = result.json() + response = result and result.json() or {} captions = response.get('captions') if captions: captions['headers'] = result.request.headers @@ -1470,7 +1187,7 @@ def _get_video_info(self): 'default': ('https://i.ytimg.com/vi/{0}/default{1}.jpg' .format(self.video_id, is_live)), }, - 'subtitles': captions or [], + 'subtitles': captions, } if _settings.use_remote_history(): @@ -1499,7 +1216,7 @@ def _get_video_info(self): 'watchtime_url': '', } - httpd_is_live = (_settings.use_mpd() and + httpd_is_live = (_settings.use_isa() and is_httpd_live(port=_settings.httpd_port())) pa_li_info = streaming_data.get('licenseInfos', []) @@ -1515,9 +1232,10 @@ def _get_video_info(self): .format(url)) license_info = { 'url': url, - 'proxy': 'http://{0}:{1}/widevine||R{{SSM}}|'.format( - _settings.httpd_listen(for_request=True), - _settings.httpd_port() + 'proxy': 'http://{address}:{port}{path}||R{{SSM}}|'.format( + address=_settings.httpd_listen(for_request=True), + port=_settings.httpd_port(), + path=paths.DRM, ), 'token': self._access_token, } @@ -1539,17 +1257,21 @@ def _get_video_info(self): self._player_js = self._get_player_js() self._cipher = Cipher(self._context, javascript=self._player_js) - manifest_url = None - if is_live: - live_type = _settings.get_live_stream_type() - if live_type == 'ia_mpd': - manifest_url = streaming_data.get('dashManifestUrl', '') - else: - stream_list.extend(self._load_hls_manifest( - streaming_data.get('hlsManifestUrl'), - live_type, meta_info, client['headers'], playback_stats - )) - elif httpd_is_live and adaptive_fmts: + manifest_url = main_stream = None + live_type = is_live and _settings.get_live_stream_type() + + if live_type == 'isa_mpd' and 'dashManifestUrl' in streaming_data: + manifest_url = streaming_data['dashManifestUrl'] + elif 'hlsManifestUrl' in streaming_data: + stream_list.extend(self._load_hls_manifest( + streaming_data['hlsManifestUrl'], + live_type, meta_info, client['headers'], playback_stats + )) + else: + live_type = None + + # extract adaptive streams and create MPEG-DASH manifest + if not manifest_url and httpd_is_live and adaptive_fmts: video_data, audio_data = self._process_stream_data( adaptive_fmts, default_lang['code'] ) @@ -1557,6 +1279,12 @@ def _get_video_info(self): video_data, audio_data, license_info.get('url') ) + # extract non-adaptive streams + if all_fmts: + stream_list.extend(self._create_stream_list( + all_fmts, meta_info, client['headers'], playback_stats + )) + if manifest_url: video_stream = { 'url': manifest_url, @@ -1566,14 +1294,18 @@ def _get_video_info(self): 'playback_stats': playback_stats } - if is_live: + if live_type: # MPD structure has segments with additional attributes # and url has changed from using a query string to using url params # This breaks the InputStream.Adaptive partial manifest update - video_stream['url'] = ('{0}?start_seq=$START_NUMBER$' - .format(video_stream['url'])) + if '?' in manifest_url: + video_stream['url'] = manifest_url + '&mpd_version=5' + elif manifest_url.endswith('/'): + video_stream['url'] = manifest_url + 'mpd_version/5' + else: + video_stream['url'] = manifest_url + '/mpd_version/5' details = self.FORMAT.get('9998') - else: + elif main_stream: details = self.FORMAT.get('9999').copy() video_info = main_stream['video'] @@ -1591,11 +1323,15 @@ def _get_video_info(self): details['title'].append(' [ASR]') if main_stream['multi_lang']: details['title'].extend(( - ' [', self._context.localize(30762), ']' + ' [', + self._context.localize('stream.multi_language'), + ']' )) if main_stream['multi_audio']: details['title'].extend(( - ' [', self._context.localize(30763), ']' + ' [', + self._context.localize('stream.multi_audio'), + ']' )) details['title'] = ''.join(details['title']) @@ -1603,13 +1339,6 @@ def _get_video_info(self): video_stream.update(details) stream_list.append(video_stream) - # extract streams from map - if all_fmts: - stream_list.extend(self._create_stream_list( - all_fmts, meta_info, client['headers'], playback_stats - )) - - # last fallback if not stream_list: raise YouTubeException('No streams found') @@ -1618,10 +1347,11 @@ def _get_video_info(self): def _process_stream_data(self, stream_data, default_lang_code='und'): _settings = self._context.get_settings() qualities = _settings.get_mpd_video_qualities() - ia_capabilities = self._context.inputstream_adaptive_capabilities() + isa_capabilities = self._context.inputstream_adaptive_capabilities() stream_features = _settings.stream_features() allow_hdr = 'hdr' in stream_features allow_hfr = 'hfr' in stream_features + disable_hfr_max = 'no_hfr_max' in stream_features allow_ssa = 'ssa' in stream_features stream_select = _settings.stream_select() @@ -1685,7 +1415,7 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): codec = 'vp9' elif codec.startswith('dts'): codec = 'dts' - if codec not in stream_features or codec not in ia_capabilities: + if codec not in stream_features or codec not in isa_capabilities: continue media_type, container = mime_type.split('/') bitrate = stream.get('bitrate', 0) @@ -1709,18 +1439,18 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): if role_type == 4 or audio_track.get('audioIsDefault'): role = 'main' - label = self._context.localize(30744) + label = self._context.localize('stream.original') elif role_type == 3: role = 'dub' - label = self._context.localize(30745) + label = self._context.localize('stream.dubbed') elif role_type == 2: role = 'description' - label = self._context.localize(30746) + label = self._context.localize('stream.descriptive') # Unsure of what other audio types are actually available # Role set to "alternate" as default fallback else: role = 'alternate' - label = self._context.localize(30747) + label = self._context.localize('stream.alternate') mime_group = '{0}_{1}.{2}'.format( mime_type, language_code, role_type @@ -1739,7 +1469,7 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): language_code = default_lang_code role = 'main' role_type = 4 - label = self._context.localize(30744) + label = self._context.localize('stream.original') mime_group = mime_type sample_rate = int(stream.get('audioSampleRate', '0'), 10) @@ -1776,7 +1506,7 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): compare_width = width compare_height = height - bounded_quality = {} + bounded_quality = None for quality in qualities: if compare_width > quality['width']: if bounded_quality: @@ -1784,7 +1514,10 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): quality = bounded_quality elif compare_height < quality['height']: quality = qualities[-1] + if fps > 30 and disable_hfr_max: + bounded_quality = None break + disable_hfr_max = disable_hfr_max and not bounded_quality bounded_quality = quality if not bounded_quality: continue @@ -1797,12 +1530,16 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): else: frame_rate = None - mime_group = mime_type + mime_group = '{mime_type}_{codec}{hdr}'.format( + mime_type=mime_type, + codec=codec, + hdr='_hdr' if hdr else '' + ) channels = language = role = role_type = sample_rate = None label = quality['label'].format(fps if fps > 30 else '', ' HDR' if hdr else '', compare_height) - quality_group = '{0}_{1}'.format(container, label) + quality_group = '{0}_{1}_{2}'.format(container, codec, label) if mime_group not in data: data[mime_group] = {} @@ -1850,7 +1587,7 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): def _stream_sort(stream): if not stream: - return (1, ) + return (1,) return ( - stream['height'], @@ -1867,7 +1604,7 @@ def _group_sort(item): main_stream = streams[0] key = ( - group != main_stream['mimeType'], + not group.startswith(main_stream['mimeType']), ) if main_stream['mediaType'] == 'video' else ( not group.startswith(main_stream['mimeType']), preferred_audio['id'] not in group, @@ -1892,10 +1629,9 @@ def _generate_mpd_manifest(self, video_data, audio_data, license_url): if not video_data or not audio_data: return None, None - basepath = 'special://temp/plugin.video.youtube/' - if not make_dirs(basepath): - self._context.log_debug('Failed to create temp directory: {0}' - .format(basepath)) + if not self.BASE_PATH: + self._context.log_error('VideoInfo._generate_mpd_manifest - ' + 'unable to access temp directory') return None, None def _filter_group(previous_group, previous_stream, item): @@ -1954,7 +1690,7 @@ def _filter_group(previous_group, previous_stream, item): 'multi_lang': False, } - out_list = [ + output = [ '\n' '", ">")) - out_list.extend(( + output.extend(( '\t\t\t\n' @@ -2060,7 +1797,7 @@ def _filter_group(previous_group, previous_stream, item): num_streams = len(streams) if media_type == 'audio': - out_list.extend((( + output.extend((( '\t\t\t\n') + output.append('\t\t\n') set_id += 1 - out_list.append('\t\n' - '\n') - out = ''.join(out_list) + output.append('\t\n' + '\n') + output = ''.join(output) if len(languages.difference({'', 'und'})) > 1: main_stream['multi_lang'] = True if roles.difference({'', 'main', 'dub'}): main_stream['multi_audio'] = True - filepath = '{0}{1}.mpd'.format(basepath, self.video_id) - success = None - with xbmcvfs.File(filepath, 'w') as mpd_file: - success = mpd_file.write(str(out)) - if not success: - return None, None - return 'http://{0}:{1}/{2}.mpd'.format( - _settings.httpd_listen(for_request=True), - _settings.httpd_port(), - self.video_id - ), main_stream + filename = '.'.join((self.video_id, 'mpd')) + filepath = os.path.join(self.BASE_PATH, filename) + try: + with xbmcvfs.File(filepath, 'w') as mpd_file: + success = mpd_file.write(output) + except (IOError, OSError): + self._context.log_error('VideoInfo._generate_mpd_manifest - ' + 'file write failed for: {file}' + .format(file=filepath)) + success = False + if success: + return 'http://{address}:{port}{path}{file}'.format( + address=_settings.httpd_listen(for_request=True), + port=_settings.httpd_port(), + path=paths.MPD, + file=filename, + ), main_stream + return None, None diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_context_menu.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_context_menu.py deleted file mode 100644 index e1b56666f1..0000000000 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_context_menu.py +++ /dev/null @@ -1,217 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2014-2016 bromix (plugin.video.youtube) - Copyright (C) 2016-2018 plugin.video.youtube - - SPDX-License-Identifier: GPL-2.0-only - See LICENSES/GPL-2.0-only for more information. -""" - -from ... import kodion - - -def append_more_for_video(context_menu, provider, context, video_id, is_logged_in=False, refresh_container=False): - _is_logged_in = '0' - if is_logged_in: - _is_logged_in = '1' - - _refresh_container = '0' - if refresh_container: - _refresh_container = '1' - - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.more']), - 'RunPlugin(%s)' % context.create_uri(['video', 'more'], - {'video_id': video_id, - 'logged_in': _is_logged_in, - 'refresh_container': _refresh_container}))) - - -def append_content_from_description(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.description.links']), - 'Container.Update(%s)' % context.create_uri(['special', 'description_links'], - {'video_id': video_id}))) - - -def append_play_with(context_menu, provider, context): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.play_with']), 'Action(SwitchPlayer)')) - - -def append_queue_video(context_menu, provider, context): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.queue']), 'Action(Queue)')) - - -def append_play_all_from_playlist(context_menu, provider, context, playlist_id, video_id=''): - if video_id: - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.playlist.play.from_here']), - 'RunPlugin(%s)' % context.create_uri(['play'], - {'playlist_id': playlist_id, - 'video_id': video_id, - 'play': '1'}))) - else: - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.playlist.play.all']), - 'RunPlugin(%s)' % context.create_uri(['play'], - {'playlist_id': playlist_id, - 'play': '1'}))) - - -def append_add_video_to_playlist(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.add_to_playlist']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'select', 'playlist'], - {'video_id': video_id}))) - - -def append_rename_playlist(context_menu, provider, context, playlist_id, playlist_name): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.rename']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'rename', 'playlist'], - {'playlist_id': playlist_id, - 'playlist_name': playlist_name}))) - - -def append_delete_playlist(context_menu, provider, context, playlist_id, playlist_name): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.delete']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'remove', 'playlist'], - {'playlist_id': playlist_id, - 'playlist_name': playlist_name}))) - - -def append_remove_as_watchlater(context_menu, provider, context, playlist_id, playlist_name): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.remove.as.watchlater']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'remove', 'watchlater'], - {'playlist_id': playlist_id, - 'playlist_name': playlist_name}))) - - -def append_set_as_watchlater(context_menu, provider, context, playlist_id, playlist_name): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.set.as.watchlater']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'set', 'watchlater'], - {'playlist_id': playlist_id, - 'playlist_name': playlist_name}))) - - -def append_remove_as_history(context_menu, provider, context, playlist_id, playlist_name): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.remove.as.history']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'remove', 'history'], - {'playlist_id': playlist_id, - 'playlist_name': playlist_name}))) - - -def append_set_as_history(context_menu, provider, context, playlist_id, playlist_name): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.set.as.history']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'set', 'history'], - {'playlist_id': playlist_id, - 'playlist_name': playlist_name}))) - - -def append_remove_my_subscriptions_filter(context_menu, provider, context, channel_name): - if context.get_settings().get_bool('youtube.folder.my_subscriptions_filtered.show', False): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.remove.my_subscriptions.filter']), - 'RunPlugin(%s)' % context.create_uri(['my_subscriptions', 'filter'], - {'channel_name': channel_name, - 'action': 'remove'}))) - - -def append_add_my_subscriptions_filter(context_menu, provider, context, channel_name): - if context.get_settings().get_bool('youtube.folder.my_subscriptions_filtered.show', False): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.add.my_subscriptions.filter']), - 'RunPlugin(%s)' % context.create_uri(['my_subscriptions', 'filter'], - {'channel_name': channel_name, - 'action': 'add'}))) - - -def append_rate_video(context_menu, provider, context, video_id, refresh_container=False): - if refresh_container: - refresh_container = '1' - else: - refresh_container = '0' - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.rate']), - 'RunPlugin(%s)' % context.create_uri(['video', 'rate'], - {'video_id': video_id, - 'refresh_container': refresh_container}))) - - -def append_watch_later(context_menu, provider, context, playlist_id, video_id): - playlist_path = kodion.utils.create_path('channel', 'mine', 'playlist', playlist_id) - if playlist_id and playlist_path != context.get_path(): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.watch_later']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'add', 'video'], - {'playlist_id': playlist_id, 'video_id': video_id}))) - - -def append_go_to_channel(context_menu, provider, context, channel_id, channel_name): - text = context.localize(provider.LOCAL_MAP['youtube.go_to_channel']) % context.get_ui().bold(channel_name) - context_menu.append((text, 'Container.Update(%s)' % context.create_uri(['channel', channel_id]))) - - -def append_related_videos(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.related_videos']), - 'Container.Update(%s)' % context.create_uri(['special', 'related_videos'], - {'video_id': video_id}))) - - -def append_clear_watch_history(context_menu, provider, context): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.clear_history']), - 'Container.Update(%s)' % context.create_uri(['history', 'clear']))) - - -def append_refresh(context_menu, provider, context): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.refresh']), 'Container.Refresh')) - - -def append_subscribe_to_channel(context_menu, provider, context, channel_id, channel_name=u''): - if channel_name: - text = context.localize(provider.LOCAL_MAP['youtube.subscribe_to']) % context.get_ui().bold(channel_name) - context_menu.append( - (text, 'RunPlugin(%s)' % context.create_uri(['subscriptions', 'add'], {'subscription_id': channel_id}))) - else: - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.subscribe']), - 'RunPlugin(%s)' % context.create_uri(['subscriptions', 'add'], - {'subscription_id': channel_id}))) - - -def append_unsubscribe_from_channel(context_menu, provider, context, channel_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.unsubscribe']), - 'RunPlugin(%s)' % context.create_uri(['subscriptions', 'remove'], - {'subscription_id': channel_id}))) - - -def append_mark_watched(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.mark.watched']), - 'RunPlugin(%s)' % context.create_uri(['playback_history'], - {'video_id': video_id, - 'action': 'mark_watched'}))) - - -def append_mark_unwatched(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.mark.unwatched']), - 'RunPlugin(%s)' % context.create_uri(['playback_history'], - {'video_id': video_id, - 'action': 'mark_unwatched'}))) - - -def append_reset_resume_point(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.reset.resume.point']), - 'RunPlugin(%s)' % context.create_uri(['playback_history'], - {'video_id': video_id, - 'action': 'reset_resume'}))) - - -def append_play_with_subtitles(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.play_with_subtitles']), - 'RunPlugin(%s)' % context.create_uri(['play'], - {'video_id': video_id, - 'prompt_for_subtitles': '1'}))) - - -def append_play_audio_only(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.play_audio_only']), - 'RunPlugin(%s)' % context.create_uri(['play'], - {'video_id': video_id, - 'audio_only': '1'}))) - - -def append_play_ask_for_quality(context_menu, provider, context, video_id): - context_menu.append((context.localize(provider.LOCAL_MAP['youtube.video.play_ask_for_quality']), - 'RunPlugin(%s)' % context.create_uri(['play'], - {'video_id': video_id, - 'ask_for_quality': '1'}))) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_login.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_login.py index faa621b68f..ac7b2bbf80 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_login.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_login.py @@ -8,10 +8,12 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import copy -import json import time -from ...youtube.youtube_exceptions import LoginException + +from ..youtube_exceptions import LoginException def process(mode, provider, context, sign_out_refresh=True): @@ -28,12 +30,11 @@ def _do_logout(): refresh_tokens = list(set(refresh_tokens)) for _refresh_token in refresh_tokens: provider.get_client(context).revoke(_refresh_token) - else: - if signout_access_manager.has_refresh_token(): - refresh_tokens = signout_access_manager.get_refresh_token().split('|') - refresh_tokens = list(set(refresh_tokens)) - for _refresh_token in refresh_tokens: - provider.get_client(context).revoke(_refresh_token) + elif signout_access_manager.has_refresh_token(): + refresh_tokens = signout_access_manager.get_refresh_token().split('|') + refresh_tokens = list(set(refresh_tokens)) + for _refresh_token in refresh_tokens: + provider.get_client(context).revoke(_refresh_token) provider.reset_client() @@ -61,54 +62,58 @@ def _do_login(_for_tv=False): user_code = json_data['user_code'] verification_url = json_data.get('verification_url', 'youtube.com/activate').lstrip('https://www.') - text = [context.localize(provider.LOCAL_MAP['youtube.sign.go_to']) % context.get_ui().bold(verification_url), - '[CR]%s %s' % (context.localize(provider.LOCAL_MAP['youtube.sign.enter_code']), + text = [context.localize('sign.go_to') % context.get_ui().bold(verification_url), + '[CR]%s %s' % (context.localize('sign.enter_code'), context.get_ui().bold(user_code))] text = ''.join(text) - dialog = context.get_ui().create_progress_dialog( - heading=context.localize(provider.LOCAL_MAP['youtube.sign.in']), text=text, background=False) - - steps = ((10 * 60 * 1000) // interval) # 10 Minutes - dialog.set_total(steps) - for i in range(steps): - dialog.update() - try: - if _for_tv: - json_data = _client.request_access_token_tv(device_code) - else: - json_data = _client.request_access_token(device_code) - except LoginException: - _do_logout() - raise - - log_data = copy.deepcopy(json_data) - if 'access_token' in log_data: - log_data['access_token'] = '' - if 'refresh_token' in log_data: - log_data['refresh_token'] = '' - context.log_debug('Requesting access token: |%s|' % json.dumps(log_data)) - - if 'error' not in json_data: - _access_token = json_data.get('access_token', '') - _expires_in = time.time() + int(json_data.get('expires_in', 3600)) - _refresh_token = json_data.get('refresh_token', '') - dialog.close() - if not _access_token and not _refresh_token: - _expires_in = 0 - return _access_token, _expires_in, _refresh_token - - elif json_data['error'] != u'authorization_pending': - message = json_data['error'] - title = '%s: %s' % (context.get_name(), message) - context.get_ui().show_notification(message, title) - context.log_error('Error requesting access token: |%s|' % message) - - if dialog.is_aborted(): - dialog.close() - return '', 0, '' - - context.sleep(interval) - dialog.close() + + with context.get_ui().create_progress_dialog( + heading=context.localize('sign.in'), text=text, background=False + ) as dialog: + steps = ((10 * 60 * 1000) // interval) # 10 Minutes + dialog.set_total(steps) + for _ in range(steps): + dialog.update() + try: + if _for_tv: + json_data = _client.request_access_token_tv(device_code) + else: + json_data = _client.request_access_token(device_code) + except LoginException: + _do_logout() + raise + + log_data = copy.deepcopy(json_data) + if 'access_token' in log_data: + log_data['access_token'] = '' + if 'refresh_token' in log_data: + log_data['refresh_token'] = '' + context.log_debug('Requesting access token: |{data}|'.format( + data=log_data + )) + + if 'error' not in json_data: + _access_token = json_data.get('access_token', '') + _refresh_token = json_data.get('refresh_token', '') + if not _access_token and not _refresh_token: + _expires_in = 0 + else: + _expires_in = (int(json_data.get('expires_in', 3600)) + + time.time()) + return _access_token, _expires_in, _refresh_token + + if json_data['error'] != 'authorization_pending': + message = json_data['error'] + title = '%s: %s' % (context.get_name(), message) + context.get_ui().show_notification(message, title) + context.log_error('Error requesting access token: |error|' + .format(error=message)) + + if dialog.is_aborted(): + break + + context.sleep(interval) + return '', 0, '' if mode == 'out': _do_logout() @@ -116,8 +121,8 @@ def _do_login(_for_tv=False): context.get_ui().refresh_container() elif mode == 'in': - context.get_ui().on_ok(context.localize(provider.LOCAL_MAP['youtube.sign.twice.title']), - context.localize(provider.LOCAL_MAP['youtube.sign.twice.text'])) + context.get_ui().on_ok(context.localize('sign.twice.title'), + context.localize('sign.twice.text')) access_token_tv, expires_in_tv, refresh_token_tv = _do_login(_for_tv=True) # abort tv login diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_old_actions.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_old_actions.py index a380e982cb..c7501eb773 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_old_actions.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_old_actions.py @@ -8,6 +8,8 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from ... import kodion @@ -53,7 +55,7 @@ def process_old_action(provider, context, re_match): """ if context.get_system_version().get_version() >= (15, 0): message = u"You're using old YouTube-Plugin calls - please review the log for updated end points starting with Isengard" - context.get_ui().show_notification(message, time_milliseconds=15000) + context.get_ui().show_notification(message, time_ms=15000) """ action = context.get_param('action', '') diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_play.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_play.py index 9de573632d..16963ce3fd 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_play.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_play.py @@ -8,240 +8,255 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import json import random -import re -import traceback - -import xbmcplugin +from traceback import format_stack -from ... import kodion -from ...kodion import constants +from ..helper import utils, v3 +from ..youtube_exceptions import YouTubeException from ...kodion.items import VideoItem -from ...kodion.impl.xbmc.xbmc_items import to_playback_item -from ...youtube.youtube_exceptions import YouTubeException -from ...youtube.helper import utils, v3 +from ...kodion.utils import select_stream def play_video(provider, context): - try: - video_id = context.get_param('video_id') - - client = provider.get_client(context) - settings = context.get_settings() - - ask_for_quality = None - if video_id and context.get_ui().get_home_window_property('ask_for_quality') == video_id: - ask_for_quality = True - context.get_ui().clear_home_window_property('ask_for_quality') - - screensaver = False - if context.get_param('screensaver', None) and str(context.get_param('screensaver')).lower() == 'true': - ask_for_quality = False - screensaver = True - - audio_only = None - if video_id and context.get_ui().get_home_window_property('audio_only') == video_id: - ask_for_quality = False - audio_only = True - context.get_ui().clear_home_window_property('audio_only') - - try: - video_streams = client.get_video_streams(context, video_id) - except YouTubeException as e: - context.get_ui().show_notification(message=e.get_message()) - context.log_error(traceback.print_exc()) - return False + params = context.get_params() + video_id = params.get('video_id') - if len(video_streams) == 0: - message = context.localize(provider.LOCAL_MAP['youtube.error.no_video_streams_found']) - context.get_ui().show_notification(message, time_milliseconds=5000) - return False + client = provider.get_client(context) + settings = context.get_settings() + ui = context.get_ui() - video_stream = kodion.utils.select_stream(context, video_streams, ask_for_quality=ask_for_quality, audio_only=audio_only) + ask_for_quality = None + if video_id and ui.get_property('ask_for_quality') == video_id: + ask_for_quality = True + ui.clear_property('ask_for_quality') - if video_stream is None: - return False + screensaver = False + if params.get('screensaver'): + ask_for_quality = False + screensaver = True - is_video = True if video_stream.get('video') else False - is_live = video_stream.get('Live') is True + audio_only = None + if video_id and ui.get_property('audio_only') == video_id: + ask_for_quality = False + audio_only = True + ui.clear_property('audio_only') - if is_video and video_stream['video'].get('rtmpe', False): - message = context.localize(provider.LOCAL_MAP['youtube.error.rtmpe_not_supported']) - context.get_ui().show_notification(message, time_milliseconds=5000) - return False + try: + video_streams = client.get_video_streams(context, video_id) + except YouTubeException as exc: + context.log_error('yt_play.play_video - {exc}:\n{details}'.format( + exc=exc, details=''.join(format_stack()) + )) + ui.show_notification(message=exc.get_message()) + return False - play_suggested = settings.get_bool('youtube.suggested_videos', False) - if play_suggested and not screensaver: - utils.add_related_video_to_playlist(provider, context, client, v3, video_id) - - metadata = video_stream.get('meta', {}) - - title = metadata.get('video', {}).get('title', '') - video_item = VideoItem(title, video_stream['url']) - - incognito = str(context.get_param('incognito', False)).lower() == 'true' - use_history = not is_live and not screensaver and not incognito - use_remote_history = use_history and settings.use_remote_history() - use_play_data = use_history and settings.use_local_history() - - - video_item = utils.update_play_info(provider, context, video_id, video_item, video_stream, - use_play_data=use_play_data) - - seek_time = None - play_count = 0 - playback_stats = video_stream.get('playback_stats') - - if use_remote_history: - if video_item.get_start_time() and video_item.use_mpd_video(): - seek_time = video_item.get_start_time() - play_count = video_item.get_play_count() if video_item.get_play_count() is not None else '0' - - item = to_playback_item(context, video_item) - item.setPath(video_item.get_uri()) - - try: - seek = float(context.get_param('seek', None)) - if seek: - seek_time = seek - except (ValueError, TypeError): - pass - - playback_json = { - "video_id": video_id, - "channel_id": metadata.get('channel', {}).get('id', ''), - "video_status": metadata.get('video', {}).get('status', {}), - "playing_file": video_item.get_uri(), - "play_count": play_count, - "use_remote_history": use_remote_history, - "use_local_history": use_play_data, - "playback_stats": playback_stats, - "seek_time": seek_time, - "refresh_only": screensaver - } - - context.get_ui().set_home_window_property('playback_json', json.dumps(playback_json)) - context.send_notification('PlaybackInit', { - 'video_id': video_id, - 'channel_id': playback_json.get('channel_id', ''), - 'status': playback_json.get('video_status', {}) - }) - xbmcplugin.setResolvedUrl(handle=context.get_handle(), succeeded=True, listitem=item) - - except YouTubeException as ex: - message = ex.get_message() - message = kodion.utils.strip_html_from_text(message) - context.get_ui().show_notification(message, time_milliseconds=15000) + if not video_streams: + message = context.localize('error.no_video_streams_found') + ui.show_notification(message, time_ms=5000) + return False + video_stream = select_stream( + context, + video_streams, + ask_for_quality=ask_for_quality, + audio_only=audio_only + ) -def play_playlist(provider, context): - videos = [] + if video_stream is None: + return False - def _load_videos(_page_token='', _progress_dialog=None): - if _progress_dialog is None: - _progress_dialog = context.get_ui().create_progress_dialog( - context.localize(provider.LOCAL_MAP['youtube.playlist.progress.updating']), - context.localize(constants.localize.COMMON_PLEASE_WAIT), background=True) - json_data = client.get_playlist_items(playlist_id, page_token=_page_token) - if not v3.handle_error(provider, context, json_data): - return None - _progress_dialog.set_total(int(json_data.get('pageInfo', {}).get('totalResults', 0))) - - result = v3.response_to_items(provider, context, json_data, process_next_page=False) - videos.extend(result) - progress_text = '%s %d/%d' % ( - context.localize(constants.localize.COMMON_PLEASE_WAIT), len(videos), _progress_dialog.get_total()) - _progress_dialog.update(steps=len(result), text=progress_text) - - next_page_token = json_data.get('nextPageToken', '') - if next_page_token: - _load_videos(_page_token=next_page_token, _progress_dialog=_progress_dialog) - - return _progress_dialog - - # select order - video_id = context.get_param('video_id', '') - order = context.get_param('order', '') - if not order: - order_list = ['default', 'reverse'] - # we support shuffle only without a starting video position - if not video_id: - order_list.append('shuffle') - items = [] - for order in order_list: - items.append((context.localize(provider.LOCAL_MAP['youtube.playlist.play.%s' % order]), order)) - - order = context.get_ui().on_select(context.localize(provider.LOCAL_MAP['youtube.playlist.play.select']), items) - if order not in order_list: - return False + is_video = video_stream.get('video') + is_live = video_stream.get('Live') - player = context.get_video_player() - player.stop() - - playlist_id = context.get_param('playlist_id') - client = provider.get_client(context) + if is_video and video_stream['video'].get('rtmpe', False): + message = context.localize('error.rtmpe_not_supported') + ui.show_notification(message, time_ms=5000) + return False - # start the loop and fill the list with video items - progress_dialog = _load_videos() - - # reverse the list - if order == 'reverse': - videos = videos[::-1] - elif order == 'shuffle': - # we have to shuffle the playlist by our self. The implementation of XBMC/KODI is quite weak :( - random.shuffle(videos) - - playlist_position = 0 - # check if we have a video as starting point for the playlist - if video_id: - find_video_id = re.compile(r'video_id=(?P[^&]+)') - for video in videos: - video_id_match = find_video_id.search(video.get_uri()) - if video_id_match and video_id_match.group('video_id') == video_id: - break - playlist_position += 1 - - # clear the playlist - playlist = context.get_video_playlist() - playlist.clear() + play_suggested = settings.get_bool('youtube.suggested_videos', False) + if play_suggested and not screensaver: + utils.add_related_video_to_playlist(provider, + context, + client, + v3, + video_id) + + metadata = video_stream.get('meta', {}) + + title = metadata.get('video', {}).get('title', '') + video_item = VideoItem(title, video_stream['url']) + + incognito = params.get('incognito', False) + use_history = not is_live and not screensaver and not incognito + use_remote_history = use_history and settings.use_remote_history() + use_play_data = use_history and settings.use_local_history() + + utils.update_play_info(provider, context, video_id, video_item, + video_stream, use_play_data=use_play_data) + + seek_time = 0.0 if params.get('resume') else params.get('seek', 0.0) + start_time = params.get('start', 0.0) + end_time = params.get('end', 0.0) + + if start_time: + video_item.set_start_time(start_time) + # Setting the duration based on end_time can cause issues with + # listing/sorting and other addons that monitor playback + # if end_time: + # video_item.set_duration_from_seconds(end_time) + + play_count = use_play_data and video_item.get_play_count() or 0 + playback_stats = video_stream.get('playback_stats') + + playback_json = { + 'video_id': video_id, + 'channel_id': metadata.get('channel', {}).get('id', ''), + 'video_status': metadata.get('video', {}).get('status', {}), + 'playing_file': video_item.get_uri(), + 'play_count': play_count, + 'use_remote_history': use_remote_history, + 'use_local_history': use_play_data, + 'playback_stats': playback_stats, + 'seek_time': seek_time, + 'start_time': start_time, + 'end_time': end_time, + 'clip': params.get('clip'), + 'refresh_only': screensaver + } + + ui.set_property('playback_json', json.dumps(playback_json, + ensure_ascii=False)) + context.send_notification('PlaybackInit', { + 'video_id': video_id, + 'channel_id': playback_json.get('channel_id', ''), + 'status': playback_json.get('video_status', {}) + }) + return video_item - # select unshuffle - if order == 'shuffle': - playlist.unshuffle() - # add videos to playlist - for video in videos: - playlist.add(video) +def play_playlist(provider, context): + videos = [] + params = context.get_params() - # we use the shuffle implementation of the playlist - """ - if order == 'shuffle': - playlist.shuffle() - """ + player = context.get_video_player() + player.stop() - if progress_dialog: - progress_dialog.close() + playlist_ids = params.get('playlist_ids') + if not playlist_ids: + playlist_ids = [params.get('playlist_id')] + + resource_manager = provider.get_resource_manager(context) + ui = context.get_ui() + + with ui.create_progress_dialog( + context.localize('playlist.progress.updating'), + context.localize('please_wait'), + background=True + ) as progress_dialog: + json_data = resource_manager.get_playlist_items(playlist_ids) + + total = sum(len(chunk.get('items', [])) for chunk in json_data.values()) + progress_dialog.set_total(total) + progress_dialog.update( + steps=0, + text='{wait} {current}/{total}'.format( + wait=context.localize('please_wait'), + current=0, + total=total + ) + ) + + # start the loop and fill the list with video items + for chunk in json_data.values(): + result = v3.response_to_items(provider, + context, + chunk, + process_next_page=False) + videos.extend(result) + + progress_dialog.update( + steps=len(result), + text='{wait} {current}/{total}'.format( + wait=context.localize('please_wait'), + current=len(videos), + total=total + ) + ) + + if not videos: + return False - if (context.get_param('play', '') == '1') and (context.get_handle() == -1): + # select order + order = params.get('order', '') + if not order: + order_list = ['default', 'reverse', 'shuffle'] + items = [(context.localize('playlist.play.%s' % order), order) + for order in order_list] + order = ui.on_select(context.localize('playlist.play.select'), + items) + if order not in order_list: + order = 'default' + + # reverse the list + if order == 'reverse': + videos = videos[::-1] + elif order == 'shuffle': + # we have to shuffle the playlist by our self. + # The implementation of XBMC/KODI is quite weak :( + random.shuffle(videos) + + # clear the playlist + playlist = context.get_video_playlist() + playlist.clear() + + # select unshuffle + if order == 'shuffle': + playlist.unshuffle() + + # check if we have a video as starting point for the playlist + video_id = params.get('video_id', '') + # add videos to playlist + playlist_position = 0 + for idx, video in enumerate(videos): + playlist.add(video) + if (video_id and not playlist_position + and video_id in video.get_uri()): + playlist_position = idx + + # we use the shuffle implementation of the playlist + """ + if order == 'shuffle': + playlist.shuffle() + """ + + if not params.get('play'): + return videos + if context.get_handle() == -1: player.play(playlist_index=playlist_position) - return - elif context.get_param('play', '') == '1': - return videos[playlist_position] - - return True + return False + return videos[playlist_position] def play_channel_live(provider, context): channel_id = context.get_param('channel_id') - index = int(context.get_param('live')) - 1 + index = context.get_param('live') - 1 if index < 0: index = 0 - json_data = provider.get_client(context).search(q='', search_type='video', event_type='live', channel_id=channel_id, safe_search=False) - if not v3.handle_error(provider, context, json_data): + json_data = provider.get_client(context).search(q='', + search_type='video', + event_type='live', + channel_id=channel_id, + safe_search=False) + if not json_data: return False - video_items = v3.response_to_items(provider, context, json_data, process_next_page=False) + video_items = v3.response_to_items(provider, + context, + json_data, + process_next_page=False) try: video_item = video_items[index] @@ -257,5 +272,5 @@ def play_channel_live(provider, context): if context.get_handle() == -1: player.play(playlist_index=0) - else: - return video_item + return False + return video_item diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py index cdf9bbc0dc..c3cf87cf03 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py @@ -8,100 +8,104 @@ See LICENSES/GPL-2.0-only for more information. """ -from ...kodion.utils.function_cache import FunctionCache +from __future__ import absolute_import, division, unicode_literals -from ... import kodion -from ...youtube.helper import v3 +from ...kodion import KodionException +from ...kodion.utils import find_video_id def _process_add_video(provider, context, keymap_action=False): - listitem_path = context.get_ui().get_info_label('Container.ListItem(0).FileNameAndPath') + path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') client = provider.get_client(context) + logged_in = provider.is_logged_in() + if not logged_in: + raise KodionException('Playlist/Add: not logged in') + watch_later_id = context.get_access_manager().get_watch_later_id() playlist_id = context.get_param('playlist_id', '') - if not playlist_id: - raise kodion.KodionException('Playlist/Add: missing playlist_id') - if playlist_id.lower() == 'watch_later': playlist_id = watch_later_id + if not playlist_id: + raise KodionException('Playlist/Add: missing playlist_id') + video_id = context.get_param('video_id', '') if not video_id: - if context.is_plugin_path(listitem_path, 'play'): - video_id = kodion.utils.find_video_id(listitem_path) + if context.is_plugin_path(path, 'play/'): + video_id = find_video_id(path) keymap_action = True if not video_id: - raise kodion.KodionException('Playlist/Add: missing video_id') + raise KodionException('Playlist/Add: missing video_id') - if playlist_id != 'HL': - json_data = client.add_video_to_playlist(playlist_id=playlist_id, video_id=video_id) - if not v3.handle_error(provider, context, json_data): - return False + json_data = client.add_video_to_playlist(playlist_id=playlist_id, + video_id=video_id) + if not json_data: + context.log_debug('Playlist/Add: failed for playlist |{playlist_id}|' + .format(playlist_id=playlist_id)) + return False - if playlist_id == watch_later_id: - notify_message = context.localize(provider.LOCAL_MAP['youtube.added.to.watch.later']) - else: - notify_message = context.localize(provider.LOCAL_MAP['youtube.added.to.playlist']) + if playlist_id == watch_later_id: + notify_message = context.localize('watch_later.added_to') + else: + notify_message = context.localize('playlist.added_to') - context.get_ui().show_notification( - message=notify_message, - time_milliseconds=2500, - audible=False - ) + context.get_ui().show_notification( + message=notify_message, + time_ms=2500, + audible=False + ) - if keymap_action: - context.get_ui().set_focus_next_item() + if keymap_action: + context.get_ui().set_focus_next_item() - return True - else: - context.log_debug('Cannot add to playlist id |%s|' % playlist_id) - - return False + return True def _process_remove_video(provider, context): - listitem_playlist_id = context.get_ui().get_info_label('Container.ListItem(0).Property(playlist_id)') - listitem_playlist_item_id = context.get_ui().get_info_label('Container.ListItem(0).Property(playlist_item_id)') - listitem_title = context.get_ui().get_info_label('Container.ListItem(0).Title') + listitem_playlist_id = context.get_infolabel('Container.ListItem(0).Property(playlist_id)') + listitem_playlist_item_id = context.get_infolabel('Container.ListItem(0).Property(playlist_item_id)') + listitem_title = context.get_infolabel('Container.ListItem(0).Title') keymap_action = False playlist_id = context.get_param('playlist_id', '') video_id = context.get_param('video_id', '') video_name = context.get_param('video_name', '') - if not playlist_id and not video_id: # keymap support - if listitem_playlist_id and listitem_playlist_id.startswith('PL') \ - and listitem_playlist_item_id and listitem_playlist_item_id.startswith('UE'): - playlist_id = listitem_playlist_id - video_id = listitem_playlist_item_id - keymap_action = True + # keymap support + if (not playlist_id and not video_id and listitem_playlist_id + and listitem_playlist_id.startswith('PL') + and listitem_playlist_item_id + and listitem_playlist_item_id.startswith('UE')): + playlist_id = listitem_playlist_id + video_id = listitem_playlist_item_id + keymap_action = True if not playlist_id: - raise kodion.KodionException('Playlist/Remove: missing playlist_id') + raise KodionException('Playlist/Remove: missing playlist_id') if not video_id: - raise kodion.KodionException('Playlist/Remove: missing video_id') + raise KodionException('Playlist/Remove: missing video_id') if not video_name: if listitem_title: video_name = listitem_title else: - raise kodion.KodionException('Playlist/Remove: missing video_name') + raise KodionException('Playlist/Remove: missing video_name') - if playlist_id != 'HL' and playlist_id.strip().lower() != 'wl': + if playlist_id.strip().lower() not in ('wl', 'hl'): if context.get_ui().on_remove_content(video_name): json_data = provider.get_client(context).remove_video_from_playlist(playlist_id=playlist_id, playlist_item_id=video_id) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False context.get_ui().refresh_container() context.get_ui().show_notification( - message=context.localize(provider.LOCAL_MAP['youtube.removed.from.playlist']), - time_milliseconds=2500, + message=context.localize('playlist.removed_from'), + time_ms=2500, audible=False ) @@ -118,15 +122,15 @@ def _process_remove_video(provider, context): def _process_remove_playlist(provider, context): playlist_id = context.get_param('playlist_id', '') if not playlist_id: - raise kodion.KodionException('Playlist/Remove: missing playlist_id') + raise KodionException('Playlist/Remove: missing playlist_id') playlist_name = context.get_param('playlist_name', '') if not playlist_name: - raise kodion.KodionException('Playlist/Remove: missing playlist_name') + raise KodionException('Playlist/Remove: missing playlist_name') if context.get_ui().on_delete_content(playlist_name): json_data = provider.get_client(context).remove_playlist(playlist_id=playlist_id) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False context.get_ui().refresh_container() @@ -134,32 +138,37 @@ def _process_remove_playlist(provider, context): def _process_select_playlist(provider, context): - listitem_path = context.get_ui().get_info_label('Container.ListItem(0).FileNameAndPath') # do this asap, relies on listitems focus - keymap_action = False + # Get listitem path asap, relies on listitems focus + path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') + ui = context.get_ui() + keymap_action = False page_token = '' current_page = 0 video_id = context.get_param('video_id', '') if not video_id: - if context.is_plugin_path(listitem_path, 'play'): - video_id = kodion.utils.find_video_id(listitem_path) + if context.is_plugin_path(path, 'play/'): + video_id = find_video_id(path) if video_id: context.set_param('video_id', video_id) keymap_action = True if not video_id: - raise kodion.KodionException('Playlist/Select: missing video_id') + raise KodionException('Playlist/Select: missing video_id') + function_cache = context.get_function_cache() + client = provider.get_client(context) while True: current_page += 1 if not page_token: - json_data = context.get_function_cache().get((FunctionCache.ONE_MINUTE // 3), - provider.get_client(context).get_playlists_of_channel, - channel_id='mine') + json_data = function_cache.get(client.get_playlists_of_channel, + function_cache.ONE_MINUTE // 3, + channel_id='mine') else: - json_data = context.get_function_cache().get((FunctionCache.ONE_MINUTE // 3), - provider.get_client(context).get_playlists_of_channel, - channel_id='mine', page_token=page_token) + json_data = function_cache.get(client.get_playlists_of_channel, + function_cache.ONE_MINUTE // 3, + channel_id='mine', + page_token=page_token) playlists = json_data.get('items', []) page_token = json_data.get('nextPageToken', False) @@ -167,93 +176,100 @@ def _process_select_playlist(provider, context): items = [] if current_page == 1: # create playlist - items.append((ui.bold(context.localize(provider.LOCAL_MAP['youtube.playlist.create'])), '', - 'playlist.create', context.create_resource_path('media', 'playlist.png'))) + items.append(( + ui.bold(context.localize('playlist.create')), '', + 'playlist.create', + context.create_resource_path('media', 'playlist.png') + )) # add the 'Watch Later' playlist resource_manager = provider.get_resource_manager(context) my_playlists = resource_manager.get_related_playlists(channel_id='mine') if 'watchLater' in my_playlists: - watch_later_playlist_id = context.get_access_manager().get_watch_later_id() - if watch_later_playlist_id: - items.append((ui.bold(context.localize(provider.LOCAL_MAP['youtube.watch_later'])), '', - watch_later_playlist_id, context.create_resource_path('media', 'watch_later.png'))) - + watch_later_id = context.get_access_manager().get_watch_later_id() + if watch_later_id: + items.append(( + ui.bold(context.localize('watch_later')), '', + watch_later_id, + context.create_resource_path('media', 'watch_later.png') + )) + + default_thumb = context.create_resource_path('media', 'playlist.png') for playlist in playlists: snippet = playlist.get('snippet', {}) title = snippet.get('title', '') description = snippet.get('description', '') - thumbnail = snippet.get('thumbnails', {}).get('default', {}).get('url', context.create_resource_path('media', 'playlist.png')) + thumbnail = snippet.get('thumbnails', {}).get('default', {}) playlist_id = playlist.get('id', '') if title and playlist_id: - items.append((title, description, playlist_id, thumbnail)) + items.append(( + title, description, + playlist_id, + thumbnail.get('url') or default_thumb + )) if page_token: - items.append((ui.bold(context.localize(provider.LOCAL_MAP['youtube.next_page'])).replace('%d', str(current_page + 1)), '', + items.append((ui.bold(context.localize('next_page')).replace('%d', str(current_page + 1)), '', 'playlist.next', 'DefaultFolder.png')) - result = context.get_ui().on_select(context.localize(provider.LOCAL_MAP['youtube.playlist.select']), items) + result = ui.on_select(context.localize('playlist.select'), items) if result == 'playlist.create': - result, text = context.get_ui().on_keyboard_input( - context.localize(provider.LOCAL_MAP['youtube.playlist.create'])) + result, text = ui.on_keyboard_input( + context.localize('playlist.create')) if result and text: - json_data = provider.get_client(context).create_playlist(title=text) - if not v3.handle_error(provider, context, json_data): + json_data = client.create_playlist(title=text) + if not json_data: break playlist_id = json_data.get('id', '') if playlist_id: - new_params = {} - new_params.update(context.get_params()) - new_params['playlist_id'] = playlist_id + new_params = dict(context.get_params(), + playlist_id=playlist_id) new_context = context.clone(new_params=new_params) _process_add_video(provider, new_context, keymap_action) break - elif result == 'playlist.next': + if result == 'playlist.next': continue - elif result != -1: - new_params = {} - new_params.update(context.get_params()) - new_params['playlist_id'] = result + if result != -1: + new_params = dict(context.get_params(), playlist_id=result) new_context = context.clone(new_params=new_params) _process_add_video(provider, new_context, keymap_action) break - else: - break + break def _process_rename_playlist(provider, context): playlist_id = context.get_param('playlist_id', '') if not playlist_id: - raise kodion.KodionException('playlist/rename: missing playlist_id') + raise KodionException('playlist/rename: missing playlist_id') current_playlist_name = context.get_param('playlist_name', '') - result, text = context.get_ui().on_keyboard_input(context.localize(provider.LOCAL_MAP['youtube.rename']), + result, text = context.get_ui().on_keyboard_input(context.localize('rename'), default=current_playlist_name) if result and text: json_data = provider.get_client(context).rename_playlist(playlist_id=playlist_id, new_title=text) - if not v3.handle_error(provider, context, json_data): + if not json_data: return context.get_ui().refresh_container() -def _watchlater_playlist_id_change(context, method): +def _watch_later_playlist_id_change(context, method): playlist_id = context.get_param('playlist_id', '') if not playlist_id: - raise kodion.KodionException('watchlater_list/%s: missing playlist_id' % method) + raise KodionException('watchlater_list/%s: missing playlist_id' % method) playlist_name = context.get_param('playlist_name', '') if not playlist_name: - raise kodion.KodionException('watchlater_list/%s: missing playlist_name' % method) + raise KodionException('watchlater_list/%s: missing playlist_name' % method) if method == 'set': - if context.get_ui().on_yes_no_input(context.get_name(), context.localize(30570) % playlist_name): + if context.get_ui().on_yes_no_input(context.get_name(), context.localize('watch_later.list.set.confirm') % playlist_name): context.get_access_manager().set_watch_later_id(playlist_id) else: return elif method == 'remove': - if context.get_ui().on_yes_no_input(context.get_name(), context.localize(30569) % playlist_name): - context.get_access_manager().set_watch_later_id(' WL') + if context.get_ui().on_yes_no_input(context.get_name(), context.localize('watch_later.list.remove.confirm') % playlist_name): + context.get_access_manager().set_watch_later_id('WL') else: return else: @@ -264,18 +280,18 @@ def _watchlater_playlist_id_change(context, method): def _history_playlist_id_change(context, method): playlist_id = context.get_param('playlist_id', '') if not playlist_id: - raise kodion.KodionException('history_list/%s: missing playlist_id' % method) + raise KodionException('history_list/%s: missing playlist_id' % method) playlist_name = context.get_param('playlist_name', '') if not playlist_name: - raise kodion.KodionException('history_list/%s: missing playlist_name' % method) + raise KodionException('history_list/%s: missing playlist_name' % method) if method == 'set': - if context.get_ui().on_yes_no_input(context.get_name(), context.localize(30574) % playlist_name): + if context.get_ui().on_yes_no_input(context.get_name(), context.localize('history.list.set.confirm') % playlist_name): context.get_access_manager().set_watch_history_id(playlist_id) else: return elif method == 'remove': - if context.get_ui().on_yes_no_input(context.get_name(), context.localize(30573) % playlist_name): + if context.get_ui().on_yes_no_input(context.get_name(), context.localize('history.list.remove.confirm') % playlist_name): context.get_access_manager().set_watch_history_id('HL') else: return @@ -287,17 +303,16 @@ def _history_playlist_id_change(context, method): def process(method, category, provider, context): if method == 'add' and category == 'video': return _process_add_video(provider, context) - elif method == 'remove' and category == 'video': + if method == 'remove' and category == 'video': return _process_remove_video(provider, context) - elif method == 'remove' and category == 'playlist': + if method == 'remove' and category == 'playlist': return _process_remove_playlist(provider, context) - elif method == 'select' and category == 'playlist': + if method == 'select' and category == 'playlist': return _process_select_playlist(provider, context) - elif method == 'rename' and category == 'playlist': + if method == 'rename' and category == 'playlist': return _process_rename_playlist(provider, context) - elif (method == 'set' or method == 'remove') and category == 'watchlater': - return _watchlater_playlist_id_change(context, method) - elif (method == 'set' or method == 'remove') and category == 'history': + if method in {'set', 'remove'} and category == 'watch_later': + return _watch_later_playlist_id_change(context, method) + if method in {'set', 'remove'} and category == 'history': return _history_playlist_id_change(context, method) - else: - raise kodion.KodionException("Unknown category '%s' or method '%s'" % (category, method)) + raise KodionException("Unknown category '%s' or method '%s'" % (category, method)) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py index 9b5bb4b7ee..39f0695f92 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py @@ -8,57 +8,59 @@ See LICENSES/GPL-2.0-only for more information. """ -from ...kodion.utils import ip_api - - -DEFAULT_LANGUAGES = {u'items': [{u'snippet': {u'name': u'Afrikaans', u'hl': u'af'}, u'id': u'af'}, {u'snippet': {u'name': u'Azerbaijani', u'hl': u'az'}, u'id': u'az'}, {u'snippet': {u'name': u'Indonesian', u'hl': u'id'}, u'id': u'id'}, {u'snippet': {u'name': u'Malay', u'hl': u'ms'}, u'id': u'ms'}, - {u'snippet': {u'name': u'Catalan', u'hl': u'ca'}, u'id': u'ca'}, {u'snippet': {u'name': u'Czech', u'hl': u'cs'}, u'id': u'cs'}, {u'snippet': {u'name': u'Danish', u'hl': u'da'}, u'id': u'da'}, {u'snippet': {u'name': u'German', u'hl': u'de'}, u'id': u'de'}, - {u'snippet': {u'name': u'Estonian', u'hl': u'et'}, u'id': u'et'}, {u'snippet': {u'name': u'English (United Kingdom)', u'hl': u'en-GB'}, u'id': u'en-GB'}, {u'snippet': {u'name': u'English', u'hl': u'en'}, u'id': u'en'}, - {u'snippet': {u'name': u'Spanish (Spain)', u'hl': u'es'}, u'id': u'es'}, {u'snippet': {u'name': u'Spanish (Latin America)', u'hl': u'es-419'}, u'id': u'es-419'}, {u'snippet': {u'name': u'Basque', u'hl': u'eu'}, u'id': u'eu'}, - {u'snippet': {u'name': u'Filipino', u'hl': u'fil'}, u'id': u'fil'}, {u'snippet': {u'name': u'French', u'hl': u'fr'}, u'id': u'fr'}, {u'snippet': {u'name': u'French (Canada)', u'hl': u'fr-CA'}, u'id': u'fr-CA'}, {u'snippet': {u'name': u'Galician', u'hl': u'gl'}, u'id': u'gl'}, - {u'snippet': {u'name': u'Croatian', u'hl': u'hr'}, u'id': u'hr'}, {u'snippet': {u'name': u'Zulu', u'hl': u'zu'}, u'id': u'zu'}, {u'snippet': {u'name': u'Icelandic', u'hl': u'is'}, u'id': u'is'}, {u'snippet': {u'name': u'Italian', u'hl': u'it'}, u'id': u'it'}, - {u'snippet': {u'name': u'Swahili', u'hl': u'sw'}, u'id': u'sw'}, {u'snippet': {u'name': u'Latvian', u'hl': u'lv'}, u'id': u'lv'}, {u'snippet': {u'name': u'Lithuanian', u'hl': u'lt'}, u'id': u'lt'}, {u'snippet': {u'name': u'Hungarian', u'hl': u'hu'}, u'id': u'hu'}, - {u'snippet': {u'name': u'Dutch', u'hl': u'nl'}, u'id': u'nl'}, {u'snippet': {u'name': u'Norwegian', u'hl': u'no'}, u'id': u'no'}, {u'snippet': {u'name': u'Uzbek', u'hl': u'uz'}, u'id': u'uz'}, {u'snippet': {u'name': u'Polish', u'hl': u'pl'}, u'id': u'pl'}, - {u'snippet': {u'name': u'Portuguese (Portugal)', u'hl': u'pt-PT'}, u'id': u'pt-PT'}, {u'snippet': {u'name': u'Portuguese (Brazil)', u'hl': u'pt'}, u'id': u'pt'}, {u'snippet': {u'name': u'Romanian', u'hl': u'ro'}, u'id': u'ro'}, - {u'snippet': {u'name': u'Albanian', u'hl': u'sq'}, u'id': u'sq'}, {u'snippet': {u'name': u'Slovak', u'hl': u'sk'}, u'id': u'sk'}, {u'snippet': {u'name': u'Slovenian', u'hl': u'sl'}, u'id': u'sl'}, {u'snippet': {u'name': u'Finnish', u'hl': u'fi'}, u'id': u'fi'}, - {u'snippet': {u'name': u'Swedish', u'hl': u'sv'}, u'id': u'sv'}, {u'snippet': {u'name': u'Vietnamese', u'hl': u'vi'}, u'id': u'vi'}, {u'snippet': {u'name': u'Turkish', u'hl': u'tr'}, u'id': u'tr'}, {u'snippet': {u'name': u'Bulgarian', u'hl': u'bg'}, u'id': u'bg'}, - {u'snippet': {u'name': u'Kyrgyz', u'hl': u'ky'}, u'id': u'ky'}, {u'snippet': {u'name': u'Kazakh', u'hl': u'kk'}, u'id': u'kk'}, {u'snippet': {u'name': u'Macedonian', u'hl': u'mk'}, u'id': u'mk'}, {u'snippet': {u'name': u'Mongolian', u'hl': u'mn'}, u'id': u'mn'}, - {u'snippet': {u'name': u'Russian', u'hl': u'ru'}, u'id': u'ru'}, {u'snippet': {u'name': u'Serbian', u'hl': u'sr'}, u'id': u'sr'}, {u'snippet': {u'name': u'Ukrainian', u'hl': u'uk'}, u'id': u'uk'}, {u'snippet': {u'name': u'Greek', u'hl': u'el'}, u'id': u'el'}, - {u'snippet': {u'name': u'Armenian', u'hl': u'hy'}, u'id': u'hy'}, {u'snippet': {u'name': u'Hebrew', u'hl': u'iw'}, u'id': u'iw'}, {u'snippet': {u'name': u'Urdu', u'hl': u'ur'}, u'id': u'ur'}, {u'snippet': {u'name': u'Arabic', u'hl': u'ar'}, u'id': u'ar'}, - {u'snippet': {u'name': u'Persian', u'hl': u'fa'}, u'id': u'fa'}, {u'snippet': {u'name': u'Nepali', u'hl': u'ne'}, u'id': u'ne'}, {u'snippet': {u'name': u'Marathi', u'hl': u'mr'}, u'id': u'mr'}, {u'snippet': {u'name': u'Hindi', u'hl': u'hi'}, u'id': u'hi'}, - {u'snippet': {u'name': u'Bengali', u'hl': u'bn'}, u'id': u'bn'}, {u'snippet': {u'name': u'Punjabi', u'hl': u'pa'}, u'id': u'pa'}, {u'snippet': {u'name': u'Gujarati', u'hl': u'gu'}, u'id': u'gu'}, {u'snippet': {u'name': u'Tamil', u'hl': u'ta'}, u'id': u'ta'}, - {u'snippet': {u'name': u'Telugu', u'hl': u'te'}, u'id': u'te'}, {u'snippet': {u'name': u'Kannada', u'hl': u'kn'}, u'id': u'kn'}, {u'snippet': {u'name': u'Malayalam', u'hl': u'ml'}, u'id': u'ml'}, {u'snippet': {u'name': u'Sinhala', u'hl': u'si'}, u'id': u'si'}, - {u'snippet': {u'name': u'Thai', u'hl': u'th'}, u'id': u'th'}, {u'snippet': {u'name': u'Lao', u'hl': u'lo'}, u'id': u'lo'}, {u'snippet': {u'name': u'Myanmar (Burmese)', u'hl': u'my'}, u'id': u'my'}, {u'snippet': {u'name': u'Georgian', u'hl': u'ka'}, u'id': u'ka'}, - {u'snippet': {u'name': u'Amharic', u'hl': u'am'}, u'id': u'am'}, {u'snippet': {u'name': u'Khmer', u'hl': u'km'}, u'id': u'km'}, {u'snippet': {u'name': u'Chinese', u'hl': u'zh-CN'}, u'id': u'zh-CN'}, {u'snippet': {u'name': u'Chinese (Taiwan)', u'hl': u'zh-TW'}, u'id': u'zh-TW'}, - {u'snippet': {u'name': u'Chinese (Hong Kong)', u'hl': u'zh-HK'}, u'id': u'zh-HK'}, {u'snippet': {u'name': u'Japanese', u'hl': u'ja'}, u'id': u'ja'}, {u'snippet': {u'name': u'Korean', u'hl': u'ko'}, u'id': u'ko'}]} -DEFAULT_REGIONS = {u'items': [{u'snippet': {u'gl': u'DZ', u'name': u'Algeria'}, u'id': u'DZ'}, {u'snippet': {u'gl': u'AR', u'name': u'Argentina'}, u'id': u'AR'}, {u'snippet': {u'gl': u'AU', u'name': u'Australia'}, u'id': u'AU'}, {u'snippet': {u'gl': u'AT', u'name': u'Austria'}, u'id': u'AT'}, - {u'snippet': {u'gl': u'AZ', u'name': u'Azerbaijan'}, u'id': u'AZ'}, {u'snippet': {u'gl': u'BH', u'name': u'Bahrain'}, u'id': u'BH'}, {u'snippet': {u'gl': u'BY', u'name': u'Belarus'}, u'id': u'BY'}, {u'snippet': {u'gl': u'BE', u'name': u'Belgium'}, u'id': u'BE'}, - {u'snippet': {u'gl': u'BA', u'name': u'Bosnia and Herzegovina'}, u'id': u'BA'}, {u'snippet': {u'gl': u'BR', u'name': u'Brazil'}, u'id': u'BR'}, {u'snippet': {u'gl': u'BG', u'name': u'Bulgaria'}, u'id': u'BG'}, {u'snippet': {u'gl': u'CA', u'name': u'Canada'}, u'id': u'CA'}, - {u'snippet': {u'gl': u'CL', u'name': u'Chile'}, u'id': u'CL'}, {u'snippet': {u'gl': u'CO', u'name': u'Colombia'}, u'id': u'CO'}, {u'snippet': {u'gl': u'HR', u'name': u'Croatia'}, u'id': u'HR'}, {u'snippet': {u'gl': u'CZ', u'name': u'Czech Republic'}, u'id': u'CZ'}, - {u'snippet': {u'gl': u'DK', u'name': u'Denmark'}, u'id': u'DK'}, {u'snippet': {u'gl': u'EG', u'name': u'Egypt'}, u'id': u'EG'}, {u'snippet': {u'gl': u'EE', u'name': u'Estonia'}, u'id': u'EE'}, {u'snippet': {u'gl': u'FI', u'name': u'Finland'}, u'id': u'FI'}, - {u'snippet': {u'gl': u'FR', u'name': u'France'}, u'id': u'FR'}, {u'snippet': {u'gl': u'GE', u'name': u'Georgia'}, u'id': u'GE'}, {u'snippet': {u'gl': u'DE', u'name': u'Germany'}, u'id': u'DE'}, {u'snippet': {u'gl': u'GH', u'name': u'Ghana'}, u'id': u'GH'}, - {u'snippet': {u'gl': u'GR', u'name': u'Greece'}, u'id': u'GR'}, {u'snippet': {u'gl': u'HK', u'name': u'Hong Kong'}, u'id': u'HK'}, {u'snippet': {u'gl': u'HU', u'name': u'Hungary'}, u'id': u'HU'}, {u'snippet': {u'gl': u'IS', u'name': u'Iceland'}, u'id': u'IS'}, - {u'snippet': {u'gl': u'IN', u'name': u'India'}, u'id': u'IN'}, {u'snippet': {u'gl': u'ID', u'name': u'Indonesia'}, u'id': u'ID'}, {u'snippet': {u'gl': u'IQ', u'name': u'Iraq'}, u'id': u'IQ'}, {u'snippet': {u'gl': u'IE', u'name': u'Ireland'}, u'id': u'IE'}, - {u'snippet': {u'gl': u'IL', u'name': u'Israel'}, u'id': u'IL'}, {u'snippet': {u'gl': u'IT', u'name': u'Italy'}, u'id': u'IT'}, {u'snippet': {u'gl': u'JM', u'name': u'Jamaica'}, u'id': u'JM'}, {u'snippet': {u'gl': u'JP', u'name': u'Japan'}, u'id': u'JP'}, - {u'snippet': {u'gl': u'JO', u'name': u'Jordan'}, u'id': u'JO'}, {u'snippet': {u'gl': u'KZ', u'name': u'Kazakhstan'}, u'id': u'KZ'}, {u'snippet': {u'gl': u'KE', u'name': u'Kenya'}, u'id': u'KE'}, {u'snippet': {u'gl': u'KW', u'name': u'Kuwait'}, u'id': u'KW'}, - {u'snippet': {u'gl': u'LV', u'name': u'Latvia'}, u'id': u'LV'}, {u'snippet': {u'gl': u'LB', u'name': u'Lebanon'}, u'id': u'LB'}, {u'snippet': {u'gl': u'LY', u'name': u'Libya'}, u'id': u'LY'}, {u'snippet': {u'gl': u'LT', u'name': u'Lithuania'}, u'id': u'LT'}, - {u'snippet': {u'gl': u'LU', u'name': u'Luxembourg'}, u'id': u'LU'}, {u'snippet': {u'gl': u'MK', u'name': u'Macedonia'}, u'id': u'MK'}, {u'snippet': {u'gl': u'MY', u'name': u'Malaysia'}, u'id': u'MY'}, {u'snippet': {u'gl': u'MX', u'name': u'Mexico'}, u'id': u'MX'}, - {u'snippet': {u'gl': u'ME', u'name': u'Montenegro'}, u'id': u'ME'}, {u'snippet': {u'gl': u'MA', u'name': u'Morocco'}, u'id': u'MA'}, {u'snippet': {u'gl': u'NP', u'name': u'Nepal'}, u'id': u'NP'}, {u'snippet': {u'gl': u'NL', u'name': u'Netherlands'}, u'id': u'NL'}, - {u'snippet': {u'gl': u'NZ', u'name': u'New Zealand'}, u'id': u'NZ'}, {u'snippet': {u'gl': u'NG', u'name': u'Nigeria'}, u'id': u'NG'}, {u'snippet': {u'gl': u'NO', u'name': u'Norway'}, u'id': u'NO'}, {u'snippet': {u'gl': u'OM', u'name': u'Oman'}, u'id': u'OM'}, - {u'snippet': {u'gl': u'PK', u'name': u'Pakistan'}, u'id': u'PK'}, {u'snippet': {u'gl': u'PE', u'name': u'Peru'}, u'id': u'PE'}, {u'snippet': {u'gl': u'PH', u'name': u'Philippines'}, u'id': u'PH'}, {u'snippet': {u'gl': u'PL', u'name': u'Poland'}, u'id': u'PL'}, - {u'snippet': {u'gl': u'PT', u'name': u'Portugal'}, u'id': u'PT'}, {u'snippet': {u'gl': u'PR', u'name': u'Puerto Rico'}, u'id': u'PR'}, {u'snippet': {u'gl': u'QA', u'name': u'Qatar'}, u'id': u'QA'}, {u'snippet': {u'gl': u'RO', u'name': u'Romania'}, u'id': u'RO'}, - {u'snippet': {u'gl': u'RU', u'name': u'Russia'}, u'id': u'RU'}, {u'snippet': {u'gl': u'SA', u'name': u'Saudi Arabia'}, u'id': u'SA'}, {u'snippet': {u'gl': u'SN', u'name': u'Senegal'}, u'id': u'SN'}, {u'snippet': {u'gl': u'RS', u'name': u'Serbia'}, u'id': u'RS'}, - {u'snippet': {u'gl': u'SG', u'name': u'Singapore'}, u'id': u'SG'}, {u'snippet': {u'gl': u'SK', u'name': u'Slovakia'}, u'id': u'SK'}, {u'snippet': {u'gl': u'SI', u'name': u'Slovenia'}, u'id': u'SI'}, {u'snippet': {u'gl': u'ZA', u'name': u'South Africa'}, u'id': u'ZA'}, - {u'snippet': {u'gl': u'KR', u'name': u'South Korea'}, u'id': u'KR'}, {u'snippet': {u'gl': u'ES', u'name': u'Spain'}, u'id': u'ES'}, {u'snippet': {u'gl': u'LK', u'name': u'Sri Lanka'}, u'id': u'LK'}, {u'snippet': {u'gl': u'SE', u'name': u'Sweden'}, u'id': u'SE'}, - {u'snippet': {u'gl': u'CH', u'name': u'Switzerland'}, u'id': u'CH'}, {u'snippet': {u'gl': u'TW', u'name': u'Taiwan'}, u'id': u'TW'}, {u'snippet': {u'gl': u'TZ', u'name': u'Tanzania'}, u'id': u'TZ'}, {u'snippet': {u'gl': u'TH', u'name': u'Thailand'}, u'id': u'TH'}, - {u'snippet': {u'gl': u'TN', u'name': u'Tunisia'}, u'id': u'TN'}, {u'snippet': {u'gl': u'TR', u'name': u'Turkey'}, u'id': u'TR'}, {u'snippet': {u'gl': u'UG', u'name': u'Uganda'}, u'id': u'UG'}, {u'snippet': {u'gl': u'UA', u'name': u'Ukraine'}, u'id': u'UA'}, - {u'snippet': {u'gl': u'AE', u'name': u'United Arab Emirates'}, u'id': u'AE'}, {u'snippet': {u'gl': u'GB', u'name': u'United Kingdom'}, u'id': u'GB'}, {u'snippet': {u'gl': u'US', u'name': u'United States'}, u'id': u'US'}, {u'snippet': {u'gl': u'VN', u'name': u'Vietnam'}, u'id': u'VN'}, - {u'snippet': {u'gl': u'YE', u'name': u'Yemen'}, u'id': u'YE'}, {u'snippet': {u'gl': u'ZW', u'name': u'Zimbabwe'}, u'id': u'ZW'}]} +from __future__ import absolute_import, division, unicode_literals + +from ...kodion.network import Locator + + +DEFAULT_LANGUAGES = {'items': [{'snippet': {'name': 'Afrikaans', 'hl': 'af'}, 'id': 'af'}, {'snippet': {'name': 'Azerbaijani', 'hl': 'az'}, 'id': 'az'}, {'snippet': {'name': 'Indonesian', 'hl': 'id'}, 'id': 'id'}, {'snippet': {'name': 'Malay', 'hl': 'ms'}, 'id': 'ms'}, + {'snippet': {'name': 'Catalan', 'hl': 'ca'}, 'id': 'ca'}, {'snippet': {'name': 'Czech', 'hl': 'cs'}, 'id': 'cs'}, {'snippet': {'name': 'Danish', 'hl': 'da'}, 'id': 'da'}, {'snippet': {'name': 'German', 'hl': 'de'}, 'id': 'de'}, + {'snippet': {'name': 'Estonian', 'hl': 'et'}, 'id': 'et'}, {'snippet': {'name': 'English (United Kingdom)', 'hl': 'en-GB'}, 'id': 'en-GB'}, {'snippet': {'name': 'English', 'hl': 'en'}, 'id': 'en'}, + {'snippet': {'name': 'Spanish (Spain)', 'hl': 'es'}, 'id': 'es'}, {'snippet': {'name': 'Spanish (Latin America)', 'hl': 'es-419'}, 'id': 'es-419'}, {'snippet': {'name': 'Basque', 'hl': 'eu'}, 'id': 'eu'}, + {'snippet': {'name': 'Filipino', 'hl': 'fil'}, 'id': 'fil'}, {'snippet': {'name': 'French', 'hl': 'fr'}, 'id': 'fr'}, {'snippet': {'name': 'French (Canada)', 'hl': 'fr-CA'}, 'id': 'fr-CA'}, {'snippet': {'name': 'Galician', 'hl': 'gl'}, 'id': 'gl'}, + {'snippet': {'name': 'Croatian', 'hl': 'hr'}, 'id': 'hr'}, {'snippet': {'name': 'Zulu', 'hl': 'zu'}, 'id': 'zu'}, {'snippet': {'name': 'Icelandic', 'hl': 'is'}, 'id': 'is'}, {'snippet': {'name': 'Italian', 'hl': 'it'}, 'id': 'it'}, + {'snippet': {'name': 'Swahili', 'hl': 'sw'}, 'id': 'sw'}, {'snippet': {'name': 'Latvian', 'hl': 'lv'}, 'id': 'lv'}, {'snippet': {'name': 'Lithuanian', 'hl': 'lt'}, 'id': 'lt'}, {'snippet': {'name': 'Hungarian', 'hl': 'hu'}, 'id': 'hu'}, + {'snippet': {'name': 'Dutch', 'hl': 'nl'}, 'id': 'nl'}, {'snippet': {'name': 'Norwegian', 'hl': 'no'}, 'id': 'no'}, {'snippet': {'name': 'Uzbek', 'hl': 'uz'}, 'id': 'uz'}, {'snippet': {'name': 'Polish', 'hl': 'pl'}, 'id': 'pl'}, + {'snippet': {'name': 'Portuguese (Portugal)', 'hl': 'pt-PT'}, 'id': 'pt-PT'}, {'snippet': {'name': 'Portuguese (Brazil)', 'hl': 'pt'}, 'id': 'pt'}, {'snippet': {'name': 'Romanian', 'hl': 'ro'}, 'id': 'ro'}, + {'snippet': {'name': 'Albanian', 'hl': 'sq'}, 'id': 'sq'}, {'snippet': {'name': 'Slovak', 'hl': 'sk'}, 'id': 'sk'}, {'snippet': {'name': 'Slovenian', 'hl': 'sl'}, 'id': 'sl'}, {'snippet': {'name': 'Finnish', 'hl': 'fi'}, 'id': 'fi'}, + {'snippet': {'name': 'Swedish', 'hl': 'sv'}, 'id': 'sv'}, {'snippet': {'name': 'Vietnamese', 'hl': 'vi'}, 'id': 'vi'}, {'snippet': {'name': 'Turkish', 'hl': 'tr'}, 'id': 'tr'}, {'snippet': {'name': 'Bulgarian', 'hl': 'bg'}, 'id': 'bg'}, + {'snippet': {'name': 'Kyrgyz', 'hl': 'ky'}, 'id': 'ky'}, {'snippet': {'name': 'Kazakh', 'hl': 'kk'}, 'id': 'kk'}, {'snippet': {'name': 'Macedonian', 'hl': 'mk'}, 'id': 'mk'}, {'snippet': {'name': 'Mongolian', 'hl': 'mn'}, 'id': 'mn'}, + {'snippet': {'name': 'Russian', 'hl': 'ru'}, 'id': 'ru'}, {'snippet': {'name': 'Serbian', 'hl': 'sr'}, 'id': 'sr'}, {'snippet': {'name': 'Ukrainian', 'hl': 'uk'}, 'id': 'uk'}, {'snippet': {'name': 'Greek', 'hl': 'el'}, 'id': 'el'}, + {'snippet': {'name': 'Armenian', 'hl': 'hy'}, 'id': 'hy'}, {'snippet': {'name': 'Hebrew', 'hl': 'iw'}, 'id': 'iw'}, {'snippet': {'name': 'Urdu', 'hl': 'ur'}, 'id': 'ur'}, {'snippet': {'name': 'Arabic', 'hl': 'ar'}, 'id': 'ar'}, + {'snippet': {'name': 'Persian', 'hl': 'fa'}, 'id': 'fa'}, {'snippet': {'name': 'Nepali', 'hl': 'ne'}, 'id': 'ne'}, {'snippet': {'name': 'Marathi', 'hl': 'mr'}, 'id': 'mr'}, {'snippet': {'name': 'Hindi', 'hl': 'hi'}, 'id': 'hi'}, + {'snippet': {'name': 'Bengali', 'hl': 'bn'}, 'id': 'bn'}, {'snippet': {'name': 'Punjabi', 'hl': 'pa'}, 'id': 'pa'}, {'snippet': {'name': 'Gujarati', 'hl': 'gu'}, 'id': 'gu'}, {'snippet': {'name': 'Tamil', 'hl': 'ta'}, 'id': 'ta'}, + {'snippet': {'name': 'Telugu', 'hl': 'te'}, 'id': 'te'}, {'snippet': {'name': 'Kannada', 'hl': 'kn'}, 'id': 'kn'}, {'snippet': {'name': 'Malayalam', 'hl': 'ml'}, 'id': 'ml'}, {'snippet': {'name': 'Sinhala', 'hl': 'si'}, 'id': 'si'}, + {'snippet': {'name': 'Thai', 'hl': 'th'}, 'id': 'th'}, {'snippet': {'name': 'Lao', 'hl': 'lo'}, 'id': 'lo'}, {'snippet': {'name': 'Myanmar (Burmese)', 'hl': 'my'}, 'id': 'my'}, {'snippet': {'name': 'Georgian', 'hl': 'ka'}, 'id': 'ka'}, + {'snippet': {'name': 'Amharic', 'hl': 'am'}, 'id': 'am'}, {'snippet': {'name': 'Khmer', 'hl': 'km'}, 'id': 'km'}, {'snippet': {'name': 'Chinese', 'hl': 'zh-CN'}, 'id': 'zh-CN'}, {'snippet': {'name': 'Chinese (Taiwan)', 'hl': 'zh-TW'}, 'id': 'zh-TW'}, + {'snippet': {'name': 'Chinese (Hong Kong)', 'hl': 'zh-HK'}, 'id': 'zh-HK'}, {'snippet': {'name': 'Japanese', 'hl': 'ja'}, 'id': 'ja'}, {'snippet': {'name': 'Korean', 'hl': 'ko'}, 'id': 'ko'}]} +DEFAULT_REGIONS = {'items': [{'snippet': {'gl': 'DZ', 'name': 'Algeria'}, 'id': 'DZ'}, {'snippet': {'gl': 'AR', 'name': 'Argentina'}, 'id': 'AR'}, {'snippet': {'gl': 'AU', 'name': 'Australia'}, 'id': 'AU'}, {'snippet': {'gl': 'AT', 'name': 'Austria'}, 'id': 'AT'}, + {'snippet': {'gl': 'AZ', 'name': 'Azerbaijan'}, 'id': 'AZ'}, {'snippet': {'gl': 'BH', 'name': 'Bahrain'}, 'id': 'BH'}, {'snippet': {'gl': 'BY', 'name': 'Belarus'}, 'id': 'BY'}, {'snippet': {'gl': 'BE', 'name': 'Belgium'}, 'id': 'BE'}, + {'snippet': {'gl': 'BA', 'name': 'Bosnia and Herzegovina'}, 'id': 'BA'}, {'snippet': {'gl': 'BR', 'name': 'Brazil'}, 'id': 'BR'}, {'snippet': {'gl': 'BG', 'name': 'Bulgaria'}, 'id': 'BG'}, {'snippet': {'gl': 'CA', 'name': 'Canada'}, 'id': 'CA'}, + {'snippet': {'gl': 'CL', 'name': 'Chile'}, 'id': 'CL'}, {'snippet': {'gl': 'CO', 'name': 'Colombia'}, 'id': 'CO'}, {'snippet': {'gl': 'HR', 'name': 'Croatia'}, 'id': 'HR'}, {'snippet': {'gl': 'CZ', 'name': 'Czech Republic'}, 'id': 'CZ'}, + {'snippet': {'gl': 'DK', 'name': 'Denmark'}, 'id': 'DK'}, {'snippet': {'gl': 'EG', 'name': 'Egypt'}, 'id': 'EG'}, {'snippet': {'gl': 'EE', 'name': 'Estonia'}, 'id': 'EE'}, {'snippet': {'gl': 'FI', 'name': 'Finland'}, 'id': 'FI'}, + {'snippet': {'gl': 'FR', 'name': 'France'}, 'id': 'FR'}, {'snippet': {'gl': 'GE', 'name': 'Georgia'}, 'id': 'GE'}, {'snippet': {'gl': 'DE', 'name': 'Germany'}, 'id': 'DE'}, {'snippet': {'gl': 'GH', 'name': 'Ghana'}, 'id': 'GH'}, + {'snippet': {'gl': 'GR', 'name': 'Greece'}, 'id': 'GR'}, {'snippet': {'gl': 'HK', 'name': 'Hong Kong'}, 'id': 'HK'}, {'snippet': {'gl': 'HU', 'name': 'Hungary'}, 'id': 'HU'}, {'snippet': {'gl': 'IS', 'name': 'Iceland'}, 'id': 'IS'}, + {'snippet': {'gl': 'IN', 'name': 'India'}, 'id': 'IN'}, {'snippet': {'gl': 'ID', 'name': 'Indonesia'}, 'id': 'ID'}, {'snippet': {'gl': 'IQ', 'name': 'Iraq'}, 'id': 'IQ'}, {'snippet': {'gl': 'IE', 'name': 'Ireland'}, 'id': 'IE'}, + {'snippet': {'gl': 'IL', 'name': 'Israel'}, 'id': 'IL'}, {'snippet': {'gl': 'IT', 'name': 'Italy'}, 'id': 'IT'}, {'snippet': {'gl': 'JM', 'name': 'Jamaica'}, 'id': 'JM'}, {'snippet': {'gl': 'JP', 'name': 'Japan'}, 'id': 'JP'}, + {'snippet': {'gl': 'JO', 'name': 'Jordan'}, 'id': 'JO'}, {'snippet': {'gl': 'KZ', 'name': 'Kazakhstan'}, 'id': 'KZ'}, {'snippet': {'gl': 'KE', 'name': 'Kenya'}, 'id': 'KE'}, {'snippet': {'gl': 'KW', 'name': 'Kuwait'}, 'id': 'KW'}, + {'snippet': {'gl': 'LV', 'name': 'Latvia'}, 'id': 'LV'}, {'snippet': {'gl': 'LB', 'name': 'Lebanon'}, 'id': 'LB'}, {'snippet': {'gl': 'LY', 'name': 'Libya'}, 'id': 'LY'}, {'snippet': {'gl': 'LT', 'name': 'Lithuania'}, 'id': 'LT'}, + {'snippet': {'gl': 'LU', 'name': 'Luxembourg'}, 'id': 'LU'}, {'snippet': {'gl': 'MK', 'name': 'Macedonia'}, 'id': 'MK'}, {'snippet': {'gl': 'MY', 'name': 'Malaysia'}, 'id': 'MY'}, {'snippet': {'gl': 'MX', 'name': 'Mexico'}, 'id': 'MX'}, + {'snippet': {'gl': 'ME', 'name': 'Montenegro'}, 'id': 'ME'}, {'snippet': {'gl': 'MA', 'name': 'Morocco'}, 'id': 'MA'}, {'snippet': {'gl': 'NP', 'name': 'Nepal'}, 'id': 'NP'}, {'snippet': {'gl': 'NL', 'name': 'Netherlands'}, 'id': 'NL'}, + {'snippet': {'gl': 'NZ', 'name': 'New Zealand'}, 'id': 'NZ'}, {'snippet': {'gl': 'NG', 'name': 'Nigeria'}, 'id': 'NG'}, {'snippet': {'gl': 'NO', 'name': 'Norway'}, 'id': 'NO'}, {'snippet': {'gl': 'OM', 'name': 'Oman'}, 'id': 'OM'}, + {'snippet': {'gl': 'PK', 'name': 'Pakistan'}, 'id': 'PK'}, {'snippet': {'gl': 'PE', 'name': 'Peru'}, 'id': 'PE'}, {'snippet': {'gl': 'PH', 'name': 'Philippines'}, 'id': 'PH'}, {'snippet': {'gl': 'PL', 'name': 'Poland'}, 'id': 'PL'}, + {'snippet': {'gl': 'PT', 'name': 'Portugal'}, 'id': 'PT'}, {'snippet': {'gl': 'PR', 'name': 'Puerto Rico'}, 'id': 'PR'}, {'snippet': {'gl': 'QA', 'name': 'Qatar'}, 'id': 'QA'}, {'snippet': {'gl': 'RO', 'name': 'Romania'}, 'id': 'RO'}, + {'snippet': {'gl': 'RU', 'name': 'Russia'}, 'id': 'RU'}, {'snippet': {'gl': 'SA', 'name': 'Saudi Arabia'}, 'id': 'SA'}, {'snippet': {'gl': 'SN', 'name': 'Senegal'}, 'id': 'SN'}, {'snippet': {'gl': 'RS', 'name': 'Serbia'}, 'id': 'RS'}, + {'snippet': {'gl': 'SG', 'name': 'Singapore'}, 'id': 'SG'}, {'snippet': {'gl': 'SK', 'name': 'Slovakia'}, 'id': 'SK'}, {'snippet': {'gl': 'SI', 'name': 'Slovenia'}, 'id': 'SI'}, {'snippet': {'gl': 'ZA', 'name': 'South Africa'}, 'id': 'ZA'}, + {'snippet': {'gl': 'KR', 'name': 'South Korea'}, 'id': 'KR'}, {'snippet': {'gl': 'ES', 'name': 'Spain'}, 'id': 'ES'}, {'snippet': {'gl': 'LK', 'name': 'Sri Lanka'}, 'id': 'LK'}, {'snippet': {'gl': 'SE', 'name': 'Sweden'}, 'id': 'SE'}, + {'snippet': {'gl': 'CH', 'name': 'Switzerland'}, 'id': 'CH'}, {'snippet': {'gl': 'TW', 'name': 'Taiwan'}, 'id': 'TW'}, {'snippet': {'gl': 'TZ', 'name': 'Tanzania'}, 'id': 'TZ'}, {'snippet': {'gl': 'TH', 'name': 'Thailand'}, 'id': 'TH'}, + {'snippet': {'gl': 'TN', 'name': 'Tunisia'}, 'id': 'TN'}, {'snippet': {'gl': 'TR', 'name': 'Turkey'}, 'id': 'TR'}, {'snippet': {'gl': 'UG', 'name': 'Uganda'}, 'id': 'UG'}, {'snippet': {'gl': 'UA', 'name': 'Ukraine'}, 'id': 'UA'}, + {'snippet': {'gl': 'AE', 'name': 'United Arab Emirates'}, 'id': 'AE'}, {'snippet': {'gl': 'GB', 'name': 'United Kingdom'}, 'id': 'GB'}, {'snippet': {'gl': 'US', 'name': 'United States'}, 'id': 'US'}, {'snippet': {'gl': 'VN', 'name': 'Vietnam'}, 'id': 'VN'}, + {'snippet': {'gl': 'YE', 'name': 'Yemen'}, 'id': 'YE'}, {'snippet': {'gl': 'ZW', 'name': 'Zimbabwe'}, 'id': 'ZW'}]} def _process_language(provider, context): - if not context.get_ui().on_yes_no_input(context.localize(provider.LOCAL_MAP['youtube.setup_wizard.adjust']), - context.localize(provider.LOCAL_MAP['youtube.setup_wizard.adjust.language_and_region'])): + if not context.get_ui().on_yes_no_input(context.localize('setup_wizard.adjust'), + context.localize('setup_wizard.adjust.language_and_region')): return client = provider.get_client(context) @@ -70,7 +72,7 @@ def _process_language(provider, context): else: items = json_data['items'] language_list = [] - invalid_ids = [u'es-419'] # causes hl not a valid language error. Issue #418 + invalid_ids = ['es-419'] # causes hl not a valid language error. Issue #418 for item in items: if item['id'] in invalid_ids: continue @@ -79,7 +81,7 @@ def _process_language(provider, context): language_list.append((language_name, hl)) language_list = sorted(language_list, key=lambda x: x[0]) language_id = context.get_ui().on_select( - context.localize(provider.LOCAL_MAP['youtube.setup_wizard.select_language']), language_list) + context.localize('setup_wizard.select_language'), language_list) if language_id == -1: return @@ -94,7 +96,7 @@ def _process_language(provider, context): gl = item['snippet']['gl'] region_list.append((region_name, gl)) region_list = sorted(region_list, key=lambda x: x[0]) - region_id = context.get_ui().on_select(context.localize(provider.LOCAL_MAP['youtube.setup_wizard.select_region']), + region_id = context.get_ui().on_select(context.localize('setup_wizard.select_region'), region_list) if region_id == -1: return @@ -105,18 +107,17 @@ def _process_language(provider, context): provider.reset_client() -def _process_geo_location(provider, context): - settings = context.get_settings() - if not context.get_ui().on_yes_no_input(context.get_name(), context.localize(provider.LOCAL_MAP['youtube.perform.geolocation'])): +def _process_geo_location(context): + if not context.get_ui().on_yes_no_input(context.get_name(), context.localize('perform_geolocation')): return - locator = ip_api.Locator(context) + locator = Locator() locator.locate_requester() coordinates = locator.coordinates() if coordinates: - settings.set_location('{lat},{lon}'.format(lat=coordinates[0], lon=coordinates[1])) + context.get_settings().set_location('{0[lat]},{0[lon]}'.format(coordinates)) def process(provider, context): _process_language(provider, context) - _process_geo_location(provider, context) + _process_geo_location(context) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_specials.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_specials.py index 89ad2b1190..1f5cc53b33 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_specials.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_specials.py @@ -8,357 +8,339 @@ See LICENSES/GPL-2.0-only for more information. """ -from ... import kodion -from ...kodion.items import DirectoryItem, UriItem -from ...youtube.helper import v3, tv, extract_urls, UrlResolver, UrlToItemConverter +from __future__ import absolute_import, division, unicode_literals + from . import utils +from ..helper import ( + UrlResolver, + UrlToItemConverter, + extract_urls, + tv, + v3, +) +from ...kodion import KodionException +from ...kodion.constants import content +from ...kodion.items import DirectoryItem, UriItem +from ...kodion.utils import strip_html_from_text def _process_related_videos(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] + context.set_content(content.VIDEO_CONTENT) + function_cache = context.get_function_cache() - page_token = context.get_param('page_token', '') video_id = context.get_param('video_id', '') if video_id: - json_data = provider.get_client(context).get_related_videos(video_id=video_id, page_token=page_token) - if not v3.handle_error(provider, context, json_data): - return False - result.extend(v3.response_to_items(provider, context, json_data, process_next_page=False)) + json_data = function_cache.get( + provider.get_client(context).get_related_videos, + function_cache.ONE_HOUR, + video_id=video_id, + page_token=context.get_param('page_token', ''), + ) + else: + json_data = function_cache.get( + provider.get_client(context).get_related_for_home, + function_cache.ONE_HOUR, + page_token=context.get_param('page_token', ''), + ) - return result + if not json_data: + return False + return v3.response_to_items(provider, context, json_data) def _process_parent_comments(provider, context): - provider.set_content_type(context, kodion.constants.content_type.FILES) - result = [] + context.set_content(content.LIST_CONTENT) - page_token = context.get_param('page_token', '') video_id = context.get_param('video_id', '') - if video_id: - json_data = provider.get_client(context).get_parent_comments(video_id=video_id, page_token=page_token) - if not v3.handle_error(provider, context, json_data): - return False - result.extend(v3.response_to_items(provider, context, json_data)) + if not video_id: + return [] + + json_data = provider.get_client(context).get_parent_comments( + video_id=video_id, page_token=context.get_param('page_token', '') + ) - return result + if not json_data: + return False + return v3.response_to_items(provider, context, json_data) def _process_child_comments(provider, context): - provider.set_content_type(context, kodion.constants.content_type.FILES) - result = [] + context.set_content(content.LIST_CONTENT) - page_token = context.get_param('page_token', '') parent_id = context.get_param('parent_id', '') - if parent_id: - json_data = provider.get_client(context).get_child_comments(parent_id=parent_id, page_token=page_token) - if not v3.handle_error(provider, context, json_data): - return False - result.extend(v3.response_to_items(provider, context, json_data)) + if not parent_id: + return [] + + json_data = provider.get_client(context).get_child_comments( + parent_id=parent_id, page_token=context.get_param('page_token', '') + ) - return result + if not json_data: + return False + return v3.response_to_items(provider, context, json_data) def _process_recommendations(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] - - page_token = context.get_param('page_token', '') - json_data = provider.get_client(context).get_activities('home', page_token=page_token) - if not v3.handle_error(provider, context, json_data): + context.set_content(content.VIDEO_CONTENT) + params = context.get_params() + function_cache = context.get_function_cache() + + json_data = function_cache.get( + provider.get_client(context).get_recommended_for_home, + function_cache.ONE_HOUR, + visitor=params.get('visitor', ''), + page_token=params.get('page_token', ''), + click_tracking=params.get('click_tracking', ''), + ) + + if not json_data: return False - result.extend(v3.response_to_items(provider, context, json_data)) - return result + return v3.response_to_items(provider, context, json_data) -def _process_popular_right_now(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] +def _process_trending(provider, context): + context.set_content(content.VIDEO_CONTENT) - page_token = context.get_param('page_token', '') - json_data = provider.get_client(context).get_popular_videos(page_token=page_token) - if not v3.handle_error(provider, context, json_data): - return False - result.extend(v3.response_to_items(provider, context, json_data)) + json_data = provider.get_client(context).get_trending_videos( + page_token=context.get_param('page_token', '') + ) - return result + if not json_data: + return False + return v3.response_to_items(provider, context, json_data) def _process_browse_channels(provider, context): - provider.set_content_type(context, kodion.constants.content_type.FILES) - result = [] - - # page_token = context.get_param('page_token', '') - guide_id = context.get_param('guide_id', '') + context.set_content(content.LIST_CONTENT) client = provider.get_client(context) + guide_id = context.get_param('guide_id', '') if guide_id: json_data = client.get_guide_category(guide_id) - if not v3.handle_error(provider, context, json_data): - return False - result.extend(v3.response_to_items(provider, context, json_data)) else: - json_data = context.get_function_cache().get(kodion.utils.FunctionCache.ONE_MONTH, client.get_guide_categories) - if not v3.handle_error(provider, context, json_data): - return False - result.extend(v3.response_to_items(provider, context, json_data)) + function_cache = context.get_function_cache() + json_data = function_cache.get(client.get_guide_categories, + function_cache.ONE_MONTH) - return result + if not json_data: + return False + return v3.response_to_items(provider, context, json_data) def _process_disliked_videos(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] + context.set_content(content.VIDEO_CONTENT) + + json_data = provider.get_client(context).get_disliked_videos( + page_token=context.get_param('page_token', '') + ) - page_token = context.get_param('page_token', '') - json_data = provider.get_client(context).get_disliked_videos(page_token=page_token) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False - result.extend(v3.response_to_items(provider, context, json_data)) - return result + return v3.response_to_items(provider, context, json_data) def _process_live_events(provider, context, event_type='live'): def _sort(x): - return x.get_aired() + return x.get_date() - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] + context.set_content(content.VIDEO_CONTENT) # TODO: cache result - page_token = context.get_param('page_token', '') - location = str(context.get_param('location', False)).lower() == 'true' + json_data = provider.get_client(context).get_live_events( + event_type=event_type, + page_token=context.get_param('page_token', ''), + location=context.get_param('location', False), + ) - json_data = provider.get_client(context).get_live_events(event_type=event_type, page_token=page_token, location=location) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False - result.extend(v3.response_to_items(provider, context, json_data, sort=_sort, reverse_sort=True)) - - return result + return v3.response_to_items(provider, context, json_data, sort=_sort) def _process_description_links(provider, context): - incognito = str(context.get_param('incognito', False)).lower() == 'true' - addon_id = context.get_param('addon_id', '') + params = context.get_params() + incognito = params.get('incognito', False) + addon_id = params.get('addon_id', '') - def _extract_urls(_video_id): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) + def _extract_urls(video_id): + context.set_content(content.VIDEO_CONTENT) url_resolver = UrlResolver(context) - result = [] - - progress_dialog = \ - context.get_ui().create_progress_dialog(heading=context.localize(kodion.constants.localize.COMMON_PLEASE_WAIT), - background=False) - - resource_manager = provider.get_resource_manager(context) - - video_data = resource_manager.get_videos([_video_id]) - yt_item = video_data[_video_id] - snippet = yt_item['snippet'] # crash if not conform - description = kodion.utils.strip_html_from_text(snippet['description']) - - urls = context.get_function_cache().get(kodion.utils.FunctionCache.ONE_WEEK, extract_urls, description) - - progress_dialog.set_total(len(urls)) - - res_urls = [] - for url in urls: - context.log_debug('Resolving url "%s"' % url) - progress_dialog.update(steps=1, text=url) - resolved_url = url_resolver.resolve(url) - context.log_debug('Resolved url "%s"' % resolved_url) - res_urls.append(resolved_url) - - if progress_dialog.is_aborted(): - context.log_debug('Resolving urls aborted') - break - - context.sleep(50) - - url_to_item_converter = UrlToItemConverter() - url_to_item_converter.add_urls(res_urls, provider, context) - - result.extend(url_to_item_converter.get_items(provider, context)) - - progress_dialog.close() - - if len(result) == 0: - progress_dialog.close() - context.get_ui().on_ok(title=context.localize(provider.LOCAL_MAP['youtube.video.description.links']), - text=context.localize( - provider.LOCAL_MAP['youtube.video.description.links.not_found'])) - return False - - return result - - def _display_channels(_channel_ids): - _channel_id_dict = {} + with context.get_ui().create_progress_dialog( + heading=context.localize('please_wait'), background=False + ) as progress_dialog: + resource_manager = provider.get_resource_manager(context) + + video_data = resource_manager.get_videos((video_id,)) + yt_item = video_data[video_id] + if not yt_item or 'snippet' not in yt_item: + context.get_ui().on_ok( + title=context.localize('video.description.links'), + text=context.localize('video.description.links.not_found') + ) + return False + snippet = yt_item['snippet'] + description = strip_html_from_text(snippet['description']) + + function_cache = context.get_function_cache() + urls = function_cache.get(extract_urls, + function_cache.ONE_WEEK, + description) + + progress_dialog.set_total(len(urls)) + + res_urls = [] + for url in urls: + progress_dialog.update(steps=1, text=url) + resolved_url = url_resolver.resolve(url) + res_urls.append(resolved_url) + + if progress_dialog.is_aborted(): + context.log_debug('Resolving urls aborted') + break + + url_to_item_converter = UrlToItemConverter() + url_to_item_converter.add_urls(res_urls, context) + result = url_to_item_converter.get_items(provider, context) + + if result: + return result + context.get_ui().on_ok( + title=context.localize('video.description.links'), + text=context.localize('video.description.links.not_found') + ) + return False + def _display_channels(channel_ids): item_params = {} if incognito: - item_params.update({'incognito': incognito}) + item_params['incognito'] = incognito if addon_id: - item_params.update({'addon_id': addon_id}) + item_params['addon_id'] = addon_id - for channel_id in _channel_ids: - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = DirectoryItem('', item_uri) - channel_item.set_fanart(provider.get_fanart(context)) - _channel_id_dict[channel_id] = channel_item + channel_id_dict = {} + for channel_id in channel_ids: + channel_item = DirectoryItem( + '', context.create_uri(['channel', channel_id], item_params) + ) + channel_id_dict[channel_id] = channel_item - _channel_item_dict = {} - utils.update_channel_infos(provider, context, _channel_id_dict, channel_items_dict=_channel_item_dict) + channel_item_dict = {} + utils.update_channel_infos(provider, + context, + channel_id_dict, + channel_items_dict=channel_item_dict) # clean up - remove empty entries - _result = [] - for key in _channel_id_dict: - _channel_item = _channel_id_dict[key] - if _channel_item.get_name(): - _result.append(_channel_item) - return _result - - def _display_playlists(_playlist_ids): - _playlist_id_dict = {} + return [channel_item + for channel_item in channel_id_dict.values() + if channel_item.get_name()] + def _display_playlists(playlist_ids): item_params = {} if incognito: - item_params.update({'incognito': incognito}) + item_params['incognito'] = incognito if addon_id: - item_params.update({'addon_id': addon_id}) - - for playlist_id in _playlist_ids: - item_uri = context.create_uri(['playlist', playlist_id], item_params) - playlist_item = DirectoryItem('', item_uri) - playlist_item.set_fanart(provider.get_fanart(context)) - _playlist_id_dict[playlist_id] = playlist_item - - _channel_item_dict = {} - utils.update_playlist_infos(provider, context, _playlist_id_dict, _channel_item_dict) - utils.update_fanarts(provider, context, _channel_item_dict) + item_params['addon_id'] = addon_id + + playlist_id_dict = {} + for playlist_id in playlist_ids: + playlist_item = DirectoryItem( + '', context.create_uri(['playlist', playlist_id], item_params) + ) + playlist_id_dict[playlist_id] = playlist_item + + channel_item_dict = {} + utils.update_playlist_infos(provider, + context, + playlist_id_dict, + channel_items_dict=channel_item_dict) + utils.update_fanarts(provider, context, channel_item_dict) # clean up - remove empty entries - _result = [] - for key in _playlist_id_dict: - _playlist_item = _playlist_id_dict[key] - if _playlist_item.get_name(): - _result.append(_playlist_item) + return [playlist_item + for playlist_item in playlist_id_dict.values() + if playlist_item.get_name()] - return _result - - video_id = context.get_param('video_id', '') + video_id = params.get('video_id', '') if video_id: return _extract_urls(video_id) - channel_ids = context.get_param('channel_ids', '') + channel_ids = params.get('channel_ids', []) if channel_ids: - channel_ids = channel_ids.split(',') - if len(channel_ids) > 0: - return _display_channels(channel_ids) + return _display_channels(channel_ids) - playlist_ids = context.get_param('playlist_ids', '') + playlist_ids = params.get('playlist_ids', []) if playlist_ids: - playlist_ids = playlist_ids.split(',') - if len(playlist_ids) > 0: - return _display_playlists(playlist_ids) + return _display_playlists(playlist_ids) context.log_error('Missing video_id or playlist_ids for description links') - return False def _process_saved_playlists_tv(provider, context): - provider.set_content_type(context, kodion.constants.content_type.FILES) - - result = [] - next_page_token = context.get_param('next_page_token', '') - offset = int(context.get_param('offset', 0)) - json_data = provider.get_client(context).get_saved_playlists(page_token=next_page_token, offset=offset) - result.extend(tv.saved_playlists_to_items(provider, context, json_data)) - - return result + context.set_content(content.LIST_CONTENT) + json_data = provider.get_client(context).get_saved_playlists( + page_token=context.get_param('next_page_token', ''), + offset=context.get_param('offset', 0) + ) -def _process_watch_history_tv(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - - result = [] - next_page_token = context.get_param('next_page_token', '') - offset = int(context.get_param('offset', 0)) - json_data = provider.get_client(context).get_watch_history(page_token=next_page_token, offset=offset) - result.extend(tv.tv_videos_to_items(provider, context, json_data)) - - return result - - -def _process_purchases_tv(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - - result = [] - next_page_token = context.get_param('next_page_token', '') - offset = int(context.get_param('offset', 0)) - json_data = provider.get_client(context).get_purchases(page_token=next_page_token, offset=offset) - result.extend(tv.tv_videos_to_items(provider, context, json_data)) - - return result - - -def _process_new_uploaded_videos_tv(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - - result = [] - next_page_token = context.get_param('next_page_token', '') - offset = int(context.get_param('offset', 0)) - json_data = provider.get_client(context).get_my_subscriptions(page_token=next_page_token, offset=offset) - result.extend(tv.my_subscriptions_to_items(provider, context, json_data)) - - return result + if not json_data: + return False + return tv.saved_playlists_to_items(provider, context, json_data) -def _process_new_uploaded_videos_tv_filtered(provider, context): - provider.set_content_type(context, kodion.constants.content_type.VIDEOS) +def _process_new_uploaded_videos_tv(provider, context, filtered=False): + context.set_content(content.VIDEO_CONTENT) - result = [] - next_page_token = context.get_param('next_page_token', '') - offset = int(context.get_param('offset', 0)) - json_data = provider.get_client(context).get_my_subscriptions(page_token=next_page_token, offset=offset) - result.extend(tv.my_subscriptions_to_items(provider, context, json_data, do_filter=True)) + json_data = provider.get_client(context).get_my_subscriptions( + page_token=context.get_param('next_page_token', ''), + offset=context.get_param('offset', 0) + ) - return result + if not json_data: + return False + return tv.my_subscriptions_to_items(provider, + context, + json_data, + do_filter=filtered) def process(category, provider, context): _ = provider.get_client(context) # required for provider.is_logged_in() - if not provider.is_logged_in() and category in ['new_uploaded_videos_tv', 'new_uploaded_videos_tv_filtered', 'disliked_videos']: - return UriItem(context.create_uri(['sign', 'in'])) + if (not provider.is_logged_in() + and category in ('new_uploaded_videos_tv', + 'new_uploaded_videos_tv_filtered', + 'disliked_videos')): + return UriItem(context.create_uri(('sign', 'in'))) if category == 'related_videos': return _process_related_videos(provider, context) - elif category == 'popular_right_now': - return _process_popular_right_now(provider, context) - elif category == 'recommendations': + if category == 'popular_right_now': + return _process_trending(provider, context) + if category == 'recommendations': return _process_recommendations(provider, context) - elif category == 'browse_channels': + if category == 'browse_channels': return _process_browse_channels(provider, context) - elif category == 'new_uploaded_videos_tv': + if category == 'new_uploaded_videos_tv': return _process_new_uploaded_videos_tv(provider, context) - elif category == 'new_uploaded_videos_tv_filtered': - return _process_new_uploaded_videos_tv_filtered(provider, context) - elif category == 'disliked_videos': + if category == 'new_uploaded_videos_tv_filtered': + return _process_new_uploaded_videos_tv(provider, context, filtered=True) + if category == 'disliked_videos': return _process_disliked_videos(provider, context) - elif category == 'live': + if category == 'live': return _process_live_events(provider, context) - elif category == 'upcoming_live': + if category == 'upcoming_live': return _process_live_events(provider, context, event_type='upcoming') - elif category == 'completed_live': + if category == 'completed_live': return _process_live_events(provider, context, event_type='completed') - elif category == 'description_links': + if category == 'description_links': return _process_description_links(provider, context) - elif category == 'parent_comments': + if category == 'parent_comments': return _process_parent_comments(provider, context) - elif category == 'child_comments': + if category == 'child_comments': return _process_child_comments(provider, context) - elif category == 'saved_playlists': + if category == 'saved_playlists': return _process_saved_playlists_tv(provider, context) - else: - raise kodion.KodionException("YouTube special category '%s' not found" % category) + raise KodionException("YouTube special category '%s' not found" % category) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py index 9dd98b0a5c..2d40944b44 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py @@ -8,9 +8,11 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + +from ..helper import v3 +from ...kodion import KodionException from ...kodion.items import UriItem -from ... import kodion -from ...youtube.helper import v3 def _process_list(provider, context): @@ -19,7 +21,7 @@ def _process_list(provider, context): page_token = context.get_param('page_token', '') # no caching json_data = provider.get_client(context).get_subscription('mine', page_token=page_token) - if not v3.handle_error(provider, context, json_data): + if not json_data: return [] result.extend(v3.response_to_items(provider, context, json_data)) @@ -27,21 +29,21 @@ def _process_list(provider, context): def _process_add(provider, context): - listitem_subscription_id = context.get_ui().get_info_label('Container.ListItem(0).Property(subscription_id)') + listitem_subscription_id = context.get_infolabel('Container.ListItem(0).Property(subscription_id)') subscription_id = context.get_param('subscription_id', '') - if not subscription_id: - if listitem_subscription_id and listitem_subscription_id.lower().startswith('uc'): - subscription_id = listitem_subscription_id + if (not subscription_id and listitem_subscription_id + and listitem_subscription_id.lower().startswith('uc')): + subscription_id = listitem_subscription_id if subscription_id: json_data = provider.get_client(context).subscribe(subscription_id) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False context.get_ui().show_notification( - context.localize(provider.LOCAL_MAP['youtube.subscribed.to.channel']), - time_milliseconds=2500, + context.localize('subscribed.to.channel'), + time_ms=2500, audible=False ) @@ -51,7 +53,7 @@ def _process_add(provider, context): def _process_remove(provider, context): - listitem_subscription_id = context.get_ui().get_info_label('Container.ListItem(0).Property(channel_subscription_id)') + listitem_subscription_id = context.get_infolabel('Container.ListItem(0).Property(channel_subscription_id)') subscription_id = context.get_param('subscription_id', '') if not subscription_id and listitem_subscription_id: @@ -59,14 +61,14 @@ def _process_remove(provider, context): if subscription_id: json_data = provider.get_client(context).unsubscribe(subscription_id) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False context.get_ui().refresh_container() context.get_ui().show_notification( - context.localize(provider.LOCAL_MAP['youtube.unsubscribed.from.channel']), - time_milliseconds=2500, + context.localize('unsubscribed.from.channel'), + time_ms=2500, audible=False ) @@ -81,7 +83,7 @@ def process(method, provider, context): # we need a login _ = provider.get_client(context) if not provider.is_logged_in(): - return UriItem(context.create_uri(['sign', 'in'])) + return UriItem(context.create_uri(('sign', 'in'))) if method == 'list': result.extend(_process_list(provider, context)) @@ -90,6 +92,6 @@ def process(method, provider, context): elif method == 'remove': return _process_remove(provider, context) else: - raise kodion.KodionException("Unknown subscriptions method '%s'" % method) + raise KodionException("Unknown subscriptions method '%s'" % method) return result diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_video.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_video.py index 7d900ea4d8..c1e3bd527a 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_video.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/helper/yt_video.py @@ -8,12 +8,15 @@ See LICENSES/GPL-2.0-only for more information. """ -from ... import kodion -from ...youtube.helper import v3 +from __future__ import absolute_import, division, unicode_literals + +from ...kodion import KodionException +from ...kodion.items import menu_items +from ...kodion.utils import find_video_id def _process_rate_video(provider, context, re_match): - listitem_path = context.get_ui().get_info_label('Container.ListItem(0).FileNameAndPath') + listitem_path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') ratings = ['like', 'dislike', 'none'] rating_param = context.get_param('rating', '') @@ -25,11 +28,11 @@ def _process_rate_video(provider, context, re_match): try: video_id = re_match.group('video_id') except IndexError: - if context.is_plugin_path(listitem_path, 'play'): - video_id = kodion.utils.find_video_id(listitem_path) + if context.is_plugin_path(listitem_path, 'play/'): + video_id = find_video_id(listitem_path) if not video_id: - raise kodion.KodionException('video/rate/: missing video_id') + raise KodionException('video/rate/: missing video_id') try: current_rating = re_match.group('rating') @@ -39,7 +42,7 @@ def _process_rate_video(provider, context, re_match): if not current_rating: client = provider.get_client(context) json_data = client.get_video_rating(video_id) - if not v3.handle_error(provider, context, json_data): + if not json_data: return False items = json_data.get('items', []) @@ -50,13 +53,12 @@ def _process_rate_video(provider, context, re_match): if not rating_param: for rating in ratings: if rating != current_rating: - rating_items.append((context.localize(provider.LOCAL_MAP['youtube.video.rate.%s' % rating]), rating)) - result = context.get_ui().on_select(context.localize(provider.LOCAL_MAP['youtube.video.rate']), rating_items) + rating_items.append((context.localize('video.rate.%s' % rating), rating)) + result = context.get_ui().on_select(context.localize('video.rate'), rating_items) + elif rating_param != current_rating: + result = rating_param else: - if rating_param != current_rating: - result = rating_param - else: - result = -1 + result = -1 if result != -1: notify_message = '' @@ -64,59 +66,52 @@ def _process_rate_video(provider, context, re_match): response = provider.get_client(context).rate_video(video_id, result) if response.get('status_code') != 204: - notify_message = context.localize(provider.LOCAL_MAP['youtube.failed']) + notify_message = context.localize('failed') elif response.get('status_code') == 204: # this will be set if we are in the 'Liked Video' playlist - if context.get_param('refresh_container', '0') == '1': + if context.get_param('refresh_container'): context.get_ui().refresh_container() if result == 'none': - notify_message = context.localize(provider.LOCAL_MAP['youtube.unrated.video']) + notify_message = context.localize('unrated.video') elif result == 'like': - notify_message = context.localize(provider.LOCAL_MAP['youtube.liked.video']) + notify_message = context.localize('liked.video') elif result == 'dislike': - notify_message = context.localize(provider.LOCAL_MAP['youtube.disliked.video']) + notify_message = context.localize('disliked.video') if notify_message: context.get_ui().show_notification( message=notify_message, - time_milliseconds=2500, + time_ms=2500, audible=False ) + return True -def _process_more_for_video(provider, context): - video_id = context.get_param('video_id', '') + +def _process_more_for_video(context): + params = context.get_params() + + video_id = params.get('video_id') if not video_id: - raise kodion.KodionException('video/more/: missing video_id') - - items = [] - - is_logged_in = context.get_param('logged_in', '0') - if is_logged_in == '1': - # add video to a playlist - items.append((context.localize(provider.LOCAL_MAP['youtube.video.add_to_playlist']), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'select', 'playlist'], {'video_id': video_id}))) - - - # default items - items.extend([(context.localize(provider.LOCAL_MAP['youtube.related_videos']), - 'Container.Update(%s)' % context.create_uri(['special', 'related_videos'], {'video_id': video_id})), - (context.localize(provider.LOCAL_MAP['youtube.video.comments']), - 'Container.Update(%s)' % context.create_uri(['special', 'parent_comments'], {'video_id': video_id})), - (context.localize(provider.LOCAL_MAP['youtube.video.description.links']), - 'Container.Update(%s)' % context.create_uri(['special', 'description_links'], - {'video_id': video_id}))]) - - if is_logged_in == '1': - # rate a video - refresh_container = context.get_param('refresh_container', '0') - items.append((context.localize(provider.LOCAL_MAP['youtube.video.rate']), - 'RunPlugin(%s)' % context.create_uri(['video', 'rate'], {'video_id': video_id, - 'refresh_container': refresh_container}))) - - result = context.get_ui().on_select(context.localize(provider.LOCAL_MAP['youtube.video.more']), items) + raise KodionException('video/more/: missing video_id') + + items = [ + menu_items.add_video_to_playlist(context, video_id), + menu_items.related_videos(context, video_id), + menu_items.video_comments(context, video_id), + menu_items.content_from_description(context, video_id), + menu_items.rate_video(context, + video_id, + params.get('refresh_container')), + ] if params.get('logged_in') else [ + menu_items.related_videos(context, video_id), + menu_items.video_comments(context, video_id), + menu_items.content_from_description(context, video_id), + ] + + result = context.get_ui().on_select(context.localize('video.more'), items) if result != -1: context.execute(result) @@ -124,7 +119,6 @@ def _process_more_for_video(provider, context): def process(method, provider, context, re_match): if method == 'rate': return _process_rate_video(provider, context, re_match) - elif method == 'more': - return _process_more_for_video(provider, context) - else: - raise kodion.KodionException("Unknown method '%s'" % method) + if method == 'more': + return _process_more_for_video(context) + raise KodionException("Unknown method '%s'" % method) diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/provider.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/provider.py index bdab66582b..26517f7c99 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/provider.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/provider.py @@ -8,207 +8,67 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import json -import os import re -import shutil -import socket from base64 import b64decode -from ..youtube.helper import yt_subscriptions -from .. import kodion -from ..kodion.utils import FunctionCache, strip_html_from_text, get_client_ip_address, is_httpd_live, find_video_id -from ..kodion.items import * -from ..youtube.client import YouTube -from .helper import v3, ResourceManager, yt_specials, yt_playlist, yt_login, yt_setup_wizard, yt_video, \ - yt_context_menu, yt_play, yt_old_actions, UrlResolver, UrlToItemConverter +from .client import YouTube +from .helper import ( + ResourceManager, + UrlResolver, + UrlToItemConverter, + v3, + yt_login, + yt_old_actions, + yt_play, + yt_playlist, + yt_setup_wizard, + yt_specials, + yt_subscriptions, + yt_video, +) from .youtube_exceptions import InvalidGrant, LoginException - -import xbmc -import xbmcaddon -import xbmcvfs -import xbmcgui -import xbmcplugin - -try: - xbmc.translatePath = xbmcvfs.translatePath -except AttributeError: - pass - - -class Provider(kodion.AbstractProvider): - LOCAL_MAP = {'youtube.search': 30102, - 'youtube.next_page': 30106, - 'youtube.watch_later': 30107, - 'youtube.video.rate.none': 30108, - 'youtube.remove': 30108, - 'youtube.sign.in': 30111, - 'youtube.sign.out': 30112, - 'youtube.rename': 30113, - 'youtube.delete': 30118, - 'youtube.api.key': 30201, - 'youtube.api.id': 30202, - 'youtube.api.secret': 30203, - 'youtube.channels': 30500, - 'youtube.playlists': 30501, - 'youtube.go_to_channel': 30502, - 'youtube.subscriptions': 30504, - 'youtube.unsubscribe': 30505, - 'youtube.subscribe': 30506, - 'youtube.my_channel': 30507, - 'youtube.video.liked': 30508, - 'youtube.history': 30509, - 'youtube.my_subscriptions': 30510, - 'youtube.video.queue': 30511, - 'youtube.browse_channels': 30512, - 'youtube.popular_right_now': 30513, - 'youtube.related_videos': 30514, - 'youtube.setting.auto_remove_watch_later': 30515, - 'youtube.subscribe_to': 30517, - 'youtube.sign.go_to': 30518, - 'youtube.sign.enter_code': 30519, - 'youtube.video.add_to_playlist': 30520, - 'youtube.playlist.select': 30521, - 'youtube.playlist.create': 30522, - 'youtube.setup_wizard.select_language': 30524, - 'youtube.setup_wizard.select_region': 30525, - 'youtube.setup_wizard.adjust': 30526, - 'youtube.setup_wizard.adjust.language_and_region': 30527, - 'youtube.video.rate': 30528, - 'youtube.video.rate.like': 30529, - 'youtube.video.rate.dislike': 30530, - 'youtube.playlist.play.all': 30531, - 'youtube.playlist.play.default': 30532, - 'youtube.playlist.play.reverse': 30533, - 'youtube.playlist.play.shuffle': 30534, - 'youtube.playlist.play.select': 30535, - 'youtube.playlist.progress.updating': 30536, - 'youtube.playlist.play.from_here': 30537, - 'youtube.video.disliked': 30538, - 'youtube.live': 30539, - 'youtube.video.play_with': 30540, - 'youtube.error.rtmpe_not_supported': 30542, - 'youtube.refresh': 30543, - 'youtube.video.description.links': 30544, - 'youtube.video.description.links.not_found': 30545, - 'youtube.sign.twice.title': 30546, - 'youtube.sign.twice.text': 30547, - 'youtube.video.more': 30548, - 'youtube.error.no_video_streams_found': 30549, - 'youtube.recommendations': 30551, - 'youtube.function.cache': 30557, - 'youtube.search.history': 30558, - 'youtube.subtitle.language': 30560, - 'youtube.none': 30561, - 'youtube.prompt': 30566, - 'youtube.set.as.watchlater': 30567, - 'youtube.remove.as.watchlater': 30568, - 'youtube.set.as.history': 30571, - 'youtube.remove.as.history': 30572, - 'youtube.succeeded': 30575, - 'youtube.failed': 30576, - 'youtube.settings': 30577, - 'youtube.mpd.enable.confirm': 30579, - 'youtube.reset.access.manager.confirm': 30581, - 'youtube.my_subscriptions_filtered': 30584, - 'youtube.add.my_subscriptions.filter': 30587, - 'youtube.remove.my_subscriptions.filter': 30588, - 'youtube.added.my_subscriptions.filter': 30589, - 'youtube.removed.my_subscriptions.filter': 30590, - 'youtube.updated_': 30597, - 'youtube.api.personal.enabled': 30598, - 'youtube.api.personal.failed': 30599, - 'youtube.subtitle._with_fallback': 30601, - 'youtube.subtitle.no.auto.generated': 30602, - 'youtube.quick.search': 30605, - 'youtube.quick.search.incognito': 30606, - 'youtube.clear_history': 30609, - 'youtube.clear_history_confirmation': 30610, - 'youtube.saved.playlists': 30611, - 'youtube.retry': 30612, - 'youtube.failed.watch_later.retry': 30614, - 'youtube.cancel': 30615, - 'youtube.must.be.signed.in': 30616, - 'youtube.select.listen.ip': 30644, - 'youtube.purchases': 30622, - 'youtube.requires.krypton': 30624, - 'youtube.inputstreamhelper.is.installed': 30625, - 'youtube.upcoming.live': 30646, - 'youtube.completed.live': 30647, - 'youtube.api.key.incorrect': 30648, - 'youtube.client.id.incorrect': 30649, - 'youtube.client.secret.incorrect': 30650, - 'youtube.perform.geolocation': 30653, - 'youtube.my_location': 30654, - 'youtube.switch.user': 30655, - 'youtube.user.new': 30656, - 'youtube.user.unnamed': 30657, - 'youtube.enter.user.name': 30658, - 'youtube.user.changed': 30659, - 'youtube.remove.a.user': 30662, - 'youtube.rename.a.user': 30663, - 'youtube.switch.user.now': 30665, - 'youtube.removed': 30666, - 'youtube.renamed': 30667, - 'youtube.playback.history': 30673, - 'youtube.mark.watched': 30670, - 'youtube.mark.unwatched': 30669, - 'youtube.reset.resume.point': 30674, - 'youtube.data.cache': 30687, - 'youtube.httpd.not.running': 30699, - 'youtube.client.ip': 30700, - 'youtube.client.ip.failed': 30701, - 'youtube.video.play_with_subtitles': 30702, - 'youtube.are.you.sure': 30703, - 'youtube.subtitles.download': 30705, - 'youtube.pre.download.subtitles': 30706, - 'youtube.untitled': 30707, - 'youtube.video.play_audio_only': 30708, - 'youtube.failed.watch_later.retry.2': 30709, - 'youtube.failed.watch_later.retry.3': 30710, - 'youtube.added.to.watch.later': 30713, - 'youtube.added.to.playlist': 30714, - 'youtube.removed.from.playlist': 30715, - 'youtube.liked.video': 30716, - 'youtube.disliked.video': 30717, - 'youtube.unrated.video': 30718, - 'youtube.subscribed.to.channel': 30719, - 'youtube.unsubscribed.from.channel': 30720, - 'youtube.uploads': 30726, - 'youtube.video.play_ask_for_quality': 30730, - 'youtube.key.requirement.notification': 30731, - 'youtube.video.comments': 30732, - 'youtube.video.comments.likes': 30733, - 'youtube.video.comments.replies': 30734, - 'youtube.video.comments.edited': 30735, - } - +from ..kodion import AbstractProvider, RegisterProviderPath +from ..kodion.constants import ( + ADDON_ID, + content, + paths, +) +from ..kodion.items import ( + DirectoryItem, + NewSearchItem, + SearchItem, + UriItem, + menu_items, +) +from ..kodion.utils import find_video_id, strip_html_from_text + + +class Provider(AbstractProvider): def __init__(self): - kodion.AbstractProvider.__init__(self) + super(Provider, self).__init__() self._resource_manager = None self._client = None - self._is_logged_in = False + self._logged_in = False - self.v3_handle_error = v3.handle_error self.yt_video = yt_video - def get_wizard_supported_views(self): - return ['default', 'episodes'] - def get_wizard_steps(self, context): - return [(yt_setup_wizard.process, [self, context])] + return [(yt_setup_wizard.process, (self, context))] def is_logged_in(self): - return self._is_logged_in + return self._logged_in @staticmethod def get_dev_config(context, addon_id, dev_configs): - _dev_config = context.get_ui().get_home_window_property('configs') - context.get_ui().clear_home_window_property('configs') + _dev_config = context.get_ui().get_property('configs') + context.get_ui().clear_property('configs') - dev_config = dict() - if _dev_config is not None: + dev_config = {} + if _dev_config: context.log_debug('Using window property for developer keys is deprecated, instead use the youtube_registration module.') try: dev_config = json.loads(_dev_config) @@ -220,29 +80,29 @@ def get_dev_config(context, addon_id, dev_configs): if dev_config and not context.get_settings().allow_dev_keys(): context.log_debug('Developer config ignored') return None - elif dev_config: + + if dev_config: if not dev_config.get('main') or not dev_config['main'].get('key') \ or not dev_config['main'].get('system') or not dev_config.get('origin') \ or not dev_config['main'].get('id') or not dev_config['main'].get('secret'): context.log_error('Error loading developer config: |invalid structure| ' 'expected: |{"origin": ADDON_ID, "main": {"system": SYSTEM_NAME, "key": API_KEY, "id": CLIENT_ID, "secret": CLIENT_SECRET}}|') - return dict() + return {} + dev_origin = dev_config['origin'] + dev_main = dev_config['main'] + dev_system = dev_main['system'] + if dev_system == 'JSONStore': + dev_key = b64decode(dev_main['key']) + dev_id = b64decode(dev_main['id']) + dev_secret = b64decode(dev_main['secret']) else: - dev_origin = dev_config['origin'] - dev_main = dev_config['main'] - dev_system = dev_main['system'] - if dev_system == 'JSONStore': - dev_key = b64decode(dev_main['key']) - dev_id = b64decode(dev_main['id']) - dev_secret = b64decode(dev_main['secret']) - else: - dev_key = dev_main['key'] - dev_id = dev_main['id'] - dev_secret = dev_main['secret'] - context.log_debug('Using developer config: origin: |{0}| system |{1}|'.format(dev_origin, dev_system)) - return {'origin': dev_origin, 'main': {'id': dev_id, 'secret': dev_secret, 'key': dev_key, 'system': dev_system}} - else: - return dict() + dev_key = dev_main['key'] + dev_id = dev_main['id'] + dev_secret = dev_main['secret'] + context.log_debug('Using developer config: origin: |{0}| system |{1}|'.format(dev_origin, dev_system)) + return {'origin': dev_origin, 'main': {'id': dev_id, 'secret': dev_secret, 'key': dev_key, 'system': dev_system}} + + return {} def reset_client(self): self._client = None @@ -256,47 +116,42 @@ def get_client(self, context): items_per_page = settings.get_items_per_page() - language = settings.get_string('youtube.language', 'en-US') + language = settings.get_string('youtube.language', 'en_US') region = settings.get_string('youtube.region', 'US') api_last_origin = access_manager.get_last_origin() youtube_config = YouTube.CONFIGS.get('main') - dev_id = context.get_param('addon_id', None) + dev_id = context.get_param('addon_id') dev_configs = YouTube.CONFIGS.get('developer') dev_config = self.get_dev_config(context, dev_id, dev_configs) - dev_keys = dict() - if dev_config: - dev_keys = dev_config.get('main') + dev_keys = dev_config.get('main') if dev_config else None - client = None - refresh_tokens = list() + refresh_tokens = [] if dev_id: - dev_origin = dev_config.get('origin') if dev_config.get('origin') else dev_id - if api_last_origin != dev_origin: - context.log_debug('API key origin changed, clearing cache. |%s|' % dev_origin) - access_manager.set_last_origin(dev_origin) - self.get_resource_manager(context).clear() + origin = dev_config.get('origin') if dev_config.get('origin') else dev_id else: - if api_last_origin != 'plugin.video.youtube': - context.log_debug('API key origin changed, clearing cache. |plugin.video.youtube|') - access_manager.set_last_origin('plugin.video.youtube') - self.get_resource_manager(context).clear() + origin = ADDON_ID + + if api_last_origin != origin: + context.log_debug('API key origin changed, clearing cache. |%s|' % origin) + access_manager.set_last_origin(origin) + self.get_resource_manager(context).clear() if dev_id: access_tokens = access_manager.get_dev_access_token(dev_id).split('|') if len(access_tokens) != 2 or access_manager.is_dev_access_token_expired(dev_id): # reset access_token access_manager.update_dev_access_token(dev_id, '') - access_tokens = list() + access_tokens = [] else: access_tokens = access_manager.get_access_token().split('|') if len(access_tokens) != 2 or access_manager.is_access_token_expired(): # reset access_token access_manager.update_access_token('') - access_tokens = list() + access_tokens = [] if dev_id: if dev_keys: @@ -323,13 +178,6 @@ def get_client(self, context): if refresh_tokens: refresh_tokens = refresh_tokens.split('|') context.log_debug('Access token count: |%d| Refresh token count: |%d|' % (len(access_tokens), len(refresh_tokens))) - # create a new access_token - - if dev_keys: - client = YouTube(language=language, region=region, items_per_page=items_per_page, config=dev_keys) - else: - client = YouTube(language=language, region=region, items_per_page=items_per_page, config=youtube_config) - else: context.log_debug('Selecting YouTube config "%s"' % youtube_config['system']) @@ -347,62 +195,56 @@ def get_client(self, context): if refresh_tokens: refresh_tokens = refresh_tokens.split('|') context.log_debug('Access token count: |%d| Refresh token count: |%d|' % (len(access_tokens), len(refresh_tokens))) - # create a new access_token - client = YouTube(language=language, region=region, items_per_page=items_per_page, config=youtube_config) - if client: - if len(access_tokens) != 2 and len(refresh_tokens) == 2: - try: + client = YouTube(context=context, + language=language, + region=region, + items_per_page=items_per_page, + config=dev_keys if dev_keys else youtube_config) - access_token_kodi, expires_in_kodi = client.refresh_token(refresh_tokens[1]) + with client: + if not refresh_tokens or not refresh_tokens[0]: + self._client = client + # create new access tokens + elif len(access_tokens) != 2 and len(refresh_tokens) == 2: + try: + access_token_kodi, expires_in_kodi = client.refresh_token(refresh_tokens[1]) access_token_tv, expires_in_tv = client.refresh_token_tv(refresh_tokens[0]) - access_tokens = [access_token_tv, access_token_kodi] - access_token = '%s|%s' % (access_token_tv, access_token_kodi) expires_in = min(expires_in_tv, expires_in_kodi) if dev_id: access_manager.update_dev_access_token(dev_id, access_token, expires_in) else: access_manager.update_access_token(access_token, expires_in) - except (InvalidGrant, LoginException) as ex: - self.handle_exception(context, ex) + except (InvalidGrant, LoginException) as exc: + self.handle_exception(context, exc) access_tokens = ['', ''] # reset access_token - if isinstance(ex, InvalidGrant): + if isinstance(exc, InvalidGrant): if dev_id: access_manager.update_dev_access_token(dev_id, access_token='', refresh_token='') else: access_manager.update_access_token(access_token='', refresh_token='') + elif dev_id: + access_manager.update_dev_access_token(dev_id, '') else: - if dev_id: - access_manager.update_dev_access_token(dev_id, '') - else: - access_manager.update_access_token('') + access_manager.update_access_token('') # we clear the cache, so none cached data of an old account will be displayed. self.get_resource_manager(context).clear() # in debug log the login status - self._is_logged_in = len(access_tokens) == 2 - if self._is_logged_in: - context.log_debug('User is logged in') - else: - context.log_debug('User is not logged in') + self._logged_in = len(access_tokens) == 2 + context.log_debug('User is logged in' if self._logged_in else + 'User is not logged in') - if len(access_tokens) == 0: + if not access_tokens: access_tokens = ['', ''] client.set_access_token(access_token=access_tokens[1]) client.set_access_token_tv(access_token_tv=access_tokens[0]) - self._client = client - self._client.set_log_error(context.log_error) - else: - self._client = YouTube(items_per_page=items_per_page, language=language, region=region, config=youtube_config) - self._client.set_log_error(context.log_error) - - # in debug log the login status - context.log_debug('User is not logged in') + self._client = client return self._client def get_resource_manager(self, context): @@ -411,69 +253,44 @@ def get_resource_manager(self, context): self._resource_manager = ResourceManager(context, self.get_client(context)) return self._resource_manager - def get_alternative_fanart(self, context): - return self.get_fanart(context) - - @staticmethod - def get_fanart(context): - return context.create_resource_path('media', 'fanart.jpg') - # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/uri2addon/$') + @RegisterProviderPath('^/uri2addon/$') def on_uri2addon(self, context, re_match): - uri = context.get_param('uri', '') + uri = context.get_param('uri') if not uri: return False resolver = UrlResolver(context) res_url = resolver.resolve(uri) url_converter = UrlToItemConverter(flatten=True) - url_converter.add_urls([res_url], self, context) - items = url_converter.get_items(self, context, title_required=False) - if len(items) > 0: + url_converter.add_url(res_url, context) + items = url_converter.get_items(self, context, skip_title=True) + if items: return items[0] return False - @kodion.RegisterProviderPath('^/playlist/(?P[^/]+)/$') - def _on_playlist(self, context, re_match): - self.set_content_type(context, kodion.constants.content_type.VIDEOS) - - result = [] - - playlist_id = re_match.group('playlist_id') - page_token = context.get_param('page_token', '') - - # no caching - json_data = self.get_client(context).get_playlist_items(playlist_id=playlist_id, page_token=page_token) - if not v3.handle_error(self, context, json_data): - return False - result.extend(v3.response_to_items(self, context, json_data)) - - return result - """ Lists the videos of a playlist. path : '/channel/(?P[^/]+)/playlist/(?P[^/]+)/' + or + path : '/playlist/(?P[^/]+)/' channel_id : ['mine'|] playlist_id: """ - @kodion.RegisterProviderPath('^/channel/(?P[^/]+)/playlist/(?P[^/]+)/$') - def _on_channel_playlist(self, context, re_match): - self.set_content_type(context, kodion.constants.content_type.VIDEOS) - client = self.get_client(context) - result = [] + @RegisterProviderPath('^(?:/channel/(?P[^/]+))?/playlist/(?P[^/]+)/$') + def _on_playlist(self, context, re_match): + context.set_content(content.VIDEO_CONTENT) + resource_manager = self.get_resource_manager(context) - playlist_id = re_match.group('playlist_id') - page_token = context.get_param('page_token', '') + batch_id = (re_match.group('playlist_id'), + context.get_param('page_token') or 0) - # no caching - json_data = client.get_playlist_items(playlist_id=playlist_id, page_token=page_token) - if not v3.handle_error(self, context, json_data): + json_data = resource_manager.get_playlist_items(batch_id=batch_id) + if not json_data: return False - result.extend(v3.response_to_items(self, context, json_data)) - + result = v3.response_to_items(self, context, json_data[batch_id]) return result """ @@ -482,36 +299,42 @@ def _on_channel_playlist(self, context, re_match): channel_id: """ - @kodion.RegisterProviderPath('^/channel/(?P[^/]+)/playlists/$') + @RegisterProviderPath('^/channel/(?P[^/]+)/playlists/$') def _on_channel_playlists(self, context, re_match): - self.set_content_type(context, kodion.constants.content_type.FILES) + context.set_content(content.LIST_CONTENT) result = [] channel_id = re_match.group('channel_id') - page_token = context.get_param('page_token', '') resource_manager = self.get_resource_manager(context) - item_params = {} - incognito = str(context.get_param('incognito', False)).lower() == 'true' - addon_id = context.get_param('addon_id', '') + params = context.get_params() + page_token = params.get('page_token', '') + incognito = params.get('incognito') + addon_id = params.get('addon_id') + + new_params = {} if incognito: - item_params.update({'incognito': incognito}) + new_params['incognito'] = incognito if addon_id: - item_params.update({'addon_id': addon_id}) + new_params['addon_id'] = addon_id playlists = resource_manager.get_related_playlists(channel_id) uploads_playlist = playlists.get('uploads', '') if uploads_playlist: - uploads_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.uploads'])), - context.create_uri(['channel', channel_id, 'playlist', uploads_playlist], - item_params), - image=context.create_resource_path('media', 'playlist.png')) + uploads_item = DirectoryItem( + context.get_ui().bold(context.localize('uploads')), + context.create_uri( + ['channel', channel_id, 'playlist', uploads_playlist], + new_params + ), + image='{media}/playlist.png', + ) result.append(uploads_item) # no caching json_data = self.get_client(context).get_playlists_of_channel(channel_id, page_token) - if not v3.handle_error(self, context, json_data): + if not json_data: return False result.extend(v3.response_to_items(self, context, json_data)) @@ -523,9 +346,9 @@ def _on_channel_playlists(self, context, re_match): channel_id: """ - @kodion.RegisterProviderPath('^/channel/(?P[^/]+)/live/$') + @RegisterProviderPath('^/channel/(?P[^/]+)/live/$') def _on_channel_live(self, context, re_match): - self.set_content_type(context, kodion.constants.content_type.VIDEOS) + context.set_content(content.VIDEO_CONTENT) result = [] channel_id = re_match.group('channel_id') @@ -533,8 +356,13 @@ def _on_channel_live(self, context, re_match): safe_search = context.get_settings().safe_search() # no caching - json_data = self.get_client(context).search(q='', search_type='video', event_type='live', channel_id=channel_id, page_token=page_token, safe_search=safe_search) - if not v3.handle_error(self, context, json_data): + json_data = self.get_client(context).search(q='', + search_type='video', + event_type='live', + channel_id=channel_id, + page_token=page_token, + safe_search=safe_search) + if not json_data: return False result.extend(v3.response_to_items(self, context, json_data)) @@ -546,21 +374,33 @@ def _on_channel_live(self, context, re_match): channel_id: """ - @kodion.RegisterProviderPath('^/(?P(channel|user))/(?P[^/]+)/$') + @RegisterProviderPath('^/(?P(channel|user))/(?P[^/]+)/$') def _on_channel(self, context, re_match): - listitem_channel_id = context.get_ui().get_info_label('Container.ListItem(0).Property(channel_id)') + listitem_channel_id = context.get_infolabel( + 'Container.ListItem(0).Property(channel_id)' + ) + + client = self.get_client(context) + localize = context.localize + create_uri = context.create_uri + function_cache = context.get_function_cache() + ui = context.get_ui() method = re_match.group('method') channel_id = re_match.group('channel_id') - if method == 'channel' and channel_id and channel_id.lower() == 'property': - if listitem_channel_id and listitem_channel_id.lower().startswith(('mine', 'uc')): - context.execute('Container.Update(%s)' % context.create_uri(['channel', listitem_channel_id])) # redirect if keymap, without redirect results in 'invalid handle -1' + if (method == 'channel' and channel_id + and channel_id.lower() == 'property' + and listitem_channel_id + and listitem_channel_id.lower().startswith(('mine', 'uc'))): + context.execute('ActivateWindow(Videos, {channel}, return)'.format( + channel=create_uri(('channel', listitem_channel_id)) + )) if method == 'channel' and not channel_id: return False - self.set_content_type(context, kodion.constants.content_type.VIDEOS) + context.set_content(content.VIDEO_CONTENT) resource_manager = self.get_resource_manager(context) @@ -568,20 +408,21 @@ def _on_channel(self, context, re_match): result = [] """ - This is a helper routine if we only have the username of a channel. This will retrieve the correct channel id - based on the username. + This is a helper routine if we only have the username of a channel. + This will retrieve the correct channel id based on the username. """ if method == 'user' or channel_id == 'mine': context.log_debug('Trying to get channel id for user "%s"' % channel_id) - json_data = context.get_function_cache().get(FunctionCache.ONE_DAY, - self.get_client(context).get_channel_by_username, channel_id) - if not v3.handle_error(self, context, json_data): + json_data = function_cache.get(client.get_channel_by_username, + function_cache.ONE_DAY, + channel_id) + if not json_data: return False # we correct the channel id based on the username items = json_data.get('items', []) - if len(items) > 0: + if items: if method == 'user': channel_id = items[0]['id'] else: @@ -591,94 +432,120 @@ def _on_channel(self, context, re_match): if method == 'user': return False - channel_fanarts = resource_manager.get_fanarts([channel_id]) - page = int(context.get_param('page', 1)) - page_token = context.get_param('page_token', '') - incognito = str(context.get_param('incognito', False)).lower() == 'true' - addon_id = context.get_param('addon_id', '') - item_params = {} + channel_fanarts = resource_manager.get_fanarts((channel_id, )) + + params = context.get_params() + page = params.get('page', 1) + page_token = params.get('page_token', '') + incognito = params.get('incognito') + addon_id = params.get('addon_id') + + new_params = {} if incognito: - item_params.update({'incognito': incognito}) + new_params['incognito'] = incognito if addon_id: - item_params.update({'addon_id': addon_id}) + new_params['addon_id'] = addon_id - hide_folders = str(context.get_param('hide_folders', False)).lower() == 'true' + hide_folders = params.get('hide_folders') if page == 1 and not hide_folders: - hide_playlists = str(context.get_param('hide_playlists', False)).lower() == 'true' - hide_search = str(context.get_param('hide_search', False)).lower() == 'true' - hide_live = str(context.get_param('hide_live', False)).lower() == 'true' + hide_playlists = params.get('hide_playlists') + hide_search = params.get('hide_search') + hide_live = params.get('hide_live') if not hide_playlists: - playlists_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.playlists'])), - context.create_uri(['channel', channel_id, 'playlists'], item_params), - image=context.create_resource_path('media', 'playlist.png')) - playlists_item.set_fanart(channel_fanarts.get(channel_id, self.get_fanart(context))) + playlists_item = DirectoryItem( + ui.bold(localize('playlists')), + create_uri(['channel', channel_id, 'playlists'], new_params), + image='{media}/playlist.png', + fanart=channel_fanarts.get(channel_id), + ) result.append(playlists_item) search_live_id = mine_id if mine_id else channel_id if not hide_search: - search_item = kodion.items.NewSearchItem(context, alt_name=context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.search'])), - image=context.create_resource_path('media', 'search.png'), - fanart=self.get_fanart(context), channel_id=search_live_id, incognito=incognito, addon_id=addon_id) - search_item.set_fanart(self.get_fanart(context)) + search_item = NewSearchItem( + context, name=ui.bold(localize('search')), + image='{media}/search.png', + channel_id=search_live_id, + incognito=incognito, + addon_id=addon_id, + ) result.append(search_item) if not hide_live: - live_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.live'])), - context.create_uri(['channel', search_live_id, 'live'], item_params), - image=context.create_resource_path('media', 'live.png')) - live_item.set_fanart(self.get_fanart(context)) + live_item = DirectoryItem( + ui.bold(localize('live')), + create_uri(['channel', search_live_id, 'live'], new_params), + image='{media}/live.png', + ) result.append(live_item) playlists = resource_manager.get_related_playlists(channel_id) upload_playlist = playlists.get('uploads', '') if upload_playlist: - json_data = context.get_function_cache().get(FunctionCache.ONE_MINUTE * 5, - self.get_client(context).get_playlist_items, upload_playlist, - page_token=page_token) - if not v3.handle_error(self, context, json_data): - return False + json_data = function_cache.get(client.get_playlist_items, + function_cache.ONE_MINUTE * 5, + upload_playlist, + page_token=page_token) + if not json_data: + return result - result.extend( - v3.response_to_items(self, context, json_data, sort=lambda x: x.get_aired(), reverse_sort=True)) + result.extend(v3.response_to_items(self, context, json_data)) return result # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/location/mine/$') + @RegisterProviderPath('^/location/mine/$') def _on_my_location(self, context, re_match): - self.set_content_type(context, kodion.constants.content_type.FILES) + context.set_content(content.LIST_CONTENT) + create_uri = context.create_uri + localize = context.localize settings = context.get_settings() - result = list() + result = [] # search - search_item = kodion.items.SearchItem(context, image=context.create_resource_path('media', 'search.png'), - fanart=self.get_fanart(context), location=True) + search_item = SearchItem( + context, + image='{media}/search.png', + location=True + ) result.append(search_item) # completed live events if settings.get_bool('youtube.folder.completed.live.show', True): - live_events_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.completed.live']), - context.create_uri(['special', 'completed_live'], params={'location': True}), - image=context.create_resource_path('media', 'live.png')) - live_events_item.set_fanart(self.get_fanart(context)) + live_events_item = DirectoryItem( + localize('live.completed'), + create_uri( + ['special', 'completed_live'], + params={'location': True} + ), + image='{media}/live.png', + ) result.append(live_events_item) # upcoming live events if settings.get_bool('youtube.folder.upcoming.live.show', True): - live_events_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.upcoming.live']), - context.create_uri(['special', 'upcoming_live'], params={'location': True}), - image=context.create_resource_path('media', 'live.png')) - live_events_item.set_fanart(self.get_fanart(context)) + live_events_item = DirectoryItem( + localize('live.upcoming'), + create_uri( + ['special', 'upcoming_live'], + params={'location': True} + ), + image='{media}/live.png', + ) result.append(live_events_item) # live events - live_events_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.live']), - context.create_uri(['special', 'live'], params={'location': True}), - image=context.create_resource_path('media', 'live.png')) - live_events_item.set_fanart(self.get_fanart(context)) + live_events_item = DirectoryItem( + localize('live'), + create_uri( + ['special', 'live'], + params={'location': True} + ), + image='{media}/live.png', + ) result.append(live_events_item) return result @@ -689,303 +556,151 @@ def _on_my_location(self, context, re_match): path for playlist: '/play/?playlist_id=XXXXXXX&mode=[OPTION]' OPTION: [normal(default)|reverse|shuffle] - + path for channel live streams: '/play/?channel_id=UCXXXXXXX&live=X - OPTION: + OPTION: live parameter required, live=1 for first live stream live = index of live stream if channel has multiple live streams """ # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/play/$') + @RegisterProviderPath('^/play/$') def on_play(self, context, re_match): - listitem_path = context.get_ui().get_info_label('Container.ListItem(0).FileNameAndPath') + ui = context.get_ui() redirect = False params = context.get_params() - if 'video_id' not in params and 'playlist_id' not in params and \ - 'channel_id' not in params and 'live' not in params: - if context.is_plugin_path(listitem_path, 'play'): - video_id = find_video_id(listitem_path) + if ({'channel_id', 'live', 'playlist_id', 'playlist_ids', 'video_id'} + .isdisjoint(params.keys())): + path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') + if context.is_plugin_path(path, 'play/'): + video_id = find_video_id(path) if video_id: context.set_param('video_id', video_id) - params = context.get_params() + params['video_id'] = video_id else: return False else: return False - if context.get_ui().get_home_window_property('prompt_for_subtitles') != params.get('video_id'): - context.get_ui().clear_home_window_property('prompt_for_subtitles') + video_id = params.get('video_id') + playlist_id = params.get('playlist_id') + + if ui.get_property('prompt_for_subtitles') != video_id: + ui.clear_property('prompt_for_subtitles') - if context.get_ui().get_home_window_property('audio_only') != params.get('video_id'): - context.get_ui().clear_home_window_property('audio_only') + if ui.get_property('audio_only') != video_id: + ui.clear_property('audio_only') - if context.get_ui().get_home_window_property('ask_for_quality') != params.get('video_id'): - context.get_ui().clear_home_window_property('ask_for_quality') + if ui.get_property('ask_for_quality') != video_id: + ui.clear_property('ask_for_quality') - if 'prompt_for_subtitles' in params: - prompt_subtitles = params['prompt_for_subtitles'] == '1' - del params['prompt_for_subtitles'] - if prompt_subtitles and 'video_id' in params and 'playlist_id' not in params: - # redirect to builtin after setting home window property, so playback url matches playable listitems - context.get_ui().set_home_window_property('prompt_for_subtitles', params['video_id']) + if video_id and not playlist_id: + if params.pop('prompt_for_subtitles', None): + # redirect to builtin after setting home window property, + # so playback url matches playable listitems + ui.set_property('prompt_for_subtitles', video_id) context.log_debug('Redirecting playback with subtitles') redirect = True - elif 'audio_only' in params: - audio_only = params['audio_only'] == '1' - del params['audio_only'] - if audio_only and 'video_id' in params and 'playlist_id' not in params: - # redirect to builtin after setting home window property, so playback url matches playable listitems - context.get_ui().set_home_window_property('audio_only', params['video_id']) + if params.pop('audio_only', None): + # redirect to builtin after setting home window property, + # so playback url matches playable listitems + ui.set_property('audio_only', video_id) context.log_debug('Redirecting audio only playback') redirect = True - elif 'ask_for_quality' in params: - ask_for_quality = params['ask_for_quality'] == '1' - del params['ask_for_quality'] - if ask_for_quality and 'video_id' in params and 'playlist_id' not in params: - # redirect to builtin after setting home window property, so playback url matches playable listitems - context.get_ui().set_home_window_property('ask_for_quality', params['video_id']) - context.log_debug('Redirecting audio only playback') + if params.pop('ask_for_quality', None): + # redirect to builtin after setting home window property, + # so playback url matches playable listitems + ui.set_property('ask_for_quality', video_id) + context.log_debug('Redirecting ask quality playback') redirect = True - if 'playlist_id' not in params and 'video_id' in params and (context.get_handle() == -1 or redirect): - builtin = 'PlayMedia(%s)' if context.get_handle() == -1 else 'RunPlugin(%s)' - if not redirect: + builtin = None + if context.get_handle() == -1: + builtin = 'PlayMedia({0})' context.log_debug('Redirecting playback, handle is -1') - context.execute(builtin % context.create_uri(['play'], {'video_id': params['video_id']})) - return - - if 'playlist_id' in params and (context.get_handle() != -1): - builtin = 'RunPlugin(%s)' - stream_url = context.create_uri(['play'], params) - xbmcplugin.setResolvedUrl(handle=context.get_handle(), succeeded=False, listitem=xbmcgui.ListItem(path=stream_url)) - context.execute(builtin % context.create_uri(['play'], params)) - return + elif redirect: + builtin = 'RunPlugin({0})' - if 'video_id' in params and 'playlist_id' not in params: + if builtin: + context.execute(builtin.format( + context.create_uri(['play'], params) + )) + return False return yt_play.play_video(self, context) - elif 'playlist_id' in params: + + if playlist_id or 'playlist_ids' in params: return yt_play.play_playlist(self, context) - elif 'channel_id' in params and 'live' in params: - if int(params['live']) > 0: - return yt_play.play_channel_live(self, context) + + if 'channel_id' in params and params.get('live', 0) > 0: + return yt_play.play_channel_live(self, context) return False - @kodion.RegisterProviderPath('^/video/(?P[^/]+)/$') + @RegisterProviderPath('^/video/(?P[^/]+)/$') def _on_video_x(self, context, re_match): method = re_match.group('method') return yt_video.process(method, self, context, re_match) - @kodion.RegisterProviderPath('^/playlist/(?P[^/]+)/(?P[^/]+)/$') + @RegisterProviderPath('^/playlist/(?P[^/]+)/(?P[^/]+)/$') def _on_playlist_x(self, context, re_match): method = re_match.group('method') category = re_match.group('category') return yt_playlist.process(method, category, self, context) - @kodion.RegisterProviderPath('^/subscriptions/(?P[^/]+)/$') + @RegisterProviderPath('^/subscriptions/(?P[^/]+)/$') def _on_subscriptions(self, context, re_match): method = re_match.group('method') resource_manager = self.get_resource_manager(context) subscriptions = yt_subscriptions.process(method, self, context) if method == 'list': - self.set_content_type(context, kodion.constants.content_type.FILES) + context.set_content(content.LIST_CONTENT) channel_ids = [] for subscription in subscriptions: channel_ids.append(subscription.get_channel_id()) + channel_ids = {subscription.get_channel_id(): subscription + for subscription in subscriptions} channel_fanarts = resource_manager.get_fanarts(channel_ids) - for subscription in subscriptions: - if channel_fanarts.get(subscription.get_channel_id()): - subscription.set_fanart(channel_fanarts.get(subscription.get_channel_id())) + for channel_id, fanart in channel_fanarts.items(): + channel_ids[channel_id].set_fanart(fanart) return subscriptions - @kodion.RegisterProviderPath('^/special/(?P[^/]+)/$') + @RegisterProviderPath('^/special/(?P[^/]+)/$') def _on_yt_specials(self, context, re_match): category = re_match.group('category') return yt_specials.process(category, self, context) - # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/history/clear/$') - def _on_yt_clear_history(self, context, re_match): - if context.get_ui().on_yes_no_input(context.get_name(), context.localize(self.LOCAL_MAP['youtube.clear_history_confirmation'])): - json_data = self.get_client(context).clear_watch_history() - if 'error' not in json_data: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - - @kodion.RegisterProviderPath('^/users/(?P[^/]+)/$') + @RegisterProviderPath('^/users/(?P[^/]+)/$') def _on_users(self, context, re_match): action = re_match.group('action') - refresh = context.get_param('refresh', 'true').lower() == 'true' - access_manager = context.get_access_manager() - ui = context.get_ui() - - def add_user(_access_manager_users): - _results = ui.on_keyboard_input(context.localize(self.LOCAL_MAP['youtube.enter.user.name'])) - if _results[0] is False: - return None - _new_user_name = _results[1] - if not _new_user_name.strip(): - _new_user_name = context.localize(self.LOCAL_MAP['youtube.user.unnamed']) - _new_users = {} - for idx, key in enumerate(list(_access_manager_users.keys())): - _new_users[str(idx)] = _access_manager_users[key] - _new_users[str(len(_new_users))] = access_manager.get_new_user(_new_user_name) - access_manager.set_users(_new_users) - return str(len(_new_users) - 1) - - def switch_to_user(_user): - _user_name = access_manager.get_users()[_user].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])) - access_manager.set_user(_user, switch_to=True) - ui.show_notification(context.localize(self.LOCAL_MAP['youtube.user.changed']) % _user_name, - context.localize(self.LOCAL_MAP['youtube.switch.user'])) - self.get_resource_manager(context).clear() - if refresh: - ui.refresh_container() - - if action == 'switch': - access_manager_users = access_manager.get_users() - current_user = access_manager.get_user() - users = [ui.bold(context.localize(self.LOCAL_MAP['youtube.user.new']))] - user_index_map = [] - for k in list(access_manager_users.keys()): - if k == current_user: - if access_manager_users[k].get('access_token') or access_manager_users[k].get('refresh_token'): - users.append( - ui.color('limegreen', - ' '.join([access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])), '*'])) - ) - else: - users.append(' '.join([access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])), '*'])) - elif access_manager_users[k].get('access_token') or access_manager_users[k].get('refresh_token'): - users.append(ui.color('limegreen', access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])))) - else: - users.append(access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed']))) - user_index_map.append(k) - result = ui.on_select(context.localize(self.LOCAL_MAP['youtube.switch.user']), users) - if result == -1: - return True - elif result == 0: - user = add_user(access_manager_users) - else: - user = user_index_map[result - 1] - - if user and (user != access_manager.get_user()): - switch_to_user(user) - - elif action == 'add': - user = add_user(access_manager.get_users()) - if user: - user_name = access_manager.get_users()[user].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])) - result = ui.on_yes_no_input(context.localize(self.LOCAL_MAP['youtube.switch.user']), context.localize(self.LOCAL_MAP['youtube.switch.user.now']) % user_name) - if result: - switch_to_user(user) - - elif action == 'remove': - access_manager_users = access_manager.get_users() - users = [] - user_index_map = [] - current_user = access_manager.get_user() - current_user_dict = access_manager_users[current_user] - for k in list(access_manager_users.keys()): - if k == current_user: - if access_manager_users[k].get('access_token') or access_manager_users[k].get('refresh_token'): - users.append( - ui.color('limegreen', - ' '.join([access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])), '*'])) - ) - else: - users.append(' '.join([access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])), '*'])) - elif access_manager_users[k].get('access_token') or access_manager_users[k].get('refresh_token'): - users.append(ui.color('limegreen', access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])))) - else: - users.append(access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed']))) - user_index_map.append(k) - result = ui.on_select(context.localize(self.LOCAL_MAP['youtube.remove.a.user']), users) - if result == -1: - return True - else: - user = user_index_map[result] - user_name = access_manager_users[user].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])) - result = ui.on_remove_content(user_name) - if result: - if user == current_user: - access_manager.set_user('0', switch_to=True) - del access_manager_users[user] - new_users = {} - for i, u in enumerate(list(access_manager_users.keys())): - if access_manager_users[u] == current_user_dict: - access_manager.set_user(str(i), switch_to=True) - new_users[str(i)] = access_manager_users[u] - - if not new_users.get(access_manager.get_user()): - access_manager.set_user('0', switch_to=True) - - access_manager.set_users(new_users) - ui.show_notification(context.localize(self.LOCAL_MAP['youtube.removed']) % user_name, - context.localize(self.LOCAL_MAP['youtube.remove'])) - - elif action == 'rename': - access_manager_users = access_manager.get_users() - users = [] - user_index_map = [] - current_user = access_manager.get_user() - for k in list(access_manager_users.keys()): - if k == current_user: - if access_manager_users[k].get('access_token') or access_manager_users[k].get('refresh_token'): - users.append( - ui.color('limegreen', - ' '.join([access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])), '*'])) - ) - else: - users.append(' '.join([access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])), '*'])) - elif access_manager_users[k].get('access_token') or access_manager_users[k].get('refresh_token'): - users.append(ui.color('limegreen', access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])))) - else: - users.append(access_manager_users[k].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed']))) - user_index_map.append(k) - result = ui.on_select(context.localize(self.LOCAL_MAP['youtube.rename.a.user']), users) - if result == -1: - return True - else: - user = user_index_map[result] - old_user_name = access_manager_users[user].get('name', context.localize(self.LOCAL_MAP['youtube.user.unnamed'])) - results = ui.on_keyboard_input(context.localize(self.LOCAL_MAP['youtube.enter.user.name']), default=old_user_name) - if results[0] is False: - return True - new_user_name = results[1] - if not new_user_name.strip() or (old_user_name == new_user_name): - return True - - access_manager_users[user]['name'] = new_user_name - access_manager.set_users(access_manager_users) - ui.show_notification(context.localize(self.LOCAL_MAP['youtube.renamed']) % (old_user_name, new_user_name), - context.localize(self.LOCAL_MAP['youtube.rename'])) + return UriItem('{addon},users/{action}'.format( + addon=ADDON_ID, action=action + )) - return True - - @kodion.RegisterProviderPath('^/sign/(?P[^/]+)/$') + @RegisterProviderPath('^/sign/(?P[^/]+)/$') def _on_sign(self, context, re_match): - sign_out_confirmed = context.get_param('confirmed', '').lower() == 'true' + sign_out_confirmed = context.get_param('confirmed') mode = re_match.group('mode') if (mode == 'in') and context.get_access_manager().has_refresh_token(): yt_login.process('out', self, context, sign_out_refresh=False) - if not sign_out_confirmed: - if (mode == 'out') and context.get_ui().on_yes_no_input(context.localize(self.LOCAL_MAP['youtube.sign.out']), context.localize(self.LOCAL_MAP['youtube.are.you.sure'])): - sign_out_confirmed = True + if (not sign_out_confirmed and mode == 'out' + and context.get_ui().on_yes_no_input( + context.localize('sign.out'), + context.localize('are_you_sure'))): + sign_out_confirmed = True - if (mode == 'in') or ((mode == 'out') and sign_out_confirmed): + if mode == 'in' or (mode == 'out' and sign_out_confirmed): yt_login.process(mode, self, context) return False - @kodion.RegisterProviderPath('^/search/$') + @RegisterProviderPath('^/search/$') def endpoint_search(self, context, re_match): - query = context.get_param('q', '') + query = context.get_param('q') if not query: return [] @@ -1001,7 +716,7 @@ def _search_channel_or_playlist(self, context, id_string): elif re.match(r'[OP]L[0-9a-zA-Z_\-]{30,40}', id_string): json_data = self.get_client(context).get_playlists(id_string) - if not json_data or not v3.handle_error(self, context, json_data): + if not json_data: return [] result.extend(v3.response_to_items(self, context, json_data)) @@ -1012,129 +727,100 @@ def on_search(self, search_text, context, re_match): if result: # found a channel or playlist matching search_text return result - channel_id = context.get_param('channel_id', '') - event_type = context.get_param('event_type', '') - hide_folders = str(context.get_param('hide_folders', False)).lower() == 'true' - location = str(context.get_param('location', False)).lower() == 'true' - page = int(context.get_param('page', 1)) - page_token = context.get_param('page_token', '') - search_type = context.get_param('search_type', 'video') + context.set_param('q', search_text) + context.set_param('category_label', search_text) + params = context.get_params() + channel_id = params.get('channel_id') + event_type = params.get('event_type') + hide_folders = params.get('hide_folders') + location = params.get('location') + page = params.get('page', 1) + page_token = params.get('page_token', '') + search_type = params.get('search_type', 'video') safe_search = context.get_settings().safe_search() - context.set_param('q', search_text) - if search_type == 'video': - self.set_content_type(context, kodion.constants.content_type.VIDEOS) + context.set_content(content.VIDEO_CONTENT) else: - self.set_content_type(context, kodion.constants.content_type.FILES) + context.set_content(content.LIST_CONTENT) if page == 1 and search_type == 'video' and not event_type and not hide_folders: if not channel_id and not location: - channel_params = {} - channel_params.update(context.get_params()) - channel_params['search_type'] = 'channel' - channel_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.channels'])), - context.create_uri([context.get_path()], channel_params), - image=context.create_resource_path('media', 'channels.png')) - channel_item.set_fanart(self.get_fanart(context)) + channel_params = dict(params, search_type='channel') + channel_item = DirectoryItem( + context.get_ui().bold(context.localize('channels')), + context.create_uri([context.get_path()], channel_params), + image='{media}/channels.png', + ) result.append(channel_item) + if not location: - playlist_params = {} - playlist_params.update(context.get_params()) - playlist_params['search_type'] = 'playlist' - playlist_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.playlists'])), - context.create_uri([context.get_path()], playlist_params), - image=context.create_resource_path('media', 'playlist.png')) - playlist_item.set_fanart(self.get_fanart(context)) + playlist_params = dict(params, search_type='playlist') + playlist_item = DirectoryItem( + context.get_ui().bold(context.localize('playlists')), + context.create_uri([context.get_path()], playlist_params), + image='{media}/playlist.png', + ) result.append(playlist_item) if not channel_id: # live - live_params = {} - live_params.update(context.get_params()) - live_params['search_type'] = 'video' - live_params['event_type'] = 'live' - live_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.live'])), - context.create_uri([context.get_path().replace('input', 'query')], live_params), - image=context.create_resource_path('media', 'live.png')) - live_item.set_fanart(self.get_fanart(context)) + live_params = dict(params, + search_type='video', + event_type='live') + live_item = DirectoryItem( + context.get_ui().bold(context.localize('live')), + context.create_uri( + [context.get_path().replace('input', 'query')], + live_params + ), + image='{media}/live.png', + ) result.append(live_item) - json_data = context.get_function_cache().get(FunctionCache.ONE_MINUTE * 10, self.get_client(context).search, - q=search_text, search_type=search_type, event_type=event_type, - safe_search=safe_search, page_token=page_token, channel_id=channel_id, location=location) - if not v3.handle_error(self, context, json_data): + function_cache = context.get_function_cache() + json_data = function_cache.get(self.get_client(context).search, + function_cache.ONE_MINUTE * 10, + q=search_text, + search_type=search_type, + event_type=event_type, + safe_search=safe_search, + page_token=page_token, + channel_id=channel_id, + location=location) + if not json_data: return False result.extend(v3.response_to_items(self, context, json_data)) return result - @kodion.RegisterProviderPath('^/config/(?P[^/]+)/$') + @RegisterProviderPath('^/config/(?P[^/]+)/$') def configure_addon(self, context, re_match): - switch = re_match.group('switch') - settings = context.get_settings() - if switch == 'youtube': - context.addon().openSettings() - context.get_ui().refresh_container() - elif switch == 'mpd': - if context.use_inputstream_adaptive(): - xbmcaddon.Addon(id='inputstream.adaptive').openSettings() - else: - settings.set_bool('kodion.video.quality.mpd', False) - elif switch == 'subtitles': - yt_language = context.get_settings().get_string('youtube.language', 'en-US') - sub_setting = context.get_settings().subtitle_languages() - - if yt_language.startswith('en'): - sub_opts = [context.localize(self.LOCAL_MAP['youtube.none']), context.localize(self.LOCAL_MAP['youtube.prompt']), - context.localize(self.LOCAL_MAP['youtube.subtitle._with_fallback']) % ('en', 'en-US/en-GB'), yt_language, - '%s (%s)' % (yt_language, context.localize(self.LOCAL_MAP['youtube.subtitle.no.auto.generated']))] - - else: - sub_opts = [context.localize(self.LOCAL_MAP['youtube.none']), context.localize(self.LOCAL_MAP['youtube.prompt']), - context.localize(self.LOCAL_MAP['youtube.subtitle._with_fallback']) % (yt_language, 'en'), yt_language, - '%s (%s)' % (yt_language, context.localize(self.LOCAL_MAP['youtube.subtitle.no.auto.generated']))] - - sub_opts[sub_setting] = context.get_ui().bold(sub_opts[sub_setting]) - - result = context.get_ui().on_select(context.localize(self.LOCAL_MAP['youtube.subtitle.language']), sub_opts) - if result > -1: - context.get_settings().set_subtitle_languages(result) - - result = context.get_ui().on_yes_no_input( - context.localize(self.LOCAL_MAP['youtube.subtitles.download']), - context.localize(self.LOCAL_MAP['youtube.pre.download.subtitles']) - ) - if result > -1: - context.get_settings().set_subtitle_download(result == 1) - elif switch == 'listen_ip': - local_ranges = ('10.', '172.16.', '192.168.') - addresses = [iface[4][0] for iface in socket.getaddrinfo(socket.gethostname(), None) if iface[4][0].startswith(local_ranges)] + ['127.0.0.1', '0.0.0.0'] - selected_address = context.get_ui().on_select(context.localize(self.LOCAL_MAP['youtube.select.listen.ip']), addresses) - if selected_address == -1: - return False - else: - context.get_settings().set_httpd_listen(addresses[selected_address]) - else: - return False + action = re_match.group('action') + return UriItem('{addon},config/{action}'.format( + addon=ADDON_ID, action=action + )) # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/my_subscriptions/filter/$') + @RegisterProviderPath('^/my_subscriptions/filter/$') def manage_my_subscription_filter(self, context, re_match): + settings = context.get_settings() + ui = context.get_ui() + params = context.get_params() action = params.get('action') channel = params.get('channel_name') if (not channel) or (not action): return - filter_enabled = context.get_settings().get_bool('youtube.folder.my_subscriptions_filtered.show', False) + filter_enabled = settings.get_bool('youtube.folder.my_subscriptions_filtered.show', False) if not filter_enabled: return channel_name = channel.lower() channel_name = channel_name.replace(',', '') - filter_string = context.get_settings().get_string('youtube.filter.my_subscriptions_filtered.list', '') + filter_string = settings.get_string('youtube.filter.my_subscriptions_filtered.list', '') filter_string = filter_string.replace(', ', ',') filter_list = filter_string.split(',') filter_list = [x.lower() for x in filter_list] @@ -1142,141 +828,87 @@ def manage_my_subscription_filter(self, context, re_match): if action == 'add': if channel_name not in filter_list: filter_list.append(channel_name) - elif action == 'remove': - if channel_name in filter_list: - filter_list = [chan_name for chan_name in filter_list if chan_name != channel_name] + elif action == 'remove' and channel_name in filter_list: + filter_list = [chan_name for chan_name in filter_list if chan_name != channel_name] modified_string = ','.join(filter_list).lstrip(',') if filter_string != modified_string: - context.get_settings().set_string('youtube.filter.my_subscriptions_filtered.list', modified_string) + settings.set_string('youtube.filter.my_subscriptions_filtered.list', modified_string) message = '' if action == 'add': - message = context.localize(self.LOCAL_MAP['youtube.added.my_subscriptions.filter']) + message = context.localize('my_subscriptions.filter.added') elif action == 'remove': - message = context.localize(self.LOCAL_MAP['youtube.removed.my_subscriptions.filter']) + message = context.localize('my_subscriptions.filter.removed') if message: - context.get_ui().show_notification(message=message) - context.get_ui().refresh_container() + ui.show_notification(message=message) + ui.refresh_container() - @kodion.RegisterProviderPath('^/maintain/(?P[^/]+)/(?P[^/]+)/$') + @RegisterProviderPath('^/maintenance/(?P[^/]+)/(?P[^/]+)/$') def maintenance_actions(self, context, re_match): - maint_type = re_match.group('maint_type') + target = re_match.group('target') action = re_match.group('action') - if action == 'clear': - if maint_type == 'function_cache': - if context.get_ui().on_remove_content(context.localize(self.LOCAL_MAP['youtube.function.cache'])): - context.get_function_cache().clear() - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - elif maint_type == 'data_cache': - if context.get_ui().on_remove_content(context.localize(self.LOCAL_MAP['youtube.data.cache'])): - context.get_data_cache().clear() - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - elif maint_type == 'search_cache': - if context.get_ui().on_remove_content(context.localize(self.LOCAL_MAP['youtube.search.history'])): - context.get_search_history().clear() - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - elif maint_type == 'playback_history': - if context.get_ui().on_remove_content(context.localize(self.LOCAL_MAP['youtube.playback.history'])): - context.get_playback_history().clear() - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - elif action == 'reset': - if maint_type == 'access_manager': - if context.get_ui().on_yes_no_input(context.get_name(), context.localize(self.LOCAL_MAP['youtube.reset.access.manager.confirm'])): - try: - context.get_function_cache().clear() - access_manager = context.get_access_manager() - client = self.get_client(context) - if access_manager.has_refresh_token(): - refresh_tokens = access_manager.get_refresh_token().split('|') - refresh_tokens = list(set(refresh_tokens)) - for refresh_token in refresh_tokens: - try: - client.revoke(refresh_token) - except: - pass - self.reset_client() - access_manager.update_access_token(access_token='', refresh_token='') - context.get_ui().refresh_container() - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - except: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.failed'])) - elif action == 'delete': - _maint_files = {'function_cache': 'cache.sqlite', - 'search_cache': 'search.sqlite', - 'data_cache': 'data_cache.sqlite', - 'playback_history': 'playback_history', - 'settings_xml': 'settings.xml', - 'api_keys': 'api_keys.json', - 'access_manager': 'access_manager.json', - 'temp_files': 'special://temp/plugin.video.youtube/'} - _file = _maint_files.get(maint_type, '') - success = False - if _file: - if 'sqlite' in _file: - _file_w_path = os.path.join(context.get_cache_path(), _file) - elif maint_type == 'temp_files': - _file_w_path = _file - elif _file == 'playback_history': - _file = ''.join([str(context.get_access_manager().get_current_user_id()), '.sqlite']) - _file_w_path = os.path.join(os.path.join(context.get_data_path(), 'playback'), _file) - else: - _file_w_path = os.path.join(context.get_data_path(), _file) - if context.get_ui().on_delete_content(_file): - if maint_type == 'temp_files': - _trans_path = xbmc.translatePath(_file_w_path) + + if action != 'reset': + return UriItem('{addon},maintenance/{action}/{target}'.format( + addon=ADDON_ID, action=action, target=target + )) + + ui = context.get_ui() + localize = context.localize + + if (target == 'access_manager' and ui.on_yes_no_input( + context.get_name(), localize('reset.access_manager.confirm') + )): + try: + context.get_function_cache().clear() + access_manager = context.get_access_manager() + client = self.get_client(context) + if access_manager.has_refresh_token(): + refresh_tokens = access_manager.get_refresh_token() + for refresh_token in set(refresh_tokens.split('|')): try: - xbmcvfs.rmdir(_trans_path, force=True) + client.revoke(refresh_token) except: pass - if xbmcvfs.exists(_trans_path): - try: - shutil.rmtree(_trans_path) - except: - pass - success = not xbmcvfs.exists(_trans_path) - elif _file_w_path: - success = xbmcvfs.delete(_file_w_path) - if success: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.succeeded'])) - else: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.failed'])) - elif action == 'install': - if maint_type == 'inputstreamhelper': - if context.get_system_version().get_version()[0] >= 17: - try: - xbmcaddon.Addon('script.module.inputstreamhelper') - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.inputstreamhelper.is.installed'])) - except RuntimeError: - context.execute('InstallAddon(script.module.inputstreamhelper)') - else: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.requires.krypton'])) + self.reset_client() + access_manager.update_access_token(access_token='', + refresh_token='') + ui.refresh_container() + ui.show_notification(localize('succeeded')) + except: + ui.show_notification(localize('failed')) + # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/api/update/$') + @RegisterProviderPath('^/api/update/$') def api_key_update(self, context, re_match): + localize = context.localize settings = context.get_settings() + ui = context.get_ui() + params = context.get_params() + api_key = params.get('api_key') client_id = params.get('client_id') client_secret = params.get('client_secret') - api_key = params.get('api_key') - enable = params.get('enable', '').lower() == 'true' + enable = params.get('enable') + updated_list = [] log_list = [] if api_key: settings.set_string('youtube.api.key', api_key) - updated_list.append(context.localize(self.LOCAL_MAP['youtube.api.key'])) + updated_list.append(localize('api.key')) log_list.append('Key') if client_id: settings.set_string('youtube.api.id', client_id) - updated_list.append(context.localize(self.LOCAL_MAP['youtube.api.id'])) + updated_list.append(localize('api.id')) log_list.append('Id') if client_secret: settings.set_string('youtube.api.secret', client_secret) - updated_list.append(context.localize(self.LOCAL_MAP['youtube.api.secret'])) + updated_list.append(localize('api.secret')) log_list.append('Secret') if updated_list: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.updated_']) % ', '.join(updated_list)) + ui.show_notification(localize('updated_') % ', '.join(updated_list)) context.log_debug('Updated API keys: %s' % ', '.join(log_list)) client_id = settings.get_string('youtube.api.id', '') @@ -1286,66 +918,101 @@ def api_key_update(self, context, re_match): log_list = [] if enable and client_id and client_secret and api_key: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.api.personal.enabled'])) + ui.show_notification(localize('api.personal.enabled')) context.log_debug('Personal API keys enabled') elif enable: if not api_key: - missing_list.append(context.localize(self.LOCAL_MAP['youtube.api.key'])) + missing_list.append(localize('api.key')) log_list.append('Key') if not client_id: - missing_list.append(context.localize(self.LOCAL_MAP['youtube.api.id'])) + missing_list.append(localize('api.id')) log_list.append('Id') if not client_secret: - missing_list.append(context.localize(self.LOCAL_MAP['youtube.api.secret'])) + missing_list.append(localize('api.secret')) log_list.append('Secret') - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.api.personal.failed']) % ', '.join(missing_list)) + ui.show_notification(localize('api.personal.failed') % ', '.join(missing_list)) context.log_debug('Failed to enable personal API keys. Missing: %s' % ', '.join(log_list)) # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/show_client_ip/$') - def show_client_ip(self, context, re_match): - port = context.get_settings().httpd_port() - - if is_httpd_live(port=port): - client_ip = get_client_ip_address(port=port) - if client_ip: - context.get_ui().on_ok(context.get_name(), context.localize(self.LOCAL_MAP['youtube.client.ip']) % client_ip) - else: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.client.ip.failed'])) - else: - context.get_ui().show_notification(context.localize(self.LOCAL_MAP['youtube.httpd.not.running'])) - - # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/playback_history/$') def on_playback_history(self, context, re_match): params = context.get_params() - video_id = params.get('video_id') action = params.get('action') - if not video_id or not action: - return True + if not action: + return False + playback_history = context.get_playback_history() - items = playback_history.get_items([video_id]) - if not items or not items.get(video_id): - item_dict = {'play_count': '0', 'total_time': '0.0', - 'played_time': '0.0', 'played_percent': '0'} - else: - item_dict = items.get(video_id) + + if action == 'list': + context.set_content(content.VIDEO_CONTENT, sub_type='history') + play_data = playback_history.get_items() + if not play_data: + return True + json_data = self.get_client(context).get_videos(play_data) + if not json_data: + return True + items = v3.response_to_items(self, context, json_data) + + for item in items: + video_id = item.video_id + context_menu = [ + menu_items.history_remove( + context, video_id + ), + menu_items.history_mark_unwatched( + context, video_id + ) if play_data[video_id]['play_count'] else + menu_items.history_mark_watched( + context, video_id + ), + menu_items.history_clear( + context + ), + ] + item.set_context_menu(context_menu) + + return items + + if (action == 'clear' and context.get_ui().on_yes_no_input( + context.get_name(), + context.localize('history.clear.confirm') + )): + playback_history.clear() + context.get_ui().refresh_container() + return True + + video_id = params.get('video_id') + if not video_id: + return False + + if action == 'remove': + playback_history.remove(video_id) + context.get_ui().refresh_container() + return True + + play_data = playback_history.get_item(video_id) + if not play_data: + play_data = { + 'play_count': 0, + 'total_time': 0, + 'played_time': 0, + 'played_percent': 0 + } + if action == 'mark_unwatched': - if int(item_dict.get('play_count', 0)) > 0: - item_dict['play_count'] = '0' - item_dict['played_time'] = '0.0' - item_dict['played_percent'] = '0' + if play_data.get('play_count', 0) > 0: + play_data['play_count'] = 0 + play_data['played_time'] = 0 + play_data['played_percent'] = 0 + elif action == 'mark_watched': - if int(item_dict.get('play_count', 0)) == 0: - item_dict['play_count'] = '1' + if not play_data.get('play_count', 0): + play_data['play_count'] = 1 + elif action == 'reset_resume': - item_dict['played_time'] = '0.0' - item_dict['played_percent'] = '0' - item_dict['play_count'] = item_dict.get('play_count', '0') - item_dict['total_time'] = item_dict.get('total_time', '0.0') - item_dict['played_time'] = item_dict.get('played_time', '0.0') - item_dict['played_percent'] = item_dict.get('played_percent', '0') - playback_history.update(video_id, item_dict['play_count'], item_dict['total_time'], item_dict['played_time'], item_dict['played_percent']) + play_data['played_time'] = 0 + play_data['played_percent'] = 0 + + playback_history.update(video_id, play_data) context.get_ui().refresh_container() return True @@ -1353,259 +1020,299 @@ def on_root(self, context, re_match): """ Support old YouTube url calls, but also log a deprecation warnings. """ - old_action = context.get_param('action', '') + old_action = context.get_param('action') if old_action: return yt_old_actions.process_old_action(self, context, re_match) + create_uri = context.create_uri + localize = context.localize settings = context.get_settings() + ui = context.get_ui() + _ = self.get_client(context) # required for self.is_logged_in() + logged_in = self.is_logged_in() - self.set_content_type(context, kodion.constants.content_type.FILES) + # context.set_content(content.LIST_CONTENT) result = [] # sign in - if not self.is_logged_in() and settings.get_bool('youtube.folder.sign.in.show', True): - sign_in_item = DirectoryItem(context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.sign.in'])), - context.create_uri(['sign', 'in']), - image=context.create_resource_path('media', 'sign_in.png')) - sign_in_item.set_action(True) - sign_in_item.set_fanart(self.get_fanart(context)) + if not logged_in and settings.get_bool('youtube.folder.sign.in.show', True): + sign_in_item = DirectoryItem( + ui.bold(localize('sign.in')), + create_uri(('sign', 'in')), + image='{media}/sign_in.png', + action=True + ) result.append(sign_in_item) - if self.is_logged_in() and settings.get_bool('youtube.folder.my_subscriptions.show', True): + if logged_in and settings.get_bool('youtube.folder.my_subscriptions.show', True): # my subscription - - #clear cache + + # clear cache cache = context.get_data_cache() - cache_items_key = 'my-subscriptions-items' - cache.set(cache_items_key, '[]') - + cache.set_item('my-subscriptions-items', '[]') + my_subscriptions_item = DirectoryItem( - context.get_ui().bold(context.localize(self.LOCAL_MAP['youtube.my_subscriptions'])), - context.create_uri(['special', 'new_uploaded_videos_tv']), - context.create_resource_path('media', 'new_uploads.png')) - my_subscriptions_item.set_fanart(self.get_fanart(context)) + ui.bold(localize('my_subscriptions')), + create_uri(('special', 'new_uploaded_videos_tv')), + image='{media}/new_uploads.png', + ) result.append(my_subscriptions_item) - if self.is_logged_in() and settings.get_bool('youtube.folder.my_subscriptions_filtered.show', True): + if logged_in and settings.get_bool('youtube.folder.my_subscriptions_filtered.show', True): # my subscriptions filtered my_subscriptions_filtered_item = DirectoryItem( - context.localize(self.LOCAL_MAP['youtube.my_subscriptions_filtered']), - context.create_uri(['special', 'new_uploaded_videos_tv_filtered']), - context.create_resource_path('media', 'new_uploads.png')) - my_subscriptions_filtered_item.set_fanart(self.get_fanart(context)) + localize('my_subscriptions.filtered'), + create_uri(('special', 'new_uploaded_videos_tv_filtered')), + image='{media}/new_uploads.png', + ) result.append(my_subscriptions_filtered_item) access_manager = context.get_access_manager() - - # Recommendations - if self.is_logged_in() and settings.get_bool('youtube.folder.recommendations.show', True): - watch_history_playlist_id = access_manager.get_watch_history_id() - if watch_history_playlist_id != 'HL': - recommendations_item = DirectoryItem( - context.localize(self.LOCAL_MAP['youtube.recommendations']), - context.create_uri(['special', 'recommendations']), - context.create_resource_path('media', 'popular.png')) - recommendations_item.set_fanart(self.get_fanart(context)) - result.append(recommendations_item) - - # what to watch + watch_later_id = logged_in and access_manager.get_watch_later_id() + history_id = logged_in and access_manager.get_watch_history_id() + local_history = settings.use_local_history() + + # Home / Recommendations + if settings.get_bool('youtube.folder.recommendations.show', True): + recommendations_item = DirectoryItem( + localize('recommendations'), + create_uri(('special', 'recommendations')), + image='{media}/home.png', + ) + result.append(recommendations_item) + + # Related + if settings.get_bool('youtube.folder.related.show', True): + if history_id or local_history: + related_item = DirectoryItem( + localize('related_videos'), + create_uri(('special', 'related_videos')), + image='{media}/related_videos.png', + ) + result.append(related_item) + + # Trending if settings.get_bool('youtube.folder.popular_right_now.show', True): - what_to_watch_item = DirectoryItem( - context.localize(self.LOCAL_MAP['youtube.popular_right_now']), - context.create_uri(['special', 'popular_right_now']), - context.create_resource_path('media', 'popular.png')) - what_to_watch_item.set_fanart(self.get_fanart(context)) - result.append(what_to_watch_item) + trending_item = DirectoryItem( + localize('trending'), + create_uri(('special', 'popular_right_now')), + image='{media}/trending.png', + ) + result.append(trending_item) # search if settings.get_bool('youtube.folder.search.show', True): - search_item = kodion.items.SearchItem(context, image=context.create_resource_path('media', 'search.png'), - fanart=self.get_fanart(context)) + search_item = SearchItem( + context, + ) result.append(search_item) if settings.get_bool('youtube.folder.quick_search.show', True): - quick_search_item = kodion.items.NewSearchItem(context, - alt_name=context.localize(self.LOCAL_MAP['youtube.quick.search']), - fanart=self.get_fanart(context)) + quick_search_item = NewSearchItem( + context, + name=localize('search.quick'), + image='{media}/quick_search.png', + ) result.append(quick_search_item) if settings.get_bool('youtube.folder.quick_search_incognito.show', True): - quick_search_incognito_item = kodion.items.NewSearchItem(context, - alt_name=context.localize(self.LOCAL_MAP['youtube.quick.search.incognito']), - image=context.create_resource_path('media', 'search.png'), - fanart=self.get_fanart(context), - incognito=True) + quick_search_incognito_item = NewSearchItem( + context, + name=localize('search.quick.incognito'), + image='{media}/incognito_search.png', + incognito=True, + ) result.append(quick_search_incognito_item) # my location if settings.get_bool('youtube.folder.my_location.show', True) and settings.get_location(): - my_location_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.my_location']), - context.create_uri(['location', 'mine']), - image=context.create_resource_path('media', 'channel.png')) - my_location_item.set_fanart(self.get_fanart(context)) + my_location_item = DirectoryItem( + localize('my_location'), + create_uri(('location', 'mine')), + image='{media}/location.png', + ) result.append(my_location_item) - # subscriptions - if self.is_logged_in(): - # my channel - if settings.get_bool('youtube.folder.my_channel.show', True): - my_channel_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.my_channel']), - context.create_uri(['channel', 'mine']), - image=context.create_resource_path('media', 'channel.png')) - my_channel_item.set_fanart(self.get_fanart(context)) - result.append(my_channel_item) - - # watch later - watch_later_playlist_id = access_manager.get_watch_later_id() - if settings.get_bool('youtube.folder.watch_later.show', True) and watch_later_playlist_id: - watch_later_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.watch_later']), - context.create_uri( - ['channel', 'mine', 'playlist', watch_later_playlist_id]), - context.create_resource_path('media', 'watch_later.png')) - watch_later_item.set_fanart(self.get_fanart(context)) - context_menu = [] - yt_context_menu.append_play_all_from_playlist(context_menu, self, context, watch_later_playlist_id) + # my channel + if logged_in and settings.get_bool('youtube.folder.my_channel.show', True): + my_channel_item = DirectoryItem( + localize('my_channel'), + create_uri(('channel', 'mine')), + image='{media}/channel.png', + ) + result.append(my_channel_item) + + # watch later + if settings.get_bool('youtube.folder.watch_later.show', True): + if watch_later_id: + watch_later_item = DirectoryItem( + localize('watch_later'), + create_uri(('channel', 'mine', 'playlist', watch_later_id)), + image='{media}/watch_later.png', + ) + context_menu = [ + menu_items.play_all_from_playlist( + context, watch_later_id + ) + ] watch_later_item.set_context_menu(context_menu) result.append(watch_later_item) + else: + watch_history_item = DirectoryItem( + localize('watch_later'), + create_uri((paths.WATCH_LATER, 'list')), + image='{media}/watch_later.png', + ) + result.append(watch_history_item) + + # liked videos + if logged_in and settings.get_bool('youtube.folder.liked_videos.show', True): + resource_manager = self.get_resource_manager(context) + playlists = resource_manager.get_related_playlists(channel_id='mine') + if 'likes' in playlists: + liked_videos_item = DirectoryItem( + localize('video.liked'), + create_uri(('channel', 'mine', 'playlist', playlists['likes'])), + image='{media}/likes.png', + ) + context_menu = [ + menu_items.play_all_from_playlist( + context, playlists['likes'] + ) + ] + liked_videos_item.set_context_menu(context_menu) + result.append(liked_videos_item) + + # disliked videos + if logged_in and settings.get_bool('youtube.folder.disliked_videos.show', True): + disliked_videos_item = DirectoryItem( + localize('video.disliked'), + create_uri(('special', 'disliked_videos')), + image='{media}/dislikes.png', + ) + result.append(disliked_videos_item) + + # history + if settings.get_bool('youtube.folder.history.show', False): + if history_id: + watch_history_item = DirectoryItem( + localize('history'), + create_uri(('channel', 'mine', 'playlist', history_id)), + image='{media}/history.png', + ) + context_menu = [ + menu_items.play_all_from_playlist( + context, history_id + ) + ] + watch_history_item.set_context_menu(context_menu) + result.append(watch_history_item) + elif local_history: + watch_history_item = DirectoryItem( + localize('history'), + create_uri([paths.HISTORY], params={'action': 'list'}), + image='{media}/history.png', + ) + result.append(watch_history_item) + + # (my) playlists + if logged_in and settings.get_bool('youtube.folder.playlists.show', True): + playlists_item = DirectoryItem( + localize('playlists'), + create_uri(('channel', 'mine', 'playlists')), + image='{media}/playlist.png', + ) + result.append(playlists_item) + + # saved playlists + if logged_in and settings.get_bool('youtube.folder.saved.playlists.show', True): + playlists_item = DirectoryItem( + localize('saved.playlists'), + create_uri(('special', 'saved_playlists')), + image='{media}/playlist.png', + ) + result.append(playlists_item) - # liked videos - if settings.get_bool('youtube.folder.liked_videos.show', True): - resource_manager = self.get_resource_manager(context) - playlists = resource_manager.get_related_playlists(channel_id='mine') - if 'likes' in playlists: - liked_videos_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.video.liked']), - context.create_uri( - ['channel', 'mine', 'playlist', playlists['likes']]), - context.create_resource_path('media', 'likes.png')) - liked_videos_item.set_fanart(self.get_fanart(context)) - context_menu = [] - yt_context_menu.append_play_all_from_playlist(context_menu, self, context, playlists['likes']) - liked_videos_item.set_context_menu(context_menu) - result.append(liked_videos_item) - - # disliked videos - if settings.get_bool('youtube.folder.disliked_videos.show', True): - disliked_videos_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.video.disliked']), - context.create_uri(['special', 'disliked_videos']), - context.create_resource_path('media', 'dislikes.png')) - disliked_videos_item.set_fanart(self.get_fanart(context)) - result.append(disliked_videos_item) - - # history - if settings.get_bool('youtube.folder.history.show', False): - watch_history_playlist_id = access_manager.get_watch_history_id() - if watch_history_playlist_id != 'HL': - watch_history_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.history']), - context.create_uri( - ['channel', 'mine', 'playlist', watch_history_playlist_id]), - context.create_resource_path('media', 'history.png')) - watch_history_item.set_fanart(self.get_fanart(context)) - context_menu = [] - yt_context_menu.append_play_all_from_playlist(context_menu, self, context, watch_history_playlist_id) - watch_history_item.set_context_menu(context_menu) - - result.append(watch_history_item) - - # (my) playlists - if settings.get_bool('youtube.folder.playlists.show', True): - playlists_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.playlists']), - context.create_uri(['channel', 'mine', 'playlists']), - context.create_resource_path('media', 'playlist.png')) - playlists_item.set_fanart(self.get_fanart(context)) - result.append(playlists_item) - - # saved playlists - if settings.get_bool('youtube.folder.saved.playlists.show', True): - playlists_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.saved.playlists']), - context.create_uri(['special', 'saved_playlists']), - context.create_resource_path('media', 'playlist.png')) - playlists_item.set_fanart(self.get_fanart(context)) - result.append(playlists_item) - - # subscriptions - if settings.get_bool('youtube.folder.subscriptions.show', True): - subscriptions_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.subscriptions']), - context.create_uri(['subscriptions', 'list']), - image=context.create_resource_path('media', 'channels.png')) - subscriptions_item.set_fanart(self.get_fanart(context)) - result.append(subscriptions_item) - - # browse channels - if settings.get_bool('youtube.folder.browse_channels.show', True): - browse_channels_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.browse_channels']), - context.create_uri(['special', 'browse_channels']), - image=context.create_resource_path('media', 'browse_channels.png')) - browse_channels_item.set_fanart(self.get_fanart(context)) - result.append(browse_channels_item) + # subscriptions + if logged_in and settings.get_bool('youtube.folder.subscriptions.show', True): + subscriptions_item = DirectoryItem( + localize('subscriptions'), + create_uri(('subscriptions', 'list')), + image='{media}/channels.png', + ) + result.append(subscriptions_item) + + # browse channels + if logged_in and settings.get_bool('youtube.folder.browse_channels.show', True): + browse_channels_item = DirectoryItem( + localize('browse_channels'), + create_uri(('special', 'browse_channels')), + image='{media}/browse_channels.png', + ) + result.append(browse_channels_item) # completed live events if settings.get_bool('youtube.folder.completed.live.show', True): - live_events_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.completed.live']), - context.create_uri(['special', 'completed_live']), - image=context.create_resource_path('media', 'live.png')) - live_events_item.set_fanart(self.get_fanart(context)) + live_events_item = DirectoryItem( + localize('live.completed'), + create_uri(('special', 'completed_live')), + image='{media}/live.png', + ) result.append(live_events_item) # upcoming live events if settings.get_bool('youtube.folder.upcoming.live.show', True): - live_events_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.upcoming.live']), - context.create_uri(['special', 'upcoming_live']), - image=context.create_resource_path('media', 'live.png')) - live_events_item.set_fanart(self.get_fanart(context)) + live_events_item = DirectoryItem( + localize('live.upcoming'), + create_uri(('special', 'upcoming_live')), + image='{media}/live.png', + ) result.append(live_events_item) # live events if settings.get_bool('youtube.folder.live.show', True): - live_events_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.live']), - context.create_uri(['special', 'live']), - image=context.create_resource_path('media', 'live.png')) - live_events_item.set_fanart(self.get_fanart(context)) + live_events_item = DirectoryItem( + localize('live'), + create_uri(('special', 'live')), + image='{media}/live.png', + ) result.append(live_events_item) # switch user if settings.get_bool('youtube.folder.switch.user.show', True): - switch_user_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.switch.user']), - context.create_uri(['users', 'switch']), - image=context.create_resource_path('media', 'channel.png')) - switch_user_item.set_action(True) - switch_user_item.set_fanart(self.get_fanart(context)) + switch_user_item = DirectoryItem( + localize('user.switch'), + create_uri(('users', 'switch')), + image='{media}/channel.png', + action=True, + ) result.append(switch_user_item) # sign out - if self.is_logged_in() and settings.get_bool('youtube.folder.sign.out.show', True): - sign_out_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.sign.out']), - context.create_uri(['sign', 'out']), - image=context.create_resource_path('media', 'sign_out.png')) - sign_out_item.set_action(True) - sign_out_item.set_fanart(self.get_fanart(context)) + if logged_in and settings.get_bool('youtube.folder.sign.out.show', True): + sign_out_item = DirectoryItem( + localize('sign.out'), + create_uri(('sign', 'out')), + image='{media}/sign_out.png', + action=True, + ) result.append(sign_out_item) if settings.get_bool('youtube.folder.settings.show', True): - settings_menu_item = DirectoryItem(context.localize(self.LOCAL_MAP['youtube.settings']), - context.create_uri(['config', 'youtube']), - image=context.create_resource_path('media', 'settings.png')) - settings_menu_item.set_action(True) - settings_menu_item.set_fanart(self.get_fanart(context)) + settings_menu_item = DirectoryItem( + localize('settings'), + create_uri(('config', 'youtube')), + image='{media}/settings.png', + action=True, + ) result.append(settings_menu_item) return result - @staticmethod - def set_content_type(context, content_type): - context.set_content_type(content_type) - if content_type == kodion.constants.content_type.VIDEOS: - context.add_sort_method(kodion.constants.sort_method.UNSORTED, - kodion.constants.sort_method.VIDEO_RUNTIME, - kodion.constants.sort_method.DATE_ADDED, - kodion.constants.sort_method.TRACK_NUMBER, - kodion.constants.sort_method.VIDEO_TITLE, - kodion.constants.sort_method.DATE) - def handle_exception(self, context, exception_to_handle): - if (isinstance(exception_to_handle, InvalidGrant) or - isinstance(exception_to_handle, LoginException)): + if isinstance(exception_to_handle, (InvalidGrant, LoginException)): ok_dialog = False message_timeout = 5000 @@ -1642,22 +1349,26 @@ def handle_exception(self, context, exception_to_handle): context.log_error('%s: %s' % (title, log_message)) if error == 'deleted_client': - message = context.localize(self.LOCAL_MAP['youtube.key.requirement.notification']) - context.get_access_manager().update_access_token(access_token='', refresh_token='') + message = context.localize('key.requirement') + context.get_access_manager().update_access_token( + access_token='', refresh_token='' + ) ok_dialog = True if error == 'invalid_client': if message == 'The OAuth client was not found.': - message = context.localize(self.LOCAL_MAP['youtube.client.id.incorrect']) + message = context.localize('client.id.incorrect') message_timeout = 7000 elif message == 'Unauthorized': - message = context.localize(self.LOCAL_MAP['youtube.client.secret.incorrect']) + message = context.localize('client.secret.incorrect') message_timeout = 7000 if ok_dialog: context.get_ui().on_ok(title, message) else: - context.get_ui().show_notification(message, title, time_milliseconds=message_timeout) + context.get_ui().show_notification(message, + title, + time_ms=message_timeout) return False diff --git a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/youtube_exceptions.py b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/youtube_exceptions.py index 1ff852d118..7611878962 100644 --- a/plugin.video.youtube/resources/lib/youtube_plugin/youtube/youtube_exceptions.py +++ b/plugin.video.youtube/resources/lib/youtube_plugin/youtube/youtube_exceptions.py @@ -8,16 +8,23 @@ See LICENSES/GPL-2.0-only for more information. """ -from .. import kodion +from __future__ import absolute_import, division, unicode_literals +from ..kodion import KodionException +from ..kodion.network import InvalidJSONError -class LoginException(kodion.KodionException): + +class LoginException(KodionException): + pass + + +class YouTubeException(KodionException): pass -class YouTubeException(kodion.KodionException): +class InvalidGrant(KodionException): pass -class InvalidGrant(kodion.KodionException): +class InvalidJSON(KodionException, InvalidJSONError): pass diff --git a/plugin.video.youtube/resources/lib/youtube_registration.py b/plugin.video.youtube/resources/lib/youtube_registration.py index ab64f9ad4d..4a8dbb7fc7 100644 --- a/plugin.video.youtube/resources/lib/youtube_registration.py +++ b/plugin.video.youtube/resources/lib/youtube_registration.py @@ -7,9 +7,13 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + from base64 import b64encode + +from youtube_plugin.kodion.constants import ADDON_ID +from youtube_plugin.kodion.context import Context from youtube_plugin.kodion.json_store import APIKeyStore -from youtube_plugin.kodion.impl import Context def register_api_keys(addon_id, api_key, client_id, client_secret): @@ -40,9 +44,9 @@ def register_api_keys(addon_id, api_key, client_id, client_secret): :param client_secret: YouTube Data v3 Client secret """ - context = Context(plugin_id='plugin.video.youtube') + context = Context() - if not addon_id or addon_id == 'plugin.video.youtube': + if not addon_id or addon_id == ADDON_ID: context.log_error('Register API Keys: |%s| Invalid addon_id' % addon_id) return diff --git a/plugin.video.youtube/resources/lib/youtube_requests.py b/plugin.video.youtube/resources/lib/youtube_requests.py index cf062d1968..822a04c38f 100644 --- a/plugin.video.youtube/resources/lib/youtube_requests.py +++ b/plugin.video.youtube/resources/lib/youtube_requests.py @@ -7,38 +7,29 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re from youtube_plugin.youtube.provider import Provider -from youtube_plugin.kodion.impl import Context +from youtube_plugin.kodion.context import Context def __get_core_components(addon_id=None): """ :param addon_id: addon id associated with developer keys to use for requests - :return: addon provider, context and client + :return: addon provider, context and client """ provider = Provider() if addon_id is not None: - context = Context(params={'addon_id': addon_id}, plugin_id='plugin.video.youtube') + context = Context(params={'addon_id': addon_id}) else: - context = Context(plugin_id='plugin.video.youtube') + context = Context() client = provider.get_client(context=context) return provider, context, client -def handle_error(context, json_data): - if json_data and 'error' in json_data: - message = json_data['error'].get('message', '') - reason = json_data['error']['errors'][0].get('reason', '') - context.log_error('Error reason: |%s| with message: |%s|' % (reason, message)) - - return False - - return True - - def v3_request(method='GET', headers=None, path=None, post_data=None, params=None, addon_id=None): """ https://developers.google.com/youtube/v3/docs/ @@ -51,11 +42,18 @@ def v3_request(method='GET', headers=None, path=None, post_data=None, params=Non :type addon_id: str """ provider, context, client = __get_core_components(addon_id) - return client.perform_v3_request(method=method, headers=headers, path=path, post_data=post_data, params=params) + return client.perform_v3_request(method=method, + headers=headers, + path=path, + post_data=post_data, + params=params, + notify=False, + pass_data=True, + raise_exc=False) def _append_missing_page_token(items): - if items and isinstance(items, list) and (items[-1].get('nextPageToken') is None): + if items and isinstance(items, list) and 'nextPageToken' not in items[-1]: items.append({'nextPageToken': ''}) return items @@ -74,11 +72,14 @@ def get_videos(video_id, addon_id=None): """ provider, context, client = __get_core_components(addon_id) - json_data = client.get_videos(video_id) - if not handle_error(context, json_data): + json_data = client.get_videos(video_id, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - return [item for item in json_data.get('items', [])] + return json_data.get('items', [{}]) def get_activities(channel_id, page_token='', all_pages=False, addon_id=None): @@ -101,23 +102,31 @@ def get_activities(channel_id, page_token='', all_pages=False, addon_id=None): items = [] def get_items(_page_token=''): - json_data = client.get_activities(channel_id, page_token=_page_token) - if not handle_error(context, json_data): + json_data = client.get_activities(channel_id, + page_token=_page_token, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - for item in json_data.get('items', []): - items.append(item) + items.extend(json_data.get('items', [{}])) + error = False next_page_token = json_data.get('nextPageToken') - if all_pages and (next_page_token is not None): - get_items(_page_token=next_page_token) - elif next_page_token is not None: + if not next_page_token: + return error + if all_pages: + error = get_items(_page_token=next_page_token) + else: items.append({'nextPageToken': next_page_token}) + return error - get_items(_page_token=page_token) + error = get_items(_page_token=page_token) + if error: + return error items = _append_missing_page_token(items) - return items @@ -142,23 +151,31 @@ def get_playlist_items(playlist_id, page_token='', all_pages=False, addon_id=Non items = [] def get_items(_page_token=''): - json_data = client.get_playlist_items(playlist_id, page_token=_page_token) - if not handle_error(context, json_data): + json_data = client.get_playlist_items(playlist_id, + page_token=_page_token, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - for item in json_data.get('items', []): - items.append(item) + items.extend(json_data.get('items', [{}])) + error = False next_page_token = json_data.get('nextPageToken') - if all_pages and (next_page_token is not None): - get_items(_page_token=next_page_token) - elif next_page_token is not None: + if not next_page_token: + return error + if all_pages: + error = get_items(_page_token=next_page_token) + else: items.append({'nextPageToken': next_page_token}) + return error - get_items(_page_token=page_token) + error = get_items(_page_token=page_token) + if error: + return error items = _append_missing_page_token(items) - return items @@ -175,11 +192,14 @@ def get_channel_id(channel_name, addon_id=None): """ provider, context, client = __get_core_components(addon_id) - json_data = client.get_channel_by_username(channel_name) - if not handle_error(context, json_data): + json_data = client.get_channel_by_username(channel_name, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - return [item for item in json_data.get('items', [])] + return json_data.get('items', [{}]) def get_channels(channel_id, addon_id=None): @@ -195,11 +215,14 @@ def get_channels(channel_id, addon_id=None): """ provider, context, client = __get_core_components(addon_id) - json_data = client.get_channels(channel_id) - if not handle_error(context, json_data): + json_data = client.get_channels(channel_id, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - return [item for item in json_data.get('items', [])] + return json_data.get('items', [{}]) def get_channel_sections(channel_id, addon_id=None): @@ -215,11 +238,14 @@ def get_channel_sections(channel_id, addon_id=None): """ provider, context, client = __get_core_components(addon_id) - json_data = client.get_channel_sections(channel_id) - if not handle_error(context, json_data): + json_data = client.get_channel_sections(channel_id, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - return [item for item in json_data.get('items', [])] + return json_data.get('items', [{}]) def get_playlists_of_channel(channel_id, page_token='', all_pages=False, addon_id=None): @@ -243,23 +269,31 @@ def get_playlists_of_channel(channel_id, page_token='', all_pages=False, addon_i items = [] def get_items(_page_token=''): - json_data = client.get_playlists_of_channel(channel_id, page_token=_page_token) - if not handle_error(context, json_data): + json_data = client.get_playlists_of_channel(channel_id, + page_token=_page_token, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - for item in json_data.get('items', []): - items.append(item) + items.extend(json_data.get('items', [{}])) + error = False next_page_token = json_data.get('nextPageToken') - if all_pages and (next_page_token is not None): - get_items(_page_token=next_page_token) - elif next_page_token is not None: + if not next_page_token: + return error + if all_pages: + error = get_items(_page_token=next_page_token) + else: items.append({'nextPageToken': next_page_token}) + return error - get_items(_page_token=page_token) + error = get_items(_page_token=page_token) + if error: + return error items = _append_missing_page_token(items) - return items @@ -276,11 +310,14 @@ def get_playlists(playlist_id, addon_id=None): """ provider, context, client = __get_core_components(addon_id) - json_data = client.get_playlists(playlist_id) - if not handle_error(context, json_data): + json_data = client.get_playlists(playlist_id, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - return [item for item in json_data.get('items', [])] + return json_data.get('items', [{}]) def get_related_videos(video_id, page_token='', addon_id=None): @@ -303,22 +340,28 @@ def get_related_videos(video_id, page_token='', addon_id=None): items = [] def get_items(_page_token=''): - json_data = client.get_related_videos(video_id, page_token=_page_token) - if not handle_error(context, json_data): + json_data = client.get_related_videos(video_id, + page_token=_page_token, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - for item in json_data.get('items', []): - if 'snippet' in item: - items.append(item) + items.extend([item for item in json_data.get('items', [{}]) + if 'snippet' in item]) + error = False next_page_token = json_data.get('nextPageToken') - if next_page_token is not None: + if next_page_token: items.append({'nextPageToken': next_page_token}) + return error - get_items(_page_token=page_token) + error = get_items(_page_token=page_token) + if error: + return error items = _append_missing_page_token(items) - return items @@ -353,22 +396,32 @@ def get_search(q, search_type='', event_type='', channel_id='', order='relevance items = [] def get_items(_page_token=''): - json_data = client.search(q, search_type=search_type, event_type=event_type, channel_id=channel_id, - order=order, safe_search=safe_search, page_token=_page_token) - if not handle_error(context, json_data): + json_data = client.search(q, + search_type=search_type, + event_type=event_type, + channel_id=channel_id, + order=order, + safe_search=safe_search, + page_token=_page_token, + notify=False, + pass_data=True, + raise_exc=False) + if not json_data or 'error' in json_data: return [json_data] - for item in json_data.get('items', []): - items.append(item) + items.extend(json_data.get('items', [{}])) + error = False next_page_token = json_data.get('nextPageToken') - if next_page_token is not None: + if next_page_token: items.append({'nextPageToken': next_page_token}) + return error - get_items(_page_token=page_token) + error = get_items(_page_token=page_token) + if error: + return error items = _append_missing_page_token(items) - return items @@ -428,7 +481,7 @@ def get_live(channel_id=None, user=None, url=None, addon_id=None): if matched_type == 'user': items = get_channel_id(matched_id, addon_id=addon_id) - if not items or not isinstance(items, list): + if not items or not isinstance(items, list) or 'id' not in items[0]: return None matched_id = items[0]['id'] diff --git a/plugin.video.youtube/resources/lib/youtube_resolver.py b/plugin.video.youtube/resources/lib/youtube_resolver.py index 28eb032561..82429150ff 100644 --- a/plugin.video.youtube/resources/lib/youtube_resolver.py +++ b/plugin.video.youtube/resources/lib/youtube_resolver.py @@ -7,18 +7,20 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + import re from youtube_plugin.youtube.provider import Provider -from youtube_plugin.kodion.impl import Context +from youtube_plugin.kodion.context import Context def _get_core_components(addon_id=None): provider = Provider() if addon_id is not None: - context = Context(params={'addon_id': addon_id}, plugin_id='plugin.video.youtube') + context = Context(params={'addon_id': addon_id}) else: - context = Context(plugin_id='plugin.video.youtube') + context = Context() client = provider.get_client(context=context) return provider, context, client diff --git a/plugin.video.youtube/resources/media/what_to_watch.png b/plugin.video.youtube/resources/media/home.png similarity index 100% rename from plugin.video.youtube/resources/media/what_to_watch.png rename to plugin.video.youtube/resources/media/home.png diff --git a/plugin.video.youtube/resources/media/incognito_search.png b/plugin.video.youtube/resources/media/incognito_search.png new file mode 100644 index 0000000000..2dcb821a33 Binary files /dev/null and b/plugin.video.youtube/resources/media/incognito_search.png differ diff --git a/plugin.video.youtube/resources/media/location.png b/plugin.video.youtube/resources/media/location.png new file mode 100644 index 0000000000..111e715b0f Binary files /dev/null and b/plugin.video.youtube/resources/media/location.png differ diff --git a/plugin.video.youtube/resources/media/new_search.png b/plugin.video.youtube/resources/media/new_search.png index 00487f2f7d..a77d7684e5 100644 Binary files a/plugin.video.youtube/resources/media/new_search.png and b/plugin.video.youtube/resources/media/new_search.png differ diff --git a/plugin.video.youtube/resources/media/quick_search.png b/plugin.video.youtube/resources/media/quick_search.png new file mode 100644 index 0000000000..25623a3ba7 Binary files /dev/null and b/plugin.video.youtube/resources/media/quick_search.png differ diff --git a/plugin.video.youtube/resources/media/related_videos.png b/plugin.video.youtube/resources/media/related_videos.png new file mode 100644 index 0000000000..fdcee48ac5 Binary files /dev/null and b/plugin.video.youtube/resources/media/related_videos.png differ diff --git a/plugin.video.youtube/resources/media/search.png b/plugin.video.youtube/resources/media/search.png index da5138ed5d..e658c7b32f 100644 Binary files a/plugin.video.youtube/resources/media/search.png and b/plugin.video.youtube/resources/media/search.png differ diff --git a/plugin.video.youtube/resources/media/popular.png b/plugin.video.youtube/resources/media/trending.png similarity index 100% rename from plugin.video.youtube/resources/media/popular.png rename to plugin.video.youtube/resources/media/trending.png diff --git a/plugin.video.youtube/resources/settings.xml b/plugin.video.youtube/resources/settings.xml index 70429df9f6..fbe786f5cd 100644 --- a/plugin.video.youtube/resources/settings.xml +++ b/plugin.video.youtube/resources/settings.xml @@ -39,7 +39,6 @@ 0 - RunPlugin(plugin://plugin.video.youtube/config/subtitles/) true @@ -51,6 +50,7 @@ true + RunScript($ID,config/subtitles) true @@ -200,11 +200,11 @@ - + - + 0 - false + true System.HasAddon(inputstream.adaptive) @@ -212,17 +212,17 @@ - + 0 - RunPlugin(plugin://plugin.video.youtube/config/mpd/) true - true + true + RunScript($ID,config/isa) true @@ -234,7 +234,7 @@ true - true + true @@ -263,7 +263,7 @@ 0 - avc1,vp9,av01,hdr,hfr,vorbis,opus,mp4a,ssa,ac-3,ec-3,dts,filter + avc1,vp9,av01,hdr,hfr,vorbis,mp4a,ssa,ac-3,ec-3,dts,filter @@ -271,6 +271,7 @@ + @@ -295,7 +296,7 @@ 0 - 1 + 3 @@ -312,37 +313,36 @@ - + 0 - 0 + 2 - + - - + + - true + true - + 0 0 - - false + false @@ -351,19 +351,19 @@ 0 - RunPlugin(plugin://plugin.video.youtube/maintain/inputstreamhelper/install/) true - true - 1 - 1 + true + 1 + 1 + RunScript($ID,config/inputstreamhelper) @@ -421,6 +421,11 @@ true + + 0 + true + + 0 true @@ -578,34 +583,34 @@ 0 - RunPlugin(plugin://plugin.video.youtube/users/add/?refresh=false) true + RunScript($ID,users/add) 0 - RunPlugin(plugin://plugin.video.youtube/users/remove/?refresh=false) true + RunScript($ID,users/remove) 0 - RunPlugin(plugin://plugin.video.youtube/users/rename/?refresh=false) true + RunScript($ID,users/rename) 0 - RunPlugin(plugin://plugin.video.youtube/users/switch/?refresh=false) true + RunScript($ID,users/switch/refresh=True) @@ -624,11 +629,37 @@ - + 0 true + + 0 + 9 + + 3 + 3 + 120 + + + false + 14045 + + + + 0 + 27 + + 10 + 1 + 120 + + + false + 14045 + + 0 false @@ -644,7 +675,7 @@ - + 0 true @@ -653,7 +684,7 @@ 0 - false + true @@ -665,7 +696,7 @@ 0 - 10 + 20 5 1 @@ -756,17 +787,21 @@ 0 - RunPlugin(plugin://plugin.video.youtube/config/listen_ip/) true + RunScript($ID,config/listen_ip) true - + 0 50152 + + 0 + 65535 + true @@ -793,10 +828,10 @@ 0 - RunPlugin(plugin://plugin.video.youtube/show_client_ip/) true + RunScript($ID,config/show_client_ip) @@ -851,78 +886,78 @@ 0 - RunPlugin(plugin://plugin.video.youtube/maintain/function_cache/clear/) true + RunScript($ID,maintenance/clear/function_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/data_cache/clear/) true + RunScript($ID,maintenance/clear/data_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/search_cache/clear/) true + RunScript($ID,maintenance/clear/search_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/playback_history/clear/) true + RunScript($ID,maintenance/clear/playback_history) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/function_cache/delete/) true + RunScript($ID,maintenance/delete/function_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/data_cache/delete/) true + RunScript($ID,maintenance/delete/data_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/search_cache/delete/) true + RunScript($ID,maintenance/delete/search_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/playback_history/delete/) true + RunScript($ID,maintenance/delete/playback_history) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/access_manager/reset/) true + RunPlugin(plugin://$ID/maintenance/reset/access_manager) true @@ -931,95 +966,38 @@ 0 - RunPlugin(plugin://plugin.video.youtube/maintain/settings_xml/delete/) true + RunScript($ID,maintenance/delete/settings_xml) true 0 - RunPlugin(plugin://plugin.video.youtube/maintain/api_keys/delete/) true + RunScript($ID,maintenance/delete/api_keys) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/access_manager/delete/) true + RunScript($ID,maintenance/delete/access_manager) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/temp_files/delete/) true + RunScript($ID,maintenance/delete/temp_files) - - 0 - - - true - - - - false - - - - - - - - 0 - - - true - - - - false - - - - - - - - 0 - 0 - - - false - - - - - - - - 0 - - - true - - - - false - - - - - -