diff --git a/Dockerfile b/Dockerfile index cba55959..9faf7339 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 '' && \ diff --git a/README.md b/README.md index 01a77066..3526a959 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,22 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers. ## Updates +### 2024-08-10 + +I have recently added new experimental feature, to play your content directly from the WebUI. This feature is still in +alpha, and missing a lot of features. But it's a start. Right now it does auto transcode on the fly to play any content in the browser. + +The feature requires that you mount your media directories to the `WatchState` container similar to the `File integrity` feature. I have plans to expand +the feature to support more controls, however, right now it's only support basic subtitles streams and default audio stream or first audio stream. + +The transcoder works by converting the media on the fly to `HLS` segments, and the subtitles are selectable via the player ui which are also converted to `vtt` format. + +Expects bugs and issues, as the feature is still in alpha. But I would love to hear your feedback. You can play the media by visiting +the history page of the item you will see red play button on top right corner of the page. If the items has a play button, then you correctly mounted +the media directories. otherwise, the button be disabled with tooltip of `Media is inaccessible`. + +The feature is not meant to replace your backend media player, the purpose of this feature is to quickly check the media without leaving the WebUI. + ### 2024-08-01 We recently enabled listening on tls connections via `8443` which can be controlled by `HTTPS_PORT` environment variable. diff --git a/composer.json b/composer.json index 0e008661..4f0e8fe0 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "ext-simplexml": "*", "ext-fileinfo": "*", "ext-redis": "*", + "ext-posix": "*", "monolog/monolog": "^3.4", "symfony/console": "^6.1.4", "symfony/cache": "^6.1.3", @@ -43,8 +44,7 @@ "psy/psysh": "^0.11.22" }, "suggest": { - "ext-sockets": "For UDP commincations.", - "ext-posix": "to check if running under super user." + "ext-sockets": "For UDP commincations." }, "require-dev": { "roave/security-advisories": "dev-latest", diff --git a/composer.lock b/composer.lock index 7a18b32a..121eda4a 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "1aac4505fd9a6fa51ceef1181c194d7a", + "content-hash": "b1b5fdc74178fdeafb640278ee9dcc00", "packages": [ { "name": "dragonmantank/cron-expression", @@ -3114,12 +3114,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "0cc79cfabcebf66307fe5a686367bc425a7b96b6" + "reference": "fe2777b484817ebbbe50ad685af7525560198c59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/0cc79cfabcebf66307fe5a686367bc425a7b96b6", - "reference": "0cc79cfabcebf66307fe5a686367bc425a7b96b6", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe2777b484817ebbbe50ad685af7525560198c59", + "reference": "fe2777b484817ebbbe50ad685af7525560198c59", "shasum": "" }, "conflict": { @@ -3216,7 +3216,7 @@ "codeigniter4/shield": "<1.0.0.0-beta8", "codiad/codiad": "<=2.8.4", "composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7", - "concrete5/concrete5": "<9.2.8", + "concrete5/concrete5": "<9.3.3", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", @@ -3237,7 +3237,7 @@ "datatables/datatables": "<1.10.10", "david-garcia/phpwhois": "<=4.3.1", "dbrisinajumi/d2files": "<1", - "dcat/laravel-admin": "<=2.1.3.0-beta", + "dcat/laravel-admin": "<=2.1.3", "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", "desperado/xml-bundle": "<=0.1.7", @@ -3303,6 +3303,7 @@ "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<6", + "fisharebest/webtrees": "<=2.1.18", "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", "fixpunkt/fp-newsletter": "<1.1.1|>=2,<2.1.2|>=2.2,<3.2.6", "flarum/core": "<1.8.5", @@ -3386,6 +3387,7 @@ "innologi/typo3-appointments": "<2.0.6", "intelliants/subrion": "<4.2.2", "inter-mediator/inter-mediator": "==5.5", + "ipl/web": "<0.10.1", "islandora/islandora": ">=2,<2.4.1", "ivankristianto/phpwhois": "<=4.3", "jackalope/jackalope-doctrine-dbal": "<1.7.4", @@ -3468,7 +3470,7 @@ "microsoft/microsoft-graph": ">=1.16,<1.109.1|>=2,<2.0.1", "microsoft/microsoft-graph-beta": "<2.0.1", "microsoft/microsoft-graph-core": "<2.0.2", - "microweber/microweber": "<=2.0.4", + "microweber/microweber": "<=2.0.16", "mikehaertl/php-shellcommand": "<1.6.1", "miniorange/miniorange-saml": "<1.4.3", "mittwald/typo3_forum": "<1.2.1", @@ -3633,8 +3635,8 @@ "serluck/phpwhois": "<=4.2.6", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<=1.2", - "shopware/core": "<6.5.8.8-dev|>=6.6.0.0-RC1-dev,<6.6.1", - "shopware/platform": "<6.5.8.8-dev|>=6.6.0.0-RC1-dev,<6.6.1", + "shopware/core": "<=6.5.8.12|>=6.6,<=6.6.5", + "shopware/platform": "<=6.5.8.12|>=6.6,<=6.6.5", "shopware/production": "<=6.3.5.2", "shopware/shopware": "<=5.7.17", "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", @@ -3764,6 +3766,7 @@ "tribalsystems/zenario": "<9.5.60602", "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", + "twbs/bootstrap": "<=3.4.1|>=4,<=4.6.2", "twig/twig": "<1.44.7|>=2,<2.15.3|>=3,<3.4.3", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", @@ -3918,7 +3921,7 @@ "type": "tidelift" } ], - "time": "2024-07-31T17:04:31+00:00" + "time": "2024-08-08T21:04:55+00:00" }, { "name": "sebastian/cli-parser", @@ -4958,7 +4961,8 @@ "ext-sodium": "*", "ext-simplexml": "*", "ext-fileinfo": "*", - "ext-redis": "*" + "ext-redis": "*", + "ext-posix": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/config/languageCodes.php b/config/languageCodes.php new file mode 100644 index 00000000..6854b8df --- /dev/null +++ b/config/languageCodes.php @@ -0,0 +1,131 @@ + [ + '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', + ], + ]; +})(); diff --git a/frontend/components/Player.vue b/frontend/components/Player.vue new file mode 100644 index 00000000..56bdf079 --- /dev/null +++ b/frontend/components/Player.vue @@ -0,0 +1,138 @@ + + + + + + diff --git a/frontend/components/TaskRunnerStatus.vue b/frontend/components/TaskRunnerStatus.vue new file mode 100644 index 00000000..fba01877 --- /dev/null +++ b/frontend/components/TaskRunnerStatus.vue @@ -0,0 +1,82 @@ + + + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 45d5f109..77eee395 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -130,6 +130,16 @@ + + +
+
- +
@@ -330,10 +342,14 @@ import {useStorage} from '@vueuse/core' import request from '~/utils/request.js' import Markdown from '~/components/Markdown.vue' import {dEvent} from '~/utils/index.js' +import TaskRunnerStatus from "~/components/TaskRunnerStatus.vue"; const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')()) const showConnection = ref(false) +const taskRunner = ref({status: true, message: '', restartable: false}) +const showTaskRunner = ref(false) + const real_api_url = useStorage('api_url', window.location.origin) const real_api_path = useStorage('api_path', '/v1/api') const real_api_token = useStorage('api_token', '') @@ -406,6 +422,10 @@ onMounted(async () => { } await getVersion() + const response = await request('/system/taskrunner') + taskRunner.value = await response.json(); + + window.addEventListener('taskrunner_update', e => taskRunner.value = e.detail) } catch (e) { } }) diff --git a/frontend/package.json b/frontend/package.json index c372eb28..8b94c3b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": {} } diff --git a/frontend/pages/history/[id]/index.vue b/frontend/pages/history/[id]/index.vue index 7ed6d282..3b2181f0 100644 --- a/frontend/pages/history/[id]/index.vue +++ b/frontend/pages/history/[id]/index.vue @@ -10,6 +10,13 @@
+

