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; position: relative;
.view-container { .view-container {
height: calc(100% - #{$media-ctrl-panel-height}); height: 100%;
overflow: auto;
} }
.controls-container { .controls-container {

View File

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

View File

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

View File

@ -13,19 +13,24 @@
<div class="view-container"> <div class="view-container">
<Header :plugin-name="pluginName" :selected-view="selectedView" :has-subtitles-plugin="hasSubtitlesPlugin" <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.state === 'play' || selectedPlayer.status.state === 'pause')
? selectedPlayer.status : results[selectedResult]" :selected-subtitles="selectedSubtitles" ? selectedPlayer.status : results[selectedResult]" :selected-subtitles="selectedSubtitles"
@search="search" @select-player="selectedPlayer = $event" @player-status="onStatusUpdate" :browser-filter="browserFilter" @search="search" @select-player="selectedPlayer = $event"
@torrent-add="downloadTorrent($event)" @show-subtitles="showSubtitlesModal = !showSubtitlesModal" /> @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)" <Results :results="results" :selected-result="selectedResult" @select="onResultSelect($event)"
@play="play" @info="$refs.mediaInfo.isVisible = true" @view="view" @download="download" @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" <TorrentView :plugin-name="torrentPlugin" :is-media="true" @play="play"
v-else-if="selectedView === 'torrents'" /> 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>
</div> </div>
</main> </main>
@ -47,6 +52,23 @@
</div> </div>
</Modal> </Modal>
</div> </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> </div>
</keep-alive> </keep-alive>
</template> </template>
@ -63,11 +85,12 @@ import Nav from "@/components/panels/Media/Nav";
import Results from "@/components/panels/Media/Results"; import Results from "@/components/panels/Media/Results";
import Subtitles from "@/components/panels/Media/Subtitles"; import Subtitles from "@/components/panels/Media/Subtitles";
import TorrentView from "@/components/panels/Torrent/View"; import TorrentView from "@/components/panels/Torrent/View";
import Browser from "@/components/File/Browser";
export default { export default {
name: "Media", name: "Media",
mixins: [Utils, MediaUtils], 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: { props: {
pluginName: { pluginName: {
type: String, type: String,
@ -96,11 +119,19 @@ export default {
selectedSubtitles: null, selectedSubtitles: null,
showSubtitlesModal: false, showSubtitlesModal: false,
awaitingPlayTorrent: null, awaitingPlayTorrent: null,
urlPlay: null,
browserFilter: null,
torrentPlugin: null, torrentPlugin: null,
torrentPlugins: [ torrentPlugins: [
'torrent', 'torrent',
'rtorrent', 'rtorrent',
], ],
sources: {
'file': true,
'youtube': true,
'torrent': true,
},
} }
}, },
@ -277,6 +308,20 @@ export default {
this.selectedSubtitles = null this.selectedSubtitles = null
} }
}, },
async playUrl(url) {
this.loading = true
try {
await this.play({
url: url,
})
this.$refs.playUrlModal.close()
} finally {
this.loading = false
}
},
}, },
mounted() { mounted() {
@ -303,6 +348,9 @@ export default {
'platypush.message.event.torrent.TorrentDownloadStartEvent') 'platypush.message.event.torrent.TorrentDownloadStartEvent')
this.subscribe(this.onTorrentDownloadCompleted,'notify-on-torrent-download-completed', this.subscribe(this.onTorrentDownloadCompleted,'notify-on-torrent-download-completed',
'platypush.message.event.torrent.TorrentDownloadCompletedEvent') 'platypush.message.event.torrent.TorrentDownloadCompletedEvent')
if ('media.plex' in this.$root.config)
this.sources.plex = true
}, },
destroy() { destroy() {
@ -316,6 +364,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "vars"; @import "vars";
@import "~@/components/Media/vars";
.media-plugin { .media-plugin {
width: 100%; width: 100%;
@ -335,8 +384,13 @@ export default {
} }
.body-container { .body-container {
height: calc(100% - #{$media-header-height}); height: calc(100% - #{$media-header-height} - #{$media-ctrl-panel-height});
margin-top: .2em; 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> </style>

View File

@ -18,6 +18,21 @@
<div class="right side" v-text="item.description" /> <div class="right side" v-text="item.description" />
</div> </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="row" v-if="item?.channelId">
<div class="left side">Channel</div> <div class="left side">Channel</div>
<div class="right side"> <div class="right side">
@ -83,10 +98,11 @@
<script> <script>
import Utils from "@/Utils"; import Utils from "@/Utils";
import MediaUtils from "@/components/Media/Utils";
export default { export default {
name: "Info", name: "Info",
mixins: [Utils], mixins: [Utils, MediaUtils],
props: { props: {
item: { item: {
type: Object, type: Object,

View File

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

View File

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

View File

@ -1,8 +1,17 @@
.fa.fa-kodi:before { @mixin icon {
content: ' '; content: ' ';
background: url('/icons/kodi.svg');
background-size: 1em 1em; background-size: 1em 1em;
width: 1em; width: 1em;
height: 1em; height: 1em;
display: inline-block; 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 json
import os import os
import pathlib import pathlib
from typing import List, Dict
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -158,5 +159,36 @@ class FilePlugin(Plugin):
""" """
pathlib.Path(self._get_path(file)).unlink() 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: # vim:sw=4:ts=4:et:

View File

@ -66,7 +66,7 @@ class MediaPlugin(Plugin):
_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', 'torrent', 'youtube'] _supported_media_types = ['file', 'plex', 'torrent', 'youtube']
_default_search_timeout = 60 # 60 seconds _default_search_timeout = 60 # 60 seconds
def __init__(self, def __init__(self,
@ -202,6 +202,8 @@ class MediaPlugin(Plugin):
resource = self._videos_queue.pop(0) resource = self._videos_queue.pop(0)
else: else:
raise RuntimeError('No media file found in torrent {}'.format(resource)) 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' assert resource, 'Unable to find any compatible media resource'
return resource return resource
@ -377,6 +379,9 @@ class MediaPlugin(Plugin):
if search_type == 'youtube': if search_type == 'youtube':
from .search import YoutubeMediaSearcher from .search import YoutubeMediaSearcher
return YoutubeMediaSearcher() return YoutubeMediaSearcher()
if search_type == 'plex':
from .search import PlexMediaSearcher
return PlexMediaSearcher()
self.logger.warning('Unsupported search type: {}'.format(search_type)) 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) self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.UNPAUSE: elif evt == Event.UNPAUSE:
self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename) 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 evt == Event.END_FILE and event.get('event', {}).get('reason') in
[EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]): [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]):
playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5) 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.context import get_plugin
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -11,7 +13,7 @@ class MediaPlexPlugin(Plugin):
* **plexapi** (``pip install plexapi``) * **plexapi** (``pip install plexapi``)
""" """
def __init__(self, server, username, password, *args, **kwargs): def __init__(self, server, username, password, **kwargs):
""" """
:param server: Plex server name :param server: Plex server name
:type server: str :type server: str
@ -24,12 +26,11 @@ class MediaPlexPlugin(Plugin):
""" """
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount
super().__init__(*args, **kwargs) super().__init__(**kwargs)
self.resource = MyPlexAccount(username, password).resource(server) self.resource = MyPlexAccount(username, password).resource(server)
self._plex = None self._plex = None
@property @property
def plex(self): def plex(self):
if not self._plex: if not self._plex:
@ -37,7 +38,6 @@ class MediaPlexPlugin(Plugin):
return self._plex return self._plex
@action @action
def get_clients(self): def get_clients(self):
""" """
@ -57,11 +57,9 @@ class MediaPlexPlugin(Plugin):
'version': c.version, 'version': c.version,
} for c in self.plex.clients()] } for c in self.plex.clients()]
def _get_client(self, name): def _get_client(self, name):
return self.plex.client(name) return self.plex.client(name)
@action @action
def search(self, section=None, title=None, **kwargs): def search(self, section=None, title=None, **kwargs):
""" """
@ -93,7 +91,6 @@ class MediaPlexPlugin(Plugin):
return ret return ret
@action @action
def playlists(self): def playlists(self):
""" """
@ -106,11 +103,10 @@ class MediaPlexPlugin(Plugin):
'duration': pl.duration, 'duration': pl.duration,
'summary': pl.summary, 'summary': pl.summary,
'viewed_at': pl.viewedAt, '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() } for pl in self.plex.playlists()
] ]
@action @action
def history(self): def history(self):
""" """
@ -121,8 +117,8 @@ class MediaPlexPlugin(Plugin):
self._flatten_item(item) for item in self.plex.history() self._flatten_item(item) for item in self.plex.history()
] ]
@staticmethod
def get_chromecast(self, chromecast): def get_chromecast(chromecast):
from .lib.plexcast import PlexController from .lib.plexcast import PlexController
hndl = PlexController() hndl = PlexController()
@ -130,8 +126,7 @@ class MediaPlexPlugin(Plugin):
cast = get_plugin('media.chromecast').get_chromecast(chromecast) cast = get_plugin('media.chromecast').get_chromecast(chromecast)
cast.register_handler(hndl) cast.register_handler(hndl)
return (cast, hndl) return cast, hndl
@action @action
def play(self, client=None, chromecast=None, **kwargs): def play(self, client=None, chromecast=None, **kwargs):
@ -157,7 +152,7 @@ class MediaPlexPlugin(Plugin):
raise RuntimeError('No client nor chromecast specified') raise RuntimeError('No client nor chromecast specified')
if client: if client:
client = plex.client(client) client = self.plex.client(client)
elif chromecast: elif chromecast:
(chromecast, handler) = self.get_chromecast(chromecast) (chromecast, handler) = self.get_chromecast(chromecast)
@ -185,7 +180,6 @@ class MediaPlexPlugin(Plugin):
elif chromecast: elif chromecast:
return handler.play_media(item, self.plex) return handler.play_media(item, self.plex)
@action @action
def pause(self, client): def pause(self, client):
""" """
@ -194,7 +188,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).pause() return self.client(client).pause()
@action @action
def stop(self, client): def stop(self, client):
""" """
@ -203,7 +196,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).stop() return self.client(client).stop()
@action @action
def seek(self, client, offset): def seek(self, client, offset):
""" """
@ -212,7 +204,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).seekTo(offset) return self.client(client).seekTo(offset)
@action @action
def forward(self, client): def forward(self, client):
""" """
@ -221,7 +212,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).stepForward() return self.client(client).stepForward()
@action @action
def back(self, client): def back(self, client):
""" """
@ -230,7 +220,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).stepBack() return self.client(client).stepBack()
@action @action
def next(self, client): def next(self, client):
""" """
@ -239,7 +228,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).skipNext() return self.client(client).skipNext()
@action @action
def previous(self, client): def previous(self, client):
""" """
@ -248,15 +236,13 @@ class MediaPlexPlugin(Plugin):
return self.client(client).skipPrevious() return self.client(client).skipPrevious()
@action @action
def set_volume(self, client, volume): def set_volume(self, client, volume):
""" """
Set the volume on a client between 0 and 100 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 @action
def repeat(self, client, repeat): def repeat(self, client, repeat):
@ -266,7 +252,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).setRepeat(repeat) return self.client(client).setRepeat(repeat)
@action @action
def random(self, client, random): def random(self, client, random):
""" """
@ -275,7 +260,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).setShuffle(random) return self.client(client).setShuffle(random)
@action @action
def up(self, client): def up(self, client):
""" """
@ -284,7 +268,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveUp() return self.client(client).moveUp()
@action @action
def down(self, client): def down(self, client):
""" """
@ -293,7 +276,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveDown() return self.client(client).moveDown()
@action @action
def left(self, client): def left(self, client):
""" """
@ -302,7 +284,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveLeft() return self.client(client).moveLeft()
@action @action
def right(self, client): def right(self, client):
""" """
@ -311,7 +292,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).moveRight() return self.client(client).moveRight()
@action @action
def go_back(self, client): def go_back(self, client):
""" """
@ -320,7 +300,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goBack() return self.client(client).goBack()
@action @action
def go_home(self, client): def go_home(self, client):
""" """
@ -329,7 +308,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goHome() return self.client(client).goHome()
@action @action
def go_to_media(self, client): def go_to_media(self, client):
""" """
@ -338,7 +316,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goToMedia() return self.client(client).goToMedia()
@action @action
def go_to_music(self, client): def go_to_music(self, client):
""" """
@ -347,7 +324,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).goToMusic() return self.client(client).goToMusic()
@action @action
def next_letter(self, client): def next_letter(self, client):
""" """
@ -356,7 +332,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).nextLetter() return self.client(client).nextLetter()
@action @action
def page_down(self, client): def page_down(self, client):
""" """
@ -365,7 +340,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).pageDown() return self.client(client).pageDown()
@action @action
def page_up(self, client): def page_up(self, client):
""" """
@ -374,7 +348,6 @@ class MediaPlexPlugin(Plugin):
return self.client(client).pageUp() return self.client(client).pageUp()
def _flatten_item(self, item): def _flatten_item(self, item):
from plexapi.video import Movie, Show from plexapi.video import Movie, Show
@ -399,7 +372,7 @@ class MediaPlexPlugin(Plugin):
_item['media'] = [ _item['media'] = [
{ {
'duration': (item.media[i].duration or 0)/1000, 'duration': (item.media[i].duration or 0) / 1000,
'width': item.media[i].width, 'width': item.media[i].width,
'height': item.media[i].height, 'height': item.media[i].height,
'audio_channels': item.media[i].audioChannels, 'audio_channels': item.media[i].audioChannels,
@ -410,7 +383,10 @@ class MediaPlexPlugin(Plugin):
'parts': [ 'parts': [
{ {
'file': part.file, '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 part in item.media[i].parts
] ]
} for i in range(0, len(item.media)) } for i in range(0, len(item.media))
@ -424,7 +400,7 @@ class MediaPlexPlugin(Plugin):
'summary': season.summary, 'summary': season.summary,
'episodes': [ 'episodes': [
{ {
'duration': episode.duration/1000, 'duration': episode.duration / 1000,
'index': episode.index, 'index': episode.index,
'year': episode.year, 'year': episode.year,
'season_number': episode.seasonNumber, 'season_number': episode.seasonNumber,
@ -435,7 +411,7 @@ class MediaPlexPlugin(Plugin):
'view_offset': episode.viewOffset, 'view_offset': episode.viewOffset,
'media': [ 'media': [
{ {
'duration': episode.media[i].duration/1000, 'duration': episode.media[i].duration / 1000,
'width': episode.media[i].width, 'width': episode.media[i].width,
'height': episode.media[i].height, 'height': episode.media[i].height,
'audio_channels': episode.media[i].audioChannels, 'audio_channels': episode.media[i].audioChannels,
@ -447,7 +423,10 @@ class MediaPlexPlugin(Plugin):
'parts': [ 'parts': [
{ {
'file': part.file, '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 part in episode.media[i].parts
] ]
} for i in range(0, len(episode.locations)) } for i in range(0, len(episode.locations))
@ -460,5 +439,4 @@ class MediaPlexPlugin(Plugin):
return _item 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 .local import LocalMediaSearcher
from .youtube import YoutubeMediaSearcher from .youtube import YoutubeMediaSearcher
from .torrent import TorrentMediaSearcher from .torrent import TorrentMediaSearcher
from .plex import PlexMediaSearcher
__all__ = ['LocalMediaSearcher', 'TorrentMediaSearcher', 'YoutubeMediaSearcher'] __all__ = ['MediaSearcher', 'LocalMediaSearcher', 'TorrentMediaSearcher', 'YoutubeMediaSearcher', 'PlexMediaSearcher']
# vim:sw=4:ts=4:et: # 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: