Compare commits
10 commits
fafc1747d6
...
52ee614ec4
Author | SHA1 | Date | |
---|---|---|---|
52ee614ec4 | |||
a83f4729a6 | |||
4814c56a2d | |||
90a9684404 | |||
cd635ea69e | |||
e66ca105d7 | |||
d1b721dba5 | |||
eb7a96ee94 | |||
d7093d18c5 | |||
f7a25a478d |
23 changed files with 1097 additions and 413 deletions
|
@ -8,24 +8,31 @@
|
||||||
Would you like to install this application locally?
|
Would you like to install this application locally?
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<DropdownContainer />
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||||
|
import DropdownContainer from "@/components/elements/DropdownContainer";
|
||||||
import Notifications from "@/components/Notifications";
|
import Notifications from "@/components/Notifications";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
import Events from "@/Events";
|
import Events from "@/Events";
|
||||||
import VoiceAssistant from "@/components/VoiceAssistant";
|
import VoiceAssistant from "@/components/VoiceAssistant";
|
||||||
import { bus } from "@/bus";
|
|
||||||
import Ntfy from "@/components/Ntfy";
|
import Ntfy from "@/components/Ntfy";
|
||||||
import Pushbullet from "@/components/Pushbullet";
|
import Pushbullet from "@/components/Pushbullet";
|
||||||
|
import { bus } from "@/bus";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
components: {
|
components: {
|
||||||
ConfirmDialog, Pushbullet, Ntfy, Notifications, Events, VoiceAssistant
|
ConfirmDialog,
|
||||||
|
DropdownContainer,
|
||||||
|
Events,
|
||||||
|
Notifications,
|
||||||
|
Ntfy,
|
||||||
|
Pushbullet,
|
||||||
|
VoiceAssistant,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -104,7 +111,6 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--suppress CssUnusedSymbol -->
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||||
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
|
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown-container" ref="container">
|
<div class="dropdown-container">
|
||||||
<button :title="title" ref="button" @click.stop="toggle($event)">
|
<button :title="title" ref="button" @click.stop="toggle($event)">
|
||||||
<i class="icon" :class="iconClass" v-if="iconClass" />
|
<i class="icon" :class="iconClass" v-if="iconClass" />
|
||||||
<span class="text" v-text="text" v-if="text" />
|
<span class="text" v-text="text" v-if="text" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown fade-in" :id="id" :class="{hidden: !visible}" ref="dropdown">
|
<div class="body-container hidden" ref="dropdownContainer">
|
||||||
<slot />
|
<DropdownBody :id="id" :keepOpenOnItemClick="keepOpenOnItemClick" ref="dropdown">
|
||||||
|
<slot />
|
||||||
|
</DropdownBody>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import DropdownBody from "./DropdownBody";
|
||||||
|
import { bus } from "@/bus";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Dropdown",
|
components: { DropdownBody },
|
||||||
emits: ['click'],
|
emits: ['click'],
|
||||||
props: {
|
props: {
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
items: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
|
|
||||||
iconClass: {
|
iconClass: {
|
||||||
default: 'fa fa-ellipsis-h',
|
default: 'fa fa-ellipsis-h',
|
||||||
},
|
},
|
||||||
|
@ -49,6 +49,39 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
dropdownWidth() {
|
||||||
|
const dropdown = this.$refs.dropdown?.$el
|
||||||
|
if (!dropdown)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return parseFloat(getComputedStyle(dropdown).width)
|
||||||
|
},
|
||||||
|
|
||||||
|
dropdownHeight() {
|
||||||
|
const dropdown = this.$refs.dropdown?.$el
|
||||||
|
if (!dropdown)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return parseFloat(getComputedStyle(dropdown).height)
|
||||||
|
},
|
||||||
|
|
||||||
|
buttonStyle() {
|
||||||
|
if (!this.$refs.button)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return getComputedStyle(this.$refs.button)
|
||||||
|
},
|
||||||
|
|
||||||
|
buttonWidth() {
|
||||||
|
return parseFloat(this.buttonStyle.width || 0)
|
||||||
|
},
|
||||||
|
|
||||||
|
buttonHeight() {
|
||||||
|
return parseFloat(this.buttonStyle.height || 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
documentClickHndl(event) {
|
documentClickHndl(event) {
|
||||||
if (!this.visible)
|
if (!this.visible)
|
||||||
|
@ -56,9 +89,7 @@ export default {
|
||||||
|
|
||||||
let element = event.target
|
let element = event.target
|
||||||
while (element) {
|
while (element) {
|
||||||
if (!this.$refs.dropdown)
|
if (element.classList.contains('dropdown'))
|
||||||
break
|
|
||||||
if (element === this.$refs.dropdown.element)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
element = element.parentElement
|
element = element.parentElement
|
||||||
|
@ -70,23 +101,40 @@ export default {
|
||||||
close() {
|
close() {
|
||||||
this.visible = false
|
this.visible = false
|
||||||
document.removeEventListener('click', this.documentClickHndl)
|
document.removeEventListener('click', this.documentClickHndl)
|
||||||
|
bus.emit('dropdown-close')
|
||||||
},
|
},
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
document.addEventListener('click', this.documentClickHndl)
|
document.addEventListener('click', this.documentClickHndl)
|
||||||
this.visible = true
|
this.visible = true
|
||||||
|
this.$refs.dropdownContainer.classList.remove('hidden')
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const buttonRect = this.$refs.button.getBoundingClientRect()
|
||||||
|
const buttonPos = {
|
||||||
|
left: buttonRect.left + window.scrollX,
|
||||||
|
top: buttonRect.top + window.scrollY,
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
const pos = {
|
||||||
const element = this.$refs.dropdown
|
left: buttonPos.left,
|
||||||
element.style.left = 0
|
top: buttonPos.top + this.buttonHeight,
|
||||||
element.style.top = parseFloat(getComputedStyle(this.$refs.button).height) + 'px'
|
}
|
||||||
|
|
||||||
if (element.getBoundingClientRect().left > window.innerWidth/2)
|
if ((pos.left + this.dropdownWidth) > (window.innerWidth + window.scrollX) / 2) {
|
||||||
element.style.left = (-element.clientWidth + parseFloat(getComputedStyle(this.$refs.button).width)) + 'px'
|
pos.left -= (this.dropdownWidth - this.buttonWidth)
|
||||||
|
}
|
||||||
|
|
||||||
if (element.getBoundingClientRect().top > window.innerHeight/2)
|
if ((pos.top + this.dropdownHeight) > (window.innerHeight + window.scrollY) / 2) {
|
||||||
element.style.top = (-element.clientHeight + parseFloat(getComputedStyle(this.$refs.button).height)) + 'px'
|
pos.top -= (this.dropdownHeight + this.buttonHeight - 10)
|
||||||
}, 10)
|
}
|
||||||
|
|
||||||
|
const element = this.$refs.dropdown.$el
|
||||||
|
element.classList.add('fade-in')
|
||||||
|
element.style.top = `${pos.top}px`
|
||||||
|
element.style.left = `${pos.left}px`
|
||||||
|
bus.emit('dropdown-open', this.$refs.dropdown)
|
||||||
|
this.$refs.dropdownContainer.classList.add('hidden')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
toggle(event) {
|
toggle(event) {
|
||||||
|
@ -128,40 +176,5 @@ export default {
|
||||||
color: $default-hover-fg;
|
color: $default-hover-fg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
position: absolute;
|
|
||||||
width: max-content;
|
|
||||||
background: $dropdown-bg;
|
|
||||||
border-radius: .25em;
|
|
||||||
border: $default-border-3;
|
|
||||||
box-shadow: $dropdown-shadow;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dropdown-container) {
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
color: $default-fg-2;
|
|
||||||
background: $dropdown-bg;
|
|
||||||
border: 0;
|
|
||||||
padding: 0.75em 0.5em;
|
|
||||||
text-align: left;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $hover-bg;
|
|
||||||
color: $default-fg-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
padding-left: 0.25em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
<template>
|
||||||
|
<div class="dropdown" :id="id">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
emits: ['click'],
|
||||||
|
props: {
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
keepOpenOnItemClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
width: max-content;
|
||||||
|
background: $dropdown-bg;
|
||||||
|
border-radius: .25em;
|
||||||
|
box-shadow: $dropdown-shadow;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-container) {
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: $default-fg-2;
|
||||||
|
background: $dropdown-bg;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.75em 0.5em;
|
||||||
|
text-align: left;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover-bg;
|
||||||
|
color: $default-fg-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
padding-left: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,38 @@
|
||||||
|
<template>
|
||||||
|
<div class="dropdown-container" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { bus } from "@/bus";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
onOpen(component) {
|
||||||
|
if (!component?.$el)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!component.keepOpenOnItemClick)
|
||||||
|
this.onClose()
|
||||||
|
|
||||||
|
this.$el.appendChild(component.$el)
|
||||||
|
},
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.$el.innerHTML = ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
bus.on('dropdown-open', this.onOpen)
|
||||||
|
bus.on('dropdown-close', this.onClose)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.dropdown-container {
|
||||||
|
:deep(.dropdown) {
|
||||||
|
border: $default-border-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -9,9 +9,9 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Icon from "@/components/elements/Icon";
|
import Icon from "@/components/elements/Icon";
|
||||||
|
import { bus } from "@/bus";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "DropdownItem",
|
|
||||||
components: {Icon},
|
components: {Icon},
|
||||||
props: {
|
props: {
|
||||||
iconClass: {
|
iconClass: {
|
||||||
|
@ -35,13 +35,12 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
clicked(event) {
|
clicked() {
|
||||||
if (this.disabled)
|
if (this.disabled)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
this.$parent.$emit('click', event)
|
|
||||||
if (!this.$parent.keepOpenOnItemClick)
|
if (!this.$parent.keepOpenOnItemClick)
|
||||||
this.$parent.visible = false
|
bus.emit('dropdown-close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,20 +49,22 @@ export default {
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row !important;
|
||||||
min-width: 7.5em;
|
min-width: 7.5em;
|
||||||
padding: 0.75em 0.5em;
|
padding: 0.75em 0.5em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $default-fg-2;
|
color: $default-fg-2;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
box-shadow: none;
|
cursor: pointer !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $hover-bg;
|
background: $hover-bg !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
font-weight: bold;
|
font-weight: bold !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="no-items-container">
|
<div class="no-items-container">
|
||||||
<div class="no-items fade-in">
|
<div class="no-items fade-in" :class="{shadow: withShadow}">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,6 +9,12 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: "NoItems",
|
name: "NoItems",
|
||||||
|
props: {
|
||||||
|
withShadow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -45,7 +51,10 @@ export default {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 1em;
|
border-radius: 1em;
|
||||||
box-shadow: $border-shadow-bottom;
|
|
||||||
|
&.shadow {
|
||||||
|
box-shadow: $border-shadow-bottom;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -33,26 +33,32 @@
|
||||||
@source-toggle="sources[$event] = !sources[$event]" />
|
@source-toggle="sources[$event] = !sources[$event]" />
|
||||||
|
|
||||||
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
|
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
|
||||||
<Results :results="results" :selected-result="selectedResult" @select="onResultSelect($event)"
|
<Results :results="results"
|
||||||
@play="play" @info="$refs.mediaInfo.isVisible = true" @view="view" @download="download"
|
:selected-result="selectedResult"
|
||||||
:sources="sources" v-if="selectedView === 'search'" />
|
:sources="sources"
|
||||||
|
:loading="loading"
|
||||||
|
@select="onResultSelect($event)"
|
||||||
|
@play="play"
|
||||||
|
@view="view"
|
||||||
|
@download="download"
|
||||||
|
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"
|
<Browser :plugin-name="torrentPlugin"
|
||||||
@path-change="browserFilter = ''" @play="play($event)" v-else-if="selectedView === 'browser'" />
|
:is-media="true"
|
||||||
|
:filter="browserFilter"
|
||||||
|
@path-change="browserFilter = ''"
|
||||||
|
@play="play($event)"
|
||||||
|
v-else-if="selectedView === 'browser'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</MediaView>
|
</MediaView>
|
||||||
|
|
||||||
<div class="media-info-container">
|
|
||||||
<Modal title="Media info" ref="mediaInfo">
|
|
||||||
<Info :item="results[selectedResult]" v-if="selectedResult != null" />
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subtitles-container">
|
<div class="subtitles-container">
|
||||||
<Modal title="Available subtitles" :visible="showSubtitlesModal" ref="subtitlesSelector"
|
<Modal title="Available subtitles" :visible="showSubtitlesModal" ref="subtitlesSelector"
|
||||||
@close="showSubtitlesModal = false">
|
@close="showSubtitlesModal = false">
|
||||||
|
@ -65,21 +71,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="play-url-container">
|
<div class="play-url-container">
|
||||||
<Modal title="Play URL" ref="playUrlModal" @open="$refs.playUrlInput.focus()">
|
<Modal title="Play URL" ref="playUrlModal" @open="onPlayUrlModalOpen">
|
||||||
<form @submit.prevent="playUrl(urlPlay)">
|
<UrlPlayer :value="urlPlay" @input="urlPlay = $event.target.value" @play="playUrl($event)" />
|
||||||
<div class="row">
|
|
||||||
<label>
|
|
||||||
Play URL (use the file:// 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">
|
|
||||||
<i class="fa fa-play"></i> Play
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -90,20 +83,33 @@
|
||||||
import Loading from "@/components/Loading";
|
import Loading from "@/components/Loading";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
|
import Browser from "@/components/File/Browser";
|
||||||
|
import Header from "@/components/panels/Media/Header";
|
||||||
import MediaUtils from "@/components/Media/Utils";
|
import MediaUtils from "@/components/Media/Utils";
|
||||||
import MediaView from "@/components/Media/View";
|
import MediaView from "@/components/Media/View";
|
||||||
import Header from "@/components/panels/Media/Header";
|
|
||||||
import Info from "@/components/panels/Media/Info";
|
|
||||||
import Nav from "@/components/panels/Media/Nav";
|
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";
|
import UrlPlayer from "@/components/panels/Media/UrlPlayer";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Media",
|
name: "Media",
|
||||||
mixins: [Utils, MediaUtils],
|
mixins: [Utils, MediaUtils],
|
||||||
components: {Browser, Loading, MediaView, Header, Results, Modal, Info, Nav, TorrentView, Subtitles},
|
components: {
|
||||||
|
Browser,
|
||||||
|
Header,
|
||||||
|
Loading,
|
||||||
|
MediaView,
|
||||||
|
Modal,
|
||||||
|
Nav,
|
||||||
|
Results,
|
||||||
|
Subtitles,
|
||||||
|
TorrentView,
|
||||||
|
UrlPlayer,
|
||||||
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
pluginName: {
|
pluginName: {
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -249,6 +255,18 @@ export default {
|
||||||
this.selectedPlayer.status = status
|
this.selectedPlayer.status = status
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onPlayUrlModalOpen() {
|
||||||
|
const modal = this.$refs.playUrlModal
|
||||||
|
this.urlPlay = ''
|
||||||
|
modal.$nextTick(() => {
|
||||||
|
const input = modal.$el.querySelector('input[type=text]')
|
||||||
|
if (input) {
|
||||||
|
input.focus()
|
||||||
|
input.select()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
onTorrentQueued(event) {
|
onTorrentQueued(event) {
|
||||||
this.notify({
|
this.notify({
|
||||||
title: 'Torrent queued for download',
|
title: 'Torrent queued for download',
|
||||||
|
@ -347,19 +365,17 @@ export default {
|
||||||
if (this.selectedResult == null || this.selectedResult !== result) {
|
if (this.selectedResult == null || this.selectedResult !== result) {
|
||||||
this.selectedResult = result
|
this.selectedResult = result
|
||||||
this.selectedSubtitles = null
|
this.selectedSubtitles = null
|
||||||
|
} else {
|
||||||
|
this.selectedResult = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
showPlayUrlModal() {
|
showPlayUrlModal() {
|
||||||
this.$refs.playUrlModal.show()
|
this.$refs.playUrlModal.show()
|
||||||
this.$refs.playUrlInput.value = ''
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.playUrlInput.value = ''
|
|
||||||
this.$refs.playUrlInput.focus()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async playUrl(url) {
|
async playUrl(url) {
|
||||||
|
this.urlPlay = url
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -452,16 +468,6 @@ export default {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.media-info-container) {
|
|
||||||
.modal-container {
|
|
||||||
.body {
|
|
||||||
max-width: calc(100vw - 2px);
|
|
||||||
padding: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.subtitles-container) {
|
:deep(.subtitles-container) {
|
||||||
.body {
|
.body {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
@ -471,45 +477,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
: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;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.media-info-container) {
|
|
||||||
.modal {
|
|
||||||
max-width: 70em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<div class="row header">
|
<div class="row header">
|
||||||
<MediaImage :item="item" />
|
<div class="image-container">
|
||||||
|
<MediaImage :item="item" @play="$emit('play')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="title">
|
<div class="title">
|
||||||
|
<i :class="typeIcons[item.type]"
|
||||||
|
:title="item.type"
|
||||||
|
v-if="typeIcons[item?.type]">
|
||||||
|
|
||||||
|
</i>
|
||||||
<a :href="item.url" target="_blank" v-if="item.url" v-text="item.title" />
|
<a :href="item.url" target="_blank" v-if="item.url" v-text="item.title" />
|
||||||
<span v-else v-text="item.title" />
|
<span v-else v-text="item.title" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -64,9 +71,26 @@
|
||||||
<div class="right side" v-text="item.status" />
|
<div class="right side" v-text="item.status" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="item?.width && item?.height">
|
||||||
|
<div class="left side">Resolution</div>
|
||||||
|
<div class="right side">
|
||||||
|
{{ item.width }}x{{ item.height }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="item?.rating">
|
<div class="row" v-if="item?.rating">
|
||||||
<div class="left side">Rating</div>
|
<div class="left side">Rating</div>
|
||||||
<div class="right side" v-text="item.rating.percentage" />
|
<div class="right side">{{ item.rating.percentage }}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="item?.critic_rating">
|
||||||
|
<div class="left side">Critic Rating</div>
|
||||||
|
<div class="right side">{{ item.critic_rating }}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="item?.community_rating">
|
||||||
|
<div class="left side">Community Rating</div>
|
||||||
|
<div class="right side">{{ item.community_rating }}%</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="item?.rating">
|
<div class="row" v-if="item?.rating">
|
||||||
|
@ -79,11 +103,10 @@
|
||||||
<div class="right side" v-text="item.genres.join(', ')" />
|
<div class="right side" v-text="item.genres.join(', ')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="item?.channelId">
|
<div class="row" v-if="channel">
|
||||||
<div class="left side">Channel</div>
|
<div class="left side">Channel</div>
|
||||||
<div class="right side">
|
<div class="right side">
|
||||||
<a :href="`https://www.youtube.com/channel/${item.channelId}`" target="_blank"
|
<a :href="channel.url" target="_blank" v-text="channel.title || channel.url" />
|
||||||
v-text="item.channelTitle || `https://www.youtube.com/channel/${item.channelId}`" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -92,9 +115,9 @@
|
||||||
<div class="right side" v-text="item.year" />
|
<div class="right side" v-text="item.year" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="item?.publishedAt">
|
<div class="row" v-if="publishedDate">
|
||||||
<div class="left side">Published at</div>
|
<div class="left side">Published at</div>
|
||||||
<div class="right side" v-text="formatDate(item.publishedAt, true)" />
|
<div class="right side" v-text="publishedDate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="item?.file">
|
<div class="row" v-if="item?.file">
|
||||||
|
@ -140,17 +163,58 @@
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
import MediaUtils from "@/components/Media/Utils";
|
import MediaUtils from "@/components/Media/Utils";
|
||||||
import MediaImage from "./MediaImage";
|
import MediaImage from "./MediaImage";
|
||||||
|
import Icons from "./icons.json";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Info",
|
name: "Info",
|
||||||
components: {MediaImage},
|
components: {MediaImage},
|
||||||
mixins: [Utils, MediaUtils],
|
mixins: [Utils, MediaUtils],
|
||||||
|
emits: ['play'],
|
||||||
props: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
typeIcons: Icons,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
channel() {
|
||||||
|
let ret = null
|
||||||
|
if (this.item?.channelId)
|
||||||
|
ret = {
|
||||||
|
url: `https://www.youtube.com/channel/${this.item.channelId}`,
|
||||||
|
}
|
||||||
|
else if (this.item?.channel_url)
|
||||||
|
ret = {
|
||||||
|
url: this.item.channel_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ret)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if (this.item?.channelTitle)
|
||||||
|
ret.title = this.item.channelTitle
|
||||||
|
else if (this.item?.channel)
|
||||||
|
ret.title = this.item.channel
|
||||||
|
|
||||||
|
return ret
|
||||||
|
},
|
||||||
|
|
||||||
|
publishedDate() {
|
||||||
|
if (this.item?.publishedAt)
|
||||||
|
return this.formatDate(this.item.publishedAt, true)
|
||||||
|
if (this.item?.created_at)
|
||||||
|
return this.formatDate(this.item.created_at, true)
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -159,10 +223,6 @@ export default {
|
||||||
|
|
||||||
.media-info {
|
.media-info {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@include from($tablet) {
|
|
||||||
width: 640px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
@ -231,6 +291,14 @@ export default {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
@include from($desktop) {
|
||||||
|
.image-container {
|
||||||
|
width: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
|
@ -238,6 +306,11 @@ export default {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
@include from($desktop) {
|
||||||
|
flex: 1;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
@ -0,0 +1,193 @@
|
||||||
|
<template>
|
||||||
|
<div class="item media-item" :class="{selected: selected}" v-if="!hidden">
|
||||||
|
<div class="thumbnail">
|
||||||
|
<MediaImage :item="item" @play="$emit('play')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<div class="row title">
|
||||||
|
<div class="col-11 left side" v-text="item.title" @click="$emit('select')" />
|
||||||
|
<div class="col-1 right side">
|
||||||
|
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
|
||||||
|
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play')"
|
||||||
|
v-if="item.type !== 'torrent'" />
|
||||||
|
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
|
||||||
|
v-if="item.type === 'torrent'" />
|
||||||
|
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view')"
|
||||||
|
v-if="item.type === 'file'" />
|
||||||
|
<DropdownItem icon-class="fa fa-info-circle" text="Info" @click="$emit('select')" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row subtitle" v-if="item.channel">
|
||||||
|
<a class="channel" :href="item.channel_url" target="_blank">
|
||||||
|
<img :src="item.channel_image" class="channel-image" v-if="item.channel_image" />
|
||||||
|
<span class="channel-name" v-text="item.channel" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Dropdown from "@/components/elements/Dropdown";
|
||||||
|
import DropdownItem from "@/components/elements/DropdownItem";
|
||||||
|
import Icons from "./icons.json";
|
||||||
|
import MediaImage from "./MediaImage";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {Dropdown, DropdownItem, MediaImage},
|
||||||
|
emits: ['play', 'select', 'view', 'download'],
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
hidden: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
selected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
typeIcons: Icons,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "vars";
|
||||||
|
|
||||||
|
.media-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: initial !important;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 1px solid transparent !important;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
box-shadow: $border-shadow-bottom;
|
||||||
|
background: $selected-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: none !important;
|
||||||
|
box-shadow: $border-shadow;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: .5em 0;
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
max-height: 3em;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: " [...]";
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-container) {
|
||||||
|
.item {
|
||||||
|
flex-direction: row;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover-bg !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
opacity: .7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $default-hover-fg-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: .9em;
|
||||||
|
color: $default-fg-2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: .5em;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.channel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.channel-name {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-image {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,5 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="image-container">
|
<div class="image-container" :class="{ 'with-image': !!item?.image }">
|
||||||
|
<div class="play-overlay" @click="$emit('play', item)">
|
||||||
|
<i class="fas fa-play" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="icon type-icon" v-if="typeIcons[item?.type]">
|
||||||
|
<a :href="item.url" target="_blank" v-if="item.url">
|
||||||
|
<i :class="typeIcons[item.type]" :title="item.type">
|
||||||
|
|
||||||
|
</i>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
<img class="image" :src="item.image" :alt="item.title" v-if="item?.image" />
|
<img class="image" :src="item.image" :alt="item.title" v-if="item?.image" />
|
||||||
<div class="image" v-else>
|
<div class="image" v-else>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
|
@ -7,7 +19,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="imdb-link" v-if="item?.imdb_id">
|
<span class="icon imdb-link" v-if="item?.imdb_id">
|
||||||
<a :href="`https://www.imdb.com/title/${item.imdb_id}`" target="_blank">
|
<a :href="`https://www.imdb.com/title/${item.imdb_id}`" target="_blank">
|
||||||
<i class="fab fa-imdb" />
|
<i class="fab fa-imdb" />
|
||||||
</a>
|
</a>
|
||||||
|
@ -19,37 +31,67 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Icons from "./icons.json";
|
||||||
import MediaUtils from "@/components/Media/Utils";
|
import MediaUtils from "@/components/Media/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [MediaUtils],
|
mixins: [Icons, MediaUtils],
|
||||||
|
emits: ['play'],
|
||||||
props: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
typeIcons: Icons,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "vars";
|
@import "vars";
|
||||||
|
|
||||||
.imdb-link {
|
.icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
width: 30px;
|
||||||
right: 0;
|
height: 30px;
|
||||||
height: 1em;
|
font-size: 30px;
|
||||||
font-size: 2em;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 0.25em;
|
||||||
|
color: $default-media-img-fg;
|
||||||
|
|
||||||
&:hover {
|
a {
|
||||||
color: $default-hover-fg;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: $default-media-img-fg;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $default-hover-fg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
background: #ffff00;
|
margin: 2.5px;
|
||||||
border-radius: 0.25em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fa-imdb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
margin: 1px 2.5px 3px 2.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-youtube {
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.imdb-link {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
|
@ -63,20 +105,43 @@ export default {
|
||||||
border-radius: 0.25em;
|
border-radius: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
.image-container {
|
.image-container {
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
min-height: 240px;
|
min-height: 200px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
img {
|
&.with-image {
|
||||||
@include from($tablet) {
|
background: black;
|
||||||
height: 480px;
|
|
||||||
|
.icon {
|
||||||
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
div.image {
|
div.image {
|
||||||
height: 400px;
|
width: 100%;
|
||||||
color: $default-media-img-fg;
|
color: $default-media-img-fg;
|
||||||
font-size: 5em;
|
font-size: 5em;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -84,14 +149,38 @@ div.image {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.inner {
|
.inner {
|
||||||
width: calc(100% - 20px);
|
width: 100%;
|
||||||
height: calc(100% - 20px);
|
height: 100%;
|
||||||
min-height: 240px;
|
|
||||||
background: $default-media-img-bg;
|
background: $default-media-img-bg;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 1em;
|
border-radius: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 2em;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 5em;
|
||||||
|
color: $default-media-img-fg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,42 +1,46 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="media-results">
|
<div class="media-results">
|
||||||
<div class="no-content" v-if="!results?.length">
|
<Loading v-if="loading" />
|
||||||
|
<NoItems v-else-if="!results?.length" :with-shadow="false">
|
||||||
No search results
|
No search results
|
||||||
|
</NoItems>
|
||||||
|
|
||||||
|
<div class="grid" v-else>
|
||||||
|
<Item v-for="(item, i) in results"
|
||||||
|
:key="i"
|
||||||
|
:item="item"
|
||||||
|
:selected="selectedResult === i"
|
||||||
|
:hidden="!sources[item.type]"
|
||||||
|
@select="$emit('select', i)"
|
||||||
|
@play="$emit('play', item)"
|
||||||
|
@view="$emit('view', item)"
|
||||||
|
@download="$emit('download', item)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row item" :class="{selected: selectedResult === i, hidden: !sources[result.type]}"
|
<Modal ref="infoModal" title="Media info" @close="$emit('select', null)">
|
||||||
v-for="(result, i) in results" :key="i" @click="$emit('select', i)">
|
<Info :item="results[selectedResult]"
|
||||||
<div class="col-10 left side">
|
@play="$emit('play', results[selectedResult])"
|
||||||
<div class="icon">
|
v-if="selectedResult != null" />
|
||||||
<i :class="typeIcons[result.type]" />
|
</Modal>
|
||||||
</div>
|
|
||||||
<div class="title" v-text="result.title" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-2 right side">
|
|
||||||
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" @click="$emit('select', i)">
|
|
||||||
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play', result)"
|
|
||||||
v-if="result?.type !== 'torrent'" />
|
|
||||||
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download', result)"
|
|
||||||
v-if="result?.type === 'torrent'" />
|
|
||||||
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view', result)"
|
|
||||||
v-if="result?.type === 'file'" />
|
|
||||||
<DropdownItem icon-class="fa fa-info" text="Info" @click="$emit('info', result)" />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Dropdown from "@/components/elements/Dropdown";
|
import Info from "@/components/panels/Media/Info";
|
||||||
import DropdownItem from "@/components/elements/DropdownItem";
|
import Item from "./Item";
|
||||||
|
import Loading from "@/components/Loading";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import NoItems from "@/components/elements/NoItems";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Results",
|
components: {Info, Item, Loading, Modal, NoItems},
|
||||||
components: {Dropdown, DropdownItem},
|
emits: ['select', 'play', 'view', 'download'],
|
||||||
emits: ['select', 'info', 'play', 'view', 'download'],
|
|
||||||
props: {
|
props: {
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
results: {
|
results: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
@ -52,22 +56,20 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
mounted() {
|
||||||
return {
|
this.$watch('selectedResult', (value) => {
|
||||||
typeIcons: {
|
if (value == null)
|
||||||
'file': 'fa fa-hdd',
|
this.$refs.infoModal?.close()
|
||||||
'torrent': 'fa fa-magnet',
|
else
|
||||||
'youtube': 'fab fa-youtube',
|
this.$refs.infoModal?.show()
|
||||||
'plex': 'fa fa-plex',
|
})
|
||||||
'jellyfin': 'fa fa-jellyfin',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "src/style/items";
|
@import "src/style/items";
|
||||||
|
@import "vars";
|
||||||
|
|
||||||
.media-results {
|
.media-results {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -75,55 +77,9 @@ export default {
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
.item {
|
.info-container {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
cursor: initial;
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background: $selected-bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&.left {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: " [...]";
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-right: .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.dropdown-container) {
|
|
||||||
.item {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
opacity: .7;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $default-hover-fg-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-content {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
.fa-youtube {
|
|
||||||
color: #d21;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
<template>
|
||||||
|
<form class="url-player" @submit.prevent="$emit('play', value)">
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
Play URL (use the file:// prefix for local files)
|
||||||
|
<input type="text"
|
||||||
|
v-model="value"
|
||||||
|
ref="playUrlInput"
|
||||||
|
autofocus />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row footer">
|
||||||
|
<button type="submit" :disabled="!value?.length">
|
||||||
|
<i class="fa fa-play"></i> Play
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
emits: ['input', 'play'],
|
||||||
|
props: {
|
||||||
|
playUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
value: this.playUrl,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.url-player {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"file": "fa fa-hdd",
|
||||||
|
"torrent": "fa fa-magnet",
|
||||||
|
"youtube": "fab fa-youtube",
|
||||||
|
"plex": "fa fa-plex",
|
||||||
|
"jellyfin": "fa fa-jellyfin"
|
||||||
|
}
|
|
@ -3,3 +3,7 @@ $media-nav-width: 2.8em;
|
||||||
$filter-header-height: 3em;
|
$filter-header-height: 3em;
|
||||||
$default-media-img-bg: #d0dad8;
|
$default-media-img-bg: #d0dad8;
|
||||||
$default-media-img-fg: white;
|
$default-media-img-fg: white;
|
||||||
|
|
||||||
|
.fa-youtube {
|
||||||
|
color: #d21;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
@use "sass:math";
|
@use "sass:math";
|
||||||
|
|
||||||
|
$tablet-small: 640px;
|
||||||
$tablet: 769px;
|
$tablet: 769px;
|
||||||
$desktop: 1024px;
|
$desktop: 1024px;
|
||||||
$widescreen: 1216px;
|
$widescreen: 1216px;
|
||||||
|
@ -13,6 +14,7 @@ $widths: (
|
||||||
);
|
);
|
||||||
|
|
||||||
$width-ranges: (
|
$width-ranges: (
|
||||||
|
tablet-small: ($tablet-small, $tablet),
|
||||||
tablet: ($tablet, $desktop),
|
tablet: ($tablet, $desktop),
|
||||||
desktop: ($desktop, $widescreen),
|
desktop: ($desktop, $widescreen),
|
||||||
widescreen: ($widescreen, $fullhd),
|
widescreen: ($widescreen, $fullhd),
|
||||||
|
@ -227,3 +229,32 @@ $width-ranges: (
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grid layout
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
row-gap: 1em;
|
||||||
|
column-gap: 1.5em;
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
@include until($tablet-small) {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include between($tablet-small, $tablet) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include between($tablet, $desktop) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include between($desktop, $widescreen) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include from($widescreen) {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,6 +60,7 @@ $border-shadow-bottom: 0 3px 2px -1px $default-shadow-color;
|
||||||
$border-shadow-left: -2.5px 0 4px 0 $default-shadow-color;
|
$border-shadow-left: -2.5px 0 4px 0 $default-shadow-color;
|
||||||
$border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
|
$border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
|
||||||
$border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
|
$border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
|
||||||
|
$border-shadow: 0 0 3px 3px #c0c0c0 !default;
|
||||||
$header-shadow: 0px 1px 3px 1px #bbb !default;
|
$header-shadow: 0px 1px 3px 1px #bbb !default;
|
||||||
$group-shadow: 3px -2px 6px 1px #98b0a0;
|
$group-shadow: 3px -2px 6px 1px #98b0a0;
|
||||||
$primary-btn-shadow: 1px 1px 0.5px 0.75px #32b64640 !default;
|
$primary-btn-shadow: 1px 1px 0.5px 0.75px #32b64640 !default;
|
||||||
|
|
|
@ -170,7 +170,6 @@ class MediaPlugin(Plugin, ABC):
|
||||||
env: Optional[Dict[str, str]] = None,
|
env: Optional[Dict[str, str]] = None,
|
||||||
volume: Optional[Union[float, int]] = None,
|
volume: Optional[Union[float, int]] = None,
|
||||||
torrent_plugin: str = 'torrent',
|
torrent_plugin: str = 'torrent',
|
||||||
# youtube_format: Optional[str] = 'bv*[height<=?1080][ext=mp4]+bestaudio/best',
|
|
||||||
youtube_format: Optional[str] = 'best[height<=?1080][ext=mp4]',
|
youtube_format: Optional[str] = 'best[height<=?1080][ext=mp4]',
|
||||||
youtube_dl: str = 'yt-dlp',
|
youtube_dl: str = 'yt-dlp',
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
|
@ -1,27 +1,28 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import create_engine
|
||||||
create_engine,
|
|
||||||
Column,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
DateTime,
|
|
||||||
PrimaryKeyConstraint,
|
|
||||||
ForeignKey,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
from platypush.common.db import declarative_base
|
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.plugins.media import MediaPlugin
|
from platypush.plugins.media import MediaPlugin
|
||||||
from platypush.plugins.media.search import MediaSearcher
|
from platypush.plugins.media.search import MediaSearcher
|
||||||
|
|
||||||
Base = declarative_base()
|
from .db import (
|
||||||
Session = scoped_session(sessionmaker())
|
Base,
|
||||||
|
MediaDirectory,
|
||||||
|
MediaFile,
|
||||||
|
MediaFileToken,
|
||||||
|
MediaToken,
|
||||||
|
Session,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .metadata import get_metadata
|
||||||
|
|
||||||
|
_db_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
class LocalMediaSearcher(MediaSearcher):
|
class LocalMediaSearcher(MediaSearcher):
|
||||||
|
@ -37,7 +38,7 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
def __init__(self, dirs, *args, **kwargs):
|
def __init__(self, dirs, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.dirs = dirs
|
self.dirs = dirs
|
||||||
db_dir = os.path.join(Config.get('workdir'), 'media')
|
db_dir = os.path.join(Config.get_workdir(), 'media')
|
||||||
os.makedirs(db_dir, exist_ok=True)
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
self.db_file = os.path.join(db_dir, 'media.db')
|
self.db_file = os.path.join(db_dir, 'media.db')
|
||||||
self._db_engine = None
|
self._db_engine = None
|
||||||
|
@ -142,6 +143,7 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
).delete(synchronize_session='fetch')
|
).delete(synchronize_session='fetch')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
media_files = []
|
||||||
stored_file_records = {
|
stored_file_records = {
|
||||||
f.path: f for f in self._get_file_records(dir_record, session)
|
f.path: f for f in self._get_file_records(dir_record, session)
|
||||||
}
|
}
|
||||||
|
@ -162,7 +164,7 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
) and not MediaPlugin.is_audio_file(filename):
|
) and not MediaPlugin.is_audio_file(filename):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.logger.debug('Syncing item {}'.format(filepath))
|
self.logger.info('Scanning item %s', filepath)
|
||||||
tokens = [
|
tokens = [
|
||||||
_.lower()
|
_.lower()
|
||||||
for _ in re.split(self._filename_separators, filename.strip())
|
for _ in re.split(self._filename_separators, filename.strip())
|
||||||
|
@ -170,9 +172,9 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
|
|
||||||
token_records = self._sync_token_records(session, *tokens)
|
token_records = self._sync_token_records(session, *tokens)
|
||||||
file_record = MediaFile.build(directory_id=dir_record.id, path=filepath)
|
file_record = MediaFile.build(directory_id=dir_record.id, path=filepath)
|
||||||
|
|
||||||
session.add(file_record)
|
session.add(file_record)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
file_record = (
|
file_record = (
|
||||||
session.query(MediaFile)
|
session.query(MediaFile)
|
||||||
.filter_by(directory_id=dir_record.id, path=filepath)
|
.filter_by(directory_id=dir_record.id, path=filepath)
|
||||||
|
@ -185,6 +187,8 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
)
|
)
|
||||||
session.add(file_token)
|
session.add(file_token)
|
||||||
|
|
||||||
|
media_files.append(file_record)
|
||||||
|
|
||||||
# stored_file_records should now only contain the records of the files
|
# stored_file_records should now only contain the records of the files
|
||||||
# that have been removed from the directory
|
# that have been removed from the directory
|
||||||
if stored_file_records:
|
if stored_file_records:
|
||||||
|
@ -198,7 +202,7 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
MediaFile.id.in_([record.id for record in stored_file_records.values()])
|
MediaFile.id.in_([record.id for record in stored_file_records.values()])
|
||||||
).delete(synchronize_session='fetch')
|
).delete(synchronize_session='fetch')
|
||||||
|
|
||||||
dir_record.last_indexed_at = datetime.datetime.now()
|
dir_record.last_indexed_at = datetime.datetime.now() # type: ignore
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
'Scanned {} in {} seconds'.format(
|
'Scanned {} in {} seconds'.format(
|
||||||
media_dir, int(time.time() - index_start_time)
|
media_dir, int(time.time() - index_start_time)
|
||||||
|
@ -207,7 +211,32 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
def search(self, query, **kwargs):
|
# Start the metadata scan in a separate thread
|
||||||
|
threading.Thread(
|
||||||
|
target=self._metadata_scan_thread, args=(media_files,), daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _metadata_scan_thread(self, records):
|
||||||
|
"""
|
||||||
|
Thread that will scan the media files in the given paths and update
|
||||||
|
their metadata.
|
||||||
|
"""
|
||||||
|
paths = [record.path for record in records]
|
||||||
|
metadata = get_metadata(*paths)
|
||||||
|
session = self._get_db_session()
|
||||||
|
|
||||||
|
for record, data in zip(records, metadata):
|
||||||
|
record = session.merge(record)
|
||||||
|
record.duration = data.get('duration') # type: ignore
|
||||||
|
record.width = data.get('width') # type: ignore
|
||||||
|
record.height = data.get('height') # type: ignore
|
||||||
|
record.image = data.get('image') # type: ignore
|
||||||
|
record.created_at = data.get('created_at') # type: ignore
|
||||||
|
session.add(record)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def search(self, query, **_):
|
||||||
"""
|
"""
|
||||||
Searches in the configured media directories given a query. It uses the
|
Searches in the configured media directories given a query. It uses the
|
||||||
built-in SQLite index if available. If any directory has changed since
|
built-in SQLite index if available. If any directory has changed since
|
||||||
|
@ -218,123 +247,46 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
session = self._get_db_session()
|
session = self._get_db_session()
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
for media_dir in self.dirs:
|
with _db_lock:
|
||||||
self.logger.info('Searching {} for "{}"'.format(media_dir, query))
|
for media_dir in self.dirs:
|
||||||
dir_record = self._get_or_create_dir_entry(session, media_dir)
|
self.logger.info('Searching {} for "{}"'.format(media_dir, query))
|
||||||
|
dir_record = self._get_or_create_dir_entry(session, media_dir)
|
||||||
|
|
||||||
if self._has_directory_changed_since_last_indexing(dir_record):
|
if self._has_directory_changed_since_last_indexing(dir_record):
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
'{} has changed since last indexing, '.format(media_dir)
|
'{} has changed since last indexing, '.format(media_dir)
|
||||||
+ 're-indexing'
|
+ 're-indexing'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.scan(media_dir, session=session, dir_record=dir_record)
|
self.scan(media_dir, session=session, dir_record=dir_record)
|
||||||
|
|
||||||
query_tokens = [
|
query_tokens = [
|
||||||
_.lower() for _ in re.split(self._filename_separators, query.strip())
|
_.lower()
|
||||||
]
|
for _ in re.split(self._filename_separators, query.strip())
|
||||||
|
]
|
||||||
|
|
||||||
for file_record in (
|
for file_record in session.query(MediaFile).where(
|
||||||
session.query(MediaFile.path)
|
MediaFile.id.in_(
|
||||||
.join(MediaFileToken)
|
session.query(MediaFile.id)
|
||||||
.join(MediaToken)
|
.join(MediaFileToken)
|
||||||
.filter(MediaToken.token.in_(query_tokens))
|
.join(MediaToken)
|
||||||
.group_by(MediaFile.path)
|
.filter(MediaToken.token.in_(query_tokens))
|
||||||
.having(func.count(MediaFileToken.token_id) >= len(query_tokens))
|
.group_by(MediaFile.id)
|
||||||
):
|
.having(
|
||||||
if os.path.isfile(file_record.path):
|
func.count(MediaFileToken.token_id) >= len(query_tokens)
|
||||||
results[file_record.path] = {
|
)
|
||||||
'url': 'file://' + file_record.path,
|
)
|
||||||
'title': os.path.basename(file_record.path),
|
):
|
||||||
'size': os.path.getsize(file_record.path),
|
if os.path.isfile(file_record.path):
|
||||||
}
|
results[file_record.path] = {
|
||||||
|
'url': 'file://' + file_record.path,
|
||||||
|
'title': os.path.basename(file_record.path),
|
||||||
|
'size': os.path.getsize(file_record.path),
|
||||||
|
'duration': file_record.duration,
|
||||||
|
'width': file_record.width,
|
||||||
|
'height': file_record.height,
|
||||||
|
'image': file_record.image,
|
||||||
|
'created_at': file_record.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
return results.values()
|
return results.values()
|
||||||
|
|
||||||
|
|
||||||
# --- Table definitions
|
|
||||||
|
|
||||||
|
|
||||||
class MediaDirectory(Base):
|
|
||||||
"""Models the MediaDirectory table"""
|
|
||||||
|
|
||||||
__tablename__ = 'MediaDirectory'
|
|
||||||
__table_args__ = {'sqlite_autoincrement': True}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
path = Column(String)
|
|
||||||
last_indexed_at = Column(DateTime)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def build(cls, path, last_indexed_at=None, id=None):
|
|
||||||
record = cls()
|
|
||||||
record.id = id
|
|
||||||
record.path = path
|
|
||||||
record.last_indexed_at = last_indexed_at
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
class MediaFile(Base):
|
|
||||||
"""Models the MediaFile table"""
|
|
||||||
|
|
||||||
__tablename__ = 'MediaFile'
|
|
||||||
__table_args__ = {'sqlite_autoincrement': True}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
directory_id = Column(
|
|
||||||
Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False
|
|
||||||
)
|
|
||||||
path = Column(String, nullable=False, unique=True)
|
|
||||||
indexed_at = Column(DateTime)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def build(cls, directory_id, path, indexed_at=None, id=None):
|
|
||||||
record = cls()
|
|
||||||
record.id = id
|
|
||||||
record.directory_id = directory_id
|
|
||||||
record.path = path
|
|
||||||
record.indexed_at = indexed_at or datetime.datetime.now()
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
class MediaToken(Base):
|
|
||||||
"""Models the MediaToken table"""
|
|
||||||
|
|
||||||
__tablename__ = 'MediaToken'
|
|
||||||
__table_args__ = {'sqlite_autoincrement': True}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
token = Column(String, nullable=False, unique=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def build(cls, token, id=None):
|
|
||||||
record = cls()
|
|
||||||
record.id = id
|
|
||||||
record.token = token
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
class MediaFileToken(Base):
|
|
||||||
"""Models the MediaFileToken table"""
|
|
||||||
|
|
||||||
__tablename__ = 'MediaFileToken'
|
|
||||||
|
|
||||||
file_id = Column(
|
|
||||||
Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False
|
|
||||||
)
|
|
||||||
token_id = Column(
|
|
||||||
Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
__table_args__ = (PrimaryKeyConstraint(file_id, token_id), {})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def build(cls, file_id, token_id, id=None):
|
|
||||||
record = cls()
|
|
||||||
record.id = id
|
|
||||||
record.file_id = file_id
|
|
||||||
record.token_id = token_id
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
|
111
platypush/plugins/media/search/local/db.py
Normal file
111
platypush/plugins/media/search/local/db.py
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Float,
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
DateTime,
|
||||||
|
PrimaryKeyConstraint,
|
||||||
|
ForeignKey,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||||
|
|
||||||
|
from platypush.common.db import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
Session = scoped_session(sessionmaker())
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDirectory(Base):
|
||||||
|
"""Models the MediaDirectory table"""
|
||||||
|
|
||||||
|
__tablename__ = 'MediaDirectory'
|
||||||
|
__table_args__ = {'sqlite_autoincrement': True}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
path = Column(String)
|
||||||
|
last_indexed_at = Column(DateTime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, path, last_indexed_at=None, id=None):
|
||||||
|
record = cls()
|
||||||
|
record.id = id
|
||||||
|
record.path = path
|
||||||
|
record.last_indexed_at = last_indexed_at
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
class MediaFile(Base):
|
||||||
|
"""Models the MediaFile table"""
|
||||||
|
|
||||||
|
__tablename__ = 'MediaFile'
|
||||||
|
__table_args__ = {'sqlite_autoincrement': True}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
directory_id = Column(
|
||||||
|
Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False
|
||||||
|
)
|
||||||
|
path = Column(String, nullable=False, unique=True)
|
||||||
|
duration = Column(Float)
|
||||||
|
width = Column(Integer)
|
||||||
|
height = Column(Integer)
|
||||||
|
image = Column(String)
|
||||||
|
created_at = Column(DateTime)
|
||||||
|
indexed_at = Column(DateTime)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, directory_id, path, indexed_at=None, id=None, **kwargs):
|
||||||
|
record = cls()
|
||||||
|
record.id = id
|
||||||
|
record.directory_id = directory_id
|
||||||
|
record.path = path
|
||||||
|
record.indexed_at = indexed_at or datetime.datetime.now()
|
||||||
|
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(record, k, v)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
class MediaToken(Base):
|
||||||
|
"""Models the MediaToken table"""
|
||||||
|
|
||||||
|
__tablename__ = 'MediaToken'
|
||||||
|
__table_args__ = {'sqlite_autoincrement': True}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
token = Column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, token, id=None):
|
||||||
|
record = cls()
|
||||||
|
record.id = id
|
||||||
|
record.token = token
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
class MediaFileToken(Base):
|
||||||
|
"""Models the MediaFileToken table"""
|
||||||
|
|
||||||
|
__tablename__ = 'MediaFileToken'
|
||||||
|
|
||||||
|
file_id = Column(
|
||||||
|
Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False
|
||||||
|
)
|
||||||
|
token_id = Column(
|
||||||
|
Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (PrimaryKeyConstraint(file_id, token_id), {})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, file_id, token_id, id=None):
|
||||||
|
record = cls()
|
||||||
|
record.id = id
|
||||||
|
record.file_id = file_id
|
||||||
|
record.token_id = token_id
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
86
platypush/plugins/media/search/local/metadata.py
Normal file
86
platypush/plugins/media/search/local/metadata.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
|
||||||
|
from dateutil.parser import isoparse
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_metadata(path: str):
|
||||||
|
"""
|
||||||
|
Retrieves the metadata of a media file using ffprobe.
|
||||||
|
"""
|
||||||
|
logger.info('Retrieving metadata for %s', path)
|
||||||
|
|
||||||
|
with subprocess.Popen(
|
||||||
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v',
|
||||||
|
'quiet',
|
||||||
|
'-print_format',
|
||||||
|
'json',
|
||||||
|
'-show_format',
|
||||||
|
'-show_streams',
|
||||||
|
path,
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
) as ffprobe:
|
||||||
|
ret = json.loads(ffprobe.communicate()[0])
|
||||||
|
|
||||||
|
video_stream = next(
|
||||||
|
iter(
|
||||||
|
[
|
||||||
|
stream
|
||||||
|
for stream in ret.get('streams', [])
|
||||||
|
if stream.get('codec_type') == 'video'
|
||||||
|
]
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
creation_time = ret.get('format', {}).get('tags', {}).get('creation_time')
|
||||||
|
if creation_time:
|
||||||
|
try:
|
||||||
|
creation_time = isoparse(creation_time)
|
||||||
|
except ValueError:
|
||||||
|
creation_time = None
|
||||||
|
|
||||||
|
if not creation_time:
|
||||||
|
creation_time = datetime.datetime.fromtimestamp(os.path.getctime(path))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'duration': ret.get('format', {}).get('duration'),
|
||||||
|
'width': video_stream.get('width'),
|
||||||
|
'height': video_stream.get('height'),
|
||||||
|
'created_at': creation_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata(*paths: str):
|
||||||
|
"""
|
||||||
|
Retrieves the metadata of media files using ffprobe.
|
||||||
|
"""
|
||||||
|
logger.info('Retrieving metadata for %d media files', len(paths))
|
||||||
|
try:
|
||||||
|
assert shutil.which(
|
||||||
|
'ffprobe'
|
||||||
|
), 'ffprobe not found in PATH. Install ffmpeg to retrieve local media metadata.'
|
||||||
|
|
||||||
|
# Run ffprobe in parallel
|
||||||
|
with ProcessPoolExecutor(
|
||||||
|
max_workers=multiprocessing.cpu_count() * 2
|
||||||
|
) as executor:
|
||||||
|
futures = [executor.submit(get_file_metadata, path) for path in paths]
|
||||||
|
results = [future.result() for future in futures]
|
||||||
|
|
||||||
|
logger.info('Retrieved metadata for %d media files', len(results))
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Failed to retrieve media metadata: %s: %s', type(e).__name__, e)
|
||||||
|
return [{} for _ in paths]
|
|
@ -200,7 +200,7 @@ class TorrentPlugin(Plugin):
|
||||||
'synopsis': result.get('synopsis'),
|
'synopsis': result.get('synopsis'),
|
||||||
'trailer': result.get('trailer'),
|
'trailer': result.get('trailer'),
|
||||||
'genres': result.get('genres', []),
|
'genres': result.get('genres', []),
|
||||||
'images': result.get('images', []),
|
'image': result.get('images', {}).get('poster'),
|
||||||
'rating': result.get('rating', {}),
|
'rating': result.get('rating', {}),
|
||||||
'language': lang,
|
'language': lang,
|
||||||
'quality': quality,
|
'quality': quality,
|
||||||
|
@ -235,7 +235,7 @@ class TorrentPlugin(Plugin):
|
||||||
+ f'[S{episode.get("season", 0):02d}E{episode.get("episode", 0):02d}] '
|
+ f'[S{episode.get("season", 0):02d}E{episode.get("episode", 0):02d}] '
|
||||||
+ f'{episode.get("title", "[No Title]")} [tv][{quality}]'
|
+ f'{episode.get("title", "[No Title]")} [tv][{quality}]'
|
||||||
),
|
),
|
||||||
'duration': int(result.get('runtime') or 0),
|
'duration': int(result.get('runtime') or 0) * 60,
|
||||||
'year': int(result.get('year') or 0),
|
'year': int(result.get('year') or 0),
|
||||||
'synopsis': result.get('synopsis'),
|
'synopsis': result.get('synopsis'),
|
||||||
'overview': episode.get('overview'),
|
'overview': episode.get('overview'),
|
||||||
|
@ -246,7 +246,7 @@ class TorrentPlugin(Plugin):
|
||||||
'network': result.get('network'),
|
'network': result.get('network'),
|
||||||
'status': result.get('status'),
|
'status': result.get('status'),
|
||||||
'genres': result.get('genres', []),
|
'genres': result.get('genres', []),
|
||||||
'images': result.get('images', []),
|
'image': result.get('images', {}).get('fanart'),
|
||||||
'rating': result.get('rating', {}),
|
'rating': result.get('rating', {}),
|
||||||
'quality': quality,
|
'quality': quality,
|
||||||
'provider': item.get('provider'),
|
'provider': item.get('provider'),
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from datetime import datetime
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
@ -61,6 +62,14 @@ class YoutubePlugin(Plugin):
|
||||||
"image": item.get("thumbnail"),
|
"image": item.get("thumbnail"),
|
||||||
"duration": item.get("duration", 0),
|
"duration": item.get("duration", 0),
|
||||||
"description": item.get("shortDescription"),
|
"description": item.get("shortDescription"),
|
||||||
|
"channel": item.get("uploaderName"),
|
||||||
|
"channel_url": "https://www.youtube.com" + item["uploaderUrl"]
|
||||||
|
if item.get("uploaderUrl")
|
||||||
|
else None,
|
||||||
|
"channel_image": item.get("uploaderAvatar"),
|
||||||
|
"created_at": datetime.fromtimestamp(item["uploaded"] / 1000)
|
||||||
|
if item.get("uploaded")
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
for item in rs.json().get("items", [])
|
for item in rs.json().get("items", [])
|
||||||
]
|
]
|
||||||
|
|
|
@ -74,6 +74,7 @@ class JellyfinCollectionSchema(JellyfinSchema, MediaCollectionSchema):
|
||||||
|
|
||||||
|
|
||||||
class JellyfinVideoSchema(JellyfinSchema, MediaVideoSchema):
|
class JellyfinVideoSchema(JellyfinSchema, MediaVideoSchema):
|
||||||
|
duration = fields.Number(attribute='RunTimeTicks')
|
||||||
community_rating = fields.Number(attribute='CommunityRating')
|
community_rating = fields.Number(attribute='CommunityRating')
|
||||||
critic_rating = fields.Number(attribute='CriticRating')
|
critic_rating = fields.Number(attribute='CriticRating')
|
||||||
|
|
||||||
|
@ -82,6 +83,18 @@ class JellyfinVideoSchema(JellyfinSchema, MediaVideoSchema):
|
||||||
self.fields['year'].attribute = 'ProductionYear'
|
self.fields['year'].attribute = 'ProductionYear'
|
||||||
self.fields['has_subtitles'].attribute = 'HasSubtitles'
|
self.fields['has_subtitles'].attribute = 'HasSubtitles'
|
||||||
|
|
||||||
|
@post_dump
|
||||||
|
def _normalize_community_rating(self, data: dict, **_) -> dict:
|
||||||
|
if data.get('community_rating'):
|
||||||
|
data['community_rating'] *= 10
|
||||||
|
return data
|
||||||
|
|
||||||
|
@post_dump
|
||||||
|
def _normalize_duration(self, data: dict, **_) -> dict:
|
||||||
|
if data.get('duration'):
|
||||||
|
data['duration'] //= 1e7
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class JellyfinMovieSchema(JellyfinVideoSchema):
|
class JellyfinMovieSchema(JellyfinVideoSchema):
|
||||||
pass
|
pass
|
||||||
|
|
Loading…
Reference in a new issue