Added experimental video player.
This commit is contained in:
@@ -30,7 +30,7 @@ ENV FPM_PORT="${PHP_FPM_PORT}"
|
||||
#
|
||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
|
||||
for ext in ${PHP_PACKAGES}; do PACKAGES="${PACKAGES} ${PHP_V}-${ext}"; done && \
|
||||
apk add --no-cache bash caddy icu-data-full nano curl procps net-tools iproute2 \
|
||||
apk add --no-cache bash caddy icu-data-full nano curl procps net-tools iproute2 ffmpeg \
|
||||
shadow sqlite redis tzdata gettext fcgi ca-certificates nss mailcap libcap ${PHP_V} ${PACKAGES} && \
|
||||
# Basic setup
|
||||
echo '' && \
|
||||
|
||||
16
README.md
16
README.md
@@ -9,6 +9,22 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.
|
||||
|
||||
## Updates
|
||||
|
||||
### 2024-08-10
|
||||
|
||||
I have recently added new experimental feature, to play your content directly from the WebUI. This feature is still in
|
||||
alpha, and missing a lot of features. But it's a start. Right now it does auto transcode on the fly to play any content in the browser.
|
||||
|
||||
The feature requires that you mount your media directories to the `WatchState` container similar to the `File integrity` feature. I have plans to expand
|
||||
the feature to support more controls, however, right now it's only support basic subtitles streams and default audio stream or first audio stream.
|
||||
|
||||
The transcoder works by converting the media on the fly to `HLS` segments, and the subtitles are selectable via the player ui which are also converted to `vtt` format.
|
||||
|
||||
Expects bugs and issues, as the feature is still in alpha. But I would love to hear your feedback. You can play the media by visiting
|
||||
the history page of the item you will see red play button on top right corner of the page. If the items has a play button, then you correctly mounted
|
||||
the media directories. otherwise, the button be disabled with tooltip of `Media is inaccessible`.
|
||||
|
||||
The feature is not meant to replace your backend media player, the purpose of this feature is to quickly check the media without leaving the WebUI.
|
||||
|
||||
### 2024-08-01
|
||||
|
||||
We recently enabled listening on tls connections via `8443` which can be controlled by `HTTPS_PORT` environment variable.
|
||||
|
||||
131
config/languageCodes.php
Normal file
131
config/languageCodes.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
return (function (): array {
|
||||
return [
|
||||
'short' => [
|
||||
'ab' => 'abk',
|
||||
'aa' => 'aar',
|
||||
'af' => 'afr',
|
||||
'ak' => 'aka',
|
||||
'sq' => 'alb',
|
||||
'am' => 'amh',
|
||||
'ar' => 'ara',
|
||||
'an' => 'arg',
|
||||
'hy' => 'arm',
|
||||
'as' => 'asm',
|
||||
'av' => 'ava',
|
||||
'ae' => 'ave',
|
||||
'ay' => 'aym',
|
||||
'az' => 'aze',
|
||||
'bm' => 'bam',
|
||||
'ba' => 'bak',
|
||||
'eu' => 'baq',
|
||||
'be' => 'bel',
|
||||
'bn' => 'ben',
|
||||
'bh' => 'bih',
|
||||
'bi' => 'bis',
|
||||
'bs' => 'bos',
|
||||
'br' => 'bre',
|
||||
'bg' => 'bul',
|
||||
'my' => 'bur',
|
||||
'ca' => 'cat',
|
||||
'ch' => 'cha',
|
||||
'ce' => 'che',
|
||||
'ny' => 'nya',
|
||||
'zh' => 'chi',
|
||||
'cv' => 'chv',
|
||||
'kw' => 'cor',
|
||||
'co' => 'cos',
|
||||
'cr' => 'cre',
|
||||
'hr' => 'hrv',
|
||||
'cs' => 'cze',
|
||||
'da' => 'dan',
|
||||
'dv' => 'div',
|
||||
'nl' => 'dut',
|
||||
'dz' => 'dzo',
|
||||
'en' => 'eng',
|
||||
'eo' => 'epo',
|
||||
'et' => 'est',
|
||||
'ee' => 'ewe',
|
||||
'fo' => 'fao',
|
||||
'fj' => 'fij',
|
||||
'fi' => 'fin',
|
||||
'fr' => 'fre',
|
||||
'ff' => 'ful',
|
||||
'gl' => 'glg',
|
||||
'ka' => 'geo',
|
||||
'de' => 'ger',
|
||||
'el' => 'gre',
|
||||
'gn' => 'grn',
|
||||
'gu' => 'guj',
|
||||
'ht' => 'hat',
|
||||
'ha' => 'hau',
|
||||
'he' => 'heb',
|
||||
'hz' => 'her',
|
||||
'hi' => 'hin',
|
||||
],
|
||||
'names' => [
|
||||
'abk' => 'Abkhazian',
|
||||
'aar' => 'Afar',
|
||||
'afr' => 'Afrikaans',
|
||||
'aka' => 'Akan',
|
||||
'alb' => 'Albanian',
|
||||
'amh' => 'Amharic',
|
||||
'ara' => 'Arabic',
|
||||
'arg' => 'Aragonese',
|
||||
'arm' => 'Armenian',
|
||||
'asm' => 'Assamese',
|
||||
'ava' => 'Avaric',
|
||||
'ave' => 'Avestan',
|
||||
'aym' => 'Aymara',
|
||||
'aze' => 'Azerbaijani',
|
||||
'bam' => 'Bambara',
|
||||
'bak' => 'Bashkir',
|
||||
'baq' => 'Basque',
|
||||
'bel' => 'Belarusian',
|
||||
'ben' => 'Bengali',
|
||||
'bih' => 'Bihari',
|
||||
'bis' => 'Bislama',
|
||||
'bos' => 'Bosnian',
|
||||
'bre' => 'Breton',
|
||||
'bul' => 'Bulgarian',
|
||||
'bur' => 'Burmese',
|
||||
'cat' => 'Catalan',
|
||||
'cha' => 'Chamorro',
|
||||
'che' => 'Chechen',
|
||||
'nya' => 'Chichewa',
|
||||
'chi' => 'Chinese',
|
||||
'chv' => 'Chuvash',
|
||||
'cor' => 'Cornish',
|
||||
'cos' => 'Corsican',
|
||||
'cre' => 'Cree',
|
||||
'hrv' => 'Croatian',
|
||||
'cze' => 'Czech',
|
||||
'dan' => 'Danish',
|
||||
'div' => 'Divehi',
|
||||
'dut' => 'Dutch',
|
||||
'dzo' => 'Dzongkha',
|
||||
'eng' => 'English',
|
||||
'epo' => 'Esperanto',
|
||||
'est' => 'Estonian',
|
||||
'ewe' => 'Ewe',
|
||||
'fao' => 'Faroese',
|
||||
'fij' => 'Fijian',
|
||||
'fin' => 'Finnish',
|
||||
'fre' => 'French',
|
||||
'ful' => 'Fulah',
|
||||
'glg' => 'Gal',
|
||||
'geo' => 'Georgian',
|
||||
'ger' => 'German',
|
||||
'gre' => 'Greek',
|
||||
'grn' => 'Guarani',
|
||||
'guj' => 'Gujarati',
|
||||
'hat' => 'Haitian',
|
||||
'hau' => 'Hausa',
|
||||
'heb' => 'Hebrew',
|
||||
'her' => 'Herero',
|
||||
'hin' => 'Hindi',
|
||||
'und' => 'Undetermined',
|
||||
],
|
||||
];
|
||||
})();
|
||||
138
frontend/components/Player.vue
Normal file
138
frontend/components/Player.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<!--suppress CssUnusedSymbol, CssInvalidPseudoSelector -->
|
||||
<style>
|
||||
:root {
|
||||
--plyr-captions-background: rgba(0, 0, 0, 0.6);
|
||||
--plyr-captions-text-color: #f3db4d;
|
||||
--webkit-text-track-display: none;
|
||||
}
|
||||
|
||||
.plyr__caption {
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-size: 140%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.plyr--full-ui ::-webkit-media-text-track-container {
|
||||
display: var(--webkit-text-track-display);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<video ref="video" :poster="poster" :controls="isControls" :title="title" preload="auto">
|
||||
<source :src="link" type="application/x-mpegURL"/>
|
||||
</video>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Hls from 'hls.js'
|
||||
import 'plyr/dist/plyr.css'
|
||||
import {notification} from '~/utils/index'
|
||||
import request from '~/utils/request'
|
||||
import Plyr from 'plyr'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
poster: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
isControls: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
debug: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
reference: {},
|
||||
})
|
||||
|
||||
const video = ref(null)
|
||||
/** @type {Plyr} */
|
||||
let player;
|
||||
/** @type {Hls} */
|
||||
let hls;
|
||||
const poster = ref()
|
||||
|
||||
const destroyPlayer = () => {
|
||||
console.debug('Destroying video player');
|
||||
if (player) {
|
||||
player.destroy()
|
||||
}
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
|
||||
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
|
||||
}
|
||||
Promise.all([getPoster(), prepareVideoPlayer()])
|
||||
})
|
||||
onUpdated(() => prepareVideoPlayer())
|
||||
onUnmounted(() => destroyPlayer())
|
||||
|
||||
const getPoster = async () => {
|
||||
if (props.poster) {
|
||||
const cb = props.poster.startsWith('/') ? request : fetch;
|
||||
const response = await cb(props.poster)
|
||||
|
||||
if (200 === response.status) {
|
||||
poster.value = URL.createObjectURL(await response.blob());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prepareVideoPlayer = async () => {
|
||||
player = new Plyr(video.value, {
|
||||
debug: props.debug,
|
||||
clickToPlay: true,
|
||||
autoplay: true,
|
||||
controls: [
|
||||
'play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'
|
||||
],
|
||||
keyboard: {focused: true, global: true},
|
||||
fullscreen: {
|
||||
enabled: true,
|
||||
fallback: true,
|
||||
iosNative: true,
|
||||
},
|
||||
storage: {
|
||||
enabled: true,
|
||||
key: 'plyr'
|
||||
},
|
||||
|
||||
mediaMetadata: {
|
||||
title: props.title
|
||||
},
|
||||
captions: {active: true, update: true, language: 'auto'},
|
||||
});
|
||||
|
||||
hls = new Hls({
|
||||
debug: props.debug,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 98,
|
||||
fragLoadingTimeOut: 100000,
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
console.warn(data);
|
||||
notification('warning', 'HLS.js', `HLs Error: ${data.error ?? 'Unknown error'}`);
|
||||
});
|
||||
|
||||
hls.loadSource(props.link)
|
||||
|
||||
if (video.value) {
|
||||
hls.attachMedia(video.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -22,7 +22,9 @@
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"plyr": "^3.7.8",
|
||||
"hls.js": "^1.4.14"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
</span>
|
||||
<div class="is-pulled-right" v-if="data?.via">
|
||||
<div class="field is-grouped">
|
||||
<p class="control" v-if="data?.content_path">
|
||||
<button @click="navigateTo(`/play/${data.id}`)" class="button has-text-white has-background-danger-50"
|
||||
v-tooltip.bottom="`${data.content_exists ? 'Play media' : 'Media is inaccessible'}`"
|
||||
:disabled="!data.content_exists">
|
||||
<span class="icon"><i class="fas fa-play"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button" @click="toggleWatched"
|
||||
:class="{ 'is-success': !data.watched, 'is-danger': data.watched }"
|
||||
|
||||
153
frontend/pages/play/[id].vue
Normal file
153
frontend/pages/play/[id].vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon"><i class="fas fa-play"></i></span>
|
||||
Play :
|
||||
<template v-if="item">{{ makeName(item) }}</template>
|
||||
<template v-else>{{ id }}</template>
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle" v-if="!isPlaying && urls.length > 0">
|
||||
Select video file to play.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!isPlaying">
|
||||
<Message v-if="isLoading" message_class="is-background-info-90 has-text-dark" icon="fas fa-spinner fa-spin"
|
||||
title="Loading" message="Loading data. Please wait..."/>
|
||||
|
||||
<Message v-if="!isLoading && urls.length < 1" title="Warning"
|
||||
message_class="is-background-warning-80 has-text-dark"
|
||||
icon="fas fa-exclamation-triangle">
|
||||
No video URLs were found.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
|
||||
<div class="column is-12" v-if="isPlaying">
|
||||
<Player :link="playUrl"/>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!isPlaying">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select v-model="selectedUrl">
|
||||
<option value="">Select video file</option>
|
||||
<template v-for="item in urls" :key="item.source">
|
||||
<optgroup :label="item.source">
|
||||
<option :value="item.url" v-text="basename(item.url)"/>
|
||||
</optgroup>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-primary" @click="generateToken" :disabled="'' === selectedUrl"
|
||||
:class="{'is-loading':isGenerating}">
|
||||
<span class="icon"><i class="fas fa-play"></i></span>
|
||||
<span>Play</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Message from '~/components/Message'
|
||||
import {basename, makeName, notification} from '~/utils/index'
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import Player from "~/components/Player.vue";
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const id = route.params.id
|
||||
const item = ref({})
|
||||
const urls = ref([])
|
||||
const isLoading = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const selectedUrl = ref('')
|
||||
const playUrl = ref('')
|
||||
|
||||
const loadContent = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await request(`/history/${id}`)
|
||||
const json = await response.json()
|
||||
item.value = json
|
||||
let vUrls = [];
|
||||
for (const key in json.metadata) {
|
||||
if ('path' in json.metadata[key]) {
|
||||
const url = json.metadata[key]['path']
|
||||
const index = vUrls.findIndex((v) => v.url === url)
|
||||
|
||||
if (index === -1) {
|
||||
vUrls.push({source: key, url: url})
|
||||
} else {
|
||||
vUrls[index].source = `${vUrls[index].source}, ${key}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (1 === vUrls.length) {
|
||||
selectedUrl.value = vUrls[0].url
|
||||
await generateToken()
|
||||
return
|
||||
}
|
||||
|
||||
urls.value = vUrls;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notification('error', 'Error', 'Failed to load item.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateToken = async () => {
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const response = await request(`/system/sign/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({path: selectedUrl.value}),
|
||||
})
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
if (200 !== response.status) {
|
||||
notification('error', 'Token generation', 'Failed to generate token.')
|
||||
return;
|
||||
}
|
||||
|
||||
const api_path = useStorage('api_path', '/v1/api').value
|
||||
const api_url = useStorage('api_url', '').value
|
||||
|
||||
let url = `${api_url}${api_path}/player/playlist/${json.token}/master.m3u8`
|
||||
if (true === json?.secure) {
|
||||
url = `${url}?apikey=${useStorage('api_token', '').value}`
|
||||
}
|
||||
playUrl.value = url
|
||||
isPlaying.value = true
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notification('error', 'Error', 'Failed to generate token.')
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
onMounted(async () => await loadContent())
|
||||
</script>
|
||||
@@ -450,6 +450,20 @@ const explode = (delimiter, string, limit = undefined) => {
|
||||
return parts.slice(0, limit);
|
||||
}
|
||||
}
|
||||
const basename = (path, ext = '') => {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
const segments = path.replace(/\\/g, '/').split('/')
|
||||
let base = segments.pop()
|
||||
while (segments.length && base === '') {
|
||||
base = segments.pop()
|
||||
}
|
||||
if (ext && base.endsWith(ext) && base !== ext) {
|
||||
base = base.substring(0, base.length - ext.length)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
export {
|
||||
r,
|
||||
@@ -471,4 +485,5 @@ export {
|
||||
TOOLTIP_DATE_FORMAT,
|
||||
makeSecret,
|
||||
explode,
|
||||
basename
|
||||
}
|
||||
|
||||
@@ -2463,6 +2463,11 @@ copy-anything@^3.0.2:
|
||||
dependencies:
|
||||
is-what "^4.1.8"
|
||||
|
||||
core-js@^3.26.1:
|
||||
version "3.38.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.38.0.tgz#8acb7c050bf2ccbb35f938c0d040132f6110f636"
|
||||
integrity sha512-XPpwqEodRljce9KswjZShh95qJ1URisBeKCjUdq27YdenkslVe7OO0ZJhlYXAChW7OhXaRLl8AAba7IBfoIHug==
|
||||
|
||||
core-util-is@~1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
|
||||
@@ -2613,6 +2618,11 @@ csstype@^3.1.3:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
custom-event-polyfill@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz#9bc993ddda937c1a30ccd335614c6c58c4f87aee"
|
||||
integrity sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==
|
||||
|
||||
db0@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/db0/-/db0-0.1.4.tgz#8df1d9600b812bad0b4129ccbbb7f1b8596a5817"
|
||||
@@ -3271,6 +3281,11 @@ hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hls.js@^1.4.14:
|
||||
version "1.5.14"
|
||||
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.14.tgz#1d52be309a06aab25fee667c4969d8abf9f8fe59"
|
||||
integrity sha512-5wLiQ2kWJMui6oUslaq8PnPOv1vjuee5gTxjJD0DSsccY12OXtDT0h137UuqjczNeHzeEYR0ROZQibKNMr7Mzg==
|
||||
|
||||
hookable@^5.5.3:
|
||||
version "5.5.3"
|
||||
resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d"
|
||||
@@ -3730,6 +3745,11 @@ listhen@^1.7.2:
|
||||
untun "^0.1.3"
|
||||
uqr "^0.1.2"
|
||||
|
||||
loadjs@^4.2.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/loadjs/-/loadjs-4.3.0.tgz#38c578cbb2e08835aa4407bd4ac6507dd1f7ed10"
|
||||
integrity sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==
|
||||
|
||||
local-pkg@^0.4.3:
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
|
||||
@@ -4592,6 +4612,17 @@ pkg-types@^1.0.3, pkg-types@^1.1.1:
|
||||
mlly "^1.7.0"
|
||||
pathe "^1.1.2"
|
||||
|
||||
plyr@^3.7.8:
|
||||
version "3.7.8"
|
||||
resolved "https://registry.yarnpkg.com/plyr/-/plyr-3.7.8.tgz#b79bccc23687705b5d9a283b2a88c124bf7471ed"
|
||||
integrity sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==
|
||||
dependencies:
|
||||
core-js "^3.26.1"
|
||||
custom-event-polyfill "^1.0.7"
|
||||
loadjs "^4.2.0"
|
||||
rangetouch "^2.0.1"
|
||||
url-polyfill "^1.1.12"
|
||||
|
||||
postcss-calc@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-10.0.0.tgz#aca29a1c66dd481ca30d08f6932b1274a1003716"
|
||||
@@ -4909,6 +4940,11 @@ range-parser@~1.2.1:
|
||||
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
||||
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
||||
|
||||
rangetouch@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/rangetouch/-/rangetouch-2.0.1.tgz#c01105110fd3afca2adcb1a580692837d883cb70"
|
||||
integrity sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==
|
||||
|
||||
rc9@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/rc9/-/rc9-2.1.2.tgz#6282ff638a50caa0a91a31d76af4a0b9cbd1080d"
|
||||
@@ -5775,6 +5811,11 @@ uqr@^0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/uqr/-/uqr-0.1.2.tgz#5c6cd5dcff9581f9bb35b982cb89e2c483a41d7d"
|
||||
integrity sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==
|
||||
|
||||
url-polyfill@^1.1.12:
|
||||
version "1.1.12"
|
||||
resolved "https://registry.yarnpkg.com/url-polyfill/-/url-polyfill-1.1.12.tgz#6cdaa17f6b022841b3aec0bf8dbd87ac0cd33331"
|
||||
integrity sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==
|
||||
|
||||
urlpattern-polyfill@8.0.2:
|
||||
version "8.0.2"
|
||||
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5"
|
||||
|
||||
@@ -48,8 +48,8 @@ final class Index
|
||||
$this->pdo = $this->db->getPDO();
|
||||
}
|
||||
|
||||
#[Get(self::URL . '[/]', name: 'history')]
|
||||
public function historyIndex(iRequest $request): iResponse
|
||||
#[Get(self::URL . '[/]', name: 'history.list')]
|
||||
public function list(iRequest $request): iResponse
|
||||
{
|
||||
$es = fn(string $val) => $this->db->identifier($val);
|
||||
$data = DataUtil::fromArray($request->getQueryParams());
|
||||
@@ -449,29 +449,27 @@ final class Index
|
||||
return api_response(Status::OK, $response);
|
||||
}
|
||||
|
||||
#[Get(self::URL . '/{id:\d+}[/]', name: 'history.view')]
|
||||
public function historyView(iRequest $request, array $args = []): iResponse
|
||||
#[Get(self::URL . '/{id:\d+}[/]', name: 'history.read')]
|
||||
public function read(string $id): iResponse
|
||||
{
|
||||
if (null === ($id = ag($args, 'id'))) {
|
||||
return api_error('Invalid value for id path parameter.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]);
|
||||
|
||||
if (null === ($item = $this->db->get($entity))) {
|
||||
return api_error('Not found', Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
return api_response(Status::OK, $this->formatEntity($item));
|
||||
}
|
||||
$entity = $this->formatEntity($item);
|
||||
|
||||
#[Delete(self::URL . '/{id:\d+}[/]', name: 'history.item.delete')]
|
||||
public function historyDelete(iRequest $request, array $args = []): iResponse
|
||||
{
|
||||
if (null === ($id = ag($args, 'id'))) {
|
||||
return api_error('Invalid value for id path parameter.', Status::BAD_REQUEST);
|
||||
if (!empty($entity['content_path'])) {
|
||||
$entity['content_exists'] = file_exists($entity['content_path']);
|
||||
}
|
||||
|
||||
return api_response(Status::OK, $entity);
|
||||
}
|
||||
|
||||
#[Delete(self::URL . '/{id:\d+}[/]', name: 'history.delete')]
|
||||
public function delete(string $id): iResponse
|
||||
{
|
||||
$entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]);
|
||||
|
||||
if (null === ($item = $this->db->get($entity))) {
|
||||
@@ -484,12 +482,8 @@ final class Index
|
||||
}
|
||||
|
||||
#[Route(['GET', 'POST', 'DELETE'], self::URL . '/{id:\d+}/watch[/]', name: 'history.watch')]
|
||||
public function historyPlayStatus(iRequest $request, array $args = []): iResponse
|
||||
public function changePlayState(iRequest $request, string $id): iResponse
|
||||
{
|
||||
if (null === ($id = ag($args, 'id'))) {
|
||||
return api_error('Invalid value for id path parameter.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]);
|
||||
|
||||
if (null === ($item = $this->db->get($entity))) {
|
||||
@@ -519,6 +513,6 @@ final class Index
|
||||
|
||||
queuePush($item);
|
||||
|
||||
return $this->historyView($request, $args);
|
||||
return $this->read($id);
|
||||
}
|
||||
}
|
||||
|
||||
82
src/API/Player/M3u8.php
Normal file
82
src/API/Player/M3u8.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\Player;
|
||||
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Stream;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
|
||||
final readonly class M3u8
|
||||
{
|
||||
public const string URL = '%{api.prefix}/player/m3u8';
|
||||
|
||||
public function __construct(private iCache $cache)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(pattern: self::URL . '/{token}[/[{fake:.*}]]')]
|
||||
public function __invoke(iRequest $request, string $token): iResponse
|
||||
{
|
||||
if (null === ($data = $this->cache->get($token, null))) {
|
||||
return api_error('Token is expired or invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($request->hasHeader('if-modified-since')) {
|
||||
return api_response(Status::NOT_MODIFIED, headers: ['Cache-Control' => 'public, max-age=25920000']);
|
||||
}
|
||||
|
||||
$isSecure = (bool)Config::get('api.secure', false);
|
||||
$duration = ag($data, 'config.duration');
|
||||
$segmentSize = number_format((float)ag($data, 'config.segment_size'), 6);
|
||||
$splits = (int)ceil($duration / $segmentSize);
|
||||
|
||||
$lines[] = "#EXTM3U";
|
||||
$lines[] = r("#EXT-X-TARGETDURATION:{duration}", ['duration' => $segmentSize]);
|
||||
$lines[] = "#EXT-X-VERSION:4";
|
||||
$lines[] = "#EXT-X-MEDIA-SEQUENCE:0";
|
||||
$lines[] = "#EXT-X-PLAYLIST-TYPE:VOD";
|
||||
|
||||
$segmentUrl = parseConfigValue(Segments::URL);
|
||||
for ($i = 0; $i < $splits; $i++) {
|
||||
$sSize = ($i + 1) === $splits ? number_format($duration - (($i * $segmentSize)), 6) : $segmentSize;
|
||||
$lines[] = r("#EXTINF:{duration},", ['duration' => $sSize]);
|
||||
|
||||
$query = [];
|
||||
|
||||
if ($isSecure) {
|
||||
$query['apikey'] = Config::get('api.key');
|
||||
}
|
||||
|
||||
if ($sSize !== $segmentSize) {
|
||||
$query['sd'] = $sSize;
|
||||
}
|
||||
|
||||
$lines[] = r('{api_url}/{token}/{seg}.ts{query}', [
|
||||
'api_url' => $segmentUrl,
|
||||
'token' => $token,
|
||||
'seg' => (string)$i,
|
||||
'query' => !empty($query) ? '?' . http_build_query($query) : '',
|
||||
]);
|
||||
}
|
||||
|
||||
$lines[] = "#EXT-X-ENDLIST";
|
||||
|
||||
return api_response(Status::OK, Stream::create(implode("\n", $lines)), [
|
||||
'Content-Type' => 'application/x-mpegurl',
|
||||
'Pragma' => 'public',
|
||||
'Cache-Control' => sprintf('public, max-age=%s', time() + 31536000),
|
||||
'Last-Modified' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time())),
|
||||
'Expires' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time() + 31536000)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
218
src/API/Player/Playlist.php
Normal file
218
src/API/Player/Playlist.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\Player;
|
||||
|
||||
use App\API\System\Sign;
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Stream;
|
||||
use DateInterval;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use SplFileInfo;
|
||||
use Throwable;
|
||||
|
||||
readonly class Playlist
|
||||
{
|
||||
public const string URL = '%{api.prefix}/player/playlist';
|
||||
public const float SEGMENT_DUR = 6.000;
|
||||
private const array ALLOWED_SUBS = ['vtt', 'srt', 'ass'];
|
||||
|
||||
public function __construct(private iCache $cache, private iLogger $logger)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(pattern: self::URL . '/{token}[/[{fake:.*}[/]]]')]
|
||||
public function __invoke(iRequest $request, string $token): iResponse
|
||||
{
|
||||
if (null === ($data = $this->cache->get($token, null))) {
|
||||
return api_error('Token is expired or invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$params = DataUtil::fromRequest($request);
|
||||
|
||||
$sConfig = (array)ag($data, 'config', []);
|
||||
|
||||
if (null === ($path = ag($data, 'path', null))) {
|
||||
return api_error('Path is empty.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$path = rawurldecode($path);
|
||||
|
||||
if ($params->get('debug')) {
|
||||
$sConfig['debug'] = true;
|
||||
}
|
||||
|
||||
$lc = require __DIR__ . '/../../../config/languageCodes.php';
|
||||
|
||||
$isSecure = (bool)Config::get('api.secure', false);
|
||||
|
||||
try {
|
||||
$ffprobe = ffprobe_file($path, $this->cache);
|
||||
|
||||
if (null === ($duration = ag($ffprobe, 'format.duration'))) {
|
||||
return api_error('format.duration is empty. probably corrupted file.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$sConfig['duration'] = $duration;
|
||||
$sConfig['externals'] = [];
|
||||
$sConfig['segment_size'] = number_format((float)$params->get('sd', self::SEGMENT_DUR), 6);
|
||||
|
||||
// -- Include sidecar subtitles in the playlist.
|
||||
foreach (findSideCarFiles(new SplFileInfo($path)) as $sideFile) {
|
||||
$extension = getExtension($sideFile);
|
||||
|
||||
if (false === in_array($extension, self::ALLOWED_SUBS)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
preg_match('#\.(\w{2,3})\.\w{3}$#', $sideFile, $lang);
|
||||
$sConfig['externals'][] = [
|
||||
'path' => $sideFile,
|
||||
'title' => 'External',
|
||||
'language' => strtolower($lang[1] ?? 'und'),
|
||||
'forced' => false,
|
||||
'codec' => [
|
||||
'short' => afterLast($sideFile, '.'),
|
||||
'long' => 'text/' . afterLast($sideFile, '.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
foreach (ag($ffprobe, 'streams', []) as $id => $stream) {
|
||||
if ('audio' === ag($stream, 'codec_type') && true === ag($stream, 'disposition.default', false)) {
|
||||
$sConfig['audio'] = (int)$id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -- if no default audio stream, pick the first audio stream.
|
||||
if (!ag_exists($sConfig, 'audio')) {
|
||||
foreach (ag($ffprobe, 'streams', []) as $id => $stream) {
|
||||
if ('audio' === ag($stream, 'codec_type')) {
|
||||
$sConfig['audio'] = (int)$id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sConfig['token'] = $token;
|
||||
$data['config'] = $sConfig;
|
||||
|
||||
Sign::update($token, $data, new DateInterval(ag($data, 'time')), $this->cache);
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '#EXTM3U';
|
||||
|
||||
$subtitleUrl = parseConfigValue(Subtitle::URL);
|
||||
|
||||
foreach (ag($sConfig, 'externals', []) as $id => $x) {
|
||||
$ext = getExtension(ag($x, 'path'));
|
||||
$file = ag($x, 'path');
|
||||
|
||||
$lang = ag($x, 'language', 'und');
|
||||
$lang = $lc['short'][$lang] ?? $lang;
|
||||
|
||||
if (isset($lc['names'][$lang])) {
|
||||
$name = r('{name} ({type})', [
|
||||
'name' => $lc['names'][$lang],
|
||||
'type' => strtoupper($ext),
|
||||
]);
|
||||
} else {
|
||||
$name = basename($file);
|
||||
}
|
||||
|
||||
$link = r('{api_url}/{token}/{type}.x{id}.m3u8{auth}', [
|
||||
'api_url' => $subtitleUrl,
|
||||
'id' => $id,
|
||||
'type' => 'webvtt',
|
||||
'token' => $token,
|
||||
'duration' => round((int)$duration),
|
||||
'auth' => $isSecure ? '?apikey=' . Config::get('api.key') : '',
|
||||
]);
|
||||
|
||||
// -- flag lang to 2 chars
|
||||
$k = array_filter($lc['short'], fn($v, $k) => $v === $lang, ARRAY_FILTER_USE_BOTH);
|
||||
if (!empty($k)) {
|
||||
$lang = array_keys($k);
|
||||
$lang = array_shift($lang);
|
||||
}
|
||||
|
||||
$lines[] = r(
|
||||
'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="(x) {name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{uri}"',
|
||||
[
|
||||
'lang' => $lang,
|
||||
'name' => $name,
|
||||
'uri' => $link,
|
||||
'index' => $id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
foreach (ag($ffprobe, 'streams', []) as $id => $x) {
|
||||
if ('subtitle' !== ag($x, 'codec_type')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lang = ag($x, 'tags.language', 'und');
|
||||
$title = ag($x, 'tags.title', 'Unknown');
|
||||
|
||||
$link = r('{api_url}/{token}/{type}.i{id}.m3u8{auth}', [
|
||||
'api_url' => $subtitleUrl,
|
||||
'id' => $id,
|
||||
'type' => 'webvtt',
|
||||
'token' => $token,
|
||||
'auth' => $isSecure ? '?apikey=' . Config::get('api.key') : '',
|
||||
]);
|
||||
|
||||
// -- flip lang to 2 chars
|
||||
$k = array_filter($lc['short'], fn($v, $k) => $v === $lang, ARRAY_FILTER_USE_BOTH);
|
||||
if (!empty($k)) {
|
||||
$lang = array_keys($k);
|
||||
$lang = array_shift($lang);
|
||||
}
|
||||
|
||||
$lines[] = r(
|
||||
'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="(i) {name} ({codec})",DEFAULT={default},AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{uri}"',
|
||||
[
|
||||
'lang' => $lang,
|
||||
'name' => $title,
|
||||
'codec' => ag($x, 'codec_name'),
|
||||
'uri' => $link,
|
||||
'index' => $id,
|
||||
'default' => true === (bool)ag($x, 'disposition.default') ? 'YES' : 'NO',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$lines[] = r('#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}', [
|
||||
'subs' => !empty(ag($sConfig, 'externals')) ? ',SUBTITLES="subs"' : ''
|
||||
]);
|
||||
|
||||
$lines[] = r('{api_url}/{token}/segments.m3u8{auth}', [
|
||||
'token' => $token,
|
||||
'api_url' => parseConfigValue(M3u8::URL),
|
||||
'auth' => $isSecure ? '?apikey=' . Config::get('api.key') : '',
|
||||
]);
|
||||
|
||||
return api_response(Status::OK, Stream::create(implode("\n", $lines)), [
|
||||
'Content-Type' => 'application/x-mpegurl',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Access-Control-Max-Age' => 300,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['trace' => $e->getTrace()]);
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
328
src/API/Player/Segments.php
Normal file
328
src/API/Player/Segments.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\Player;
|
||||
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Stream;
|
||||
use JsonException;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
readonly class Segments
|
||||
{
|
||||
public const string URL = '%{api.prefix}/player/segments';
|
||||
|
||||
private const array OVERLAY = [
|
||||
'hdmv_pgs_subtitle',
|
||||
'dvd_subtitle',
|
||||
];
|
||||
|
||||
public function __construct(private iCache $cache)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(pattern: self::URL . '/{token}/{segment}[.{type}]')]
|
||||
public function __invoke(iRequest $request, string $token, string $segment): iResponse
|
||||
{
|
||||
if (null === ($json = $this->cache->get($token, null))) {
|
||||
return api_error('Token is expired or invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($request->hasHeader('if-modified-since')) {
|
||||
return api_response(Status::NOT_MODIFIED, headers: ['Cache-Control' => 'public, max-age=25920000']);
|
||||
}
|
||||
|
||||
if (null === ($path = ag($json, 'path', null))) {
|
||||
return api_error('Path is empty.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$path = rawurldecode($path);
|
||||
|
||||
if (false === file_exists($path)) {
|
||||
return api_error('Path not found.', Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!is_file($path)) {
|
||||
return api_error(r("Path '{path}' is not a file.", ['path' => $path]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$segment = (int)$segment;
|
||||
$sConfig = (array)ag($json, 'config', []);
|
||||
|
||||
try {
|
||||
$json = ffprobe_file($path, $this->cache);
|
||||
} catch (RuntimeException|JsonException $e) {
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$incr = 0;
|
||||
$subIndex = [];
|
||||
foreach (ag($json, 'streams', []) as $id => $stream) {
|
||||
if ('subtitle' !== ag($stream, 'codec_type')) {
|
||||
continue;
|
||||
}
|
||||
$subIndex[$id] = $incr;
|
||||
$incr++;
|
||||
}
|
||||
|
||||
$sConfig['segment_size'] = number_format((int)$sConfig['segment_size'], 6);
|
||||
|
||||
$params = DataUtil::fromArray($sConfig);
|
||||
|
||||
$audio = $params->get('audio');
|
||||
$subtitle = $params->get('subtitle');
|
||||
$external = $params->get('external', null);
|
||||
$hwaccel = (bool)$params->get('hwaccel', false);
|
||||
$vaapi_device = $params->get('vaapi_device', '/dev/dri/renderD128');
|
||||
$vCodec = $params->get('video_codec', $hwaccel ? 'h264_vaapi' : 'libx264');
|
||||
$isIntel = $hwaccel && 'h264_vaapi' === $vCodec;
|
||||
$segmentSize = number_format((int)$params->get('segment_size', Playlist::SEGMENT_DUR), 6);
|
||||
|
||||
if ($hwaccel && false === file_exists($vaapi_device)) {
|
||||
return api_error(r("VAAPI device '{device}' not found.", ['device' => $vaapi_device]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (null !== $external && false === file_exists($external)) {
|
||||
return api_error(r("External subtitle '{path}' not found.", ['path' => $external]), Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
// -- Only transcode one segment per file, Otherwise wait until it finishes.
|
||||
$tmpVidLock = r("{path}/t-{name}.lock", [
|
||||
'path' => sys_get_temp_dir(),
|
||||
'name' => $token,
|
||||
'type' => getExtension($path),
|
||||
]);
|
||||
|
||||
while (true === file_exists($tmpVidLock)) {
|
||||
$pid = (int)file_get_contents($tmpVidLock);
|
||||
if (false === file_exists("/proc/{$pid}")) {
|
||||
@unlink($tmpVidLock);
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
}
|
||||
|
||||
$cmd = [
|
||||
'ffmpeg',
|
||||
'-ss',
|
||||
(string)($segment === 0 ? 0 : ($segmentSize * $segment)),
|
||||
'-t',
|
||||
(string)(ag($request->getQueryParams(), 'sd', $segmentSize)),
|
||||
'-xerror',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
];
|
||||
|
||||
$tmpSubFile = null;
|
||||
$tmpVidFile = r("{path}/t-{name}-vlink.{type}", [
|
||||
'path' => sys_get_temp_dir(),
|
||||
'name' => $token,
|
||||
'type' => getExtension($path),
|
||||
]);
|
||||
|
||||
// -- video section. overlay picture based subs.
|
||||
$overlay = empty($external) && null !== $subtitle &&
|
||||
in_array(ag($this->getStream(ag($json, 'streams', []), $subtitle), 'codec_name', ''), self::OVERLAY);
|
||||
|
||||
if (false === file_exists($tmpVidFile)) {
|
||||
symlink($path, $tmpVidFile);
|
||||
}
|
||||
|
||||
$cmd[] = '-copyts';
|
||||
if ($isIntel) {
|
||||
$cmd[] = '-hwaccel';
|
||||
$cmd[] = 'vaapi';
|
||||
$cmd[] = '-vaapi_device';
|
||||
$cmd[] = $vaapi_device;
|
||||
if ($overlay) {
|
||||
$cmd[] = '-hwaccel_output_format';
|
||||
$cmd[] = 'vaapi';
|
||||
}
|
||||
}
|
||||
|
||||
$cmd[] = '-i';
|
||||
$cmd[] = 'file:' . $tmpVidFile;
|
||||
|
||||
# remove garbage metadata.
|
||||
$cmd[] = '-map_metadata';
|
||||
$cmd[] = '-1';
|
||||
$cmd[] = '-map_chapters';
|
||||
$cmd[] = '-1';
|
||||
|
||||
$cmd[] = '-pix_fmt';
|
||||
$cmd[] = $isIntel ? 'vaapi_vld' : 'yuv420p';
|
||||
|
||||
$cmd[] = '-g';
|
||||
$cmd[] = '52';
|
||||
|
||||
if ($overlay && empty($external) && null !== $subtitle) {
|
||||
$cmd[] = '-filter_complex';
|
||||
if ($isIntel) {
|
||||
$cmd[] = "[0:0]hwdownload,format=nv12[base];[base][0:" . $subtitle . "]overlay[v];[v]hwupload[k]";
|
||||
$cmd[] = '-map';
|
||||
$cmd[] = '[k]';
|
||||
} else {
|
||||
$cmd[] = "[0:v:0][0:" . $subtitle . "]overlay[v]";
|
||||
$cmd[] = '-map';
|
||||
$cmd[] = '[v]';
|
||||
}
|
||||
} else {
|
||||
$cmd[] = '-map';
|
||||
$cmd[] = '0:v:0';
|
||||
}
|
||||
|
||||
$cmd[] = '-strict';
|
||||
$cmd[] = '-2';
|
||||
if (empty($external) && $isIntel) {
|
||||
$cmd[] = '-vf';
|
||||
$cmd[] = 'format=nv12,hwupload';
|
||||
}
|
||||
$cmd[] = '-codec:v';
|
||||
$cmd[] = $vCodec;
|
||||
|
||||
$cmd[] = '-crf';
|
||||
$cmd[] = $params->get('video_crf', '23');
|
||||
$cmd[] = '-preset:v';
|
||||
$cmd[] = $params->get('video_preset', 'fast');
|
||||
|
||||
if (0 !== (int)$params->get('video_bitrate', 0)) {
|
||||
$cmd[] = '-b:v';
|
||||
$cmd[] = $params->get('video_bitrate', '192k');
|
||||
}
|
||||
|
||||
$cmd[] = '-level';
|
||||
$cmd[] = $params->get('video_level', '4.1');
|
||||
$cmd[] = '-profile:v';
|
||||
$cmd[] = $params->get('video_profile', 'main');
|
||||
|
||||
// -- audio section.
|
||||
$cmd[] = '-map';
|
||||
$cmd[] = null === $audio ? '0:a:0' : "0:{$audio}";
|
||||
|
||||
$cmd[] = '-codec:a';
|
||||
$cmd[] = 'aac';
|
||||
|
||||
$cmd[] = '-b:a';
|
||||
$cmd[] = $params->get('audio_bitrate', '192k');
|
||||
$cmd[] = '-ar';
|
||||
$cmd[] = $params->get('audio_sampling_rate', '22050');
|
||||
$cmd[] = '-ac';
|
||||
$cmd[] = $params->get('audio_channels', '2');
|
||||
|
||||
// -- subtitles.
|
||||
if (null !== $external) {
|
||||
$tmpSubFile = r("{path}/t-{name}-slink.{type}", [
|
||||
'path' => sys_get_temp_dir(),
|
||||
'name' => $token,
|
||||
'type' => getExtension($external),
|
||||
]);
|
||||
if (false === file_exists($tmpSubFile)) {
|
||||
symlink($external, $tmpSubFile);
|
||||
}
|
||||
$cmd[] = '-vf';
|
||||
$cmd[] = "subtitles={$tmpSubFile}" . ($isIntel ? ',format=nv12,hwupload' : '');
|
||||
} elseif (null !== $subtitle && !$overlay) {
|
||||
$cmd[] = '-vf';
|
||||
$cmd[] = "subtitles={$tmpVidFile}:stream_index=" . (int)$subIndex[$subtitle] . ($isIntel ? ',format=nv12,hwupload' : '');
|
||||
} else {
|
||||
$cmd[] = '-sn';
|
||||
}
|
||||
|
||||
$cmd[] = '-muxdelay';
|
||||
$cmd[] = '0';
|
||||
$cmd[] = '-f';
|
||||
$cmd[] = 'mpegts';
|
||||
$cmd[] = 'pipe:1';
|
||||
|
||||
$debug = (bool)ag($sConfig, 'debug', false);
|
||||
|
||||
try {
|
||||
$start = microtime(true);
|
||||
$process = new Process($cmd);
|
||||
$process->setTimeout(60);
|
||||
$process->start();
|
||||
|
||||
$lock = new Stream($tmpVidLock, 'w');
|
||||
$lock->write((string)$process->getPid());
|
||||
$lock->close();
|
||||
|
||||
$process->wait();
|
||||
$end = microtime(true);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
if (true === $debug) {
|
||||
return api_error($process->getErrorOutput(), Status::INTERNAL_SERVER_ERROR, [
|
||||
'stdout' => $process->getOutput(),
|
||||
'stderr' => $process->getErrorOutput(),
|
||||
'Ffmpeg' => $process->getCommandLine(),
|
||||
'config' => $sConfig,
|
||||
'command' => implode(' ', $cmd),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_error('Failed to generate segment.', Status::INTERNAL_SERVER_ERROR, headers: [
|
||||
'X-Transcode-Time' => round($end - $start, 6),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = api_response(Status::OK, body: Stream::create($process->getOutput()), headers: [
|
||||
'Content-Type' => 'video/mpegts',
|
||||
'X-Transcode-Time' => round($end - $start, 6),
|
||||
'X-Emitter-Flush' => 1,
|
||||
'Pragma' => 'public',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Cache-Control' => sprintf('public, max-age=%s', time() + 31536000),
|
||||
'Last-Modified' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time())),
|
||||
'Expires' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time() + 31536000)),
|
||||
]);
|
||||
|
||||
if (true === $debug) {
|
||||
$response = $response
|
||||
->withHeader('X-Ffmpeg', $process->getCommandLine())
|
||||
->withHeader(
|
||||
'X-Transcode-Config',
|
||||
json_encode($sConfig, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (Throwable $e) {
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
} finally {
|
||||
if (file_exists($tmpVidLock)) {
|
||||
unlink($tmpVidLock);
|
||||
}
|
||||
|
||||
if (file_exists($tmpVidFile) && is_link($tmpVidFile)) {
|
||||
unlink($tmpVidFile);
|
||||
}
|
||||
|
||||
if (null !== $tmpSubFile && file_exists($tmpSubFile) && is_link($tmpSubFile)) {
|
||||
unlink($tmpSubFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getStream(array $streams, int $index): array
|
||||
{
|
||||
foreach ($streams as $stream) {
|
||||
if ((int)ag($stream, 'index') === $index) {
|
||||
return $stream;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
301
src/API/Player/Subtitle.php
Normal file
301
src/API/Player/Subtitle.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\Player;
|
||||
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Stream;
|
||||
use JsonException;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final readonly class Subtitle
|
||||
{
|
||||
private const array FORMATS = [
|
||||
'vtt' => 'text/vtt',
|
||||
'webvtt' => 'text/vtt',
|
||||
'srt' => 'text/srt',
|
||||
'ass' => 'text/ass',
|
||||
];
|
||||
|
||||
private const array INTERNAL_NAMING = [
|
||||
'subrip',
|
||||
'ass',
|
||||
'vtt'
|
||||
];
|
||||
|
||||
public const string URL = '%{api.prefix}/player/subtitle';
|
||||
private const string EXTERNAL = 'x';
|
||||
private const string INTERNAL = 'i';
|
||||
|
||||
public function __construct(private iCache $cache, private iLogger $logger)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(pattern: self::URL . '/{token}/{type}.{source:\w{1}}{index:number}.m3u8')]
|
||||
public function m3u8(iRequest $request, string $token, string $source, string $index): iResponse
|
||||
{
|
||||
if (null === ($data = $this->cache->get($token, null))) {
|
||||
return api_error('Token is expired or invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($request->hasHeader('if-modified-since')) {
|
||||
return api_response(Status::NOT_MODIFIED, headers: ['Cache-Control' => 'public, max-age=25920000']);
|
||||
}
|
||||
|
||||
if ('x' === $source) {
|
||||
$subtitles = ag($data, 'config.externals', []);
|
||||
if (empty($subtitles)) {
|
||||
return api_error('No external subtitles found.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$subtitle = array_filter($subtitles, fn($s) => $s === (int)$index, ARRAY_FILTER_USE_KEY);
|
||||
if (empty($subtitle)) {
|
||||
return api_error('Subtitle not found.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$subtitle = array_shift($subtitle);
|
||||
}
|
||||
|
||||
$isSecure = (bool)Config::get('api.secure', false);
|
||||
$subtitleUrl = parseConfigValue(Subtitle::URL);
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '#EXTM3U';
|
||||
$lines[] = '#EXT-X-TARGETDURATION:' . ag($data, 'config.duration');
|
||||
$lines[] = '#EXT-X-PLAYLIST-TYPE:VOD';
|
||||
$lines[] = '#EXT-X-VERSION:3';
|
||||
$lines[] = '#EXT-X-MEDIA-SEQUENCE:0';
|
||||
|
||||
$lines[] = '#EXTINF:' . ag($data, 'config.duration') . ',';
|
||||
$lines[] = r('{api_url}/{token}/{source}{index}.webvtt{auth}', [
|
||||
'api_url' => $subtitleUrl,
|
||||
'token' => $token,
|
||||
'source' => $source,
|
||||
'index' => $index,
|
||||
'auth' => $isSecure ? '?apikey=' . Config::get('api.key') : '',
|
||||
]);
|
||||
$lines[] = '#EXT-X-ENDLIST';
|
||||
|
||||
return api_response(Status::OK, Stream::create(implode("\n", $lines)), [
|
||||
'Content-Type' => 'application/x-mpegurl',
|
||||
'Pragma' => 'public',
|
||||
'Cache-Control' => sprintf('public, max-age=%s', time() + 31536000),
|
||||
'Last-Modified' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time())),
|
||||
'Expires' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time() + 31536000)),
|
||||
'Access-Control-Max-Age' => 3600 * 24 * 30,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(pattern: self::URL . '/{token}/{source:\w{1}}{index:\d{1}}.{ext:\w{3,10}}')]
|
||||
public function convert(iRequest $request, string $token, string $source, string $index): iResponse
|
||||
{
|
||||
if (null === ($data = $this->cache->get($token, null))) {
|
||||
return api_error('Token is expired or invalid.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($request->hasHeader('if-modified-since')) {
|
||||
return api_response(Status::NOT_MODIFIED, headers: ['Cache-Control' => 'public, max-age=25920000']);
|
||||
}
|
||||
|
||||
$stream = null;
|
||||
|
||||
switch ($source) {
|
||||
case self::EXTERNAL:
|
||||
{
|
||||
$subtitles = ag($data, 'config.externals', []);
|
||||
if (empty($subtitles)) {
|
||||
return api_error('No external subtitles found.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$subtitle = array_filter($subtitles, fn($s) => $s === (int)$index, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
if (empty($subtitle)) {
|
||||
return api_error('Subtitle not found.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$subtitle = array_shift($subtitle);
|
||||
|
||||
if (null === ($path = ag($subtitle, 'path'))) {
|
||||
return api_error('Subtitle path not found.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$path = rawurldecode($path);
|
||||
}
|
||||
break;
|
||||
case self::INTERNAL:
|
||||
{
|
||||
if (null === ($path = ag($data, 'path', null))) {
|
||||
return api_error('Path is empty.', Status::BAD_REQUEST);
|
||||
}
|
||||
$path = rawurldecode($path);
|
||||
$stream = (int)$index;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return api_error(r("Invalid source '{source}' was specified.", [
|
||||
'source' => $source
|
||||
]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$response = $this->make($path, $stream, (bool)ag($data, 'config.debug', false));
|
||||
|
||||
if (Status::OK !== Status::from($response->getStatusCode())) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
return api_response(Status::from($response->getStatusCode()), $response->getBody(), [
|
||||
'Content-Type' => $response->getHeaderLine('Content-Type'),
|
||||
'Pragma' => 'public',
|
||||
'Cache-Control' => sprintf('public, max-age=%s', time() + 31536000),
|
||||
'Last-Modified' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time())),
|
||||
'Expires' => sprintf('%s GMT', gmdate('D, d M Y H:i:s', time() + 31536000)),
|
||||
'Access-Control-Max-Age' => 3600 * 24 * 30,
|
||||
'X-Cache' => $response->getHeaderLine('X-Cache'),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), $e->getTrace());
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function make(string $file, int|null $stream = null, bool $debug = false): iResponse
|
||||
{
|
||||
if (false === file_exists($file)) {
|
||||
return api_error(r("Path '{path}' is not found.", ['path' => $file]), Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if (false === is_file($file)) {
|
||||
return api_error(r("Path '{path}' is not a file.", ['path' => $file]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$type = 'webvtt';
|
||||
$size = filesize($file);
|
||||
$kStream = '';
|
||||
if (null !== $stream) {
|
||||
$kStream = ":{$stream}";
|
||||
}
|
||||
|
||||
$cacheKey = md5("{$file}{$kStream}:{$size}");
|
||||
if ($this->cache->has($cacheKey)) {
|
||||
return api_response(Status::OK, Stream::create($this->cache->get($cacheKey)), [
|
||||
'Content-Type' => 'text/vtt',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Max-Age' => 300,
|
||||
'X-Cache' => 'hit',
|
||||
]);
|
||||
}
|
||||
|
||||
if (null === $stream && !array_key_exists(getExtension($file), self::FORMATS)) {
|
||||
return api_error("Unsupported subtitle file.", Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$tmpFile = sys_get_temp_dir() . '/ffmpeg_' . $cacheKey . '.' . $type;
|
||||
if (!file_exists($tmpFile)) {
|
||||
symlink($file, $tmpFile);
|
||||
}
|
||||
|
||||
if (null !== $stream) {
|
||||
try {
|
||||
$streamInfo = $this->getStream(ag(ffprobe_file($file, $this->cache), 'streams', []), $stream);
|
||||
$codecType = ag($streamInfo, 'codec_type', '');
|
||||
|
||||
if ('subtitle' !== $codecType) {
|
||||
return api_error("Only subtitle stream conversion is supported.", Status::BAD_REQUEST, $streamInfo);
|
||||
}
|
||||
|
||||
$codec = ag($streamInfo, 'codec_name', '');
|
||||
|
||||
if (false === in_array($codec, self::INTERNAL_NAMING)) {
|
||||
return api_error(r("This codec type '{codec}' is not supported.", [
|
||||
'codec' => $codec
|
||||
]), Status::BAD_REQUEST, $streamInfo);
|
||||
}
|
||||
} catch (RuntimeException|JsonException $e) {
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
$cmd = [
|
||||
'ffmpeg',
|
||||
'-xerror',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-i',
|
||||
'file:' . $tmpFile
|
||||
];
|
||||
|
||||
if (null !== $stream) {
|
||||
$cmd[] = '-map';
|
||||
$cmd[] = "0:{$stream}";
|
||||
}
|
||||
|
||||
$cmd[] = '-f';
|
||||
$cmd[] = $type;
|
||||
$cmd[] = 'pipe:1';
|
||||
|
||||
try {
|
||||
$process = new Process($cmd);
|
||||
$process->setTimeout($stream ? 120 : 60);
|
||||
$process->start();
|
||||
|
||||
$process->wait();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
if (true === $debug) {
|
||||
return api_error($process->getErrorOutput(), Status::INTERNAL_SERVER_ERROR, headers: [
|
||||
'X-FFmpeg' => $process->getCommandLine()
|
||||
]);
|
||||
}
|
||||
|
||||
return api_error('Failed to convert subtitle.', Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$body = $process->getOutput();
|
||||
|
||||
$this->cache->set($cacheKey, $body);
|
||||
|
||||
return api_response(Status::OK, Stream::create($body), [
|
||||
'Content-Type' => self::FORMATS[$type],
|
||||
'X-Cache' => 'miss'
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
} finally {
|
||||
if (file_exists($tmpFile) && is_link($tmpFile)) {
|
||||
unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getStream(array $streams, int $index): array
|
||||
{
|
||||
foreach ($streams as $stream) {
|
||||
if ((int)ag($stream, 'index') === $index) {
|
||||
return $stream;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
100
src/API/System/Sign.php
Normal file
100
src/API/System/Sign.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Post;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use DateInterval;
|
||||
use DateTimeInterface;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
|
||||
final readonly class Sign
|
||||
{
|
||||
public const string URL = '%{api.prefix}/system/sign';
|
||||
|
||||
public function __construct(private iCache $cache, private iDB $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception if the time passed to the DateInterval is invalid.
|
||||
*/
|
||||
#[Post(pattern: self::URL . '/{id:number}[/]')]
|
||||
public function __invoke(iRequest $request, string $id): iResponse
|
||||
{
|
||||
$params = DataUtil::fromRequest($request);
|
||||
|
||||
if (null === ($path = $params->get('path', null))) {
|
||||
return api_error('Path is empty.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (false === file_exists($path)) {
|
||||
return api_error('Path not found.', Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
$entity = $this->db->get(Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]));
|
||||
|
||||
if (null === $entity) {
|
||||
return api_error('Reference entity not found.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$time = $params->get('time', 'PT24H');
|
||||
$expires = new DateInterval($time);
|
||||
|
||||
$key = self::sign([
|
||||
'id' => $id,
|
||||
'path' => $path,
|
||||
'time' => $time,
|
||||
'config' => [],
|
||||
'version' => getAppVersion(),
|
||||
], $expires, $this->cache);
|
||||
|
||||
return api_response(Status::OK, [
|
||||
'token' => $key,
|
||||
'secure' => (bool)Config::get('api.secure', false),
|
||||
'expires' => makeDate()->add($expires)->format(DateTimeInterface::ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function sign(array $data, DateInterval|null $ttl = null, iCache|null $cache = null): string
|
||||
{
|
||||
if (null === $cache) {
|
||||
$cache = Container::get(iCache::class);
|
||||
}
|
||||
|
||||
$key = self::key($data);
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
$cache->set($key, $data, $ttl);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public static function update(
|
||||
string $key,
|
||||
array $data,
|
||||
DateInterval|null $ttl = null,
|
||||
iCache|null $cache = null
|
||||
): bool {
|
||||
if (null === $cache) {
|
||||
$cache = Container::get(iCache::class);
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return $cache->set($key, $data, $ttl);
|
||||
}
|
||||
|
||||
private static function key(array $config): string
|
||||
{
|
||||
return 'play-' . substr(bin2hex(openssl_digest(json_encode($config), 'shake256', true)), 0, 12);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
|
||||
* Routes that follow the open route policy. However, those routes are subject to user configuration.
|
||||
*/
|
||||
private const array OPEN_ROUTES = [
|
||||
'/webhook'
|
||||
'/webhook',
|
||||
'%{api.prefix}/player/'
|
||||
];
|
||||
|
||||
public function process(iRequest $request, iHandler $handler): iResponse
|
||||
|
||||
@@ -1707,3 +1707,133 @@ if (!function_exists('restartTaskWorker')) {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('findSideCarFiles')) {
|
||||
function findSideCarFiles(SplFileInfo $path): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$possibleExtensions = ['jpg', 'jpeg', 'png'];
|
||||
foreach ($possibleExtensions as $ext) {
|
||||
if (file_exists($path->getPath() . "/poster.{$ext}")) {
|
||||
$list[] = $path->getPath() . "/poster.{$ext}";
|
||||
}
|
||||
|
||||
if (file_exists($path->getPath() . "/fanart.{$ext}")) {
|
||||
$list[] = $path->getPath() . "/fanart.{$ext}";
|
||||
}
|
||||
}
|
||||
|
||||
$pat = $path->getPath() . '/' . before($path->getFilename(), '.');
|
||||
$pat = preg_replace('/([*?\[])/', '[$1]', $pat);
|
||||
|
||||
$glob = glob($pat . '*');
|
||||
|
||||
if (false === $glob) {
|
||||
return $list;
|
||||
}
|
||||
|
||||
foreach ($glob as $item) {
|
||||
$item = new SplFileInfo($item);
|
||||
|
||||
if (!$item->isFile() || $item->getFilename() === $path->getFilename()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = $item->getRealPath();
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('array_change_key_case_recursive')) {
|
||||
function array_change_key_case_recursive(array $input, int $case = CASE_LOWER): array
|
||||
{
|
||||
if (!in_array($case, [CASE_UPPER, CASE_LOWER])) {
|
||||
throw new RuntimeException("Case parameter '{$case}' is invalid.");
|
||||
}
|
||||
|
||||
$input = array_change_key_case($input, $case);
|
||||
|
||||
foreach ($input as $key => $array) {
|
||||
if (is_array($array)) {
|
||||
$input[$key] = array_change_key_case_recursive($array, $case);
|
||||
}
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('getMimeType')) {
|
||||
function getMimeType(string $file): string
|
||||
{
|
||||
static $fileInfo = null;
|
||||
|
||||
if (null === $fileInfo) {
|
||||
$fileInfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
}
|
||||
|
||||
return $fileInfo->file($file);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('getExtension')) {
|
||||
function getExtension(string $filename): string
|
||||
{
|
||||
return (new SplFileInfo($filename))->getExtension();
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('ffprobe_file')) {
|
||||
/**
|
||||
* Get FFProbe Info.
|
||||
*
|
||||
* @param string $path
|
||||
* @param iCache|null $cache
|
||||
* @return array
|
||||
* @noinspection PhpDocMissingThrowsInspection
|
||||
*/
|
||||
function ffprobe_file(string $path, iCache|null $cache = null): array
|
||||
{
|
||||
$cacheKey = md5($path . filesize($path));
|
||||
|
||||
if (null !== $cache && $cache->has($cacheKey)) {
|
||||
$data = $cache->get($cacheKey);
|
||||
return (is_array($data) ? $data : json_decode($data, true));
|
||||
}
|
||||
|
||||
$mimeType = getMimeType($path);
|
||||
|
||||
$isTs = str_ends_with($path, '.ts') && 'application/octet-stream' === $mimeType;
|
||||
if (!str_starts_with($mimeType, 'video/') && !str_starts_with($mimeType, 'audio/') && !$isTs) {
|
||||
throw new RuntimeException(sprintf("Unable to run ffprobe on '%s'", $path));
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'quiet',
|
||||
'-print_format',
|
||||
'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
'file:' . basename($path)
|
||||
], cwd: dirname($path));
|
||||
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new RuntimeException(sprintf("Failed to run ffprobe on '%s'. %s", $path, $process->getErrorOutput()));
|
||||
}
|
||||
|
||||
$json = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$data = array_change_key_case_recursive($json, CASE_LOWER);
|
||||
|
||||
$cache?->set($cacheKey, $data, new DateInterval('PT24H'));
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user