Added Jellyfin integration

This commit is contained in:
Fabio Manganiello 2022-03-01 01:32:50 +01:00
parent 0b293ff214
commit 0d0797a465
Signed by: blacklight
GPG key ID: D90FBA7F76362774
65 changed files with 665 additions and 99 deletions

View file

@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2.
## [0.23.0] - 2022-03-01
### Added
- Added [Jellyfin integration](https://git.platypush.tech/platypush/platypush/-/issues/208).
### Fixed
- Merged several PRs from `dependabot`.
- Fixed management of the `CN` field in the `calendar.ical` plugin.
## [0.22.10] - 2022-02-07 ## [0.22.10] - 2022-02-07
### Added ### Added

View file

@ -0,0 +1,6 @@
``media.jellyfin``
================================
.. automodule:: platypush.plugins.media.jellyfin
:members:

View file

@ -77,6 +77,7 @@ Plugins
platypush/plugins/mastodon.rst platypush/plugins/mastodon.rst
platypush/plugins/media.chromecast.rst platypush/plugins/media.chromecast.rst
platypush/plugins/media.gstreamer.rst platypush/plugins/media.gstreamer.rst
platypush/plugins/media.jellyfin.rst
platypush/plugins/media.kodi.rst platypush/plugins/media.kodi.rst
platypush/plugins/media.mplayer.rst platypush/plugins/media.mplayer.rst
platypush/plugins/media.mpv.rst platypush/plugins/media.mpv.rst

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-label="Jellyfin" role="img" viewBox="0 0 512 512">
<rect width="512" height="512" rx="15%" fill="#fff" fill-opacity="0"/>
<defs>
<path d="M190.56 329.07c8.63 17.3 122.4 17.12 130.93 0 8.52-17.1-47.9-119.78-65.46-119.8-17.57 0-74.1 102.5-65.47 119.8z" id="A"/>
<linearGradient id="B" gradientUnits="userSpaceOnUse" x1="126.15" y1="219.32" x2="457.68" y2="410.73">
<stop offset="0%" stop-color="#aa5cc3"/>
<stop offset="100%" stop-color="#00a4dc"/>
</linearGradient>
<path d="M58.75 417.03c25.97 52.15 368.86 51.55 394.55 0S308.93 56.08 256.03 56.08c-52.92 0-223.25 308.8-197.28 360.95zm68.04-45.25c-17.02-34.17 94.6-236.5 129.26-236.5 34.67 0 146.1 202.7 129.26 236.5-16.83 33.8-241.5 34.17-258.52 0z" id="C"/>
</defs>
<use xlink:href="#A" fill="url(#B)"/>
<use xlink:href="#A" fill-opacity="0" stroke="#000" stroke-opacity="0"/>
<use xlink:href="#C" fill="url(#B)"/>
<use xlink:href="#C" fill-opacity="0" stroke="#000" stroke-opacity="0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-55f142bc"],{"2f2a":function(e,t,r){},"372b":function(e,t,r){"use strict";r("2f2a")},c306:function(e,t,r){"use strict";r.r(t);var n=r("7a23"),s=Object(n["K"])("data-v-24745ce0");Object(n["u"])("data-v-24745ce0");var i={class:"rss-news"},c={key:0,class:"article"};Object(n["s"])();var u=s((function(e,t,r,s,u,a){return Object(n["r"])(),Object(n["e"])("div",i,[e.currentArticle?(Object(n["r"])(),Object(n["e"])("div",c,[Object(n["h"])("div",{class:"source",textContent:Object(n["C"])(e.currentArticle.feed_title||e.currentArticle.feed_url)},null,8,["textContent"]),Object(n["h"])("div",{class:"title",textContent:Object(n["C"])(e.currentArticle.title)},null,8,["textContent"]),Object(n["h"])("div",{class:"published",textContent:Object(n["C"])(new Date(e.currentArticle.published).toDateString()+", "+new Date(e.currentArticle.published).toTimeString().substring(0,5))},null,8,["textContent"])])):Object(n["f"])("",!0)])})),a=r("2909"),l=r("1da1"),o=(r("96cf"),r("a9e3"),r("b680"),r("3e54")),h={name:"RssNews",mixins:[o["a"]],props:{limit:{type:Number,required:!1,default:25},refreshSeconds:{type:Number,required:!1,default:15}},data:function(){return{articles:[],queue:[],currentArticle:void 0}},methods:{refresh:function(){var e=Object(l["a"])(regeneratorRuntime.mark((function e(){return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:if(this.queue.length){e.next=5;break}return e.next=3,this.request("rss.get_latest_entries",{limit:this.limit});case 3:this.articles=e.sent,this.queue=Object(a["a"])(this.articles).reverse();case 5:if(this.queue.length){e.next=7;break}return e.abrupt("return");case 7:this.currentArticle=this.queue.pop();case 8:case"end":return e.stop()}}),e,this)})));function t(){return e.apply(this,arguments)}return t}()},mounted:function(){this.refresh(),setInterval(this.refresh,parseInt((1e3*this.refreshSeconds).toFixed(0)))}};r("372b");h.render=u,h.__scopeId="data-v-24745ce0";t["default"]=h}}]);
//# sourceMappingURL=chunk-55f142bc.f287d0c8.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/widgets/RssNews/Index.vue?8981","webpack:///./src/components/widgets/RssNews/Index.vue","webpack:///./src/components/widgets/RssNews/Index.vue?6001"],"names":["class","currentArticle","feed_title","feed_url","title","Date","published","toDateString","toTimeString","substring","name","mixins","Utils","props","limit","type","Number","required","default","refreshSeconds","data","articles","queue","undefined","methods","refresh","this","length","request","reverse","pop","mounted","setInterval","parseInt","toFixed","render","__scopeId"],"mappings":"2IAAA,W,sICCOA,MAAM,Y,SACJA,MAAM,W,wEADb,eAMM,MANN,EAMM,CALuB,EAAAC,gB,iBAA3B,eAIM,MAJN,EAIM,CAHJ,eAAwF,OAAnFD,MAAM,S,YAAS,eAA6D,EAAvC,eAACE,YAAc,EAAAD,eAAeE,W,wBACxE,eAAuD,OAAlDH,MAAM,Q,YAAQ,eAA6B,EAAP,eAACI,Q,wBAC1C,eAAkK,OAA7JJ,MAAM,Y,YAAY,eAAoI,IAAxHK,KAAK,EAAAJ,eAAeK,WAAWC,eAAY,SAAgBF,KAAK,EAAAJ,eAAeK,WAAWE,eAAeC,UAAS,O,0HAY5I,GACbC,KAAM,UACNC,OAAQ,CAACC,EAAA,MACTC,MAAO,CAELC,MAAO,CACLC,KAAMC,OACNC,UAAU,EACVC,QAAS,IAIXC,eAAgB,CACdJ,KAAMC,OACNC,UAAU,EACVC,QAAS,KAIbE,KAAM,WACJ,MAAO,CACLC,SAAU,GACVC,MAAO,GACPrB,oBAAgBsB,IAIpBC,QAAS,CACPC,QAAS,WAAF,8CAAE,iGACFC,KAAKJ,MAAMK,OADT,gCAEiBD,KAAKE,QAAQ,yBAA0B,CAC3Dd,MAAOY,KAAKZ,QAHT,OAELY,KAAKL,SAFA,OAMLK,KAAKJ,MAAQ,eAAII,KAAKL,UAAUQ,UAN3B,UASFH,KAAKJ,MAAMK,OATT,iDAYPD,KAAKzB,eAAiByB,KAAKJ,MAAMQ,MAZ1B,gDAAF,qDAAE,IAgBXC,QAAS,WACPL,KAAKD,UACLO,YAAYN,KAAKD,QAASQ,UAA8B,IAApBP,KAAKP,gBAAqBe,QAAQ,O,UC1D1E,EAAOC,OAASA,EAChB,EAAOC,UAAY,kBAEJ","file":"static/js/chunk-55f142bc.f287d0c8.js","sourcesContent":["export * from \"-!../../../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--8-oneOf-1-0!../../../../node_modules/css-loader/dist/cjs.js??ref--8-oneOf-1-1!../../../../node_modules/vue-loader-v16/dist/stylePostLoader.js!../../../../node_modules/postcss-loader/src/index.js??ref--8-oneOf-1-2!../../../../node_modules/sass-loader/dist/cjs.js??ref--8-oneOf-1-3!../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../node_modules/vue-loader-v16/dist/index.js??ref--0-1!./Index.vue?vue&type=style&index=0&id=24745ce0&lang=scss&scoped=true\"","<template>\n <div class=\"rss-news\">\n <div class=\"article\" v-if=\"currentArticle\">\n <div class=\"source\" v-text=\"currentArticle.feed_title || currentArticle.feed_url\"></div>\n <div class=\"title\" v-text=\"currentArticle.title\"></div>\n <div class=\"published\" v-text=\"new Date(currentArticle.published).toDateString() + ', ' + new Date(currentArticle.published).toTimeString().substring(0,5)\"></div>\n </div>\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\n\n/**\n * In order to use this widget you need to configure the `rss` plugin\n * with a list of subscriptions.\n */\nexport default {\n name: \"RssNews\",\n mixins: [Utils],\n props: {\n // Maximum number of items to be shown in a cycle.\n limit: {\n type: Number,\n required: false,\n default: 25,\n },\n\n // How long an entry should be displayed before moving to the next one.\n refreshSeconds: {\n type: Number,\n required: false,\n default: 15,\n },\n },\n\n data: function() {\n return {\n articles: [],\n queue: [],\n currentArticle: undefined,\n }\n },\n\n methods: {\n refresh: async function() {\n if (!this.queue.length) {\n this.articles = await this.request('rss.get_latest_entries', {\n limit: this.limit\n })\n\n this.queue = [...this.articles].reverse()\n }\n\n if (!this.queue.length)\n return\n\n this.currentArticle = this.queue.pop()\n },\n },\n\n mounted: function() {\n this.refresh()\n setInterval(this.refresh, parseInt((this.refreshSeconds*1000).toFixed(0)))\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.rss-news {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n letter-spacing: .025em;\n\n .article {\n width: 90%;\n padding: 0 2em;\n\n .source {\n font-size: 1.7em;\n font-weight: bold;\n margin-bottom: .5em;\n }\n\n .title {\n font-size: 1.8em;\n font-weight: normal;\n margin-bottom: .5em;\n }\n\n .published {\n text-align: right;\n font-size: 1.1em;\n }\n }\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=24745ce0&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=24745ce0&lang=scss&scoped=true\"\nscript.render = render\nscript.__scopeId = \"data-v-24745ce0\"\n\nexport default script"],"sourceRoot":""}

