Merge pull request #531 from arabcoders/dev

Improved play video page.
This commit is contained in:
Abdulmohsen
2024-08-11 21:33:10 +03:00
committed by GitHub
10 changed files with 764 additions and 164 deletions

View File

@@ -25,6 +25,7 @@
"ext-fileinfo": "*",
"ext-redis": "*",
"ext-posix": "*",
"ext-openssl": "*",
"monolog/monolog": "^3.4",
"symfony/console": "^6.1.4",
"symfony/cache": "^6.1.3",

5
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b1b5fdc74178fdeafb640278ee9dcc00",
"content-hash": "e386d8f65a092fddeb914449ac6231a0",
"packages": [
{
"name": "dragonmantank/cron-expression",
@@ -4962,7 +4962,8 @@
"ext-simplexml": "*",
"ext-fileinfo": "*",
"ext-redis": "*",
"ext-posix": "*"
"ext-posix": "*",
"ext-openssl": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"

View File

@@ -10,7 +10,7 @@
</span>
<div class="is-pulled-right" v-if="data?.via">
<div class="field is-grouped">
<p class="control" v-if="data?.content_path">
<p class="control" v-if="data?.files?.length>0">
<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">
@@ -509,7 +509,7 @@ const data = ref({
const loadContent = async (id) => {
isLoading.value = true
const response = await request(`/history/${id}`)
const response = await request(`/history/${id}?files=true`)
const json = await response.json()
if (useRoute().name !== 'history-id') {

View File

@@ -9,12 +9,26 @@
<template v-else>{{ id }}</template>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<div class="field is-grouped" v-if="isPlaying">
<div class="control">
<button class="button is-warning" @click="closeStream" v-tooltip.bottom="'Go back.'">
<span class="icon"><i class="fas fa-backspace"></i></span>
</button>
</div>
<p class="control">
<button class="button" @click="toggleWatched"
:class="{ 'is-success': !item.watched, 'is-danger': item.watched }"
v-tooltip.bottom="'Toggle watch state'">
<span class="icon">
<i class="fas" :class="{'fa-eye-slash':item.watched,'fa-eye':!item.watched}"></i>
</span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle" v-if="!isPlaying && urls.length > 0">
Select video file to play.
<span class="subtitle" v-if="item?.content_title">
{{ item?.content_title }}
</span>
</div>
</div>
@@ -23,7 +37,7 @@
<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 v-if="!isLoading && item?.files?.length < 1" title="Warning"
message_class="is-background-warning-80 has-text-dark"
icon="fas fa-exclamation-triangle">
No video URLs were found.
@@ -38,91 +52,205 @@
</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 class="card">
<div class="card-header">
<p class="card-header-title">Select settings.</p>
<p class="card-header-icon"></p>
</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 class="card-content">
<div class="field">
<label class="label">Select source file</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="config.path" @change="(e) => changeStream(e)">
<option value="">Select...</option>
<template v-for="item in item?.files" :key="item.path">
<optgroup :label="`In: ${item.source.join(', ')}`">
<option :value="item.path" v-text="basename(item.path)"/>
</optgroup>
</template>
</select>
</div>
<div class="icon is-left">
<i class="fas fa-file-video"></i>
</div>
</div>
</div>
<div class="field" v-if="selectedItem?.ffprobe?.streams">
<label class="label">Select audio stream</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="config.audio">
<option value="">Select audio stream...</option>
<template v-for="item in filterStreams('audio')" :key="`audio-${item.index}`">
<option :value="item.index">
{{ item.index }} - {{ item.codec_name }} - {{ ag(item.tags, 'title') }}
<template v-if="ag(item.tags, 'language')">
- {{ ag(item.tags, 'language') }}
</template>
</option>
</template>
</select>
</div>
<div class="icon is-left">
<i class="fas fa-file-audio"></i>
</div>
</div>
</div>
<div class="field" v-if="filterStreams('subtitle').length > 0 || selectedItem?.subtitles?.length > 0">
<label class="label">Burn subtitles</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="config.subtitle">
<option value="">Select subtitle...</option>
<template v-if="filterStreams('subtitle').length > 0">
<optgroup label="Internal Subtitles">
<option v-for="item in filterStreams('subtitle')" :key="`subtitle-${item.index}`"
:value="item.index">
{{ item.index }} - {{ item.codec_name }} - {{ ag(item.tags, 'title') }}
<template v-if="ag(item.tags, 'language')">
- {{ ag(item.tags, 'language') }}
</template>
</option>
</optgroup>
</template>
<template v-if="selectedItem?.subtitles">
<optgroup label="External Subtitles">
<option v-for="item in selectedItem.subtitles" :key="`subtitle-${item}`" :value="item">
{{ basename(item) }}
</option>
</optgroup>
</template>
</select>
</div>
<div class="icon is-left">
<i class="fas fa-closed-captioning"></i>
</div>
</div>
<p class="help">
<span class="icon"><i class="fas fa-info"></i></span>
We recommend using the burn subtitle function only when you are using a picture based subtitles,
Text based subtitles are able to be selected and converted on the fly using the player.
</p>
</div>
<div class="is-justify-content-end field is-grouped" v-if="config?.path">
<div class="control">
<button class="button is-warning" @click="showAdvanced=!showAdvanced" :disabled="true"
v-tooltip="'Advanced settings not yet implemented.'">
<span class="icon"><i class="fas fa-cog"></i></span>
<span>Advanced settings</span>
</button>
</div>
<div class="control">
<button class="button has-text-white has-background-danger-50" @click="generateToken"
:disabled="isGenerating">
<span class="icon"><i class="fas fa-play"></i></span>
<span>Play</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="column is-12" v-if="!isPlaying">
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
<ul>
<li>Selecting subtitle for burn in will force the video stream to be converted. We attempt to direct play
compatible streams when possible. Text based subtitles can be converted on the fly in the player. and
require no burn in.
</li>
<li>
Right now the transcoding is done via CPU and is not optimized for best performance. We have plans to
include GPU acceleration in the future.
</li>
<li>If you select subtitle for burn in the player will no longer show text based subtitles for selection.
</li>
<li>Right now we are transcoding all streams to <code>H264</code> for video and <code>AAC</code> for audio,
regardless of the stream is compatible with the browser or not, this will hopefully change in the feature
to allow direct play of compatible streams. we have the code in place to allow such thing, i just haven't
be able to get reliable results with it yet.
</li>
</ul>
</Message>
</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";
import {useStorage} from '@vueuse/core'
import Player from '~/components/Player'
import request from "~/utils/request.js";
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 showAdvanced = ref(false)
const show_page_tips = useStorage('show_page_tips', true)
const config = ref({
path: '',
audio: '',
subtitle: '',
})
const selectedItem = 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;
const response = await request(`/history/${id}?files=true`)
item.value = await response.json()
} catch (error) {
console.error(error)
notification('error', 'Error', 'Failed to load item.')
} finally {
isLoading.value = false
}
if (1 === item.value.files?.length) {
config.value.path = item.value.files[0].path
selectedItem.value = item.value.files[0]
await changeStream(null, item.value.files[0].path)
}
}
const generateToken = async () => {
isGenerating.value = true
try {
let userConfig = {
path: config.value.path,
config: {
audio: config.value.audio,
}
};
if (config.value.subtitle) {
// -- check if the value is number it's internal subtitle
if (String(config.value.subtitle).match(/^\d+$/)) {
userConfig.config.subtitle = config.value.subtitle;
} else {
userConfig.config.external = config.value.subtitle;
}
}
const response = await request(`/system/sign/${id}`, {
method: 'POST',
body: JSON.stringify({path: selectedUrl.value}),
body: JSON.stringify(userConfig),
})
const json = await response.json()
@@ -136,12 +264,18 @@ const generateToken = async () => {
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
await useRouter().push({
path: `/play/${id}`,
query: {token: json.token}
})
} catch (error) {
console.error(error)
notification('error', 'Error', 'Failed to generate token.')
@@ -149,5 +283,91 @@ const generateToken = async () => {
isGenerating.value = false
}
}
onMounted(async () => await loadContent())
const changeStream = async (e, path = null) => {
if (!path) {
path = e.target.value
}
if (!path) {
selectedItem.value = {}
return
}
selectedItem.value = item.value.files.find(item => item.path === path);
filterStreams(['subtitle', 'audio']).forEach(s => {
if (1 === parseInt(ag(s, 'disposition.default', 0))) {
console.debug(`Setting default '${s.codec_type}' stream to '${s.index}'`)
config.value['audio' === s.codec_type ? 'audio' : 'subtitle'] = s.index
}
})
}
const filterStreams = type => {
if (!selectedItem?.value || !selectedItem.value?.ffprobe?.streams) {
return []
}
if (!type) {
return selectedItem.value?.ffprobe?.streams
}
if (typeof type === 'string') {
type = [type]
}
return selectedItem.value?.ffprobe?.streams.filter(s => type.includes(s.codec_type))
}
const closeStream = async () => {
isPlaying.value = false
playUrl.value = ''
await useRouter().push({path: `/history/${id}`})
}
const toggleWatched = async () => {
if (!item.value) {
return
}
if (!confirm(`Mark '${makeName(item.value)}' as ${item.value.watched ? 'unplayed' : 'played'}?`)) {
return
}
try {
const response = await request(`/history/${item.value.id}/watch`, {
method: item.value.watched ? 'DELETE' : 'POST'
})
const json = await response.json()
if (200 !== response.status) {
notification('error', 'Error', `${json.error.code}: ${json.error.message}`)
return
}
item.value.watched = !item.value.watched
notification('success', '', `Marked '${makeName(item.value)}' as ${item.value.watched ? 'played' : 'unplayed'}`)
} catch (e) {
notification('error', 'Error', `Request error. ${e}`)
}
}
const onPopState = () => {
if (route.query?.token) {
playUrl.value = `${useStorage('api_url', '').value}${useStorage('api_path', '/v1/api').value}/player/playlist/${route.query.token}/master.m3u8`
isPlaying.value = true
} else {
isPlaying.value = false
playUrl.value = ''
}
}
onMounted(async () => {
window.addEventListener('popstate', onPopState)
await loadContent()
if (route.query?.token) {
playUrl.value = `${useStorage('api_url', '').value}${useStorage('api_path', '/v1/api').value}/player/playlist/${route.query.token}/master.m3u8`
isPlaying.value = true
}
})
onUnmounted(() => window.removeEventListener('popstate', onPopState))
</script>

34
frontend/utils/awaiter.ts Normal file
View File

@@ -0,0 +1,34 @@
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// for non arrays, length is undefined, so != 0
const isNotTruthy = (val: any) => val === undefined || val === false || val === null || val.length === 0;
/**
* Waits for the test function to return a truthy value.
*
* @param test - The function to test
* @param timeout_ms - The maximum time to wait in milliseconds.
* @param frequency - The frequency to check the test function in milliseconds.
*
* @returns The result of the test function.
*/
export default async function awaiter(test: Function, timeout_ms: number = 20 * 1000, frequency: number = 200) {
if (typeof (test) != "function") {
throw new Error("test should be a function in awaiter(test, [timeout_ms], [frequency])")
}
const endTime: number = Date.now() + timeout_ms;
let result = test();
while (isNotTruthy(result)) {
if (Date.now() > endTime) {
return false;
}
await sleep(frequency);
result = test();
}
return result;
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\API\History;
use App\API\Player\Subtitle;
use App\Libs\Attributes\Route\Delete;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Route;
@@ -15,9 +16,13 @@ use App\Libs\Enums\Http\Status;
use App\Libs\Guid;
use App\Libs\Mappers\Import\DirectMapper;
use App\Libs\Traits\APITraits;
use JsonException;
use PDO;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\CacheInterface as iCache;
use RuntimeException;
use SplFileInfo;
final class Index
{
@@ -43,7 +48,7 @@ final class Index
public const string URL = '%{api.prefix}/history';
private PDO $pdo;
public function __construct(private readonly iDB $db, private DirectMapper $mapper)
public function __construct(private readonly iDB $db, private DirectMapper $mapper, private iCache $cache)
{
$this->pdo = $this->db->getPDO();
}
@@ -450,7 +455,7 @@ final class Index
}
#[Get(self::URL . '/{id:\d+}[/]', name: 'history.read')]
public function read(string $id): iResponse
public function read(iRequest $request, string $id): iResponse
{
$entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]);
@@ -464,6 +469,45 @@ final class Index
$entity['content_exists'] = file_exists($entity['content_path']);
}
$params = DataUtil::fromArray($request->getQueryParams());
if ($params->get('files')) {
$ffprobe = [];
foreach ($item->getMetadata() as $backend => $metadata) {
if (null === ($file = ag($metadata, 'path', null))) {
continue;
}
if (false !== ($key = array_search($file, array_column($ffprobe, 'path'), true))) {
$ffprobe[$key]['source'][] = $backend;
continue;
}
if (false === file_exists($file)) {
continue;
}
try {
$data = ffprobe_file($file, $this->cache);
} catch (RuntimeException|JsonException) {
continue;
}
$ffprobe[] = [
'path' => $file,
'source' => [$backend],
'ffprobe' => $data,
'subtitles' => array_filter(
findSideCarFiles(new SplFileInfo($file)),
fn($sideCar) => isset(Subtitle::FORMATS[getExtension($sideCar)])
)
];
}
$entity['files'] = $ffprobe;
}
return api_response(Status::OK, $entity);
}
@@ -513,6 +557,6 @@ final class Index
queuePush($item);
return $this->read($id);
return $this->read($request, $id);
}
}

View File

@@ -23,7 +23,6 @@ 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)
{
@@ -57,6 +56,8 @@ readonly class Playlist
$isSecure = (bool)Config::get('api.secure', false);
$hasSelectedSubs = !empty(ag($sConfig, ['subtitle', 'external'], null));
try {
$ffprobe = ffprobe_file($path, $this->cache);
@@ -68,42 +69,46 @@ readonly class Playlist
$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 === $hasSelectedSubs) {
// -- Include sidecar subtitles in the playlist.
foreach (findSideCarFiles(new SplFileInfo($path)) as $sideFile) {
$extension = getExtension($sideFile);
if (false === in_array($extension, array_keys(Subtitle::FORMATS))) {
continue;
}
if (false === in_array($extension, array_keys(Subtitle::FORMATS))) {
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;
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, '.'),
],
];
}
}
// -- 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')) {
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;
@@ -116,87 +121,88 @@ readonly class Playlist
$subtitleUrl = parseConfigValue(Subtitle::URL);
foreach (ag($sConfig, 'externals', []) as $id => $x) {
$ext = getExtension(ag($x, 'path'));
$file = ag($x, 'path');
if (false === $hasSelectedSubs) {
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;
$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),
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') : '',
]);
} else {
$name = basename($file);
// -- 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,
]
);
}
$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') : '',
]);
foreach (ag($ffprobe, 'streams', []) as $id => $x) {
if ('subtitle' !== ag($x, 'codec_type')) {
continue;
}
// -- 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);
if (false === in_array(ag($x, 'codec_name'), Subtitle::INTERNAL_NAMING)) {
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=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{uri}"',
[
'lang' => $lang,
'name' => $title,
'codec' => ag($x, 'codec_name'),
'uri' => $link,
'index' => $id,
]
);
}
$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;
}
if (false === in_array(ag($x, 'codec_name'), Subtitle::INTERNAL_NAMING)) {
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}', [

