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?
|
||||
</ConfirmDialog>
|
||||
|
||||
<DropdownContainer />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||
import DropdownContainer from "@/components/elements/DropdownContainer";
|
||||
import Notifications from "@/components/Notifications";
|
||||
import Utils from "@/Utils";
|
||||
import Events from "@/Events";
|
||||
import VoiceAssistant from "@/components/VoiceAssistant";
|
||||
import { bus } from "@/bus";
|
||||
import Ntfy from "@/components/Ntfy";
|
||||
import Pushbullet from "@/components/Pushbullet";
|
||||
import { bus } from "@/bus";
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
mixins: [Utils],
|
||||
components: {
|
||||
ConfirmDialog, Pushbullet, Ntfy, Notifications, Events, VoiceAssistant
|
||||
ConfirmDialog,
|
||||
DropdownContainer,
|
||||
Events,
|
||||
Notifications,
|
||||
Ntfy,
|
||||
Pushbullet,
|
||||
VoiceAssistant,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
@ -104,7 +111,6 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style lang="scss">
|
||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
<template>
|
||||
<div class="dropdown-container" ref="container">
|
||||
<div class="dropdown-container">
|
||||
<button :title="title" ref="button" @click.stop="toggle($event)">
|
||||
<i class="icon" :class="iconClass" v-if="iconClass" />
|
||||
<span class="text" v-text="text" v-if="text" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown fade-in" :id="id" :class="{hidden: !visible}" ref="dropdown">
|
||||
<slot />
|
||||
<div class="body-container hidden" ref="dropdownContainer">
|
||||
<DropdownBody :id="id" :keepOpenOnItemClick="keepOpenOnItemClick" ref="dropdown">
|
||||
<slot />
|
||||
</DropdownBody>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DropdownBody from "./DropdownBody";
|
||||
import { bus } from "@/bus";
|
||||
|
||||
export default {
|
||||
name: "Dropdown",
|
||||
components: { DropdownBody },
|
||||
emits: ['click'],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
iconClass: {
|
||||
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: {
|
||||
documentClickHndl(event) {
|
||||
if (!this.visible)
|
||||
|
@ -56,9 +89,7 @@ export default {
|
|||
|
||||
let element = event.target
|
||||
while (element) {
|
||||
if (!this.$refs.dropdown)
|
||||
break
|
||||
if (element === this.$refs.dropdown.element)
|
||||
if (element.classList.contains('dropdown'))
|
||||
return
|
||||
|
||||
element = element.parentElement
|
||||
|
@ -70,23 +101,40 @@ export default {
|
|||
close() {
|
||||
this.visible = false
|
||||
document.removeEventListener('click', this.documentClickHndl)
|
||||
bus.emit('dropdown-close')
|
||||
},
|
||||
|
||||
open() {
|
||||
document.addEventListener('click', this.documentClickHndl)
|
||||
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 element = this.$refs.dropdown
|
||||
element.style.left = 0
|
||||
element.style.top = parseFloat(getComputedStyle(this.$refs.button).height) + 'px'
|
||||
const pos = {
|
||||
left: buttonPos.left,
|
||||
top: buttonPos.top + this.buttonHeight,
|
||||
}
|
||||
|
||||
if (element.getBoundingClientRect().left > window.innerWidth/2)
|
||||
element.style.left = (-element.clientWidth + parseFloat(getComputedStyle(this.$refs.button).width)) + 'px'
|
||||
if ((pos.left + this.dropdownWidth) > (window.innerWidth + window.scrollX) / 2) {
|
||||
pos.left -= (this.dropdownWidth - this.buttonWidth)
|
||||
}
|
||||
|
||||
if (element.getBoundingClientRect().top > window.innerHeight/2)
|
||||
element.style.top = (-element.clientHeight + parseFloat(getComputedStyle(this.$refs.button).height)) + 'px'
|
||||
}, 10)
|
||||
if ((pos.top + this.dropdownHeight) > (window.innerHeight + window.scrollY) / 2) {
|
||||
pos.top -= (this.dropdownHeight + this.buttonHeight - 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) {
|
||||
|
@ -128,40 +176,5 @@ export default {
|
|||
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>
|
||||
|
|
|
@ -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>
|
||||
import Icon from "@/components/elements/Icon";
|
||||
import { bus } from "@/bus";
|
||||
|
||||
export default {
|
||||
name: "DropdownItem",
|
||||
components: {Icon},
|
||||
props: {
|
||||
iconClass: {
|
||||
|
@ -35,13 +35,12 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
clicked(event) {
|
||||
clicked() {
|
||||
if (this.disabled)
|
||||
return false
|
||||
|
||||
this.$parent.$emit('click', event)
|
||||
if (!this.$parent.keepOpenOnItemClick)
|
||||
this.$parent.visible = false
|
||||
bus.emit('dropdown-close')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,20 +49,22 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
min-width: 7.5em;
|
||||
padding: 0.75em 0.5em;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
color: $default-fg-2;
|
||||
border: 0 !important;
|
||||
box-shadow: none;
|
||||
cursor: pointer !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
background: $hover-bg !important;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
font-weight: bold;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="no-items-container">
|
||||
<div class="no-items fade-in">
|
||||
<div class="no-items fade-in" :class="{shadow: withShadow}">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,6 +9,12 @@
|
|||
<script>
|
||||
export default {
|
||||
name: "NoItems",
|
||||
props: {
|
||||
withShadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -45,7 +51,10 @@ export default {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1em;
|
||||
box-shadow: $border-shadow-bottom;
|
||||
|
||||
&.shadow {
|
||||
box-shadow: $border-shadow-bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -33,26 +33,32 @@
|
|||
@source-toggle="sources[$event] = !sources[$event]" />
|
||||
|
||||
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
|
||||
<Results :results="results" :selected-result="selectedResult" @select="onResultSelect($event)"
|
||||
@play="play" @info="$refs.mediaInfo.isVisible = true" @view="view" @download="download"
|
||||
:sources="sources" v-if="selectedView === 'search'" />
|
||||
<Results :results="results"
|
||||
:selected-result="selectedResult"
|
||||
: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'" />
|
||||
|
||||
<Browser :plugin-name="torrentPlugin" :is-media="true" :filter="browserFilter"
|
||||
@path-change="browserFilter = ''" @play="play($event)" v-else-if="selectedView === 'browser'" />
|
||||
<Browser :plugin-name="torrentPlugin"
|
||||
:is-media="true"
|
||||
:filter="browserFilter"
|
||||
@path-change="browserFilter = ''"
|
||||
@play="play($event)"
|
||||
v-else-if="selectedView === 'browser'" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</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">
|
||||
<Modal title="Available subtitles" :visible="showSubtitlesModal" ref="subtitlesSelector"
|
||||
@close="showSubtitlesModal = false">
|
||||
|
@ -65,21 +71,8 @@
|
|||
</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 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 title="Play URL" ref="playUrlModal" @open="onPlayUrlModalOpen">
|
||||
<UrlPlayer :value="urlPlay" @input="urlPlay = $event.target.value" @play="playUrl($event)" />
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,20 +83,33 @@
|
|||
import Loading from "@/components/Loading";
|
||||
import Modal from "@/components/Modal";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
import Browser from "@/components/File/Browser";
|
||||
import Header from "@/components/panels/Media/Header";
|
||||
import MediaUtils from "@/components/Media/Utils";
|
||||
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 Results from "@/components/panels/Media/Results";
|
||||
import Subtitles from "@/components/panels/Media/Subtitles";
|
||||
import TorrentView from "@/components/panels/Torrent/View";
|
||||
import Browser from "@/components/File/Browser";
|
||||
import UrlPlayer from "@/components/panels/Media/UrlPlayer";
|
||||
|
||||
export default {
|
||||
name: "Media",
|
||||
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: {
|
||||
pluginName: {
|
||||
type: String,
|
||||
|
@ -249,6 +255,18 @@ export default {
|
|||
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) {
|
||||
this.notify({
|
||||
title: 'Torrent queued for download',
|
||||
|
@ -347,19 +365,17 @@ export default {
|
|||
if (this.selectedResult == null || this.selectedResult !== result) {
|
||||
this.selectedResult = result
|
||||
this.selectedSubtitles = null
|
||||
} else {
|
||||
this.selectedResult = null
|
||||
}
|
||||
},
|
||||
|
||||
showPlayUrlModal() {
|
||||
this.$refs.playUrlModal.show()
|
||||
this.$refs.playUrlInput.value = ''
|
||||
this.$nextTick(() => {
|
||||
this.$refs.playUrlInput.value = ''
|
||||
this.$refs.playUrlInput.focus()
|
||||
})
|
||||
},
|
||||
|
||||
async playUrl(url) {
|
||||
this.urlPlay = url
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
|
@ -452,16 +468,6 @@ export default {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.media-info-container) {
|
||||
.modal-container {
|
||||
.body {
|
||||
max-width: calc(100vw - 2px);
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.subtitles-container) {
|
||||
.body {
|
||||
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>
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
<template>
|
||||
<div class="media-info">
|
||||
<div class="row header">
|
||||
<MediaImage :item="item" />
|
||||
<div class="image-container">
|
||||
<MediaImage :item="item" @play="$emit('play')" />
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<span v-else v-text="item.title" />
|
||||
</div>
|
||||
|
@ -64,9 +71,26 @@
|
|||
<div class="right side" v-text="item.status" />
|
||||
</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="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 class="row" v-if="item?.rating">
|
||||
|
@ -79,11 +103,10 @@
|
|||
<div class="right side" v-text="item.genres.join(', ')" />
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="item?.channelId">
|
||||
<div class="row" v-if="channel">
|
||||
<div class="left side">Channel</div>
|
||||
<div class="right side">
|
||||
<a :href="`https://www.youtube.com/channel/${item.channelId}`" target="_blank"
|
||||
v-text="item.channelTitle || `https://www.youtube.com/channel/${item.channelId}`" />
|
||||
<a :href="channel.url" target="_blank" v-text="channel.title || channel.url" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -92,9 +115,9 @@
|
|||
<div class="right side" v-text="item.year" />
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="item?.publishedAt">
|
||||
<div class="row" v-if="publishedDate">
|
||||
<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 class="row" v-if="item?.file">
|
||||
|
@ -140,17 +163,58 @@
|
|||
import Utils from "@/Utils";
|
||||
import MediaUtils from "@/components/Media/Utils";
|
||||
import MediaImage from "./MediaImage";
|
||||
import Icons from "./icons.json";
|
||||
|
||||
export default {
|
||||
name: "Info",
|
||||
components: {MediaImage},
|
||||
mixins: [Utils, MediaUtils],
|
||||
emits: ['play'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
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>
|
||||
|
||||
|
@ -159,10 +223,6 @@ export default {
|
|||
|
||||
.media-info {
|
||||
width: 100%;
|
||||
|
||||
@include from($tablet) {
|
||||
width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
|
@ -231,6 +291,14 @@ export default {
|
|||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.image-container {
|
||||
@include from($desktop) {
|
||||
.image-container {
|
||||
width: 420px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
font-size: 1.5em;
|
||||
|
@ -238,6 +306,11 @@ export default {
|
|||
margin-top: 0.5em;
|
||||
text-align: center;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@include from($desktop) {
|
||||
flex: 1;
|
||||
padding-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
&: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>
|
||||
<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" />
|
||||
<div class="image" v-else>
|
||||
<div class="inner">
|
||||
|
@ -7,7 +19,7 @@
|
|||
</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">
|
||||
<i class="fab fa-imdb" />
|
||||
</a>
|
||||
|
@ -19,37 +31,67 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Icons from "./icons.json";
|
||||
import MediaUtils from "@/components/Media/Utils";
|
||||
|
||||
export default {
|
||||
mixins: [MediaUtils],
|
||||
mixins: [Icons, MediaUtils],
|
||||
emits: ['play'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
typeIcons: Icons,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
|
||||
.imdb-link {
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 1em;
|
||||
font-size: 2em;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 30px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 0.25em;
|
||||
color: $default-media-img-fg;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
a {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: $default-media-img-fg;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
background: #ffff00;
|
||||
border-radius: 0.25em;
|
||||
margin: 2.5px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
@ -63,20 +105,43 @@ export default {
|
|||
border-radius: 0.25em;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
max-width: 100%;
|
||||
min-height: 200px;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
@include from($tablet) {
|
||||
height: 480px;
|
||||
&.with-image {
|
||||
background: black;
|
||||
|
||||
.icon {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div.image {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
color: $default-media-img-fg;
|
||||
font-size: 5em;
|
||||
display: flex;
|
||||
|
@ -84,14 +149,38 @@ div.image {
|
|||
justify-content: center;
|
||||
|
||||
.inner {
|
||||
width: calc(100% - 20px);
|
||||
height: calc(100% - 20px);
|
||||
min-height: 240px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $default-media-img-bg;
|
||||
display: flex;
|
||||
align-items: 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>
|
||||
|
|
|
@ -1,42 +1,46 @@
|
|||
<template>
|
||||
<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
|
||||
</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 class="row item" :class="{selected: selectedResult === i, hidden: !sources[result.type]}"
|
||||
v-for="(result, i) in results" :key="i" @click="$emit('select', i)">
|
||||
<div class="col-10 left side">
|
||||
<div class="icon">
|
||||
<i :class="typeIcons[result.type]" />
|
||||
</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>
|
||||
<Modal ref="infoModal" title="Media info" @close="$emit('select', null)">
|
||||
<Info :item="results[selectedResult]"
|
||||
@play="$emit('play', results[selectedResult])"
|
||||
v-if="selectedResult != null" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Dropdown from "@/components/elements/Dropdown";
|
||||
import DropdownItem from "@/components/elements/DropdownItem";
|
||||
import Info from "@/components/panels/Media/Info";
|
||||
import Item from "./Item";
|
||||
import Loading from "@/components/Loading";
|
||||
import Modal from "@/components/Modal";
|
||||
import NoItems from "@/components/elements/NoItems";
|
||||
|
||||
export default {
|
||||
name: "Results",
|
||||
components: {Dropdown, DropdownItem},
|
||||
emits: ['select', 'info', 'play', 'view', 'download'],
|
||||
components: {Info, Item, Loading, Modal, NoItems},
|
||||
emits: ['select', 'play', 'view', 'download'],
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
results: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
|
@ -52,22 +56,20 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
typeIcons: {
|
||||
'file': 'fa fa-hdd',
|
||||
'torrent': 'fa fa-magnet',
|
||||
'youtube': 'fab fa-youtube',
|
||||
'plex': 'fa fa-plex',
|
||||
'jellyfin': 'fa fa-jellyfin',
|
||||
},
|
||||
}
|
||||
mounted() {
|
||||
this.$watch('selectedResult', (value) => {
|
||||
if (value == null)
|
||||
this.$refs.infoModal?.close()
|
||||
else
|
||||
this.$refs.infoModal?.show()
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "src/style/items";
|
||||
@import "vars";
|
||||
|
||||
.media-results {
|
||||
width: 100%;
|
||||
|
@ -75,55 +77,9 @@ export default {
|
|||
background: $background-color;
|
||||
overflow: auto;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.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;
|
||||
}
|
||||
.info-container {
|
||||
width: 100%;
|
||||
cursor: initial;
|
||||
}
|
||||
}
|
||||
</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;
|
||||
$default-media-img-bg: #d0dad8;
|
||||
$default-media-img-fg: white;
|
||||
|
||||
.fa-youtube {
|
||||
color: #d21;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@use "sass:math";
|
||||
|
||||
$tablet-small: 640px;
|
||||
$tablet: 769px;
|
||||
$desktop: 1024px;
|
||||
$widescreen: 1216px;
|
||||
|
@ -13,6 +14,7 @@ $widths: (
|
|||
);
|
||||
|
||||
$width-ranges: (
|
||||
tablet-small: ($tablet-small, $tablet),
|
||||
tablet: ($tablet, $desktop),
|
||||
desktop: ($desktop, $widescreen),
|
||||
widescreen: ($widescreen, $fullhd),
|
||||
|
@ -227,3 +229,32 @@ $width-ranges: (
|
|||
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-right: 2.5px 0 4px 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;
|
||||
$group-shadow: 3px -2px 6px 1px #98b0a0;
|
||||
$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,
|
||||
volume: Optional[Union[float, int]] = None,
|
||||
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_dl: str = 'yt-dlp',
|
||||
**kwargs,
|
||||
|
|
|
@ -1,27 +1,28 @@
|
|||
import datetime
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
PrimaryKeyConstraint,
|
||||
ForeignKey,
|
||||
)
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from platypush.common.db import declarative_base
|
||||
from platypush.config import Config
|
||||
from platypush.plugins.media import MediaPlugin
|
||||
from platypush.plugins.media.search import MediaSearcher
|
||||
|
||||
Base = declarative_base()
|
||||
Session = scoped_session(sessionmaker())
|
||||
from .db import (
|
||||
Base,
|
||||
MediaDirectory,
|
||||
MediaFile,
|
||||
MediaFileToken,
|
||||
MediaToken,
|
||||
Session,
|
||||
)
|
||||
|
||||
from .metadata import get_metadata
|
||||
|
||||
_db_lock = threading.RLock()
|
||||
|
||||
|
||||
class LocalMediaSearcher(MediaSearcher):
|
||||
|
@ -37,7 +38,7 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
def __init__(self, dirs, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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)
|
||||
self.db_file = os.path.join(db_dir, 'media.db')
|
||||
self._db_engine = None
|
||||
|
@ -142,6 +143,7 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
).delete(synchronize_session='fetch')
|
||||
return
|
||||
|
||||
media_files = []
|
||||
stored_file_records = {
|
||||
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):
|
||||
continue
|
||||
|
||||
self.logger.debug('Syncing item {}'.format(filepath))
|
||||
self.logger.info('Scanning item %s', filepath)
|
||||
tokens = [
|
||||
_.lower()
|
||||
for _ in re.split(self._filename_separators, filename.strip())
|
||||
|
@ -170,9 +172,9 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
|
||||
token_records = self._sync_token_records(session, *tokens)
|
||||
file_record = MediaFile.build(directory_id=dir_record.id, path=filepath)
|
||||
|
||||
session.add(file_record)
|
||||
session.commit()
|
||||
|
||||
file_record = (
|
||||
session.query(MediaFile)
|
||||
.filter_by(directory_id=dir_record.id, path=filepath)
|
||||
|
@ -185,6 +187,8 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
)
|
||||
session.add(file_token)
|
||||
|
||||
media_files.append(file_record)
|
||||
|
||||
# stored_file_records should now only contain the records of the files
|
||||
# that have been removed from the directory
|
||||
if stored_file_records:
|
||||
|
@ -198,7 +202,7 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
MediaFile.id.in_([record.id for record in stored_file_records.values()])
|
||||
).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(
|
||||
'Scanned {} in {} seconds'.format(
|
||||
media_dir, int(time.time() - index_start_time)
|
||||
|
@ -207,7 +211,32 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
|
||||
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
|
||||
built-in SQLite index if available. If any directory has changed since
|
||||
|
@ -218,123 +247,46 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
session = self._get_db_session()
|
||||
results = {}
|
||||
|
||||
for media_dir in self.dirs:
|
||||
self.logger.info('Searching {} for "{}"'.format(media_dir, query))
|
||||
dir_record = self._get_or_create_dir_entry(session, media_dir)
|
||||
with _db_lock:
|
||||
for media_dir in self.dirs:
|
||||
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):
|
||||
self.logger.info(
|
||||
'{} has changed since last indexing, '.format(media_dir)
|
||||
+ 're-indexing'
|
||||
)
|
||||
if self._has_directory_changed_since_last_indexing(dir_record):
|
||||
self.logger.info(
|
||||
'{} has changed since last indexing, '.format(media_dir)
|
||||
+ 're-indexing'
|
||||
)
|
||||
|
||||
self.scan(media_dir, session=session, dir_record=dir_record)
|
||||
self.scan(media_dir, session=session, dir_record=dir_record)
|
||||
|
||||
query_tokens = [
|
||||
_.lower() for _ in re.split(self._filename_separators, query.strip())
|
||||
]
|
||||
query_tokens = [
|
||||
_.lower()
|
||||
for _ in re.split(self._filename_separators, query.strip())
|
||||
]
|
||||
|
||||
for file_record in (
|
||||
session.query(MediaFile.path)
|
||||
.join(MediaFileToken)
|
||||
.join(MediaToken)
|
||||
.filter(MediaToken.token.in_(query_tokens))
|
||||
.group_by(MediaFile.path)
|
||||
.having(func.count(MediaFileToken.token_id) >= len(query_tokens))
|
||||
):
|
||||
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),
|
||||
}
|
||||
for file_record in session.query(MediaFile).where(
|
||||
MediaFile.id.in_(
|
||||
session.query(MediaFile.id)
|
||||
.join(MediaFileToken)
|
||||
.join(MediaToken)
|
||||
.filter(MediaToken.token.in_(query_tokens))
|
||||
.group_by(MediaFile.id)
|
||||
.having(
|
||||
func.count(MediaFileToken.token_id) >= len(query_tokens)
|
||||
)
|
||||
)
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
# --- 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'),
|
||||
'trailer': result.get('trailer'),
|
||||
'genres': result.get('genres', []),
|
||||
'images': result.get('images', []),
|
||||
'image': result.get('images', {}).get('poster'),
|
||||
'rating': result.get('rating', {}),
|
||||
'language': lang,
|
||||
'quality': quality,
|
||||
|
@ -235,7 +235,7 @@ class TorrentPlugin(Plugin):
|
|||
+ f'[S{episode.get("season", 0):02d}E{episode.get("episode", 0):02d}] '
|
||||
+ 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),
|
||||
'synopsis': result.get('synopsis'),
|
||||
'overview': episode.get('overview'),
|
||||
|
@ -246,7 +246,7 @@ class TorrentPlugin(Plugin):
|
|||
'network': result.get('network'),
|
||||
'status': result.get('status'),
|
||||
'genres': result.get('genres', []),
|
||||
'images': result.get('images', []),
|
||||
'image': result.get('images', {}).get('fanart'),
|
||||
'rating': result.get('rating', {}),
|
||||
'quality': quality,
|
||||
'provider': item.get('provider'),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import datetime
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
|
@ -61,6 +62,14 @@ class YoutubePlugin(Plugin):
|
|||
"image": item.get("thumbnail"),
|
||||
"duration": item.get("duration", 0),
|
||||
"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", [])
|
||||
]
|
||||
|
|
|
@ -74,6 +74,7 @@ class JellyfinCollectionSchema(JellyfinSchema, MediaCollectionSchema):
|
|||
|
||||
|
||||
class JellyfinVideoSchema(JellyfinSchema, MediaVideoSchema):
|
||||
duration = fields.Number(attribute='RunTimeTicks')
|
||||
community_rating = fields.Number(attribute='CommunityRating')
|
||||
critic_rating = fields.Number(attribute='CriticRating')
|
||||
|
||||
|
@ -82,6 +83,18 @@ class JellyfinVideoSchema(JellyfinSchema, MediaVideoSchema):
|
|||
self.fields['year'].attribute = 'ProductionYear'
|
||||
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):
|
||||
pass
|
||||
|
|
Loading…
Reference in a new issue