View file

@ -1,2 +0,0 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-7babe442"],{"0d43":function(e,t,r){"use strict";r("ddbd")},c306:function(e,t,r){"use strict";r.r(t);var n=r("7a23"),s=Object(n["K"])("data-v-52a823f4");Object(n["u"])("data-v-52a823f4");var i={class:"rss-news"},c={key:0,class:"article"};Object(n["s"])();var u=s((function(e,t,r,s,u,a){return Object(n["r"])(),Object(n["e"])("div",i,[e.currentArticle?(Object(n["r"])(),Object(n["e"])("div",c,[Object(n["h"])("div",{class:"source",textContent:Object(n["C"])(e.currentArticle.feed_title||e.currentArticle.feed_url)},null,8,["textContent"]),Object(n["h"])("div",{class:"title",textContent:Object(n["C"])(e.currentArticle.title)},null,8,["textContent"]),Object(n["h"])("div",{class:"published",textContent:Object(n["C"])(new Date(e.currentArticle.published).toDateString()+", "+new Date(e.currentArticle.published).toTimeString().substring(0,5))},null,8,["textContent"])])):Object(n["f"])("",!0)])})),a=r("2909"),l=r("1da1"),d=(r("96cf"),r("a9e3"),r("b680"),r("3e54")),o={name:"RssNews",mixins:[d["a"]],props:{limit:{type:Number,required:!1,default:25},refreshSeconds:{type:Number,required:!1,default:15}},data:function(){return{articles:[],queue:[],currentArticle:void 0}},methods:{refresh:function(){var e=Object(l["a"])(regeneratorRuntime.mark((function e(){return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:if(this.queue.length){e.next=5;break}return e.next=3,this.request("rss.get_latest_entries",{limit:this.limit});case 3:this.articles=e.sent,this.queue=Object(a["a"])(this.articles).reverse();case 5:if(this.queue.length){e.next=7;break}return e.abrupt("return");case 7:this.currentArticle=this.queue.pop();case 8:case"end":return e.stop()}}),e,this)})));function t(){return e.apply(this,arguments)}return t}()},mounted:function(){this.refresh(),setInterval(this.refresh,parseInt((1e3*this.refreshSeconds).toFixed(0)))}};r("0d43");o.render=u,o.__scopeId="data-v-52a823f4";t["default"]=o},ddbd:function(e,t,r){}}]);
//# sourceMappingURL=chunk-7babe442.e3a7971d.js.map

