forked from platypush/platypush
Added Jellyfin integration
This commit is contained in:
parent
0b293ff214
commit
0d0797a465
65 changed files with 665 additions and 99 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -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
|
||||||
|
|
6
docs/source/platypush/plugins/media.jellyfin.rst
Normal file
6
docs/source/platypush/plugins/media.jellyfin.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
``media.jellyfin``
|
||||||
|
================================
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.media.jellyfin
|
||||||
|
:members:
|
||||||
|
|
|
@ -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
|
||||||
|
|
16
platypush/backend/http/webapp/dist/icons/jellyfin.svg
vendored
Normal file
16
platypush/backend/http/webapp/dist/icons/jellyfin.svg
vendored
Normal 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
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
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
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
1
platypush/backend/http/webapp/dist/static/css/chunk-55f142bc.8e09018c.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/chunk-55f142bc.8e09018c.css
vendored
Normal file
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
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
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
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
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
2
platypush/backend/http/webapp/dist/static/js/chunk-55f142bc.f287d0c8.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/chunk-55f142bc.f287d0c8.js
vendored
Normal 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
|
1
platypush/backend/http/webapp/dist/static/js/chunk-55f142bc.f287d0c8.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/chunk-55f142bc.f287d0c8.js.map
vendored
Normal 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":""}
|
|
@ -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
|
|
|
@ -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":""}
|
|
2
platypush/backend/http/webapp/dist/static/js/chunk-c7dc785e.1fa68197.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/chunk-c7dc785e.1fa68197.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/chunk-c7dc785e.1fa68197.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/chunk-c7dc785e.1fa68197.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
16
platypush/backend/http/webapp/public/icons/jellyfin.svg
Normal file
16
platypush/backend/http/webapp/public/icons/jellyfin.svg
Normal 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 |
|
@ -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() {
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
285
platypush/plugins/media/jellyfin/__init__.py
Normal file
285
platypush/plugins/media/jellyfin/__init__.py
Normal 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)
|
||||||
|
|
4
platypush/plugins/media/jellyfin/manifest.yaml
Normal file
4
platypush/plugins/media/jellyfin/manifest.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
manifest:
|
||||||
|
events: {}
|
||||||
|
package: platypush.plugins.media.jellyfin
|
||||||
|
type: plugin
|
|
@ -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:
|
||||||
|
|
27
platypush/plugins/media/search/jellyfin.py
Normal file
27
platypush/plugins/media/search/jellyfin.py
Normal 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:
|
72
platypush/schemas/media/__init__.py
Normal file
72
platypush/schemas/media/__init__.py
Normal 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
|
||||||
|
|
111
platypush/schemas/media/jellyfin.py
Normal file
111
platypush/schemas/media/jellyfin.py
Normal 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
|
||||||
|
|
Loading…
Reference in a new issue