@@ -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
5
composer.lock
generated
@@ -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"
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
34
frontend/utils/awaiter.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}', [
|
||||
|
||||
@@ -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), [
|
||||
|
||||
@@ -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
269
src/Libs/VttConverter.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user