View file

@ -1 +0,0 @@
{"version":3,"sources":["webpack:///./src/components/widgets/RssNews/Index.vue?a9df","webpack:///./src/components/widgets/RssNews/Index.vue","webpack:///./src/components/widgets/RssNews/Index.vue?6001"],"names":["class","currentArticle","feed_title","feed_url","title","Date","published","toDateString","toTimeString","substring","name","mixins","Utils","props","limit","type","Number","required","default","refreshSeconds","data","articles","queue","undefined","methods","refresh","this","length","request","reverse","pop","mounted","setInterval","parseInt","toFixed","render","__scopeId"],"mappings":"kHAAA,W,sICCOA,MAAM,Y,SACJA,MAAM,W,wEADb,eAMM,MANN,EAMM,CALuB,EAAAC,gB,iBAA3B,eAIM,MAJN,EAIM,CAHJ,eAAwF,OAAnFD,MAAM,S,YAAS,eAA6D,EAAvC,eAACE,YAAc,EAAAD,eAAeE,W,wBACxE,eAAuD,OAAlDH,MAAM,Q,YAAQ,eAA6B,EAAP,eAACI,Q,wBAC1C,eAAkK,OAA7JJ,MAAM,Y,YAAY,eAAoI,IAAxHK,KAAK,EAAAJ,eAAeK,WAAWC,eAAY,SAAgBF,KAAK,EAAAJ,eAAeK,WAAWE,eAAeC,UAAS,O,0HAY5I,GACbC,KAAM,UACNC,OAAQ,CAACC,EAAA,MACTC,MAAO,CAELC,MAAO,CACLC,KAAMC,OACNC,UAAU,EACVC,QAAS,IAIXC,eAAgB,CACdJ,KAAMC,OACNC,UAAU,EACVC,QAAS,KAIbE,KAAM,WACJ,MAAO,CACLC,SAAU,GACVC,MAAO,GACPrB,oBAAgBsB,IAIpBC,QAAS,CACPC,QAAS,WAAF,8CAAE,iGACFC,KAAKJ,MAAMK,OADT,gCAEiBD,KAAKE,QAAQ,yBAA0B,CAC3Dd,MAAOY,KAAKZ,QAHT,OAELY,KAAKL,SAFA,OAMLK,KAAKJ,MAAQ,eAAII,KAAKL,UAAUQ,UAN3B,UASFH,KAAKJ,MAAMK,OATT,iDAYPD,KAAKzB,eAAiByB,KAAKJ,MAAMQ,MAZ1B,gDAAF,qDAAE,IAgBXC,QAAS,WACPL,KAAKD,UACLO,YAAYN,KAAKD,QAASQ,UAA8B,IAApBP,KAAKP,gBAAqBe,QAAQ,O,UC1D1E,EAAOC,OAASA,EAChB,EAAOC,UAAY,kBAEJ,gB","file":"static/js/chunk-7babe442.e3a7971d.js","sourcesContent":["export * from \"-!../../../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--8-oneOf-1-0!../../../../node_modules/css-loader/dist/cjs.js??ref--8-oneOf-1-1!../../../../node_modules/vue-loader-v16/dist/stylePostLoader.js!../../../../node_modules/postcss-loader/src/index.js??ref--8-oneOf-1-2!../../../../node_modules/sass-loader/dist/cjs.js??ref--8-oneOf-1-3!../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../node_modules/vue-loader-v16/dist/index.js??ref--0-1!./Index.vue?vue&type=style&index=0&id=52a823f4&lang=scss&scoped=true\"","<template>\n <div class=\"rss-news\">\n <div class=\"article\" v-if=\"currentArticle\">\n <div class=\"source\" v-text=\"currentArticle.feed_title || currentArticle.feed_url\"></div>\n <div class=\"title\" v-text=\"currentArticle.title\"></div>\n <div class=\"published\" v-text=\"new Date(currentArticle.published).toDateString() + ', ' + new Date(currentArticle.published).toTimeString().substring(0,5)\"></div>\n </div>\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\n\n/**\n * In order to use this widget you need to configure the `backend.http.poll` backend to\n * poll a list of RSS sources.\n */\nexport default {\n name: \"RssNews\",\n mixins: [Utils],\n props: {\n // Maximum number of items to be shown in a cycle.\n limit: {\n type: Number,\n required: false,\n default: 25,\n },\n\n // How long an entry should be displayed before moving to the next one.\n refreshSeconds: {\n type: Number,\n required: false,\n default: 15,\n },\n },\n\n data: function() {\n return {\n articles: [],\n queue: [],\n currentArticle: undefined,\n }\n },\n\n methods: {\n refresh: async function() {\n if (!this.queue.length) {\n this.articles = await this.request('rss.get_latest_entries', {\n limit: this.limit\n })\n\n this.queue = [...this.articles].reverse()\n }\n\n if (!this.queue.length)\n return\n\n this.currentArticle = this.queue.pop()\n },\n },\n\n mounted: function() {\n this.refresh()\n setInterval(this.refresh, parseInt((this.refreshSeconds*1000).toFixed(0)))\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.rss-news {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n letter-spacing: .025em;\n\n .article {\n width: 90%;\n padding: 0 2em;\n\n .source {\n font-size: 1.7em;\n font-weight: bold;\n margin-bottom: .5em;\n }\n\n .title {\n font-size: 1.8em;\n font-weight: normal;\n margin-bottom: .5em;\n }\n\n .published {\n text-align: right;\n font-size: 1.1em;\n }\n }\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=52a823f4&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=52a823f4&lang=scss&scoped=true\"\nscript.render = render\nscript.__scopeId = \"data-v-52a823f4\"\n\nexport default script"],"sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-label="Jellyfin" role="img" viewBox="0 0 512 512">
<rect width="512" height="512" rx="15%" fill="#fff" fill-opacity="0"/>
<defs>
<path d="M190.56 329.07c8.63 17.3 122.4 17.12 130.93 0 8.52-17.1-47.9-119.78-65.46-119.8-17.57 0-74.1 102.5-65.47 119.8z" id="A"/>
<linearGradient id="B" gradientUnits="userSpaceOnUse" x1="126.15" y1="219.32" x2="457.68" y2="410.73">
<stop offset="0%" stop-color="#aa5cc3"/>
<stop offset="100%" stop-color="#00a4dc"/>
</linearGradient>
<path d="M58.75 417.03c25.97 52.15 368.86 51.55 394.55 0S308.93 56.08 256.03 56.08c-52.92 0-223.25 308.8-197.28 360.95zm68.04-45.25c-17.02-34.17 94.6-236.5 129.26-236.5 34.67 0 146.1 202.7 129.26 236.5-16.83 33.8-241.5 34.17-258.52 0z" id="C"/>
</defs>
<use xlink:href="#A" fill="url(#B)"/>
<use xlink:href="#A" fill-opacity="0" stroke="#000" stroke-opacity="0"/>
<use xlink:href="#C" fill="url(#B)"/>
<use xlink:href="#C" fill-opacity="0" stroke="#000" stroke-opacity="0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -351,6 +351,9 @@ export default {
if ('media.plex' in this.$root.config) if ('media.plex' in this.$root.config)
this.sources.plex = true this.sources.plex = true
if ('media.jellyfin' in this.$root.config)
this.sources.jellyfin = true
}, },
destroy() { destroy() {

View file

@ -59,6 +59,7 @@ export default {
'torrent': 'fa fa-magnet', 'torrent': 'fa fa-magnet',
'youtube': 'fab fa-youtube', 'youtube': 'fab fa-youtube',
'plex': 'fa fa-plex', 'plex': 'fa fa-plex',
'jellyfin': 'fa fa-jellyfin',
}, },
} }
}, },

