Added experimental video player.

This commit is contained in:
Abdulmhsen B. A. A.
2024-08-10 22:11:01 +03:00
parent e103841da8
commit 2d9d465863
17 changed files with 1681 additions and 24 deletions

View File

@@ -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 '' && \

View File

@@ -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
View 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',
],
];
})();

View 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>

View File

@@ -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": {}
}

View File

@@ -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 }"

View 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>

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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
View 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
View 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
View 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
View 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
View 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);
}
}

View File

@@ -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

View File

@@ -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;
}
}