View File

@@ -8,6 +8,7 @@ use App\Libs\Attributes\Route\Get;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use App\Libs\Stream;
use App\Libs\VttConverter;
use JsonException;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
@@ -153,7 +154,12 @@ final readonly class Subtitle
]), Status::BAD_REQUEST);
}
$response = $this->make($path, $stream, (bool)ag($data, 'config.debug', false));
$response = $this->make(
$path,
$stream,
(bool)ag($data, 'config.debug', false),
(bool)ag($request->getQueryParams(), 'reload', false)
);
if (Status::OK !== Status::from($response->getStatusCode())) {
return $response;
@@ -178,7 +184,7 @@ final readonly class Subtitle
/**
* @throws InvalidArgumentException
*/
private function make(string $file, int|null $stream = null, bool $debug = false): iResponse
private function make(string $file, int|null $stream = null, bool $debug = false, bool $noCache = false): iResponse
{
if (false === file_exists($file)) {
return api_error(r("Path '{path}' is not found.", ['path' => $file]), Status::NOT_FOUND);
@@ -196,7 +202,7 @@ final readonly class Subtitle
}
$cacheKey = md5("{$file}{$kStream}:{$size}");
if ($this->cache->has($cacheKey)) {
if (false === $noCache && $this->cache->has($cacheKey)) {
return api_response(Status::OK, Stream::create($this->cache->get($cacheKey)), [
'Content-Type' => 'text/vtt',
'X-Accel-Buffering' => 'no',
@@ -274,6 +280,25 @@ final readonly class Subtitle
$body = $process->getOutput();
try {
$vtt = VttConverter::parse($body);
if (!empty($vtt) && count($vtt) > 2) {
$firstKey = array_key_first($vtt);
$lastKey = array_key_last($vtt);
if (null !== $firstKey && null !== $lastKey && $firstKey !== $lastKey) {
$firstEndTime = $vtt[$firstKey]['end'];
$lastEndTime = $vtt[$lastKey]['end'];
if ($firstEndTime === $lastEndTime) {
unset($vtt[$firstKey]);
$body = VttConverter::export($vtt);
}
}
}
} catch (Throwable) {
// -- pass subtitles as it is.
}
$this->cache->set($cacheKey, $body);
return api_response(Status::OK, Stream::create($body), [

View File

@@ -54,7 +54,7 @@ final readonly class Sign
'id' => $id,
'path' => $path,
'time' => $time,
'config' => [],
'config' => $params->get('config'),
'version' => getAppVersion(),
], $expires, $this->cache);

269
src/Libs/VttConverter.php Normal file
View File

@@ -0,0 +1,269 @@
<?php
namespace App\Libs;
use InvalidArgumentException;
/**
* Class VttConverter
* Based on {@link https://github.com/mantas-done/subtitles/blob/master/src/Code/Converters/VttConverter.php}
*/
final readonly class VttConverter
{
private const string TIME_FORMAT = '/(?:\d{2}[:;])(?:\d{1,2}[:;])(?:\d{1,2}[:;])\d{1,3}|(?:\d{1,2}[:;])?(?:\d{1,2}[:;])\d{1,3}(?:[.,]\d+)?(?!\d)|\d{1,5}[.,]\d{1,3}/';
public static function parse(string $contents): array
{
$content = self::removeComments($contents);
$lines = mb_split("\n", $content);
$colonCount = 1;
$internalFormat = [];
$i = -1;
$seenFirstTimestamp = false;
$lastLineWasEmpty = true;
foreach ($lines as $line) {
$parts = self::getLineParts($line, $colonCount, 2);
if (false === $seenFirstTimestamp && $parts['start'] && $parts['end'] && str_contains($line, '-->')) {
$seenFirstTimestamp = true;
}
if (!$seenFirstTimestamp) {
continue;
}
if ($parts['start'] && $parts['end'] && true === str_contains($line, '-->')) {
$i++;
$internalFormat[$i]['start'] = self::vttTimeToInternal($parts['start']);
$internalFormat[$i]['end'] = self::vttTimeToInternal($parts['end']);
$internalFormat[$i]['lines'] = [];
// styles
preg_match(
'/((?:\d{1,2}:){1,2}\d{2}\.\d{1,3})\s+-->\s+((?:\d{1,2}:){1,2}\d{2}\.\d{1,3}) *(.*)/',
$line,
$matches
);
if (isset($matches[3]) && ltrim($matches[3])) {
$internalFormat[$i]['vtt']['settings'] = ltrim($matches[3]);
}
// cue
if (!$lastLineWasEmpty && isset($internalFormat[$i - 1])) {
$count = count($internalFormat[$i - 1]['lines']);
if ($count === 1) {
$internalFormat[$i - 1]['lines'][0] = '';
} else {
unset($internalFormat[$i - 1]['lines'][$count - 1]);
}
}
} elseif ('' !== trim($line)) {
$textLine = $line;
// speaker
$speaker = null;
if (preg_match('/<v(?: (.*?))?>((?:.*?)<\/v>)/', $textLine, $matches)) {
$speaker = $matches[1] ?? null;
$textLine = $matches[2];
}
// html
$textLine = strip_tags($textLine);
$internalFormat[$i]['lines'][] = $textLine;
$internalFormat[$i]['vtt']['speakers'][] = $speaker;
}
// remove if empty speakers array.
if (isset($internalFormat[$i]['vtt']['speakers'])) {
$is_speaker = false;
foreach ($internalFormat[$i]['vtt']['speakers'] as $tmp_speaker) {
if ($tmp_speaker !== null) {
$is_speaker = true;
}
}
if (false === $is_speaker) {
unset($internalFormat[$i]['vtt']['speakers']);
if (0 === count($internalFormat[$i]['vtt'])) {
/** @noinspection PhpConditionAlreadyCheckedInspection */
unset($internalFormat[$i]['vtt']);
}
}
}
$lastLineWasEmpty = '' === trim($line);
}
return $internalFormat;
}
public static function export(array $data): string
{
$fileContent = "WEBVTT\r\n\r\n";
foreach ($data as $block) {
$start = self::internalTimeToVtt($block['start']);
$end = self::internalTimeToVtt($block['end']);
$newLines = '';
foreach ($block['lines'] as $i => $line) {
if (isset($block['vtt']['speakers'][$i])) {
$speaker = $block['vtt']['speakers'][$i];
$newLines .= '<v ' . $speaker . '>' . $line . "</v>\r\n";
} else {
$newLines .= $line . "\r\n";
}
}
$vttSettings = '';
if (isset($block['vtt']['settings'])) {
$vttSettings = ' ' . $block['vtt']['settings'];
}
$fileContent .= $start . ' --> ' . $end . $vttSettings . "\r\n";
$fileContent .= $newLines;
$fileContent .= "\r\n";
}
return trim($fileContent);
}
private static function vttTimeToInternal($vtt_time): float
{
$corrected_time = str_replace(',', '.', $vtt_time);
$parts = explode('.', $corrected_time);
// parts[0] could be mm:ss or hh:mm:ss format -> always use hh:mm:ss
$parts[0] = 2 === substr_count($parts[0], ':') ? $parts[0] : '00:' . $parts[0];
if (!isset($parts[1])) {
throw new InvalidArgumentException("Invalid timestamp - time doesn't have milliseconds: " . $vtt_time);
}
$only_seconds = strtotime("1970-01-01 {$parts[0]} UTC");
$milliseconds = (float)('0.' . $parts[1]);
return $only_seconds + $milliseconds;
}
private static function internalTimeToVtt($internal_time): string
{
$parts = explode('.', $internal_time); // 1.23
$whole = $parts[0]; // 1
$decimal = isset($parts[1]) ? substr($parts[1], 0, 3) : 0; // 23
return gmdate("H:i:s", floor($whole)) . '.' . str_pad($decimal, 3, '0', STR_PAD_RIGHT);
}
private static function removeComments($content): string
{
$lines = mb_split("\n", $content);
$lines = array_map('trim', $lines);
$new_lines = [];
$is_comment = false;
foreach ($lines as $line) {
if ($is_comment && strlen($line)) {
continue;
}
if (str_starts_with($line, 'NOTE ')) {
$is_comment = true;
continue;
}
$is_comment = false;
$new_lines[] = $line;
}
return implode("\n", $new_lines);
}
private static function getLineParts($line, $colon_count, $timestamp_count)
{
$matches = [
'start' => null,
'end' => null,
'text' => null,
];
$timestamps = self::timestampsFromLine($line);
// there shouldn't be any text before the timestamp
// if there is text before it, then it is not a timestamp
$right_timestamp = '';
if (isset($timestamps['start']) && (substr_count($timestamps['start'], ':') >= $colon_count || substr_count(
$timestamps['start'],
';'
) >= $colon_count)) {
$text_before_timestamp = substr($line, 0, strpos($line, $timestamps['start']));
if (!self::hasText($text_before_timestamp)) {
// start
$matches['start'] = $timestamps['start'];
$right_timestamp = $matches['start'];
if ($timestamp_count === 2 && isset($timestamps['end']) && (substr_count(
$timestamps['end'],
':'
) >= $colon_count || substr_count($timestamps['end'], ';') >= $colon_count)) {
// end
$matches['end'] = $timestamps['end'];
$right_timestamp = $matches['end'];
}
}
}
// check if there is any text after the timestamp
if ($right_timestamp) {
$tmp_parts = explode($right_timestamp, $line); // if start and end timestamp are equals
$right_text = end($tmp_parts); // take text after the end timestamp
if (self::hasText($right_text) || self::hasDigit($right_text)) {
$matches['text'] = trim($right_text);
}
} else {
$matches['text'] = $line;
}
return $matches;
}
private static function timestampsFromLine(string $line)
{
preg_match_all(self::TIME_FORMAT . 'm', $line, $timestamps);
$result = [
'start' => null,
'end' => null,
];
if (isset($timestamps[0][0])) {
$result['start'] = $timestamps[0][0];
}
if (isset($timestamps[0][1])) {
$result['end'] = $timestamps[0][1];
}
if ($result['start']) {
$text_before_timestamp = substr($line, 0, strpos($line, $result['start']));
if (self::hasText($text_before_timestamp)) {
$result = [
'start' => null,
'end' => null,
];
}
}
return $result;
}
private static function hasText(string $line): bool
{
return 1 === preg_match('/\p{L}/u', $line);
}
private static function hasDigit(string $line): bool
{
return 1 === preg_match('/\d/', $line);
}
}