View file

@ -15,3 +15,8 @@
@include icon; @include icon;
background: url('/icons/plex.svg'); background: url('/icons/plex.svg');
} }
.fa.fa-jellyfin:before {
@include icon;
background: url('/icons/jellyfin.svg');
}

View file

@ -67,7 +67,7 @@ class MediaPlugin(Plugin, ABC):
_supported_media_plugins = {'media.mplayer', 'media.omxplayer', 'media.mpv', _supported_media_plugins = {'media.mplayer', 'media.omxplayer', 'media.mpv',
'media.vlc', 'media.chromecast', 'media.gstreamer'} 'media.vlc', 'media.chromecast', 'media.gstreamer'}
_supported_media_types = ['file', 'plex', 'torrent', 'youtube'] _supported_media_types = ['file', 'jellyfin', 'plex', 'torrent', 'youtube']
_default_search_timeout = 60 # 60 seconds _default_search_timeout = 60 # 60 seconds
def __init__(self, def __init__(self,
@ -417,6 +417,9 @@ class MediaPlugin(Plugin, ABC):
if search_type == 'plex': if search_type == 'plex':
from .search import PlexMediaSearcher from .search import PlexMediaSearcher
return PlexMediaSearcher(media_plugin=self) return PlexMediaSearcher(media_plugin=self)
if search_type == 'jellyfin':
from .search import JellyfinMediaSearcher
return JellyfinMediaSearcher(media_plugin=self)
self.logger.warning('Unsupported search type: {}'.format(search_type)) self.logger.warning('Unsupported search type: {}'.format(search_type))

View file

@ -0,0 +1,285 @@
from typing import Iterable, Optional, Type
import requests
from marshmallow import Schema
from platypush.plugins import Plugin, action
from platypush.schemas.media.jellyfin import JellyfinArtistSchema, \
JellyfinCollectionSchema, JellyfinMovieSchema, JellyfinEpisodeSchema
class MediaJellyfinPlugin(Plugin):
"""
Plugin to interact with a Jellyfin media server.
Note: As of February 2022, this plugin also works with Emby
media server instances. Future back-compatibility if the two
APIs diverge, however, is not guaranteed.
"""
# Maximum number of results returned per query action
_default_limit = 100
def __init__(self, server: str, api_key: str, username: Optional[str] = None, **kwargs):
"""
:param server: Jellyfin base server URL (including ``http://`` or ``https://``).
:param api_key: Server API key. You can generate one from
``http(s)://your-server/web/index.html#!/apikeys.html``.
:param username: Customize results for the specified user
(default: user associated to the API token if it's a user token, or the first created
user on the platform).
"""
super().__init__(**kwargs)
self.server = server.rstrip('/')
self.username = username
self._api_key = api_key
self.__user_id = None
def _execute(
self, method: str, url: str, *args, **kwargs
) -> dict:
url = '/' + url.lstrip('/')
url = self.server + url
kwargs['headers'] = {
**kwargs.get('headers', {}),
'X-Emby-Authorization': 'MediaBrowser Client="Platypush", Device="Platypush", '
f'Token="{self._api_key}"'
}
rs = getattr(requests, method.lower())(url, *args, **kwargs)
rs.raise_for_status()
return rs.json()
@property
def _user_id(self) -> str:
if not self.__user_id:
try:
self.__user_id = self._execute('GET', '/Users/Me')['Id']
except requests.exceptions.HTTPError as e:
assert e.response.status_code == 400, (
f'Could not get the current user: {e}'
)
self.__user_id = self._execute('GET', '/Users')[0]['Id']
return self.__user_id
def _query(
self, url: str,
schema_class: Optional[Type[Schema]] = None,
query: Optional[str] = None,
limit: Optional[int] = _default_limit, offset: int = 0,
parent_id: Optional[str] = None,
is_played: Optional[bool] = None,
is_favourite: Optional[bool] = None,
is_liked: Optional[bool] = None,
genres: Optional[Iterable[str]] = None,
tags: Optional[Iterable[str]] = None,
years: Optional[Iterable[int]] = None,
**kwargs
) -> Iterable[dict]:
filters = []
if is_played is not None:
filters.append('IsPlayed' if is_played else 'IsUnplayed')
if is_liked is not None:
filters.append('Likes' if is_liked else 'Dislikes')
kwargs['params'] = {
**({'isFavorite': is_favourite} if is_favourite is not None else {}),
**({'searchTerm': query} if query else {}),
**({'limit': limit} if limit else {}),
'startIndex': offset,
'includeMedia': True,
'includeOverview': True,
'recursive': True,
**({'parentId': parent_id} if parent_id else {}),
**({'genres': '|'.join(genres)} if genres else {}),
**({'tags': '|'.join(tags)} if tags else {}),
**({'years': ','.join(map(str, years))} if years else {}),
**kwargs.get('params', {}),
}
results = self._execute(method='get', url=url, **kwargs).get('Items', [])
if schema_class:
results = schema_class().dump(results, many=True)
return results
def _flatten_series_result(
self, search_result: dict
) -> Iterable[dict]:
episodes = []
show_id = search_result['Id']
seasons = self._execute(
'get', f'/Shows/{show_id}/Seasons',
params={
'userId': self._user_id,
}
).get('Items', [])
for i, season in enumerate(seasons):
episodes.extend(
JellyfinEpisodeSchema().dump([
{**episode, 'SeasonIndex': i+1}
for episode in self._execute(
'get', f'/Shows/{show_id}/Episodes',
params={
'userId': self._user_id,
'seasonId': season['Id'],
}
).get('Items', [])
], many=True)
)
return episodes
def _serialize_search_results(self, search_results: Iterable[dict]) -> Iterable[dict]:
serialized_results = []
for result in search_results:
if result['Type'] == 'CollectionFolder':
result = JellyfinCollectionSchema().dump(result)
result['type'] = 'collection' # type: ignore
elif result['Type'] == 'Movie':
result = JellyfinMovieSchema().dump(result)
result['type'] = 'movie' # type: ignore
elif result['Type'] == 'Movie':
result = JellyfinMovieSchema().dump(result)
result['type'] = 'movie' # type: ignore
elif result['Type'] == 'Series':
serialized_results += self._flatten_series_result(result)
for r in serialized_results:
r['type'] = 'episode'
if isinstance(result, dict) and result.get('type'):
serialized_results.append(result)
return serialized_results
@action
def get_artists(
self,
limit: Optional[int] = _default_limit,
offset: int = 0,
query: Optional[str] = None,
is_played: Optional[bool] = None,
is_favourite: Optional[bool] = None,
is_liked: Optional[bool] = None,
genres: Optional[Iterable[str]] = None,
tags: Optional[Iterable[str]] = None,
years: Optional[Iterable[int]] = None,
) -> Iterable[dict]:
"""
Get a list of artists on the server.
:param limit: Maximum number of items to return (default: 100).
:param offset: Return results starting from this (0-based) index (default: 0).
:param query: Filter items by this term.
:param is_played: Return only played items (or unplayed if set to False).
:param is_liked: Return only liked items (or not liked if set to False).
:param is_favourite: Return only favourite items (or not favourite if set to False).
:param genres: Filter results by (a list of) genres.
:param tags: Filter results by (a list of) tags.
:param years: Filter results by (a list of) years.
:return: .. schema:: jellyfin.JellyfinArtistSchema(many=True)
"""
return self._query(
'/Artists', schema_class=JellyfinArtistSchema,
limit=limit, offset=offset, is_favourite=is_favourite,
is_played=is_played, is_liked=is_liked, genres=genres,
query=query, tags=tags, years=years
)
@action
def get_collections(self) -> Iterable[dict]:
"""
Get the list of collections associated to the user on the server (Movies, Series, Channels etc.)
:return: .. schema:: jellyfin.JellyfinCollectionSchema(many=True)
"""
return self._query(
f'/Users/{self._user_id}/Items',
parent_id=None,
schema_class=JellyfinCollectionSchema,
params=dict(recursive=False),
)
@action
def search(
self,
limit: Optional[int] = _default_limit,
offset: int = 0,
sort_desc: Optional[bool] = None,
query: Optional[str] = None,
collection: Optional[str] = None,
parent_id: Optional[str] = None,
has_subtitles: Optional[bool] = None,
minimum_community_rating: Optional[int] = None,
minimum_critic_rating: Optional[int] = None,
is_played: Optional[bool] = None,
is_favourite: Optional[bool] = None,
is_liked: Optional[bool] = None,
genres: Optional[Iterable[str]] = None,
tags: Optional[Iterable[str]] = None,
years: Optional[Iterable[int]] = None,
) -> Iterable[dict]:
"""
Perform a search on the server.
:param limit: Maximum number of items to return (default: 100).
:param offset: Return results starting from this (0-based) index (default: 0).
:param sort_desc: Return results in descending order if true, ascending if false.
:param query: Filter items by this term.
:param collection: ID/name of the collection to search (Movies, TV, Channels etc.)
:param parent_id: Filter items under the specified parent ID.
:param has_subtitles: Filter items with/without subtitles.
:param minimum_community_rating: Filter by minimum community rating.
:param minimum_critic_rating: Filter by minimum critic rating.
:param is_played: Return only played items (or unplayed if set to False).
:param is_liked: Return only liked items (or not liked if set to False).
:param is_favourite: Return only favourite items (or not favourite if set to False).
:param genres: Filter results by (a list of) genres.
:param tags: Filter results by (a list of) tags.
:param years: Filter results by (a list of) years.
"""
if collection:
collections = self.get_collections().output # type: ignore
matching_collections = [
c for c in collections
if c['id'] == collection or c['name'].lower() == collection.lower()
]
if not matching_collections:
return [] # No matching collections
if not parent_id:
parent_id = matching_collections[0]['id']
results = self._query(
f'/Users/{self._user_id}/Items',
limit=limit, offset=offset, is_favourite=is_favourite,
is_played=is_played, is_liked=is_liked, genres=genres,
query=query, tags=tags, years=years, parent_id=parent_id,
params={
**(
{'sortOrder': 'Descending' if sort_desc else 'Ascending'}
if sort_desc is not None else {}
),
**(
{'hasSubtitles': has_subtitles}
if has_subtitles is not None else {}
),
**(
{'minCriticRating': minimum_critic_rating}
if minimum_critic_rating is not None else {}
),
**(
{'minCommunityRating': minimum_community_rating}
if minimum_community_rating is not None else {}
),
}
)
return self._serialize_search_results(results)

View file

@ -0,0 +1,4 @@
manifest:
events: {}
package: platypush.plugins.media.jellyfin
type: plugin

View file

@ -22,8 +22,12 @@ from .local import LocalMediaSearcher
from .youtube import YoutubeMediaSearcher from .youtube import YoutubeMediaSearcher
from .torrent import TorrentMediaSearcher from .torrent import TorrentMediaSearcher
from .plex import PlexMediaSearcher from .plex import PlexMediaSearcher
from .jellyfin import JellyfinMediaSearcher
__all__ = ['MediaSearcher', 'LocalMediaSearcher', 'TorrentMediaSearcher', 'YoutubeMediaSearcher', 'PlexMediaSearcher'] __all__ = [
'MediaSearcher', 'LocalMediaSearcher', 'TorrentMediaSearcher',
'YoutubeMediaSearcher', 'PlexMediaSearcher', 'JellyfinMediaSearcher',
]
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -0,0 +1,27 @@
from platypush.context import get_plugin
from platypush.plugins.media.search import MediaSearcher
class JellyfinMediaSearcher(MediaSearcher):
def search(self, query, **_):
"""
Performs a search on a Jellyfin server using the configured
:class:`platypush.plugins.media.jellyfin.MediaJellyfinPlugin`
instance (if configured).
"""
try:
media = get_plugin('media.jellyfin')
except RuntimeError:
return []
if not media:
return []
self.logger.info('Searching Jellyfin for "{}"'.format(query))
results = media.search(query=query).output
self.logger.info('{} Jellyfin results found for the search query "{}"'.format(len(results), query))
return results
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,72 @@
from marshmallow import fields
from marshmallow.schema import Schema
class MediaCollectionSchema(Schema):
id = fields.String(
metadata=dict(
description='Collection ID',
)
)
name = fields.String(
required=True,
metadata=dict(
description='Collection name',
)
)
type = fields.String(
metadata=dict(
description='Collection type (movies, music, series etc.)',
)
)
image = fields.URL(
metadata=dict(
description='Collection image (URL)',
)
)
class MediaArtistSchema(Schema):
id = fields.String(
metadata=dict(
description='Artist ID',
)
)
name = fields.String(
required=True,
metadata=dict(
description='Artist name',
)
)
image = fields.URL(
metadata=dict(
description='Artist main image (URL)',
)
)
class MediaItemSchema(Schema):
id = fields.String()
title = fields.String(required=True)
url = fields.URL()
file = fields.String()
image = fields.URL()
class MediaVideoSchema(MediaItemSchema):
year = fields.Integer()
has_subtitles = fields.Boolean()
class MediaMovieSchema(MediaItemSchema):
pass
class MediaEpisodeSchema(MediaItemSchema):
pass

View file

@ -0,0 +1,111 @@
import warnings
from marshmallow import Schema, fields, pre_dump, post_dump
from platypush.context import get_plugin
from . import MediaArtistSchema, MediaCollectionSchema, MediaVideoSchema
class JellyfinSchema(Schema):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'id' in self.fields:
self.fields['id'].attribute = 'Id'
if 'name' in self.fields:
self.fields['name'].attribute = 'Name'
elif 'title' in self.fields:
self.fields['title'].attribute = 'Name'
@post_dump
def gen_img_url(self, data: dict, **_) -> dict:
if 'image' in self.fields:
data['image'] = (
get_plugin('media.jellyfin').server + # type: ignore
f'/Items/{data["id"]}'
'/Images/Primary?fillHeight=333&fillWidth=222&quality=96'
)
return data
@pre_dump
def _gen_video_url(self, data, **_):
if data.get('MediaType') != 'Video':
return data
video_format = None
containers_priority = ['mp4', 'mkv', 'm4a', 'mov', 'avi']
available_containers = data.get('Container', '').split(',')
for container in containers_priority:
if container in available_containers:
video_format = container
break
if not video_format:
if not available_containers:
warnings.warn(
f'The media ID {data["Id"]} has no available video containers'
)
return data
video_format = available_containers[0]
plugin = get_plugin('media.jellyfin')
assert plugin, 'The media.jellyfin plugin is not configured'
url = (
f'{plugin.server}/Videos/{data["Id"]}'
f'/stream.{video_format}'
f'?Static=true&api_key={plugin._api_key}'
)
data['url'] = data['file'] = url
return data
class JellyfinArtistSchema(JellyfinSchema, MediaArtistSchema):
pass
class JellyfinCollectionSchema(JellyfinSchema, MediaCollectionSchema):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['type'].attribute = 'CollectionType'
class JellyfinVideoSchema(JellyfinSchema, MediaVideoSchema):
community_rating = fields.Number(attribute='CommunityRating')
critic_rating = fields.Number(attribute='CriticRating')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['year'].attribute = 'ProductionYear'
self.fields['has_subtitles'].attribute = 'HasSubtitles'
class JellyfinMovieSchema(JellyfinVideoSchema):
pass
class JellyfinEpisodeSchema(JellyfinVideoSchema):
@pre_dump
def _normalize_episode_name(self, data: dict, **_) -> dict:
prefix = ''
series_name = data.get('SeriesName')
if series_name:
prefix = series_name
episode_index = data.get('IndexNumber')
if episode_index:
season_index = data.get('SeasonIndex', 1)
episode_index = 's{:02d}e{:02d}'.format(
season_index, episode_index
)
if episode_index:
prefix += f'{" " if prefix else ""}[{episode_index}] '
data['Name'] = prefix + data.get('Name', '')
return data