+ +

+

+
+
+
+ + + + + diff --git a/frontend/pages/tasks.vue b/frontend/pages/tasks.vue index 375d2b4a..e0337302 100644 --- a/frontend/pages/tasks.vue +++ b/frontend/pages/tasks.vue @@ -8,13 +8,6 @@
-

- -

-
- - {{ status.message }} -

- - To restart the task runner, you have to restart the container. -

-
-
-
@@ -192,10 +171,8 @@ useHead({title: 'Tasks'}) const tasks = ref([]) const queued = ref([]) -const status = ref({}) const isLoading = ref(false) const show_page_tips = useStorage('show_page_tips', true) -const show_worker_status = useStorage('show_worker_status', false) const loadContent = async () => { isLoading.value = true @@ -208,7 +185,8 @@ const loadContent = async () => { } tasks.value = json.tasks queued.value = json.queued - status.value = json.status + + dEvent('taskrunner_update', json.status); } catch (e) { notification('error', 'Error', `Request error. ${e.message}`) } finally { @@ -216,7 +194,7 @@ const loadContent = async () => { } } -onMounted(() => loadContent()) +onMounted(async () => await loadContent()) const toggleTask = async task => { try { diff --git a/frontend/utils/index.js b/frontend/utils/index.js index 41d9efc4..209b13e4 100644 --- a/frontend/utils/index.js +++ b/frontend/utils/index.js @@ -336,12 +336,9 @@ const makeSearchLink = (type, query) => { * * @param eventName * @param detail - * @returns {void} + * @returns {boolean} */ -const dEvent = (eventName, detail = {}) => { - console.debug('Dispatching event', eventName, detail); - window.dispatchEvent(new CustomEvent(eventName, {detail})) -} +const dEvent = (eventName, detail = {}) => window.dispatchEvent(new CustomEvent(eventName, {detail})) /** * Make name @@ -453,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, @@ -474,4 +485,5 @@ export { TOOLTIP_DATE_FORMAT, makeSecret, explode, + basename } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index dab6f703..099984af 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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" diff --git a/src/API/History/Index.php b/src/API/History/Index.php index 0d8e7d70..63f94d7e 100644 --- a/src/API/History/Index.php +++ b/src/API/History/Index.php @@ -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); } } diff --git a/src/API/Player/M3u8.php b/src/API/Player/M3u8.php new file mode 100644 index 00000000..804f38e3 --- /dev/null +++ b/src/API/Player/M3u8.php @@ -0,0 +1,82 @@ +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)), + ]); + } +} diff --git a/src/API/Player/Playlist.php b/src/API/Player/Playlist.php new file mode 100644 index 00000000..b2cefb01 --- /dev/null +++ b/src/API/Player/Playlist.php @@ -0,0 +1,218 @@ +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); + } + } +} diff --git a/src/API/Player/Segments.php b/src/API/Player/Segments.php new file mode 100644 index 00000000..d38c6697 --- /dev/null +++ b/src/API/Player/Segments.php @@ -0,0 +1,328 @@ +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 []; + } +} diff --git a/src/API/Player/Subtitle.php b/src/API/Player/Subtitle.php new file mode 100644 index 00000000..87a7a65a --- /dev/null +++ b/src/API/Player/Subtitle.php @@ -0,0 +1,301 @@ + '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 []; + } +} diff --git a/src/API/System/Sign.php b/src/API/System/Sign.php new file mode 100644 index 00000000..1faecf6a --- /dev/null +++ b/src/API/System/Sign.php @@ -0,0 +1,100 @@ +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); + } +} diff --git a/src/API/System/TaskRunner.php b/src/API/System/TaskRunner.php new file mode 100644 index 00000000..46c7f982 --- /dev/null +++ b/src/API/System/TaskRunner.php @@ -0,0 +1,42 @@ +status = isTaskWorkerRunning(); + } + + #[Get(self::URL . '[/]', name: 'system.taskrunner.status')] + public function status(): iResponse + { + return api_response(Status::OK, $this->status); + } + + #[Post(self::URL . '/restart[/]', name: 'system.taskrunner.restart')] + public function restart(): iResponse + { + if (true === (bool)env('DISABLE_CRON', false)) { + return api_error("Task runner is disabled via 'DISABLE_CRON' environment variable.", Status::BAD_REQUEST); + } + + if (!inContainer()) { + return api_error('WatchState is not running in a container.', Status::BAD_REQUEST); + } + + return api_response(Status::OK, restartTaskWorker()); + } +} diff --git a/src/Libs/Middlewares/APIKeyRequiredMiddleware.php b/src/Libs/Middlewares/APIKeyRequiredMiddleware.php index 6fec9208..458205a7 100644 --- a/src/Libs/Middlewares/APIKeyRequiredMiddleware.php +++ b/src/Libs/Middlewares/APIKeyRequiredMiddleware.php @@ -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 diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index bfd31b52..13f13894 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -1093,7 +1093,6 @@ if (false === function_exists('isValidURL')) { } } - if (false === function_exists('getSystemMemoryInfo')) { /** * Get system memory information. @@ -1616,14 +1615,27 @@ if (!function_exists('isTaskWorkerRunning')) { if (false === $ignoreContainer && !inContainer()) { return [ 'status' => true, + 'restartable' => false, 'message' => 'We can only track the task worker status when running in a container.' ]; } + if (true === (bool)env('DISABLE_CRON', false)) { + return [ + 'status' => false, + 'restartable' => false, + 'message' => "Task runner is disabled via 'DISABLE_CRON' environment variable." + ]; + } + $pidFile = '/tmp/ws-job-runner.pid'; if (!file_exists($pidFile)) { - return ['status' => false, 'message' => 'No PID file was found - Likely means task worker failed to run.']; + return [ + 'status' => false, + 'restartable' => true, + 'message' => 'No PID file was found - Likely means task worker failed to run.' + ]; } try { @@ -1633,14 +1645,195 @@ if (!function_exists('isTaskWorkerRunning')) { } if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) { - return ['status' => true, 'message' => 'Task worker is running.']; + return ['status' => true, 'restartable' => true, 'message' => 'Task worker is running.']; } return [ 'status' => false, + 'restartable' => true, 'message' => r("Found PID '{pid}' in file, but it seems the process is not active.", [ 'pid' => $pid ]) ]; } } + +if (!function_exists('restartTaskWorker')) { + /** + * Restart the task worker. + * + * @param bool $ignoreContainer (Optional) Whether to ignore the container check. + * @param bool $force (Optional) Whether to force kill the task worker. + * + * @return array{ status: bool, message: string } + */ + function restartTaskWorker(bool $ignoreContainer = false, bool $force = false): array + { + if (false === $ignoreContainer && !inContainer()) { + return [ + 'status' => true, + 'restartable' => false, + 'message' => 'We can only restart the task worker when running in a container.' + ]; + } + + $pidFile = '/tmp/ws-job-runner.pid'; + + if (true === file_exists($pidFile)) { + try { + $pid = trim((string)(new Stream($pidFile))); + } catch (Throwable $e) { + return ['status' => false, 'restartable' => true, 'message' => $e->getMessage()]; + } + + if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) { + @posix_kill((int)$pid, $force ? SIGKILL : SIGHUP); + } + + clearstatcache(true, $pidFile); + + if (true === file_exists($pidFile)) { + @unlink($pidFile); + } + } + + $process = Process::fromShellCommandline('/opt/bin/job-runner 2>&1 &'); + $process->run(); + + return [ + 'status' => $process->isSuccessful(), + 'restartable' => true, + 'message' => $process->isSuccessful() ? 'Task worker restarted.' : $process->getErrorOutput(), + ]; + } +} + +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; + } +}