Added frontend support for Plex

This commit is contained in:
Fabio Manganiello 2021-01-18 01:28:10 +01:00
parent 85f56cf98c
commit 370a7d4c15
77 changed files with 575 additions and 220 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="15%" fill="#282a2d"/><path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-092add3f.ef0e3fb1.css" rel="prefetch"><link href="/static/css/chunk-17cd846b.ef0e3fb1.css" rel="prefetch"><link href="/static/css/chunk-18cd0234.ef0e3fb1.css" rel="prefetch"><link href="/static/css/chunk-24ff873d.934c66a7.css" rel="prefetch"><link href="/static/css/chunk-35b45d59.120a35b6.css" rel="prefetch"><link href="/static/css/chunk-3d60f62e.804fa9fd.css" rel="prefetch"><link href="/static/css/chunk-4201fea8.d5bec80e.css" rel="prefetch"><link href="/static/css/chunk-427f1aa2.3bfca7ea.css" rel="prefetch"><link href="/static/css/chunk-432b400a.348c1f35.css" rel="prefetch"><link href="/static/css/chunk-45939517.f01c29cc.css" rel="prefetch"><link href="/static/css/chunk-4bbbb9a3.1a24453a.css" rel="prefetch"><link href="/static/css/chunk-4bc2706b.85a389d1.css" rel="prefetch"><link href="/static/css/chunk-545459d0.6b856cb1.css" rel="prefetch"><link href="/static/css/chunk-62a3d08e.a5b70794.css" rel="prefetch"><link href="/static/css/chunk-9684cd10.cb260423.css" rel="prefetch"><link href="/static/css/chunk-a3d822ca.ef0e3fb1.css" rel="prefetch"><link href="/static/css/chunk-a6931176.29aa43aa.css" rel="prefetch"><link href="/static/css/chunk-cd9a889e.63f19efc.css" rel="prefetch"><link href="/static/css/chunk-d8561e02.105050d2.css" rel="prefetch"><link href="/static/css/chunk-e8078048.9f279ae9.css" rel="prefetch"><link href="/static/js/chunk-092add3f.f11cf693.js" rel="prefetch"><link href="/static/js/chunk-17cd846b.10943176.js" rel="prefetch"><link href="/static/js/chunk-18cd0234.21a689a3.js" rel="prefetch"><link href="/static/js/chunk-24ff873d.691c883d.js" rel="prefetch"><link href="/static/js/chunk-2d0cc2be.b3a583d9.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.1e51ae4c.js" rel="prefetch"><link href="/static/js/chunk-2d21da1a.adf909a2.js" rel="prefetch"><link href="/static/js/chunk-2d237d41.3427f74b.js" rel="prefetch"><link href="/static/js/chunk-35b45d59.9a57c504.js" rel="prefetch"><link href="/static/js/chunk-3d60f62e.907e4050.js" rel="prefetch"><link href="/static/js/chunk-4201fea8.29361f0f.js" rel="prefetch"><link href="/static/js/chunk-427f1aa2.0a437283.js" rel="prefetch"><link href="/static/js/chunk-432b400a.719d7f81.js" rel="prefetch"><link href="/static/js/chunk-45939517.c0034c6b.js" rel="prefetch"><link href="/static/js/chunk-4bbbb9a3.251fff37.js" rel="prefetch"><link href="/static/js/chunk-4bc2706b.38882fe9.js" rel="prefetch"><link href="/static/js/chunk-545459d0.da1ea7e5.js" rel="prefetch"><link href="/static/js/chunk-62a3d08e.17d3c86d.js" rel="prefetch"><link href="/static/js/chunk-9684cd10.7051bb65.js" rel="prefetch"><link href="/static/js/chunk-a3d822ca.ac168508.js" rel="prefetch"><link href="/static/js/chunk-a6931176.7f85fbf3.js" rel="prefetch"><link href="/static/js/chunk-cd9a889e.ec43fdb3.js" rel="prefetch"><link href="/static/js/chunk-d8561e02.1e366cb3.js" rel="prefetch"><link href="/static/js/chunk-e8078048.ce29b8d4.js" rel="prefetch"><link href="/static/css/app.0bb8bfb7.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="preload" as="style"><link href="/static/js/app.d6d33181.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.ac361ae9.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="stylesheet"><link href="/static/css/app.0bb8bfb7.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.ac361ae9.js"></script><script src="/static/js/app.d6d33181.js"></script></body></html>
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-076c5199.fc1132f4.css" rel="prefetch"><link href="/static/css/chunk-1293e286.fc1132f4.css" rel="prefetch"><link href="/static/css/chunk-14f3b6ed.fc1132f4.css" rel="prefetch"><link href="/static/css/chunk-24ff873d.25b446f2.css" rel="prefetch"><link href="/static/css/chunk-35b45d59.3285a5c5.css" rel="prefetch"><link href="/static/css/chunk-3d60f62e.4fa298b8.css" rel="prefetch"><link href="/static/css/chunk-4201fea8.ab45ee69.css" rel="prefetch"><link href="/static/css/chunk-45939517.ce9c914b.css" rel="prefetch"><link href="/static/css/chunk-4bbbb9a3.8b96bf08.css" rel="prefetch"><link href="/static/css/chunk-4bc2706b.1e09b17a.css" rel="prefetch"><link href="/static/css/chunk-545459d0.4806f03d.css" rel="prefetch"><link href="/static/css/chunk-59396623.5084b7e8.css" rel="prefetch"><link href="/static/css/chunk-62a3d08e.d329d923.css" rel="prefetch"><link href="/static/css/chunk-9684cd10.6046f7ac.css" rel="prefetch"><link href="/static/css/chunk-abbc1cdc.fc1132f4.css" rel="prefetch"><link href="/static/css/chunk-cb418146.1cc7af9d.css" rel="prefetch"><link href="/static/css/chunk-cd9a889e.b5ec8fac.css" rel="prefetch"><link href="/static/css/chunk-d8561e02.0d73779a.css" rel="prefetch"><link href="/static/css/chunk-e8078048.04620e86.css" rel="prefetch"><link href="/static/css/chunk-fa20b8a0.d7316f99.css" rel="prefetch"><link href="/static/js/chunk-076c5199.377e9834.js" rel="prefetch"><link href="/static/js/chunk-1293e286.eb2fa695.js" rel="prefetch"><link href="/static/js/chunk-14f3b6ed.d6fcafdc.js" rel="prefetch"><link href="/static/js/chunk-24ff873d.691c883d.js" rel="prefetch"><link href="/static/js/chunk-2d0cc2be.b3a583d9.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.1e51ae4c.js" rel="prefetch"><link href="/static/js/chunk-2d21da1a.adf909a2.js" rel="prefetch"><link href="/static/js/chunk-2d237d41.3427f74b.js" rel="prefetch"><link href="/static/js/chunk-35b45d59.9a57c504.js" rel="prefetch"><link href="/static/js/chunk-3d60f62e.907e4050.js" rel="prefetch"><link href="/static/js/chunk-4201fea8.29361f0f.js" rel="prefetch"><link href="/static/js/chunk-45939517.c0034c6b.js" rel="prefetch"><link href="/static/js/chunk-4bbbb9a3.251fff37.js" rel="prefetch"><link href="/static/js/chunk-4bc2706b.38882fe9.js" rel="prefetch"><link href="/static/js/chunk-545459d0.da1ea7e5.js" rel="prefetch"><link href="/static/js/chunk-59396623.19b5fca7.js" rel="prefetch"><link href="/static/js/chunk-62a3d08e.17d3c86d.js" rel="prefetch"><link href="/static/js/chunk-9684cd10.7051bb65.js" rel="prefetch"><link href="/static/js/chunk-abbc1cdc.47491a05.js" rel="prefetch"><link href="/static/js/chunk-cb418146.7a824439.js" rel="prefetch"><link href="/static/js/chunk-cd9a889e.ec43fdb3.js" rel="prefetch"><link href="/static/js/chunk-d8561e02.1e366cb3.js" rel="prefetch"><link href="/static/js/chunk-e8078048.ce29b8d4.js" rel="prefetch"><link href="/static/js/chunk-fa20b8a0.78555b70.js" rel="prefetch"><link href="/static/css/app.9ee642c5.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="preload" as="style"><link href="/static/js/app.1abebcd8.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.30e3a6cb.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="stylesheet"><link href="/static/css/app.9ee642c5.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.30e3a6cb.js"></script><script src="/static/js/app.1abebcd8.js"></script></body></html>

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

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="15%" fill="#282a2d"/><path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@ -0,0 +1,107 @@
<template>
<div class="browser-container">
<Loading v-if="loading" />
<div class="row item" @click="path = (path || '') + '/..'" v-if="path?.length && path !== '/'">
<div class="col-10 left side">
<i class="icon fa fa-folder" />
<span class="name">..</span>
</div>
</div>
<div class="row item" v-for="(file, i) in filteredFiles" :key="i" @click="path = file.path">
<div class="col-10">
<i class="icon fa" :class="{'fa-file': file.type !== 'directory', 'fa-folder': file.type === 'directory'}" />
<span class="name">
{{ file.name }}
</span>
</div>
<div class="col-2 actions">
<Dropdown>
<DropdownItem icon-class="fa fa-play" text="Play"
@click="$emit('play', {type: 'file', url: `file://${file.path}`})"
v-if="isMedia && mediaExtensions.has(file.name.split('.').pop())" />
</Dropdown>
</div>
</div>
</div>
</template>
<script>
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import MediaUtils from "@/components/Media/Utils";
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
export default {
name: "Browser",
components: {DropdownItem, Dropdown, Loading},
mixins: [Utils, MediaUtils],
emits: ['path-change'],
props: {
initialPath: {
type: String,
},
isMedia: {
type: Boolean,
},
filter: {
type: String,
default: '',
},
},
data() {
return {
loading: false,
path: this.initialPath,
files: [],
}
},
computed: {
filteredFiles() {
if (!this.filter?.length)
return this.files
return this.files.filter((file) => (file?.name || '').toLowerCase().indexOf(this.filter.toLowerCase()) >= 0)
},
},
methods: {
async refresh() {
this.loading = true
try {
this.files = await this.request('file.list', {path: this.path})
this.$emit('path-change', this.path)
} finally {
this.loading = false
}
},
},
mounted() {
this.$watch(() => this.path, () => this.refresh())
this.refresh()
},
}
</script>
<style lang="scss" scoped>
@import "src/style/items";
.browser-container {
.item {
.actions {
display: inline-flex;
justify-content: right;
}
}
}
</style>

View File

@ -53,8 +53,7 @@ export default {
position: relative;
.view-container {
height: calc(100% - #{$media-ctrl-panel-height});
overflow: auto;
height: 100%;
}
.controls-container {

View File

@ -52,9 +52,10 @@ export default {
let element = event.target
while (element) {
if (element === this.$refs.dropdown.element) {
if (!this.$refs.dropdown)
break
if (element === this.$refs.dropdown.element)
return
}
element = element.parentElement
}

View File

@ -2,7 +2,8 @@
<div class="header" :class="{'with-filter': filterVisible}">
<div class="row">
<div class="col-7 left side" v-if="selectedView === 'search'">
<button title="Filter" @click="filterVisible = !filterVisible">
<button title="Filter" class="filter-btn" :class="{selected: filterVisible}"
@click="filterVisible = !filterVisible">
<i class="fa fa-filter" />
</button>
@ -21,6 +22,13 @@
</form>
</div>
<div class="col-7 left side" v-else-if="selectedView === 'browser'">
<label class="search-box">
<input type="search" placeholder="Filter" :value="browserFilter" @change="$emit('filter', $event.target.value)"
@keyup="$emit('filter', $event.target.value)">
</label>
</div>
<div class="col-5 right side">
<button title="Select subtitles" class="captions-btn" :class="{selected: selectedSubtitles != null}"
@click="$emit('show-subtitles')" v-if="hasSubtitlesPlugin && selectedItem &&
@ -30,15 +38,16 @@
<Players :plugin-name="pluginName" @select="$emit('select-player', $event)"
@status="$emit('player-status', $event)" />
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
<DropdownItem text="Play URL" icon-class="fa fa-play-circle" />
</Dropdown>
<button title="Play URL" @click="$emit('play-url')">
<i class="fa fa-plus-circle" />
</button>
</div>
</div>
<div class="row filter fade-in" :class="{hidden: !filterVisible}">
<label v-for="source in Object.keys(sources)" :key="source">
<input type="checkbox" v-model="sources[source]" />
<input type="checkbox" :checked="sources[source]" @change="$emit('source-toggle', source)" />
{{ source }}
</label>
</div>
@ -46,13 +55,12 @@
</template>
<script>
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
import Players from "@/components/panels/Media/Players";
export default {
name: "Header",
components: {Players, DropdownItem, Dropdown},
emits: ['search', 'select-player', 'player-status', 'torrent-add', 'show-subtitles'],
components: {Players},
emits: ['search', 'select-player', 'player-status', 'torrent-add', 'show-subtitles', 'play-url', 'filter',
'source-toggle'],
props: {
pluginName: {
@ -77,6 +85,16 @@ export default {
type: Boolean,
default: false,
},
browserFilter: {
type: String,
default: '',
},
sources: {
type: Object,
default: () => {},
}
},
data() {
@ -84,11 +102,6 @@ export default {
filterVisible: false,
query: '',
torrentURL: '',
sources: {
'file': true,
'youtube': true,
'torrent': true,
},
}
},
@ -103,7 +116,15 @@ export default {
types: types,
})
},
}
},
mounted() {
this.$watch(() => this.selectedView, () => {
this.$emit('filter', '')
this.torrentURL = ''
this.query = ''
})
},
}
</script>
@ -118,6 +139,10 @@ export default {
padding: .5em;
box-shadow: $border-shadow-bottom;
.filter-btn.selected {
color: $selected-fg;
}
.row {
display: flex;
align-items: center;
@ -125,6 +150,7 @@ export default {
&.with-filter {
height: calc(#{$media-header-height} + #{$filter-header-height});
padding-bottom: 0;
}
.side {
@ -166,10 +192,9 @@ export default {
}
.filter {
position: absolute;
top: $media-header-height;
width: 100%;
height: $filter-header-height;
padding-bottom: 1em;
margin-top: .5em;
label {
display: inline-flex;

View File

@ -13,19 +13,24 @@
<div class="view-container">
<Header :plugin-name="pluginName" :selected-view="selectedView" :has-subtitles-plugin="hasSubtitlesPlugin"
:selected-item="selectedPlayer && selectedPlayer.status &&
ref="header" :sources="sources" :selected-item="selectedPlayer && selectedPlayer.status &&
(selectedPlayer.status.state === 'play' || selectedPlayer.status.state === 'pause')
? selectedPlayer.status : results[selectedResult]" :selected-subtitles="selectedSubtitles"
@search="search" @select-player="selectedPlayer = $event" @player-status="onStatusUpdate"
@torrent-add="downloadTorrent($event)" @show-subtitles="showSubtitlesModal = !showSubtitlesModal" />
:browser-filter="browserFilter" @search="search" @select-player="selectedPlayer = $event"
@player-status="onStatusUpdate" @torrent-add="downloadTorrent($event)"
@show-subtitles="showSubtitlesModal = !showSubtitlesModal" @play-url="$refs.playUrlModal.show()"
@filter="browserFilter = $event" @source-toggle="sources[$event] = !sources[$event]" />
<div class="body-container">
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
<Results :results="results" :selected-result="selectedResult" @select="onResultSelect($event)"
@play="play" @info="$refs.mediaInfo.isVisible = true" @view="view" @download="download"
v-if="selectedView === 'search'" />
:sources="sources" v-if="selectedView === 'search'" />
<TorrentView :plugin-name="torrentPlugin" :is-media="true" @play="play"
v-else-if="selectedView === 'torrents'" />
<Browser :plugin-name="torrentPlugin" :is-media="true" :filter="browserFilter"
@path-change="browserFilter = ''" @play="play($event)" v-else-if="selectedView === 'browser'" />
</div>
</div>
</main>
@ -47,6 +52,23 @@
</div>
</Modal>
</div>
<div class="play-url-container">
<Modal title="Play URL" ref="playUrlModal" @open="$refs.playUrlInput.focus()">
<form @submit.prevent="playUrl(urlPlay)">
<div class="row">
<label>
Play URL (use <tt>file://</tt> prefix for local files)
<input type="text" v-model="urlPlay" ref="playUrlInput" autofocus />
</label>
</div>
<div class="row footer">
<button type="submit" :disabled="!urlPlay?.length">Play</button>
</div>
</form>
</Modal>
</div>
</div>
</keep-alive>
</template>
@ -63,11 +85,12 @@ import Nav from "@/components/panels/Media/Nav";
import Results from "@/components/panels/Media/Results";
import Subtitles from "@/components/panels/Media/Subtitles";
import TorrentView from "@/components/panels/Torrent/View";
import Browser from "@/components/File/Browser";
export default {
name: "Media",
mixins: [Utils, MediaUtils],
components: {Loading, MediaView, Header, Results, Modal, Info, Nav, TorrentView, Subtitles},
components: {Browser, Loading, MediaView, Header, Results, Modal, Info, Nav, TorrentView, Subtitles},
props: {
pluginName: {
type: String,
@ -96,11 +119,19 @@ export default {
selectedSubtitles: null,
showSubtitlesModal: false,
awaitingPlayTorrent: null,
urlPlay: null,
browserFilter: null,
torrentPlugin: null,
torrentPlugins: [
'torrent',
'rtorrent',
],
sources: {
'file': true,
'youtube': true,
'torrent': true,
},
}
},
@ -277,6 +308,20 @@ export default {
this.selectedSubtitles = null
}
},
async playUrl(url) {
this.loading = true
try {
await this.play({
url: url,
})
this.$refs.playUrlModal.close()
} finally {
this.loading = false
}
},
},
mounted() {
@ -303,6 +348,9 @@ export default {
'platypush.message.event.torrent.TorrentDownloadStartEvent')
this.subscribe(this.onTorrentDownloadCompleted,'notify-on-torrent-download-completed',
'platypush.message.event.torrent.TorrentDownloadCompletedEvent')
if ('media.plex' in this.$root.config)
this.sources.plex = true
},
destroy() {
@ -316,6 +364,7 @@ export default {
<style lang="scss" scoped>
@import "vars";
@import "~@/components/Media/vars";
.media-plugin {
width: 100%;
@ -335,8 +384,13 @@ export default {
}
.body-container {
height: calc(100% - #{$media-header-height});
margin-top: .2em;
height: calc(100% - #{$media-header-height} - #{$media-ctrl-panel-height});
padding-top: .1em;
overflow: auto;
&.expanded-header {
height: calc(100% - #{$media-header-height} - #{$filter-header-height} - #{$media-ctrl-panel-height});
}
}
}
}
@ -367,4 +421,38 @@ export default {
}
}
}
::v-deep(.play-url-container) {
.body {
padding: 1em !important;
}
form {
padding: 0;
margin: 0;
border: none;
border-radius: 0;
box-shadow: none;
}
input[type=text] {
width: 100%;
}
[type=submit] {
background: initial;
border-color: initial;
border-radius: 1.5em;
&:hover {
color: $default-hover-fg-2;
}
}
.footer {
display: flex;
justify-content: right;
padding: 0;
}
}
</style>

View File

@ -18,6 +18,21 @@
<div class="right side" v-text="item.description" />
</div>
<div class="row" v-if="item?.summary">
<div class="left side">Summary</div>
<div class="right side" v-text="item.summary" />
</div>
<div class="row" v-if="item?.duration">
<div class="left side">Duration</div>
<div class="right side" v-text="convertTime(item.duration)" />
</div>
<div class="row" v-if="item?.genres">
<div class="left side">Genres</div>
<div class="right side" v-text="item.genres.join(', ')" />
</div>
<div class="row" v-if="item?.channelId">
<div class="left side">Channel</div>
<div class="right side">
@ -83,10 +98,11 @@
<script>
import Utils from "@/Utils";
import MediaUtils from "@/components/Media/Utils";
export default {
name: "Info",
mixins: [Utils],
mixins: [Utils, MediaUtils],
props: {
item: {
type: Object,

View File

@ -30,9 +30,9 @@ export default {
displayName: 'Search',
},
browse: {
browser: {
iconClass: 'fa fa-folder',
displayName: 'Browse',
displayName: 'Browser',
},
torrents: {

View File

@ -93,7 +93,8 @@ export default {
supports(resource) {
return resource?.type === 'file' || resource?.type === 'youtube' ||
(resource.url || resource).startsWith('http://') || (resource.url || resource).startsWith('https://')
(resource.url || resource).startsWith('file://') || (resource.url || resource).startsWith('http://') ||
(resource.url || resource).startsWith('https://')
},
},

View File

@ -1,7 +1,11 @@
<template>
<div class="media-results">
<div class="row item" :class="{selected: selectedResult === i}" v-for="(result, i) in results" :key="i"
@click="$emit('select', i)">
<div class="no-content" v-if="!results?.length">
No search results
</div>
<div class="row item" :class="{selected: selectedResult === i, hidden: !sources[result.type]}"
v-for="(result, i) in results" :key="i" @click="$emit('select', i)">
<div class="col-10 left side">
<div class="icon">
<i :class="typeIcons[result.type]" />
@ -41,6 +45,11 @@ export default {
selectedResult: {
type: Number,
},
sources: {
type: Object,
default: () => {},
},
},
data() {
@ -49,6 +58,7 @@ export default {
'file': 'fa fa-hdd',
'torrent': 'fa fa-magnet',
'youtube': 'fab fa-youtube',
'plex': 'fa fa-plex',
},
}
},
@ -98,5 +108,15 @@ export default {
}
}
}
.no-content {
height: 100%;
}
.icon {
.fa-youtube {
color: #d21;
}
}
}
</style>

View File

@ -1,8 +1,17 @@
.fa.fa-kodi:before {
@mixin icon {
content: ' ';
background: url('/icons/kodi.svg');
background-size: 1em 1em;
width: 1em;
height: 1em;
display: inline-block;
}
.fa.fa-kodi:before {
@include icon;
background: url('/icons/kodi.svg');
}
.fa.fa-plex:before {
@include icon;
background: url('/icons/plex.svg');
}

View File

@ -1,6 +1,7 @@
import json
import os
import pathlib
from typing import List, Dict
from platypush.plugins import Plugin, action
@ -158,5 +159,36 @@ class FilePlugin(Plugin):
"""
pathlib.Path(self._get_path(file)).unlink()
@action
def list(self, path: str = os.path.abspath(os.sep)) -> List[Dict[str, str]]:
"""
List a file or all the files in a directory.
:param path: File or directory (default: root directory).
:return: List of files in the specified path, or absolute path of the specified path if ``path`` is a file and
it exists. Each item will contain the fields ``type`` (``file`` or ``directory``) and ``path``.
"""
path = self._get_path(path)
assert os.path.exists(path), 'No such file or directory: {}'.format(path)
if not os.path.isdir(path):
return [{
'type': 'file',
'path': path,
'name': os.path.basename(path),
}]
return sorted(
[
{
'type': 'directory' if os.path.isdir(os.path.join(path, f)) else 'file',
'path': os.path.join(path, f),
'name': os.path.basename(f),
}
for f in sorted(os.listdir(path))
],
key=lambda f: (f.get('type'), f.get('name'))
)
# vim:sw=4:ts=4:et:

View File

@ -66,7 +66,7 @@ class MediaPlugin(Plugin):
_supported_media_plugins = {'media.mplayer', 'media.omxplayer', 'media.mpv',
'media.vlc', 'media.chromecast', 'media.gstreamer'}
_supported_media_types = ['file', 'torrent', 'youtube']
_supported_media_types = ['file', 'plex', 'torrent', 'youtube']
_default_search_timeout = 60 # 60 seconds
def __init__(self,
@ -202,6 +202,8 @@ class MediaPlugin(Plugin):
resource = self._videos_queue.pop(0)
else:
raise RuntimeError('No media file found in torrent {}'.format(resource))
elif re.search(r'^https?://', resource):
return resource
assert resource, 'Unable to find any compatible media resource'
return resource
@ -377,6 +379,9 @@ class MediaPlugin(Plugin):
if search_type == 'youtube':
from .search import YoutubeMediaSearcher
return YoutubeMediaSearcher()
if search_type == 'plex':
from .search import PlexMediaSearcher
return PlexMediaSearcher()
self.logger.warning('Unsupported search type: {}'.format(search_type))

View File

@ -85,7 +85,7 @@ class MediaMpvPlugin(MediaPlugin):
self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.UNPAUSE:
self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.SHUTDOWN or (
elif evt == Event.SHUTDOWN or evt == Event.IDLE or (
evt == Event.END_FILE and event.get('event', {}).get('reason') in
[EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]):
playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5)

View File

@ -1,3 +1,5 @@
import urllib.parse
from platypush.context import get_plugin
from platypush.plugins import Plugin, action
@ -11,7 +13,7 @@ class MediaPlexPlugin(Plugin):
* **plexapi** (``pip install plexapi``)
"""
def __init__(self, server, username, password, *args, **kwargs):
def __init__(self, server, username, password, **kwargs):
"""
:param server: Plex server name
:type server: str
@ -24,12 +26,11 @@ class MediaPlexPlugin(Plugin):
"""
from plexapi.myplex import MyPlexAccount
super().__init__(*args, **kwargs)
super().__init__(**kwargs)
self.resource = MyPlexAccount(username, password).resource(server)
self._plex = None
@property
def plex(self):
if not self._plex:
@ -37,7 +38,6 @@ class MediaPlexPlugin(Plugin):
return self._plex
@action
def get_clients(self):
"""
@ -57,11 +57,9 @@ class MediaPlexPlugin(Plugin):
'version': c.version,
} for c in self.plex.clients()]
def _get_client(self, name):
return self.plex.client(name)
@action
def search(self, section=None, title=None, **kwargs):
"""
@ -93,7 +91,6 @@ class MediaPlexPlugin(Plugin):
return ret
@action
def playlists(self):
"""
@ -106,11 +103,10 @@ class MediaPlexPlugin(Plugin):
'duration': pl.duration,
'summary': pl.summary,
'viewed_at': pl.viewedAt,
'items': [ self._flatten_item(item) for item in pl.items() ],
'items': [self._flatten_item(item) for item in pl.items()],
} for pl in self.plex.playlists()
]
@action
def history(self):
"""
@ -121,8 +117,8 @@ class MediaPlexPlugin(Plugin):
self._flatten_item(item) for item in self.plex.history()
]
def get_chromecast(self, chromecast):
@staticmethod
def get_chromecast(chromecast):
from .lib.plexcast import PlexController
hndl = PlexController()
@ -130,8 +126,7 @@ class MediaPlexPlugin(Plugin):
cast = get_plugin('media.chromecast').get_chromecast(chromecast)
cast.register_handler(hndl)
return (cast, hndl)
return cast, hndl
@action
def play(self, client=None, chromecast=None, **kwargs):
@ -157,7 +152,7 @@ class MediaPlexPlugin(Plugin):
raise RuntimeError('No client nor chromecast specified')
if client:
client = plex.client(client)
client = self.plex.client(client)
elif chromecast:
(chromecast, handler) = self.get_chromecast(chromecast)
@ -185,7 +180,6 @@ class MediaPlexPlugin(Plugin):
elif chromecast:
return handler.play_media(item, self.plex)
@action
def pause(self, client):
"""
@ -194,7 +188,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).pause()
@action
def stop(self, client):
"""
@ -203,7 +196,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).stop()
@action
def seek(self, client, offset):
"""
@ -212,7 +204,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).seekTo(offset)
@action
def forward(self, client):
"""
@ -221,7 +212,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).stepForward()
@action
def back(self, client):
"""
@ -230,7 +220,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).stepBack()
@action
def next(self, client):
"""
@ -239,7 +228,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).skipNext()
@action
def previous(self, client):
"""
@ -248,15 +236,13 @@ class MediaPlexPlugin(Plugin):
return self.client(client).skipPrevious()
@action
def set_volume(self, client, volume):
"""
Set the volume on a client between 0 and 100
"""
return self.client(client).setVolume(volume/100)
return self.client(client).setVolume(volume / 100)
@action
def repeat(self, client, repeat):
@ -266,7 +252,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).setRepeat(repeat)
@action
def random(self, client, random):
"""
@ -275,7 +260,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).setShuffle(random)
@action
def up(self, client):
"""
@ -284,7 +268,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveUp()
@action
def down(self, client):
"""
@ -293,7 +276,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveDown()
@action
def left(self, client):
"""
@ -302,7 +284,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveLeft()
@action
def right(self, client):
"""
@ -311,7 +292,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveRight()
@action
def go_back(self, client):
"""
@ -320,7 +300,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goBack()
@action
def go_home(self, client):
"""
@ -329,7 +308,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goHome()
@action
def go_to_media(self, client):
"""
@ -338,7 +316,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goToMedia()
@action
def go_to_music(self, client):
"""
@ -347,7 +324,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goToMusic()
@action
def next_letter(self, client):
"""
@ -356,7 +332,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).nextLetter()
@action
def page_down(self, client):
"""
@ -365,7 +340,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).pageDown()
@action
def page_up(self, client):
"""
@ -374,7 +348,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).pageUp()
def _flatten_item(self, item):
from plexapi.video import Movie, Show
@ -399,7 +372,7 @@ class MediaPlexPlugin(Plugin):
_item['media'] = [
{
'duration': (item.media[i].duration or 0)/1000,
'duration': (item.media[i].duration or 0) / 1000,
'width': item.media[i].width,
'height': item.media[i].height,
'audio_channels': item.media[i].audioChannels,
@ -410,7 +383,10 @@ class MediaPlexPlugin(Plugin):
'parts': [
{
'file': part.file,
'duration': (part.duration or 0)/1000,
'duration': (part.duration or 0) / 1000,
'url': self.plex.url(part.key) + '?' + urllib.parse.urlencode({
'X-Plex-Token': self.plex._token,
}),
} for part in item.media[i].parts
]
} for i in range(0, len(item.media))
@ -424,7 +400,7 @@ class MediaPlexPlugin(Plugin):
'summary': season.summary,
'episodes': [
{
'duration': episode.duration/1000,
'duration': episode.duration / 1000,
'index': episode.index,
'year': episode.year,
'season_number': episode.seasonNumber,
@ -435,7 +411,7 @@ class MediaPlexPlugin(Plugin):
'view_offset': episode.viewOffset,
'media': [
{
'duration': episode.media[i].duration/1000,
'duration': episode.media[i].duration / 1000,
'width': episode.media[i].width,
'height': episode.media[i].height,
'audio_channels': episode.media[i].audioChannels,
@ -447,7 +423,10 @@ class MediaPlexPlugin(Plugin):
'parts': [
{
'file': part.file,
'duration': part.duration/1000,
'duration': part.duration / 1000,
'url': self.plex.url(part.key) + '?' + urllib.parse.urlencode({
'X-Plex-Token': self.plex._token,
}),
} for part in episode.media[i].parts
]
} for i in range(0, len(episode.locations))
@ -460,5 +439,4 @@ class MediaPlexPlugin(Plugin):
return _item
# vim:sw=4:ts=4:et:
# vim:sw=4:ts=4:et:

View File

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

View File

@ -0,0 +1,61 @@
from platypush.context import get_plugin
from platypush.plugins.media.search import MediaSearcher
class PlexMediaSearcher(MediaSearcher):
def search(self, query, **kwargs):
"""
Performs a Plex search using the configured :class:`platypush.plugins.media.plex.MediaPlexPlugin` instance if
it is available.
"""
try:
plex = get_plugin('media.plex')
except RuntimeError:
return []
self.logger.info('Searching Plex for "{}"'.format(query))
results = []
for result in plex.search(title=query).output:
results.extend(self._flatten_result(result))
self.logger.info('{} Plex results found for the search query "{}"'.format(len(results), query))
return results
@staticmethod
def _flatten_result(result):
def parse_part(media, part, episode=None, sub_media=None):
if 'episodes' in media:
del media['episodes']
return {
**{k: v for k, v in result.items() if k not in ['media', 'type']},
'media_type': result.get('type'),
'type': 'plex',
**{k: v for k, v in media.items() if k not in ['parts']},
**part,
'title': '{}{}{}'.format(
result.get('title', ''),
' [{}]'.format(episode['season_episode']) if (episode or {}).get('season_episode') else '',
' {}'.format(sub_media['title']) if (sub_media or {}).get('title') else '',
),
'summary': episode['summary'] if (episode or {}).get('summary') else media.get('summary'),
}
results = []
for media in result.get('media', []):
if 'episodes' in media:
for episode in media['episodes']:
for sub_media in episode.get('media', []):
for part in sub_media.get('parts', []):
results.append(parse_part(media=media, episode=episode, sub_media=sub_media, part=part))
else:
for part in media.get('parts', []):
results.append(parse_part(media=media, part=part))
return results
# vim:sw=4:ts=4:et: