├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── scripts │ └── generate_changelog.py └── workflows │ ├── auto-release.yml │ ├── build.yml │ ├── delete-old-builds.yml │ └── stale.yml ├── .gitignore ├── .phpstorm.meta.php ├── CHANGELOG.md ├── Dockerfile ├── FAQ.md ├── LICENSE ├── NEWS.md ├── README.md ├── bin └── console ├── composer.json ├── composer.lock ├── config ├── Middlewares.php ├── config.php ├── directories.php ├── env.spec.php ├── languageCodes.php ├── servers.spec.php └── services.php ├── container └── files │ ├── init-container.sh │ └── redis.conf ├── frontend ├── .gitignore ├── assets │ ├── css │ │ ├── all.css │ │ ├── bulma-switch.css │ │ ├── bulma.css │ │ └── style.css │ └── webfonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff2 │ │ ├── fa-solid-900.ttf │ │ ├── fa-solid-900.woff2 │ │ ├── fa-v4compatibility.ttf │ │ └── fa-v4compatibility.woff2 ├── components │ ├── BackendAdd.vue │ ├── Confirm.vue │ ├── EventView.vue │ ├── Lazy.vue │ ├── Markdown.vue │ ├── Message.vue │ ├── Overlay.vue │ ├── Pager.vue │ ├── Player.vue │ ├── Settings.vue │ ├── TaskScheduler.vue │ └── UserSelection.vue ├── composables │ └── useToast.js ├── error.vue ├── layouts │ ├── default.vue │ └── guest.vue ├── middleware │ └── auth.global.js ├── nuxt.config.ts ├── package.json ├── pages │ ├── auth.vue │ ├── backend │ │ └── [backend] │ │ │ ├── delete.vue │ │ │ ├── edit.vue │ │ │ ├── index.vue │ │ │ ├── libraries.vue │ │ │ ├── mismatched.vue │ │ │ ├── search.vue │ │ │ ├── sessions.vue │ │ │ ├── stale │ │ │ └── [id].vue │ │ │ ├── unmatched.vue │ │ │ └── users.vue │ ├── backends │ │ └── index.vue │ ├── backup.vue │ ├── changelog.vue │ ├── console.vue │ ├── custom │ │ ├── add.vue │ │ ├── addlink.vue │ │ └── index.vue │ ├── env.vue │ ├── events.vue │ ├── help │ │ ├── [...slug].vue │ │ └── index.vue │ ├── history │ │ ├── [id] │ │ │ └── index.vue │ │ └── index.vue │ ├── ignore.vue │ ├── index.vue │ ├── integrity.vue │ ├── logs │ │ ├── [filename].vue │ │ └── index.vue │ ├── parity.vue │ ├── play │ │ └── [id].vue │ ├── processes.vue │ ├── purge_cache.vue │ ├── report.vue │ ├── reset.vue │ ├── suppression.vue │ ├── tasks.vue │ ├── tools │ │ ├── plex_token.vue │ │ └── sub_users.vue │ └── url_check.vue ├── plugins │ ├── toast.js │ └── vuedraggable.client.js ├── pnpm-lock.yaml ├── public │ ├── favicon.ico │ └── images │ │ └── logo.png ├── store │ └── auth.js ├── tsconfig.json ├── utils │ ├── awaiter.ts │ ├── cache.js │ ├── events │ │ └── helpers.js │ ├── index.js │ └── request.js └── web-types.json ├── guides ├── one-way-sync.md ├── two-way-sync.md ├── webhooks-legacy.md └── webhooks.md ├── migrations ├── sqlite_1644418046_create_state_table.sql ├── sqlite_1718473650_add_created_updated_at_fields.sql ├── sqlite_1723926051_add_events_table.sql └── sqlite_1723988129_add-reference-to-events.sql ├── phpstan.neon ├── phpunit.xml.dist ├── pre_init.php ├── psalm.xml.dist ├── public └── index.php ├── screenshots ├── add_backend.png └── index.png ├── src ├── API │ ├── Backend │ │ ├── AccessToken.php │ │ ├── Delete.php │ │ ├── Discover.php │ │ ├── Ignore.php │ │ ├── Index.php │ │ ├── Info.php │ │ ├── Library.php │ │ ├── Mismatched.php │ │ ├── Option.php │ │ ├── Search.php │ │ ├── Sessions.php │ │ ├── Stale.php │ │ ├── Unmatched.php │ │ ├── Update.php │ │ ├── Users.php │ │ ├── Version.php │ │ └── Webhooks.php │ ├── Backends │ │ ├── AccessToken.php │ │ ├── Add.php │ │ ├── Discover.php │ │ ├── Index.php │ │ ├── Mapper.php │ │ ├── PlexToken.php │ │ ├── Spec.php │ │ ├── UUid.php │ │ ├── Users.php │ │ └── ValidateToken.php │ ├── History │ │ └── Index.php │ ├── Ignore │ │ └── Index.php │ ├── Logs │ │ └── Index.php │ ├── Player │ │ ├── Index.php │ │ ├── M3u8.php │ │ ├── Playlist.php │ │ ├── Segments.php │ │ └── Subtitle.php │ ├── System │ │ ├── Auth.php │ │ ├── Backup.php │ │ ├── Cache.php │ │ ├── Command.php │ │ ├── Env.php │ │ ├── Events.php │ │ ├── Explode.php │ │ ├── Guids.php │ │ ├── HealthCheck.php │ │ ├── Images.php │ │ ├── Integrity.php │ │ ├── Parity.php │ │ ├── Processes.php │ │ ├── Report.php │ │ ├── Reset.php │ │ ├── Scheduler.php │ │ ├── Sign.php │ │ ├── Supported.php │ │ ├── Suppressor.php │ │ ├── ToYaml.php │ │ ├── UrlChecker.php │ │ ├── Users.php │ │ └── Version.php │ ├── Tasks.php │ └── Webhook │ │ └── Index.php ├── Backends │ ├── Common │ │ ├── Cache.php │ │ ├── ClientInterface.php │ │ ├── CommonTrait.php │ │ ├── Context.php │ │ ├── Error.php │ │ ├── GuidInterface.php │ │ ├── Levels.php │ │ ├── ManageInterface.php │ │ ├── Request.php │ │ └── Response.php │ ├── Emby │ │ ├── Action │ │ │ ├── Backup.php │ │ │ ├── Export.php │ │ │ ├── GenerateAccessToken.php │ │ │ ├── GetIdentifier.php │ │ │ ├── GetInfo.php │ │ │ ├── GetLibrariesList.php │ │ │ ├── GetLibrary.php │ │ │ ├── GetMetaData.php │ │ │ ├── GetSessions.php │ │ │ ├── GetUser.php │ │ │ ├── GetUsersList.php │ │ │ ├── GetVersion.php │ │ │ ├── GetWebUrl.php │ │ │ ├── Import.php │ │ │ ├── InspectRequest.php │ │ │ ├── ParseWebhook.php │ │ │ ├── Progress.php │ │ │ ├── Proxy.php │ │ │ ├── Push.php │ │ │ ├── SearchId.php │ │ │ ├── SearchQuery.php │ │ │ ├── ToEntity.php │ │ │ ├── UpdateState.php │ │ │ └── getImagesUrl.php │ │ ├── EmbyActionTrait.php │ │ ├── EmbyClient.php │ │ ├── EmbyGuid.php │ │ ├── EmbyManage.php │ │ └── EmbyValidateContext.php │ ├── Jellyfin │ │ ├── Action │ │ │ ├── Backup.php │ │ │ ├── Export.php │ │ │ ├── GenerateAccessToken.php │ │ │ ├── GetIdentifier.php │ │ │ ├── GetInfo.php │ │ │ ├── GetLibrariesList.php │ │ │ ├── GetLibrary.php │ │ │ ├── GetMetaData.php │ │ │ ├── GetSessions.php │ │ │ ├── GetUser.php │ │ │ ├── GetUsersList.php │ │ │ ├── GetVersion.php │ │ │ ├── GetWebUrl.php │ │ │ ├── Import.php │ │ │ ├── InspectRequest.php │ │ │ ├── ParseWebhook.php │ │ │ ├── Progress.php │ │ │ ├── Proxy.php │ │ │ ├── Push.php │ │ │ ├── SearchId.php │ │ │ ├── SearchQuery.php │ │ │ ├── ToEntity.php │ │ │ ├── UpdateState.php │ │ │ └── getImagesUrl.php │ │ ├── JellyfinActionTrait.php │ │ ├── JellyfinClient.php │ │ ├── JellyfinGuid.php │ │ ├── JellyfinManage.php │ │ └── JellyfinValidateContext.php │ └── Plex │ │ ├── Action │ │ ├── Backup.php │ │ ├── Export.php │ │ ├── GetIdentifier.php │ │ ├── GetInfo.php │ │ ├── GetLibrariesList.php │ │ ├── GetLibrary.php │ │ ├── GetMetaData.php │ │ ├── GetSessions.php │ │ ├── GetUser.php │ │ ├── GetUserToken.php │ │ ├── GetUsersList.php │ │ ├── GetVersion.php │ │ ├── GetWebUrl.php │ │ ├── Import.php │ │ ├── InspectRequest.php │ │ ├── ParseWebhook.php │ │ ├── Progress.php │ │ ├── Proxy.php │ │ ├── Push.php │ │ ├── SearchId.php │ │ ├── SearchQuery.php │ │ ├── ToEntity.php │ │ ├── UpdateState.php │ │ └── getImagesUrl.php │ │ ├── Commands │ │ ├── AccessTokenCommand.php │ │ ├── CheckTokenCommand.php │ │ └── DiscoverCommand.php │ │ ├── PlexActionTrait.php │ │ ├── PlexClient.php │ │ ├── PlexGuid.php │ │ ├── PlexManage.php │ │ └── PlexValidateContext.php ├── Cli.php ├── Command.php ├── Commands │ ├── Backend │ │ ├── CreateUsersCommand.php │ │ ├── Ignore │ │ │ ├── ListCommand.php │ │ │ └── ManageCommand.php │ │ ├── InfoCommand.php │ │ ├── Library │ │ │ ├── IgnoreCommand.php │ │ │ ├── ListCommand.php │ │ │ ├── MismatchCommand.php │ │ │ └── UnmatchedCommand.php │ │ ├── RestoreCommand.php │ │ ├── Search │ │ │ ├── IdCommand.php │ │ │ └── QueryCommand.php │ │ ├── Users │ │ │ ├── ListCommand.php │ │ │ └── SessionsCommand.php │ │ └── VersionCommand.php │ ├── Config │ │ ├── AddCommand.php │ │ ├── DeleteCommand.php │ │ ├── EditCommand.php │ │ ├── ListCommand.php │ │ ├── ManageCommand.php │ │ ├── TestCommand.php │ │ └── ViewCommand.php │ ├── Database │ │ ├── ListCommand.php │ │ ├── ParityCommand.php │ │ ├── QueueCommand.php │ │ └── RepairCommand.php │ ├── Events │ │ ├── CacheCommand.php │ │ ├── DispatchCommand.php │ │ ├── ListenersCommand.php │ │ └── QueuedCommand.php │ ├── State │ │ ├── BackupCommand.php │ │ ├── ExportCommand.php │ │ ├── ImportCommand.php │ │ └── ValidateCommand.php │ └── System │ │ ├── APIKeyCommand.php │ │ ├── DiffCommand.php │ │ ├── EnvCommand.php │ │ ├── IndexCommand.php │ │ ├── LogsCommand.php │ │ ├── MaintenanceCommand.php │ │ ├── MakeCommand.php │ │ ├── MigrationsCommand.php │ │ ├── PHPCommand.php │ │ ├── PruneCommand.php │ │ ├── ReportCommand.php │ │ ├── ResetCommand.php │ │ ├── ResetPasswordCommand.php │ │ ├── RoutesCommand.php │ │ ├── SchedulerCommand.php │ │ ├── ServerCommand.php │ │ ├── SuppressCommand.php │ │ ├── TasksCommand.php │ │ └── TinkerCommand.php ├── Libs │ ├── APIResponse.php │ ├── Attributes │ │ ├── DI │ │ │ └── Inject.php │ │ ├── Route │ │ │ ├── Cli.php │ │ │ ├── Delete.php │ │ │ ├── Get.php │ │ │ ├── Head.php │ │ │ ├── Options.php │ │ │ ├── Patch.php │ │ │ ├── Post.php │ │ │ ├── Put.php │ │ │ └── Route.php │ │ └── Scanner │ │ │ ├── Attributes.php │ │ │ ├── Item.php │ │ │ └── Target.php │ ├── Config.php │ ├── ConfigFile.php │ ├── Container.php │ ├── DataUtil.php │ ├── Database │ │ ├── DBLayer.php │ │ ├── DatabaseInterface.php │ │ └── PDO │ │ │ ├── PDOAdapter.php │ │ │ ├── PDODataMigration.php │ │ │ ├── PDOIndexer.php │ │ │ └── PDOMigrations.php │ ├── Emitter.php │ ├── Entity │ │ ├── StateEntity.php │ │ └── StateInterface.php │ ├── Enums │ │ └── Http │ │ │ ├── Method.php │ │ │ └── Status.php │ ├── EnvFile.php │ ├── Events │ │ └── DataEvent.php │ ├── Exceptions │ │ ├── AppExceptionInterface.php │ │ ├── Backends │ │ │ ├── BackendException.php │ │ │ ├── InvalidArgumentException.php │ │ │ ├── InvalidContextException.php │ │ │ ├── NotImplementedException.php │ │ │ ├── RuntimeException.php │ │ │ └── UnexpectedVersionException.php │ │ ├── DBAdapterException.php │ │ ├── DBLayerException.php │ │ ├── EmitterException.php │ │ ├── ErrorException.php │ │ ├── HttpException.php │ │ ├── InvalidArgumentException.php │ │ ├── RuntimeException.php │ │ ├── UseAppException.php │ │ └── ValidationException.php │ ├── Extends │ │ ├── ConsoleHandler.php │ │ ├── ConsoleOutput.php │ │ ├── Date.php │ │ ├── HttpClient.php │ │ ├── LogMessageProcessor.php │ │ ├── LoggerProxy.php │ │ ├── MockHttpClient.php │ │ ├── PSRContainer.php │ │ ├── ProxyHandler.php │ │ ├── ReflectionContainer.php │ │ ├── RemoteHandler.php │ │ ├── RetryableHttpClient.php │ │ ├── RouterStrategy.php │ │ ├── StreamLogHandler.php │ │ └── StreamableChunks.php │ ├── Guid.php │ ├── Initializer.php │ ├── IpUtils.php │ ├── LogSuppressor.php │ ├── Mappers │ │ ├── Import │ │ │ ├── DirectMapper.php │ │ │ ├── MemoryMapper.php │ │ │ ├── ReadOnlyMapper.php │ │ │ └── RestoreMapper.php │ │ └── ImportInterface.php │ ├── Message.php │ ├── Middlewares │ │ ├── AddCorsMiddleware.php │ │ ├── AddTimingMiddleware.php │ │ ├── AuthorizationMiddleware.php │ │ ├── ExceptionHandlerMiddleware.php │ │ ├── NoAccessLogMiddleware.php │ │ └── ParseJsonBodyMiddleware.php │ ├── Options.php │ ├── Profiler.php │ ├── QueueRequests.php │ ├── Response.php │ ├── ServeStatic.php │ ├── Server.php │ ├── Stream.php │ ├── StreamedBody.php │ ├── TestCase.php │ ├── TokenUtil.php │ ├── Traits │ │ └── APITraits.php │ ├── Uri.php │ ├── UserContext.php │ ├── Utils.php │ ├── VttConverter.php │ └── helpers.php ├── Listeners │ ├── ProcessProfileEvent.php │ ├── ProcessProgressEvent.php │ ├── ProcessPushEvent.php │ └── ProcessRequestEvent.php └── Model │ ├── Base │ ├── BasicModel.php │ ├── BasicValidation.php │ ├── Enums │ │ ├── ScalarType.php │ │ └── TransformType.php │ ├── Exceptions │ │ ├── MustBeNonEmpty.php │ │ ├── VDateTypeException.php │ │ ├── VFilterException.php │ │ ├── VValidateException.php │ │ └── ValidationException.php │ ├── Interfaces │ │ └── IDInterface.php │ ├── Traits │ │ ├── UsesBasicRepository.php │ │ └── UsesPaging.php │ └── Transformers │ │ ├── ArrayTransformer.php │ │ ├── DateTransformer.php │ │ ├── EnumTransformer.php │ │ ├── JSONTransformer.php │ │ ├── ScalarTransformer.php │ │ ├── SerializeTransformer.php │ │ └── TimestampTransformer.php │ └── Events │ ├── Event.php │ ├── EventListener.php │ ├── EventStatus.php │ ├── EventValidation.php │ ├── EventsRepository.php │ └── EventsTable.php ├── tests ├── Backends │ ├── Common │ │ ├── CacheTest.php │ │ ├── ErrorTest.php │ │ └── ResponseTest.php │ ├── Emby │ │ └── EmbyGuidTest.php │ ├── Jellyfin │ │ └── JellyfinGuidTest.php │ └── Plex │ │ ├── GetLibrariesListTest.php │ │ └── PlexGuidTest.php ├── Database │ ├── DBLayerTest.php │ └── PDOAdapterTest.php ├── Fixtures │ ├── EpisodeEntity.php │ ├── MovieEntity.php │ ├── local_data │ │ ├── fanart.png │ │ ├── poster.jpg │ │ ├── test.mkv │ │ ├── test.png │ │ └── test.srt │ ├── meminfo_data.txt │ ├── plex_data.json │ ├── static_data │ │ ├── test.css │ │ ├── test.html │ │ ├── test.js │ │ ├── test.json │ │ ├── test.md │ │ ├── test.woff2 │ │ ├── test │ │ │ └── index.html │ │ └── test2 │ │ │ └── test.html │ ├── subtitle.exported.vtt │ ├── subtitle.json │ ├── subtitle.vtt │ ├── test_env_vars │ └── test_servers.yaml ├── Libs │ ├── APIResponseTest.php │ ├── ConfigFileTest.php │ ├── ConfigTest.php │ ├── DataUtilTest.php │ ├── EmitterTest.php │ ├── Enums │ │ └── Http │ │ │ ├── MethodTest.php │ │ │ └── StatusTest.php │ ├── Events │ │ └── DataEventTest.php │ ├── GuidTest.php │ ├── HelpersTest.php │ ├── LogSuppressorTest.php │ ├── MessageTest.php │ ├── Middlewares │ │ ├── AddCorsMiddlewareTest.php │ │ ├── AuthorizationMiddlewareTest.php │ │ ├── ExceptionHandlerMiddlewareTest.php │ │ ├── NoAccessLogMiddlewareTest.php │ │ └── ParseJsonBodyMiddlewareTest.php │ ├── QueueRequestsTest.php │ ├── ResponseTest.php │ ├── ServeStaticTest.php │ ├── StateEntityTest.php │ ├── StreamTest.php │ ├── StreamedBodyTest.php │ ├── Traits │ │ └── APITraitsTest.php │ ├── UriTest.php │ ├── VttConverterTest.php │ └── envFileTest.php ├── Mappers │ └── Import │ │ ├── DirectMapperTest.php │ │ ├── MapperAbstract.php │ │ └── MemoryMapperTest.php ├── Server │ ├── ServerTest.php │ └── resources │ │ ├── index.html │ │ └── index.php ├── Support │ └── RequestResponseTrait.php └── bootstrap.php └── var └── .gitignore /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.idea 2 | **/.git 3 | **/vendor 4 | ./var/* 5 | !./var/.gitignore 6 | .phpunit.result.cache 7 | frontend/.nuxt 8 | frontend/node_modules 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # All PHP files MUST use the Unix LF (linefeed) line ending. 7 | # Code MUST use an indent of 4 spaces, and MUST NOT use tabs for indenting. 8 | # All PHP files MUST end with a single blank line. 9 | # There MUST NOT be trailing whitespace at the end of non-blank lines. 10 | [*] 11 | charset = utf-8 12 | end_of_line = lf 13 | insert_final_newline = true 14 | trim_trailing_whitespace = true 15 | indent_style = space 16 | 17 | # PHP-Files, Composer.json, MD-Files 18 | [{*.php,composer.json,*.md}] 19 | indent_size = 4 20 | 21 | # HTML-Files LESS-Files SASS-Files CSS-Files JS-Files JSON-Files 22 | [{*.html,*.less,*.sass,*.css,*.js,*.json}] 23 | indent_size = 4 24 | 25 | # Gitlab-CI, Travis-CI 26 | [{*.yml,*.yaml}] 27 | indent_size = 2 28 | 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report bug/problem in the tool. 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 14 | 15 | **To Reproduce** 16 | 19 | 20 | **Expected behavior** 21 | 24 | 25 | **Screenshots** 26 | 29 | 30 | **Basic report** 31 | 39 | 40 | ``` 41 | Paste the system:report output here 42 | ``` 43 | 44 | **Additional context** 45 | 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature request 4 | url: https://github.com/arabcoders/watchstate/discussions 5 | about: Please use the discussions tab for features or enhancements requests. 6 | - name: Questions & Answers 7 | url: https://github.com/arabcoders/watchstate/discussions 8 | about: Please use the discussions tab for long questions and general discussions. 9 | - name: Discord Link. 10 | url: https://discord.gg/haUXHJyj6Y 11 | about: Use Discord for quick questions like can you do XX? etc or quick support requests. 12 | -------------------------------------------------------------------------------- /.github/workflows/delete-old-builds.yml: -------------------------------------------------------------------------------- 1 | name: Remove old builds. 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | logLevel: 7 | description: "Log level" 8 | required: false 9 | default: "warning" 10 | type: choice 11 | options: 12 | - info 13 | - warning 14 | - debug 15 | 16 | jobs: 17 | remove-builds: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | packages: write 21 | steps: 22 | - uses: actions/delete-package-versions@v4 23 | with: 24 | package-name: "watchstate" 25 | package-type: "container" 26 | min-versions-to-keep: 50 27 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale Issue Closer 2 | permissions: 3 | issues: write 4 | 5 | on: 6 | schedule: 7 | # ┌───────────── minute (0 - 59) 8 | # │ ┌───────────── hour (0 - 23) 9 | # │ │ ┌───────────── day of the month (1 - 31) 10 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 11 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 12 | # │ │ │ │ │ 13 | # │ │ │ │ │ 14 | # │ │ │ │ │ 15 | # * * * * * 16 | - cron: "0 0 * * *" 17 | 18 | jobs: 19 | main: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Actions 23 | uses: actions/checkout@v3 24 | with: 25 | repository: "microsoft/vscode-github-triage-actions" 26 | path: ./actions 27 | ref: stable 28 | - name: Install Actions 29 | run: npm install --production --prefix ./actions 30 | - name: Run Stale Issue Closer 31 | uses: ./actions/needs-more-info-closer 32 | with: 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | label: waiting for user response 35 | # Close issues that are marked a 'waiting for user response' label and were last interacted with by a contributor or bot, after 30 days has passed. 36 | closeDays: 12 37 | closeComment: "This issue has been closed automatically because it needs more information and has not had recent activity. If the issue still persists, please reopen with the information requested. Thanks." 38 | # Ping the assignee if the last comment was by someone other than a team member or bot after 7 days has passed. 39 | pingDays: 7 40 | pingComment: "Hey @${assignee}, this issue might need further attention.\n\n@${author}, you can help us out by closing this issue if the problem no longer exists, or adding more information." 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/* 2 | /vendor/* 3 | /.env 4 | /.phpunit.result.cache 5 | /.vscode 6 | /frontend/exported/ 7 | .phpactor.json 8 | **.php-cs-fixer.cache 9 | -------------------------------------------------------------------------------- /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | '@'])); 6 | override(\League\Container\ReflectionContainer::get(0), map(['' => '@'])); 7 | override( 8 | \App\Command::getHelper(0), 9 | map([ 10 | 'question' => QuestionHelper::class, 11 | ]) 12 | ); 13 | override( 14 | \Symfony\Component\Console\Command\Command::getHelper(0), 15 | map([ 16 | 'question' => \Symfony\Component\Console\Helper\SymfonyQuestionHelper::class, 17 | ]) 18 | ); 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This file will be populated automatically via github action for containers. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 ArabCoders 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | $e::class, 37 | '{line}' => $e->getLine(), 38 | '{message}' => $e->getMessage(), 39 | '{file}' => after($e->getFile(), ROOT_PATH), 40 | ]); 41 | 42 | fwrite(STDERR, $message . PHP_EOL); 43 | exit(502); 44 | }); 45 | 46 | if (!file_exists(__DIR__ . '/../vendor/autoload.php')) { 47 | print 'Dependencies are missing please refer to https://github.com/arabcoders/watchstate/blob/master/FAQ.md'; 48 | exit(Command::FAILURE); 49 | } 50 | 51 | require __DIR__ . '/../vendor/autoload.php'; 52 | 53 | try { 54 | $app = new App\Libs\Initializer()->boot(); 55 | } catch (Throwable $e) { 56 | $message = strtr( 57 | 'CLI: Exception [{kind}] was thrown unhandled during CLI boot context. Error [{message} @ {file}:{line}].', 58 | [ 59 | '{kind}' => $e::class, 60 | '{line}' => $e->getLine(), 61 | '{message}' => $e->getMessage(), 62 | '{file}' => array_reverse(explode(ROOT_PATH, $e->getFile(), 2))[0], 63 | ] 64 | ); 65 | fwrite(STDERR, $message . PHP_EOL); 66 | fwrite(STDERR, $e->getTraceAsString() . PHP_EOL); 67 | exit(503); 68 | } 69 | 70 | $app->console(); 71 | -------------------------------------------------------------------------------- /config/Middlewares.php: -------------------------------------------------------------------------------- 1 | [ 12 | fn() => new AddTimingMiddleware(), 13 | fn() => new AuthorizationMiddleware(), 14 | fn() => new ParseJsonBodyMiddleware(), 15 | fn() => new NoAccessLogMiddleware(), 16 | fn() => new AddCorsMiddleware(), 17 | ]; 18 | -------------------------------------------------------------------------------- /config/directories.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 | 7 | 8 | {{ toggle ? 'Close' : 'Open' }} 9 |
10 |
12 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 | 68 | -------------------------------------------------------------------------------- /frontend/components/Overlay.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 48 | -------------------------------------------------------------------------------- /frontend/components/UserSelection.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 69 | -------------------------------------------------------------------------------- /frontend/composables/useToast.js: -------------------------------------------------------------------------------- 1 | import {useToast} from "vue-toastification"; 2 | 3 | export default () => useToast() 4 | -------------------------------------------------------------------------------- /frontend/error.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 48 | -------------------------------------------------------------------------------- /frontend/layouts/guest.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /frontend/middleware/auth.global.js: -------------------------------------------------------------------------------- 1 | import {storeToRefs} from 'pinia' 2 | import {useAuthStore} from '~/store/auth' 3 | import {useStorage} from '@vueuse/core' 4 | 5 | let next_check = 0 6 | 7 | export default defineNuxtRouteMiddleware(async to => { 8 | if (to.fullPath.startsWith('/auth') || to.fullPath.startsWith('/v1/api')) { 9 | return 10 | } 11 | 12 | const {authenticated} = storeToRefs(useAuthStore()) 13 | const token = useStorage('token', null) 14 | 15 | if (token.value) { 16 | if (Date.now() > next_check) { 17 | console.debug('Validating user token...') 18 | const {validate} = useAuthStore() 19 | if (!await validate(token.value)) { 20 | token.value = null 21 | abortNavigation() 22 | console.error('Token is invalid, redirecting to login page...') 23 | return navigateTo('/auth') 24 | } 25 | console.debug('Token is valid.') 26 | next_check = Date.now() + 1000 * 60 * 5 27 | } 28 | 29 | authenticated.value = true 30 | } 31 | 32 | if (!token.value && to?.name !== 'auth') { 33 | abortNavigation() 34 | return navigateTo('/auth') 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /frontend/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | 3 | import path from 'path' 4 | 5 | let extraNitro = {} 6 | try { 7 | const API_URL = import.meta.env.NUXT_API_URL; 8 | if (API_URL) { 9 | extraNitro = { 10 | devProxy: { 11 | '/v1/api/': { 12 | target: API_URL + '/v1/api/', 13 | changeOrigin: true 14 | }, 15 | '/guides/': { 16 | target: API_URL + '/guides/', 17 | changeOrigin: true 18 | }, 19 | } 20 | } 21 | } 22 | } catch (e) { 23 | } 24 | 25 | export default defineNuxtConfig({ 26 | ssr: false, 27 | devtools: {enabled: true}, 28 | 29 | devServer: { 30 | port: 8081, 31 | host: "0.0.0.0", 32 | }, 33 | runtimeConfig: { 34 | public: { 35 | domain: '/', 36 | version: '1.0.0', 37 | } 38 | }, 39 | app: { 40 | head: { 41 | "meta": [ 42 | {"charset": "utf-8"}, 43 | {"name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0"}, 44 | {"name": "theme-color", "content": "#000000"} 45 | ], 46 | }, 47 | buildAssetsDir: "assets", 48 | pageTransition: {name: 'page', mode: 'out-in'} 49 | }, 50 | 51 | router: { 52 | options: { 53 | linkActiveClass: "is-selected", 54 | } 55 | }, 56 | 57 | modules: [ 58 | '@vueuse/nuxt', 59 | 'floating-vue/nuxt', 60 | '@pinia/nuxt', 61 | ], 62 | 63 | nitro: { 64 | output: { 65 | publicDir: path.join(__dirname, 'exported') 66 | }, 67 | ...extraNitro, 68 | }, 69 | 70 | build: { 71 | transpile: ['vue-toastification'], 72 | }, 73 | 74 | css: [ 75 | 'vue-toastification/dist/index.css' 76 | ], 77 | 78 | telemetry: false, 79 | compatibilityDate: "2024-12-28", 80 | }) 81 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "web-types": "./web-types.json", 13 | "dependencies": { 14 | "@microsoft/fetch-event-source": "^2.0.1", 15 | "@pinia/nuxt": "^0.11.0", 16 | "@vueuse/core": "^13.2.0", 17 | "@vueuse/nuxt": "^13.2.0", 18 | "@xterm/addon-fit": "^0.10.0", 19 | "@xterm/xterm": "^5.5.0", 20 | "cronstrue": "^2.61.0", 21 | "floating-vue": "^5.2.2", 22 | "hls.js": "^1.6.2", 23 | "marked": "^15.0.12", 24 | "marked-alert": "^2.1.2", 25 | "marked-base-url": "^1.1.6", 26 | "marked-gfm-heading-id": "^4.1.1", 27 | "moment": "^2.30.1", 28 | "nuxt": "^3.17.4", 29 | "pinia": "^3.0.2", 30 | "plyr": "^3.7.8", 31 | "vue": "^3.5.14", 32 | "vue-router": "^4.5.1", 33 | "vue-toastification": "2.0.0-rc.5", 34 | "vuedraggable": "^4.1.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/pages/help/[...slug].vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 33 | -------------------------------------------------------------------------------- /frontend/plugins/toast.js: -------------------------------------------------------------------------------- 1 | import Toast from 'vue-toastification' 2 | 3 | export default defineNuxtPlugin(nuxtApp => nuxtApp.vueApp.use(Toast, { 4 | transition: "Vue-Toastification__bounce", 5 | maxToasts: 5, 6 | closeOnClick: false, 7 | newestOnTop: true, 8 | showCloseButtonOnHover: true, 9 | })) 10 | -------------------------------------------------------------------------------- /frontend/plugins/vuedraggable.client.js: -------------------------------------------------------------------------------- 1 | import draggable from 'vuedraggable' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.component('draggable', draggable) 5 | }) 6 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/frontend/public/images/logo.png -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/utils/awaiter.ts: -------------------------------------------------------------------------------- 1 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 2 | 3 | // for non arrays, length is undefined, so != 0 4 | const isNotTruthy = (val: any) => val === undefined || val === false || val === null || val.length === 0; 5 | 6 | /** 7 | * Waits for the test function to return a truthy value. 8 | * 9 | * @param test - The function to test 10 | * @param timeout_ms - The maximum time to wait in milliseconds. 11 | * @param frequency - The frequency to check the test function in milliseconds. 12 | * 13 | * @returns The result of the test function. 14 | */ 15 | export default async function awaiter(test: Function, timeout_ms: number = 20 * 1000, frequency: number = 200) { 16 | if (typeof (test) != "function") { 17 | throw new Error("test should be a function in awaiter(test, [timeout_ms], [frequency])") 18 | } 19 | 20 | const endTime: number = Date.now() + timeout_ms; 21 | 22 | let result = test(); 23 | 24 | while (isNotTruthy(result)) { 25 | if (Date.now() > endTime) { 26 | return false; 27 | } 28 | await sleep(frequency); 29 | result = test(); 30 | } 31 | 32 | return result; 33 | } 34 | 35 | -------------------------------------------------------------------------------- /frontend/utils/events/helpers.js: -------------------------------------------------------------------------------- 1 | const makeName = id => id.replace(/-/g, '').slice(0, 12) 2 | 3 | const getStatusClass = status => { 4 | switch (status) { 5 | case 0: 6 | return 'is-light has-text-dark' 7 | case 1: 8 | return 'is-warning' 9 | case 2: 10 | return 'is-success' 11 | case 3: 12 | return 'is-danger' 13 | case 4: 14 | return 'is-danger is-light' 15 | default: 16 | return 'is-light has-text-dark' 17 | } 18 | } 19 | 20 | export {makeName, getStatusClass} 21 | -------------------------------------------------------------------------------- /frontend/utils/request.js: -------------------------------------------------------------------------------- 1 | import { useStorage } from "@vueuse/core"; 2 | 3 | const token = useStorage('token', '') 4 | const api_user = useStorage('api_user', 'main') 5 | 6 | /** 7 | * Request content from the API. This function will automatically add the API token to the request headers. 8 | * And prefix the URL with the API URL and path. 9 | * 10 | * @param url {string} 11 | * @param options {RequestInit} 12 | * 13 | * @returns {Promise} 14 | */ 15 | export default async function request(url, options = {}) { 16 | options = options || {}; 17 | options.method = options.method || 'GET'; 18 | options.headers = options.headers || {}; 19 | 20 | if (options.headers['Authorization'] === undefined && token.value) { 21 | options.headers['Authorization'] = 'Token ' + token.value; 22 | } 23 | 24 | if (options.headers['Content-Type'] === undefined) { 25 | options.headers['Content-Type'] = 'application/json'; 26 | } 27 | 28 | if (options.headers['Accept'] === undefined) { 29 | options.headers['Accept'] = 'application/json'; 30 | } 31 | 32 | if (options.headers['X-User'] === undefined) { 33 | options.headers['X-User'] = api_user.value; 34 | } 35 | 36 | return fetch(`/v1/api${url}`, options); 37 | } 38 | 39 | -------------------------------------------------------------------------------- /migrations/sqlite_1644418046_create_state_table.sql: -------------------------------------------------------------------------------- 1 | -- # migrate_up 2 | 3 | CREATE TABLE IF NOT EXISTS "state" 4 | ( 5 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | "type" text NOT NULL, 7 | "updated" integer NOT NULL, 8 | "watched" integer NOT NULL DEFAULT '0', 9 | "via" text NOT NULL, 10 | "title" text NOT NULL, 11 | "year" integer NULL, 12 | "season" integer NULL, 13 | "episode" integer NULL, 14 | "parent" text NULL, 15 | "guids" text NULL, 16 | "metadata" text NULL, 17 | "extra" text NULL 18 | ); 19 | 20 | -- # migrate_down 21 | 22 | DROP TABLE IF EXISTS "state"; 23 | -------------------------------------------------------------------------------- /migrations/sqlite_1718473650_add_created_updated_at_fields.sql: -------------------------------------------------------------------------------- 1 | -- # migrate_up 2 | 3 | ALTER TABLE "state" 4 | ADD COLUMN "created_at" integer NOT NULL DEFAULT '0'; 5 | ALTER TABLE "state" 6 | ADD COLUMN "updated_at" integer NOT NULL DEFAULT '0'; 7 | UPDATE "state" 8 | SET created_at = updated, 9 | updated_at = updated 10 | WHERE created_at = 0; 11 | CREATE INDEX IF NOT EXISTS "state_created_at" ON "state" (created_at); 12 | CREATE INDEX IF NOT EXISTS "state_updated_at" ON "state" (updated_at); 13 | 14 | -- # migrate_down 15 | 16 | ALTER TABLE "state" 17 | DROP COLUMN "created_at"; 18 | ALTER TABLE "state" 19 | DROP COLUMN "updated_at"; 20 | DROP INDEX IF EXISTS "state_created_at"; 21 | DROP INDEX IF EXISTS "state_updated_at"; 22 | -------------------------------------------------------------------------------- /migrations/sqlite_1723926051_add_events_table.sql: -------------------------------------------------------------------------------- 1 | -- # migrate_up 2 | 3 | CREATE TABLE `events` 4 | ( 5 | `id` char(36) NOT NULL, 6 | `status` tinyint(1) NOT NULL DEFAULT 0, 7 | `event` varchar(255) NOT NULL, 8 | `event_data` longtext NOT NULL DEFAULT '{}', 9 | `options` longtext NOT NULL DEFAULT '{}', 10 | `attempts` tinyint(1) NOT NULL DEFAULT 0, 11 | `logs` longtext NOT NULL DEFAULT '{}', 12 | `created_at` datetime NOT NULL, 13 | `updated_at` datetime DEFAULT NULL, 14 | PRIMARY KEY (`id`) 15 | ); 16 | 17 | CREATE INDEX "events_event" ON "events" ("event"); 18 | CREATE INDEX "events_status" ON "events" ("status"); 19 | 20 | -- # migrate_down 21 | 22 | DROP TABLE IF EXISTS "events"; 23 | -------------------------------------------------------------------------------- /migrations/sqlite_1723988129_add-reference-to-events.sql: -------------------------------------------------------------------------------- 1 | -- # migrate_up 2 | 3 | CREATE TABLE "_tmp_events" 4 | ( 5 | "id" text NOT NULL, 6 | "status" integer NOT NULL DEFAULT '0', 7 | "reference" text NULL, 8 | "event" text NOT NULL, 9 | "event_data" text NOT NULL DEFAULT '{}', 10 | "options" text NOT NULL DEFAULT '{}', 11 | "attempts" integer NOT NULL DEFAULT '0', 12 | "logs" text NOT NULL DEFAULT '{}', 13 | "created_at" numeric NOT NULL, 14 | "updated_at" numeric NULL, 15 | PRIMARY KEY ("id") 16 | ); 17 | 18 | INSERT INTO "_tmp_events" ("id", "status", "event", "event_data", "options", "attempts", "logs", "created_at", 19 | "updated_at") 20 | SELECT "id", 21 | "status", 22 | "event", 23 | "event_data", 24 | "options", 25 | "attempts", 26 | "logs", 27 | "created_at", 28 | "updated_at" 29 | FROM "events"; 30 | 31 | DROP TABLE "events"; 32 | ALTER TABLE "_tmp_events" 33 | RENAME TO "events"; 34 | CREATE INDEX "events_event" ON "events" ("event"); 35 | CREATE INDEX "events_status" ON "events" ("status"); 36 | CREATE INDEX "events_reference" ON "events" ("reference"); 37 | 38 | -- # migrate_down 39 | 40 | CREATE TABLE "_tmp_events" 41 | ( 42 | "id" text NOT NULL, 43 | "status" integer NOT NULL DEFAULT '0', 44 | "event" text NOT NULL, 45 | "event_data" text NOT NULL DEFAULT '{}', 46 | "options" text NOT NULL DEFAULT '{}', 47 | "attempts" integer NOT NULL DEFAULT '0', 48 | "logs" text NOT NULL DEFAULT '{}', 49 | "created_at" numeric NOT NULL, 50 | "updated_at" numeric NULL, 51 | PRIMARY KEY ("id") 52 | ); 53 | 54 | INSERT INTO "_tmp_events" ("id", "status", "event", "event_data", "options", "attempts", "logs", "created_at", 55 | "updated_at") 56 | SELECT "id", 57 | "status", 58 | "event", 59 | "event_data", 60 | "options", 61 | "attempts", 62 | "logs", 63 | "created_at", 64 | "updated_at" 65 | FROM "events"; 66 | 67 | DROP TABLE "events"; 68 | ALTER TABLE "_tmp_events" 69 | RENAME TO "events"; 70 | CREATE INDEX "events_event" ON "events" ("event"); 71 | CREATE INDEX "events_status" ON "events" ("status"); 72 | 73 | -- put your downgrade database commands here. 74 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | bootstrapFiles: 4 | - ./pre_init.php 5 | excludePaths: 6 | - ./tests/* 7 | - ./vendor/* 8 | - ./data/* 9 | - ./public/* 10 | - ./assets/* 11 | fileExtensions: 12 | - php 13 | paths: 14 | - ./src/ 15 | - ./config/ 16 | - ./bin/ 17 | - ./public/ 18 | ignoreErrors: 19 | - '#iterable type (array|PDOStatement|iterable)#' 20 | - '#Property (.+) \(string\) does not accept mixed.#' 21 | - '#Cannot cast mixed to string.#' 22 | - '#Parameter \#\d+ .+ of function .+ expects .+, mixed given.#' 23 | - '#Method .+ should return .+ but returns mixed.#' 24 | - '#Cannot access offset .+ on mixed.#' 25 | - '#Cannot cast mixed to .+#' 26 | - '#Argument of an invalid type mixed supplied for foreach, only iterables are supported.#' 27 | - '#Parameter \#\d+ .+ of .+ expects .+, mixed given.#' 28 | - '#Cannot access property .+ on mixed.#' 29 | - '#set_error_handler#' 30 | - '#Property .+ does not accept mixed.#' 31 | - '#Function .+ should return .+ but returns mixed.#' 32 | - '#Cannot call method .+ on mixed.#' 33 | - '#Parameter .+ of class .+ expects .+, mixed given.#' 34 | - '#Binary operation .+ between .+ and mixed results in an error.#' 35 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | tests 8 | ./tests/Mappers/Import.php 9 | 10 | 11 | 12 | 13 | 14 | src 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /pre_init.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /screenshots/add_backend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/screenshots/add_backend.png -------------------------------------------------------------------------------- /screenshots/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/screenshots/index.png -------------------------------------------------------------------------------- /src/API/Backend/Discover.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 30 | } catch (RuntimeException $e) { 31 | return api_error($e->getMessage(), Status::NOT_FOUND); 32 | } 33 | 34 | if (null === $this->getBackend(name: $name, userContext: $userContext)) { 35 | return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND); 36 | } 37 | 38 | try { 39 | $client = $this->getClient(name: $name, userContext: $userContext); 40 | 41 | if (PlexClient::CLIENT_NAME !== $client->getType()) { 42 | return api_error('Discover is only available for Plex backends.', Status::BAD_REQUEST); 43 | } 44 | 45 | assert($client instanceof PlexClient); 46 | 47 | $context = $client->getContext(); 48 | $opts = []; 49 | 50 | if (null !== ($adminToken = ag($context->options, Options::ADMIN_TOKEN))) { 51 | $opts[Options::ADMIN_TOKEN] = $adminToken; 52 | } 53 | 54 | $list = $client::discover($http, $context->backendToken, $opts); 55 | return api_response(Status::OK, ag($list, 'list', [])); 56 | } catch (InvalidArgumentException $e) { 57 | return api_error($e->getMessage(), Status::NOT_FOUND); 58 | } catch (Throwable $e) { 59 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/API/Backend/Index.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 27 | } catch (RuntimeException $e) { 28 | return api_error($e->getMessage(), Status::NOT_FOUND); 29 | } 30 | 31 | if (null === ($data = $this->getBackend(name: $name, userContext: $userContext))) { 32 | return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND); 33 | } 34 | 35 | return api_response(Status::OK, $data); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/API/Backend/Info.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 29 | } catch (RuntimeException $e) { 30 | return api_error($e->getMessage(), Status::NOT_FOUND); 31 | } 32 | 33 | if (null === $this->getBackend(name: $name, userContext: $userContext)) { 34 | return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND); 35 | } 36 | 37 | try { 38 | $client = $this->getClient(name: $name, userContext: $userContext); 39 | } catch (InvalidArgumentException $e) { 40 | return api_error($e->getMessage(), Status::NOT_FOUND); 41 | } 42 | 43 | $opts = []; 44 | $params = DataUtil::fromRequest($request, true); 45 | 46 | if (true === (bool)$params->get('raw', false)) { 47 | $opts[Options::RAW_RESPONSE] = true; 48 | } 49 | 50 | try { 51 | $data = $client->getInfo($opts); 52 | return api_response(Status::OK, $data); 53 | } catch (Throwable $e) { 54 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/API/Backend/Sessions.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 29 | } catch (RuntimeException $e) { 30 | return api_error($e->getMessage(), Status::NOT_FOUND); 31 | } 32 | 33 | if (null === $this->getBackend(name: $name, userContext: $userContext)) { 34 | return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND); 35 | } 36 | 37 | try { 38 | $client = $this->getClient(name: $name, userContext: $userContext); 39 | } catch (InvalidArgumentException $e) { 40 | return api_error($e->getMessage(), Status::NOT_FOUND); 41 | } 42 | 43 | $opts = []; 44 | $params = DataUtil::fromRequest($request, true); 45 | 46 | if (true === (bool)$params->get('raw', false)) { 47 | $opts[Options::RAW_RESPONSE] = true; 48 | } 49 | 50 | try { 51 | $sessions = $client->getSessions($opts); 52 | return api_response(Status::OK, ag($sessions, 'sessions', [])); 53 | } catch (Throwable $e) { 54 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/API/Backend/Users.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 29 | } catch (RuntimeException $e) { 30 | return api_error($e->getMessage(), Status::NOT_FOUND); 31 | } 32 | 33 | if (null === $this->getBackend(name: $name, userContext: $userContext)) { 34 | return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND); 35 | } 36 | 37 | $opts = []; 38 | $params = DataUtil::fromRequest($request, true); 39 | 40 | if (true === (bool)$params->get('tokens', false)) { 41 | $opts['tokens'] = true; 42 | } 43 | 44 | if (true === (bool)$params->get('raw', false)) { 45 | $opts[Options::RAW_RESPONSE] = true; 46 | } 47 | 48 | try { 49 | return api_response( 50 | Status::OK, 51 | $this->getClient(name: $name, userContext: $userContext)->getUsersList($opts) 52 | ); 53 | } catch (InvalidArgumentException $e) { 54 | return api_error($e->getMessage(), Status::NOT_FOUND); 55 | } catch (Throwable $e) { 56 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/API/Backend/Version.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 27 | } catch (RuntimeException $e) { 28 | return api_error($e->getMessage(), Status::NOT_FOUND); 29 | } 30 | 31 | if (null === $this->getBackend(name: $name, userContext: $userContext)) { 32 | return api_error(r("Backend '{name}' not found.", ['name' => $name]), Status::NOT_FOUND); 33 | } 34 | 35 | try { 36 | return api_response(Status::OK, [ 37 | 'version' => $this->getClient(name: $name, userContext: $userContext)->getVersion() 38 | ]); 39 | } catch (InvalidArgumentException $e) { 40 | return api_error($e->getMessage(), Status::NOT_FOUND); 41 | } catch (Throwable $e) { 42 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/API/Backends/AccessToken.php: -------------------------------------------------------------------------------- 1 | get('username'); 31 | $password = $params->get('password'); 32 | 33 | if (empty($username) || empty($password)) { 34 | return api_error('Invalid username or password.', Status::BAD_REQUEST); 35 | } 36 | 37 | if (false === in_array($type, ['jellyfin', 'emby'])) { 38 | return api_error('Access token endpoint only supported on jellyfin, emby.', Status::BAD_REQUEST); 39 | } 40 | 41 | try { 42 | $client = $this->getBasicClient($type, $params->with('token', 'accesstoken_request')); 43 | } catch (InvalidArgumentException $e) { 44 | return api_error($e->getMessage(), Status::BAD_REQUEST); 45 | } 46 | 47 | try { 48 | $info = $client->generateAccessToken($username, $password); 49 | } catch (Throwable $e) { 50 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 51 | } 52 | 53 | return api_response(Status::OK, $info); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/API/Backends/Discover.php: -------------------------------------------------------------------------------- 1 | getBasicClient($type, DataUtil::fromRequest($request, true)); 40 | assert($client instanceof PlexClient); 41 | } catch (InvalidArgumentException $e) { 42 | return api_error($e->getMessage(), Status::BAD_REQUEST); 43 | } 44 | 45 | try { 46 | $opts = []; 47 | 48 | if (null !== ($adminToken = ag($request->getParsedBody(), 'options.' . Options::ADMIN_TOKEN))) { 49 | $opts[Options::ADMIN_TOKEN] = $adminToken; 50 | } 51 | 52 | $list = $client::discover($this->http, $client->getContext()->backendToken, $opts); 53 | return api_response(Status::OK, ag($list, 'list', [])); 54 | } catch (Throwable $e) { 55 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/API/Backends/Index.php: -------------------------------------------------------------------------------- 1 | getUserContext($request, $mapper, $logger); 34 | 35 | foreach ($this->getBackends(userContext: $user) as $backend) { 36 | if (!$request->getAttribute(Options::INTERNAL_REQUEST)) { 37 | $item = array_filter( 38 | $backend, 39 | fn ($key) => false === in_array($key, ['options', 'webhook'], true), 40 | ARRAY_FILTER_USE_KEY 41 | ); 42 | 43 | $item = ag_set( 44 | $item, 45 | 'options.' . Options::IMPORT_METADATA_ONLY, 46 | (bool)ag($backend, 'options.' . Options::IMPORT_METADATA_ONLY, false) 47 | ); 48 | $list[] = $item; 49 | continue; 50 | } 51 | 52 | $list[] = $backend; 53 | } 54 | 55 | return api_response(Status::OK, $list); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/API/Backends/Spec.php: -------------------------------------------------------------------------------- 1 | $spec['key'], 27 | 'type' => $spec['type'], 28 | 'description' => $spec['description'], 29 | ]; 30 | 31 | if (ag_exists($spec, 'choices')) { 32 | $item['choices'] = $spec['choices']; 33 | } 34 | 35 | $list[] = $item; 36 | } 37 | 38 | return api_response(Status::OK, $list); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/API/Backends/UUid.php: -------------------------------------------------------------------------------- 1 | getBasicClient($type, DataUtil::fromRequest($request, true)); 28 | 29 | $info = $client->getInfo(); 30 | 31 | return api_response(Status::OK, [ 32 | 'type' => strtolower((string)ag($info, 'type')), 33 | 'identifier' => ag($info, 'identifier'), 34 | ]); 35 | } catch (InvalidArgumentException $e) { 36 | return api_error($e->getMessage(), Status::BAD_REQUEST); 37 | } catch (\Throwable $e) { 38 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/API/Backends/Users.php: -------------------------------------------------------------------------------- 1 | getBasicClient($type, $params); 29 | } catch (InvalidArgumentException $e) { 30 | return api_error($e->getMessage(), Status::BAD_REQUEST); 31 | } 32 | 33 | $users = $opts = []; 34 | 35 | if (true === (bool)$params->get(Options::GET_TOKENS, false)) { 36 | $opts[Options::GET_TOKENS] = true; 37 | } 38 | 39 | if (true === (bool)$params->get('no_cache', false)) { 40 | $opts[Options::NO_CACHE] = true; 41 | } 42 | 43 | try { 44 | foreach ($client->getUsersList($opts) as $user) { 45 | $users[] = $user; 46 | } 47 | } catch (Throwable $e) { 48 | $logger->error($e->getMessage(), $e->getTrace()); 49 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 50 | } 51 | 52 | return api_response(Status::OK, $users); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/API/Backends/ValidateToken.php: -------------------------------------------------------------------------------- 1 | get('token'))) { 31 | return api_error('Token is required.', Status::BAD_REQUEST); 32 | } 33 | 34 | try { 35 | $status = PlexClient::validate_token($http, $token); 36 | 37 | if (true === $status) { 38 | return api_message('Token is valid.', Status::OK); 39 | } 40 | 41 | return api_error('non 200 OK request received.', Status::UNAUTHORIZED); 42 | } catch (Throwable $e) { 43 | return api_error($e->getMessage(), Status::tryFrom($e->getCode()) ?? Status::BAD_REQUEST); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/API/Player/Index.php: -------------------------------------------------------------------------------- 1 | flushDB(true); 22 | } catch (RedisException $e) { 23 | return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR); 24 | } 25 | 26 | return api_message( 27 | $status ? 'Cache purged successfully.' : 'Failed to purge cache.', 28 | $status ? Status::OK : Status::INTERNAL_SERVER_ERROR 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/API/System/Explode.php: -------------------------------------------------------------------------------- 1 | 'ok', 21 | 'message' => 'System is healthy', 22 | ], headers: ['X-No-AccessLog' => '1']); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/API/System/Report.php: -------------------------------------------------------------------------------- 1 | ini_get_all()]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/API/System/Reset.php: -------------------------------------------------------------------------------- 1 | db->reset(); 26 | 27 | // -- reset last import/export date. 28 | foreach (array_keys($userContext->config->getAll()) as $name) { 29 | $userContext->config->set("{$name}.import.lastSync", null); 30 | $userContext->config->set("{$name}.export.lastSync", null); 31 | } 32 | 33 | // -- persist changes. 34 | $userContext->config->persist(true); 35 | } 36 | 37 | // -- reset cache data. 38 | try { 39 | $redis->flushDB(); 40 | } catch (RedisException) { 41 | } 42 | 43 | return api_response(Status::OK, ['message' => 'System reset is complete.']); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/API/System/Scheduler.php: -------------------------------------------------------------------------------- 1 | '1', 21 | ]); 22 | } 23 | 24 | #[Post(self::URL . '/restart[/]', name: 'system.task_scheduler.restart')] 25 | public function restart(): iResponse 26 | { 27 | if (true === (bool)env('DISABLE_CRON', false)) { 28 | return api_error( 29 | "Task scheduler is disabled via 'DISABLE_CRON' environment variable.", 30 | Status::BAD_REQUEST 31 | ); 32 | } 33 | 34 | if (!inContainer()) { 35 | return api_error('WatchState is not running in a container.', Status::BAD_REQUEST); 36 | } 37 | 38 | return api_response(Status::OK, restartScheduler()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/API/System/Supported.php: -------------------------------------------------------------------------------- 1 | getQueryParams()); 25 | try { 26 | $stream = Stream::create(Yaml::dump( 27 | input: $request->getParsedBody(), 28 | inline: (int)$params->get('inline', 4), 29 | indent: (int)$params->get('indent', 2), 30 | flags: Yaml::DUMP_OBJECT | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK 31 | )); 32 | } catch (DumpException $e) { 33 | return api_error(r("Failed to convert to yaml. '{error}'.", ['error' => $e->getMessage()]), Status::BAD_REQUEST); 34 | } 35 | 36 | return api_response(Status::OK, body:$stream, headers: [ 37 | 'Content-Type' => 'text/yaml', 38 | 'Content-Disposition' => r('{mode}; filename="{filename}"', [ 39 | 'mode' => $filename ? 'attachment' : 'inline', 40 | 'filename' => $filename ?? 'to_yaml.yaml', 41 | ]) 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/API/System/Users.php: -------------------------------------------------------------------------------- 1 | $userContext) { 27 | $users[] = [ 28 | 'user' => $username, 29 | 'backends' => array_keys($userContext->config->getAll()) 30 | ]; 31 | } 32 | 33 | return api_response(Status::OK, ['users' => $users]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/API/System/Version.php: -------------------------------------------------------------------------------- 1 | getAppVersion(), 'container' => inContainer()]); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Backends/Common/GuidInterface.php: -------------------------------------------------------------------------------- 1 | id = generateUUID(); 34 | } 35 | 36 | public function toRequest(): array 37 | { 38 | return [ 39 | 'method' => $this->method->value, 40 | 'url' => (string)$this->url, 41 | 'options' => $this->options, 42 | ]; 43 | } 44 | 45 | public function __debugInfo() 46 | { 47 | return [ 48 | 'id' => $this->id, 49 | 'method' => $this->method, 50 | 'url' => $this->url, 51 | 'options' => $this->options, 52 | 'success' => $this->success, 53 | 'error' => $this->error, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Backends/Common/Response.php: -------------------------------------------------------------------------------- 1 | error; 33 | } 34 | 35 | /** 36 | * Return error response. 37 | * 38 | * @return Error the error object if exists otherwise dummy error object is returned. 39 | */ 40 | public function getError(): Error 41 | { 42 | return $this->error ?? new Error('No error logged.'); 43 | } 44 | 45 | /** 46 | * Is the operation successful? 47 | * 48 | * @return bool true if the operation is successful. 49 | */ 50 | public function isSuccessful(): bool 51 | { 52 | return $this->status; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Backends/Emby/Action/Backup.php: -------------------------------------------------------------------------------- 1 | tryResponse( 29 | context: $context, 30 | fn: fn() => new Response( 31 | status: true, 32 | response: [ 33 | 'poster' => $context->backendUrl->withPath("/emby/Items/{$id}/Images/Primary/"), 34 | 'background' => $context->backendUrl->withPath("/emby/Items/{$id}/Images/Backdrop/"), 35 | ] 36 | ), 37 | action: $this->action 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Backends/Emby/EmbyActionTrait.php: -------------------------------------------------------------------------------- 1 | isSuccessful()) { 32 | return $response->response; 33 | } 34 | 35 | throw new RuntimeException(message: $response->error->format(), previous: $response->error->previous); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Backends/Emby/EmbyGuid.php: -------------------------------------------------------------------------------- 1 | tryResponse( 49 | context: $context, 50 | fn: function () use ($context, $opts) { 51 | $info = new GetInfo($this->http, $this->logger)(context: $context, opts: $opts); 52 | 53 | if (false === $info->status) { 54 | return $info; 55 | } 56 | 57 | return new Response(status: true, response: ag($info->response, 'identifier')); 58 | }, 59 | action: $this->action, 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Backends/Jellyfin/Action/GetVersion.php: -------------------------------------------------------------------------------- 1 | tryResponse( 48 | context: $context, 49 | fn: function () use ($context, $opts) { 50 | $info = new GetInfo($this->http, $this->logger)(context: $context, opts: $opts); 51 | 52 | if (false === $info->status) { 53 | return $info; 54 | } 55 | 56 | return new Response(status: true, response: ag($info->response, 'version')); 57 | }, 58 | action: $this->action, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Backends/Jellyfin/Action/ToEntity.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public function __invoke(Context $context, array $item, array $opts = []): Response 35 | { 36 | return $this->tryResponse( 37 | context: $context, 38 | fn: fn() => $this->createEntity($context, $this->guid->withContext($context), $item, $opts), 39 | action: $this->action 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Backends/Jellyfin/Action/getImagesUrl.php: -------------------------------------------------------------------------------- 1 | tryResponse( 29 | context: $context, 30 | fn: fn() => new Response( 31 | status: true, 32 | response: [ 33 | 'poster' => $context->backendUrl->withPath("/Items/{$id}/Images/Primary/"), 34 | 'background' => $context->backendUrl->withPath("/Items/{$id}/Images/Backdrop/"), 35 | ] 36 | ), 37 | action: $this->action 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Backends/Plex/Action/GetIdentifier.php: -------------------------------------------------------------------------------- 1 | tryResponse( 34 | context: $context, 35 | fn: function () use ($context, $opts) { 36 | $info = new GetInfo($this->http, $this->logger)(context: $context, opts: $opts); 37 | 38 | if (false === $info->status) { 39 | return $info; 40 | } 41 | 42 | return new Response(status: true, response: ag($info->response, 'identifier')); 43 | }, 44 | action: $this->action 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Backends/Plex/Action/GetUser.php: -------------------------------------------------------------------------------- 1 | http = new RetryableHttpClient(client: $http, maxRetries: $this->maxRetry, logger: $this->logger); 27 | } 28 | 29 | /** 30 | * Get Users list. 31 | * 32 | * @param Context $context 33 | * @param array $opts optional options. 34 | * 35 | * @return Response 36 | */ 37 | public function __invoke(Context $context, array $opts = []): Response 38 | { 39 | return $this->tryResponse( 40 | context: $context, 41 | fn: fn() => $this->getUser($context, $opts), 42 | action: $this->action 43 | ); 44 | } 45 | 46 | /** 47 | * Get User list. 48 | * 49 | * @throws InvalidArgumentException if user id is not found. 50 | */ 51 | private function getUser(Context $context, array $opts = []): Response 52 | { 53 | $users = Container::get(GetUsersList::class)($context, $opts); 54 | 55 | if ($users->hasError()) { 56 | return $users; 57 | } 58 | 59 | foreach ($users->response as $user) { 60 | if ((int)$user['id'] === (int)$context->backendUser) { 61 | return new Response(status: true, response: $user); 62 | } 63 | } 64 | 65 | throw new InvalidArgumentException(r("Did not find matching user id '{id}' in users list.", [ 66 | 'id' => $context->backendUser, 67 | ])); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Backends/Plex/Action/GetVersion.php: -------------------------------------------------------------------------------- 1 | tryResponse( 34 | context: $context, 35 | fn: function () use ($context, $opts) { 36 | $info = new GetInfo($this->http, $this->logger)(context: $context, opts: $opts); 37 | 38 | if (false === $info->status) { 39 | return $info; 40 | } 41 | 42 | return new Response(status: true, response: ag($info->response, 'version')); 43 | }, 44 | action: $this->action 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Backends/Plex/Action/ToEntity.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public function __invoke(Context $context, array $item, array $opts = []): Response 35 | { 36 | return $this->tryResponse( 37 | context: $context, 38 | fn: fn() => $this->createEntity($context, $this->guid->withContext($context), $item, $opts), 39 | action: $this->action 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Backends/Plex/Action/getImagesUrl.php: -------------------------------------------------------------------------------- 1 | getItemInfo($context, $id, opts: [Options::CACHE_TTL => new DateInterval('PT60M')]); 35 | if (false === $response->isSuccessful()) { 36 | return $response; 37 | } 38 | $data = ag($response->response, 'MediaContainer.Metadata.0', []); 39 | 40 | $poster = ag($data, 'thumb', null); 41 | $background = ag($data, 'art', null); 42 | 43 | return $this->tryResponse( 44 | context: $context, 45 | fn: fn() => new Response( 46 | status: true, 47 | response: [ 48 | 'poster' => $poster ? $context->backendUrl->withPath($poster) : null, 49 | 'background' => $background ? $context->backendUrl->withPath($background) : null, 50 | ] 51 | ), 52 | action: $this->action 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Commands/Backend/VersionCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::ROUTE) 30 | ->setDescription('Get backend product version.') 31 | ->addOption('select-backend', 's', InputOption::VALUE_REQUIRED, 'Select backend.'); 32 | } 33 | 34 | /** 35 | * Runs the command. 36 | * 37 | * @param InputInterface $input The input interface. 38 | * @param OutputInterface $output The output interface. 39 | * 40 | * @return int The exit code of the command. 41 | */ 42 | protected function runCommand(InputInterface $input, OutputInterface $output): int 43 | { 44 | $name = $input->getOption('select-backend'); 45 | if (empty($name)) { 46 | $output->writeln(r('ERROR: Backend not specified. Please use [-s, --select-backend].')); 47 | return self::FAILURE; 48 | } 49 | 50 | try { 51 | $backend = $this->getBackend($name); 52 | } catch (RuntimeException) { 53 | $output->writeln(r("ERROR: Backend '{backend}' not found.", ['backend' => $name])); 54 | return self::FAILURE; 55 | } 56 | 57 | $output->writeln($backend->getVersion()); 58 | 59 | return self::SUCCESS; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Commands/Events/CacheCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::ROUTE) 20 | ->setDescription('Force cache invalidation for the events registrar.'); 21 | } 22 | 23 | protected function execute(InputInterface $input, OutputInterface $output): int 24 | { 25 | registerEvents(ignoreCache: true); 26 | 27 | return self::SUCCESS; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Commands/Events/ListenersCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::ROUTE)->setDescription('Show registered events Listeners.'); 27 | } 28 | 29 | protected function execute(InputInterface $input, OutputInterface $output): int 30 | { 31 | $mode = $input->getOption('output'); 32 | $keys = []; 33 | 34 | assert($this->dispatcher instanceof EventDispatcher); 35 | foreach ($this->dispatcher->getListeners() as $key => $val) { 36 | $listeners = []; 37 | 38 | foreach ($val as $listener) { 39 | $listeners[] = get_debug_type($listener); 40 | } 41 | 42 | $keys[$key] = join(', ', $listeners); 43 | } 44 | 45 | if ('table' === $mode) { 46 | $list = []; 47 | 48 | foreach ($keys as $key => $val) { 49 | $list[] = ['Event' => $key, 'value' => $val]; 50 | } 51 | 52 | $keys = $list; 53 | } 54 | 55 | $this->displayContent($keys, $output, $mode); 56 | 57 | return self::SUCCESS; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Commands/Events/QueuedCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::ROUTE) 29 | ->addOption('all', 'a', InputOption::VALUE_NONE, 'Show all.') 30 | ->setDescription('Show queued events.'); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $filter = [ 36 | EventsTable::COLUMN_STATUS => Status::PENDING->value 37 | ]; 38 | 39 | if ($input->getOption('all')) { 40 | $filter = []; 41 | } 42 | 43 | $events = $this->repo->findAll($filter); 44 | 45 | $mode = $input->getOption('output'); 46 | 47 | if ('table' === $mode) { 48 | $list = []; 49 | 50 | foreach ($events as $event) { 51 | $list[] = [ 52 | 'id' => $event->id, 53 | 'event' => $event->event, 54 | 'added' => $event->created_at, 55 | 'status' => ucfirst(strtolower($event->status->name)), 56 | 'Dispatched' => $event->updated_at ?? 'N/A', 57 | ]; 58 | } 59 | 60 | $keys = $list; 61 | } else { 62 | $keys = array_map(fn($event) => $event->getAll(), $events); 63 | } 64 | 65 | $this->displayContent($keys, $output, $mode); 66 | 67 | return self::SUCCESS; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Commands/System/APIKeyCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::ROUTE) 24 | ->addOption('regenerate', 'r', InputOption::VALUE_NONE, 'Re-generate a new API key.') 25 | ->setDescription('Show current API key or generate a new one.'); 26 | } 27 | 28 | protected function runCommand(iInput $input, iOutput $output): int 29 | { 30 | $regenerate = (bool)$input->getOption('regenerate'); 31 | if ($regenerate || null === ($apiKey = Config::get('api.key'))) { 32 | return $this->regenerate($output); 33 | } 34 | 35 | $output->writeln('Current system API key:'); 36 | $output->writeln('' . $apiKey . ''); 37 | 38 | return self::SUCCESS; 39 | } 40 | 41 | private function regenerate(iOutput $output): int 42 | { 43 | $apiKey = TokenUtil::generateSecret(16); 44 | $response = APIRequest('POST', '/system/env/WS_API_KEY', [ 45 | 'value' => $apiKey, 46 | ]); 47 | 48 | if (Status::OK !== $response->status) { 49 | $output->writeln(r("Failed to set the new API key.")); 50 | return self::FAILURE; 51 | } 52 | 53 | $output->writeln('The New system API key:'); 54 | $output->writeln('' . $apiKey . ''); 55 | 56 | return self::SUCCESS; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Commands/System/ResetPasswordCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::ROUTE) 21 | ->setDescription('Reset the system user and password.') 22 | ->setHelp('Resets the current system user and password.'); 23 | } 24 | 25 | protected function runCommand(iInput $input, iOutput $output): int 26 | { 27 | $response = APIRequest('DELETE', '/system/env/WS_SYSTEM_SECRET'); 28 | if (Status::OK !== $response->status) { 29 | $output->writeln(r("Failed to reset the system secret key.")); 30 | return self::FAILURE; 31 | } 32 | 33 | $response = APIRequest('DELETE', '/system/env/WS_SYSTEM_USER'); 34 | if (Status::OK !== $response->status) { 35 | $output->writeln(r("Failed to reset the system user.")); 36 | return self::FAILURE; 37 | } 38 | 39 | $response = APIRequest('DELETE', '/system/env/WS_SYSTEM_PASSWORD'); 40 | if (Status::OK !== $response->status) { 41 | $output->writeln(r("Failed to reset the system password.")); 42 | return self::FAILURE; 43 | } 44 | 45 | $output->writeln(r("System user and password has been reset.")); 46 | 47 | return self::SUCCESS; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Libs/APIResponse.php: -------------------------------------------------------------------------------- 1 | stream; 33 | } 34 | 35 | /** 36 | * Check if the response has a body. 37 | * 38 | * @return bool True if the response has a body, false otherwise. 39 | */ 40 | public function hasBody(): bool 41 | { 42 | return !empty($this->body); 43 | } 44 | 45 | /** 46 | * Check if the response has headers. 47 | * 48 | * @return bool True if the response has headers, false otherwise. 49 | */ 50 | public function hasHeaders(): bool 51 | { 52 | return !empty($this->headers); 53 | } 54 | 55 | /** 56 | * Get Header from the response headers. 57 | * 58 | * @param string $key The key to get from the headers. 59 | * @param mixed|null $default The default value to return if the key is not found. 60 | * 61 | * @return mixed The value of the key from the headers. If the key is not found, the default value is returned. 62 | */ 63 | public function getHeader(string $key, mixed $default = null): mixed 64 | { 65 | return ag($this->headers, $key, $default); 66 | } 67 | 68 | /** 69 | * Get Parameter from the parsed body content. 70 | * 71 | * @param string $key The key to get from the body. 72 | * @param mixed|null $default The default value to return if the key is not found. 73 | * 74 | * @return mixed The value of the key from the body. If the key is not found, the default value is returned. 75 | */ 76 | public function getParam(string $key, mixed $default = null): mixed 77 | { 78 | return ag($this->body, $key, $default); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Libs/Attributes/DI/Inject.php: -------------------------------------------------------------------------------- 1 | 24 | * class Example 25 | * { 26 | * public function __invoke(#[Inject(FileLogger::class)] private LoggerInterface $logger) {} 27 | * } 28 | * 29 | * 30 | * @param string $name 31 | */ 32 | public function __construct(public string $name) 33 | { 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Libs/Attributes/Route/Cli.php: -------------------------------------------------------------------------------- 1 | true]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Libs/Attributes/Route/Delete.php: -------------------------------------------------------------------------------- 1 | name = $name; 43 | $this->methods = $methods; 44 | $this->pattern = parseConfigValue($pattern); 45 | $this->middleware = is_string($middleware) ? [$middleware] : $middleware; 46 | $this->port = null !== $port ? parseConfigValue($port) : $port; 47 | $this->scheme = null !== $scheme ? parseConfigValue($scheme) : $scheme; 48 | $this->host = null !== $host ? parseConfigValue($host, fn($v) => parse_url($v, PHP_URL_HOST)) : $host; 49 | 50 | $this->isCli = true === (bool)ag($opts, 'cli', false); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Libs/Attributes/Scanner/Item.php: -------------------------------------------------------------------------------- 1 | callable; 23 | } 24 | 25 | public function call(...$args): mixed 26 | { 27 | $callable = $this->callable; 28 | 29 | if (is_string($callable) && str_contains($callable, '::')) { 30 | $callable = explode('::', $callable); 31 | } 32 | 33 | if (is_array($callable) && isset($callable[0]) && is_object($callable[0])) { 34 | $callable = [$callable[0], $callable[1]]; 35 | } 36 | 37 | if (is_array($callable) && isset($callable[0]) && is_string($callable[0])) { 38 | $callable = [$this->resolve($callable[0]), $callable[1]]; 39 | } 40 | 41 | if (is_string($callable)) { 42 | $callable = $this->resolve($callable); 43 | } 44 | 45 | return $callable(...$args); 46 | } 47 | 48 | public function getTarget(): Target 49 | { 50 | return $this->target; 51 | } 52 | 53 | public function getAttribute(): string 54 | { 55 | return $this->attribute; 56 | } 57 | 58 | public function getData(): array 59 | { 60 | return $this->data; 61 | } 62 | 63 | private function resolve(string $class) 64 | { 65 | if (Container::has($class)) { 66 | return Container::get($class); 67 | } 68 | 69 | if (class_exists($class)) { 70 | return new $class(); 71 | } 72 | 73 | return $class; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Libs/Attributes/Scanner/Target.php: -------------------------------------------------------------------------------- 1 | data = getValue($data); 19 | } 20 | 21 | public static function fromArray(array $data): self 22 | { 23 | return new self($data); 24 | } 25 | 26 | public static function fromRequest(iRequest $request, bool $includeQueryParams = false): self 27 | { 28 | $params = $includeQueryParams ? $request->getQueryParams() : []; 29 | 30 | if (null !== ($data = $request->getParsedBody())) { 31 | $params = array_replace_recursive($params, is_object($data) ? (array)$data : $data); 32 | } 33 | 34 | return new self($params); 35 | } 36 | 37 | public function get(string $key, mixed $default = null): mixed 38 | { 39 | return ag($this->data, $key, $default); 40 | } 41 | 42 | public function getAll(): array 43 | { 44 | return $this->data; 45 | } 46 | 47 | public function has(string $key): bool 48 | { 49 | return ag_exists($this->data, $key); 50 | } 51 | 52 | public function map(callable $callback): self 53 | { 54 | return new self(array_map($callback, $this->data)); 55 | } 56 | 57 | public function filter(callable $callback): self 58 | { 59 | return new self(array_filter($this->data, $callback, ARRAY_FILTER_USE_BOTH)); 60 | } 61 | 62 | public function with(string $key, mixed $value): self 63 | { 64 | return new self(ag_set($this->data, $key, $value)); 65 | } 66 | 67 | public function without(string $key): self 68 | { 69 | return new self(ag_delete($this->data, $key)); 70 | } 71 | 72 | public function jsonSerialize(): mixed 73 | { 74 | return $this->data; 75 | } 76 | 77 | public function __toString(): string 78 | { 79 | return json_encode($this->jsonSerialize()); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Libs/Enums/Http/Method.php: -------------------------------------------------------------------------------- 1 | status = $eventInfo->status; 16 | } 17 | 18 | public function getStatus(): EventStatus 19 | { 20 | return $this->status; 21 | } 22 | 23 | public function setStatus(EventStatus $status): DataEvent 24 | { 25 | $this->status = $status; 26 | return $this; 27 | } 28 | 29 | public function getEvent(): EventInfo 30 | { 31 | return $this->eventInfo; 32 | } 33 | 34 | public function getReference(): string|null 35 | { 36 | return $this->eventInfo->reference; 37 | } 38 | 39 | public function addLog(string $log): void 40 | { 41 | if (count($this->eventInfo->logs) >= 200) { 42 | array_shift($this->eventInfo->logs); 43 | } 44 | $this->eventInfo->logs[] = $log; 45 | } 46 | 47 | public function clearLogs(): void 48 | { 49 | $this->eventInfo->logs = []; 50 | } 51 | 52 | public function getLogs(): array 53 | { 54 | return $this->eventInfo->logs; 55 | } 56 | 57 | public function getData(): array 58 | { 59 | return $this->eventInfo->event_data; 60 | } 61 | 62 | public function getOptions(): array 63 | { 64 | return $this->eventInfo->options; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Libs/Exceptions/AppExceptionInterface.php: -------------------------------------------------------------------------------- 1 | queryString = $queryString; 40 | $this->bind = $bind; 41 | $this->errorInfo = $errorInfo; 42 | $this->code = $errorCode; 43 | 44 | return $this; 45 | } 46 | 47 | public function getQueryString(): string 48 | { 49 | return $this->queryString; 50 | } 51 | 52 | public function getQueryBind(): array 53 | { 54 | return $this->bind; 55 | } 56 | 57 | public function setFile(string $file): DBAdapterException 58 | { 59 | $this->file = $file; 60 | 61 | return $this; 62 | } 63 | 64 | public function setLine(int $line): DBAdapterException 65 | { 66 | $this->line = $line; 67 | 68 | return $this; 69 | } 70 | 71 | public function setOptions(array $options): DBAdapterException 72 | { 73 | $this->options = $options; 74 | 75 | return $this; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Libs/Exceptions/DBLayerException.php: -------------------------------------------------------------------------------- 1 | queryString = $queryString; 40 | $this->bind = $bind; 41 | $this->errorInfo = $errorInfo; 42 | $this->code = $errorCode; 43 | 44 | return $this; 45 | } 46 | 47 | public function getQueryString(): string 48 | { 49 | return $this->queryString; 50 | } 51 | 52 | public function getQueryBind(): array 53 | { 54 | return $this->bind; 55 | } 56 | 57 | public function setFile(string $file): DBLayerException 58 | { 59 | $this->file = $file; 60 | 61 | return $this; 62 | } 63 | 64 | public function setLine(int $line): DBLayerException 65 | { 66 | $this->line = $line; 67 | 68 | return $this; 69 | } 70 | 71 | public function setOptions(array $options): DBLayerException 72 | { 73 | $this->options = $options; 74 | 75 | return $this; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Libs/Exceptions/EmitterException.php: -------------------------------------------------------------------------------- 1 | $filename, 20 | 'line' => $line, 21 | ]), code: self::HEADERS_SENT); 22 | } 23 | 24 | public static function forOutputSent(): self 25 | { 26 | return new self('Output has been emitted previously. Cannot emit response.', code: self::OUTPUT_SENT); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Libs/Exceptions/ErrorException.php: -------------------------------------------------------------------------------- 1 | appContext = ag_set($this->appContext, $key, $value); 12 | return $this; 13 | } 14 | 15 | public function setContext(array $context): AppExceptionInterface 16 | { 17 | $this->appContext = $context; 18 | 19 | return $this; 20 | } 21 | 22 | public function getContext(string|null $key = null): mixed 23 | { 24 | if (null === $key) { 25 | return $this->appContext; 26 | } 27 | 28 | return ag($this->appContext, $key); 29 | } 30 | 31 | public function hasContext(): bool 32 | { 33 | return !empty($this->appContext); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Libs/Exceptions/ValidationException.php: -------------------------------------------------------------------------------- 1 | $time, 32 | 'ori' => $ori, 33 | 'message' => $e->getMessage(), 34 | ]), $e->getCode(), $e 35 | ); 36 | } 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return $this->format(DateTimeInterface::ATOM); 42 | } 43 | 44 | public function jsonSerialize(): string 45 | { 46 | return $this->__toString(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Libs/Extends/LogMessageProcessor.php: -------------------------------------------------------------------------------- 1 | message, '{')) { 30 | return $record; 31 | } 32 | 33 | $repl = r_array(text: $record->message, context: $record->context, opts: [ 34 | 'log_behavior' => true 35 | ]); 36 | 37 | return $record->with(...$repl); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Libs/Extends/MockHttpClient.php: -------------------------------------------------------------------------------- 1 | value; 20 | } 21 | 22 | return parent::request($method, $url, $options); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Libs/Extends/PSRContainer.php: -------------------------------------------------------------------------------- 1 | $id 16 | * @return T 17 | */ 18 | public function get($id) 19 | { 20 | return parent::get($id); 21 | } 22 | 23 | /** 24 | * Get new instance of a class. 25 | * 26 | * @template T 27 | * @param class-string $id 28 | * @return T 29 | */ 30 | public function getNew($id) 31 | { 32 | return parent::getNew($id); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Libs/Extends/ProxyHandler.php: -------------------------------------------------------------------------------- 1 | bubble = true; 24 | parent::__construct($level, true); 25 | } 26 | 27 | public static function create(Closure $callback, $level = Level::Debug): self 28 | { 29 | return new self($callback, $level); 30 | } 31 | 32 | public function close(): void 33 | { 34 | $this->closed = true; 35 | } 36 | 37 | protected function write(LogRecord $record): void 38 | { 39 | if (true === $this->closed) { 40 | return; 41 | } 42 | 43 | $date = $record['datetime'] ?? 'No date set'; 44 | 45 | if (true === ($date instanceof DateTimeInterface)) { 46 | $date = $date->format(DateTimeInterface::ATOM); 47 | } 48 | 49 | $message = r('[{date}] {level}: {message}', [ 50 | 'date' => $date, 51 | 'level' => $record['level_name'] ?? $record['level'] ?? '??', 52 | 'message' => $record['message'], 53 | ]); 54 | 55 | if (false === empty($record['context']) && true === (bool)Config::get('logs.context')) { 56 | $message .= ' ' . arrayToJson($record['context']); 57 | } 58 | 59 | ($this->callback)($message, $record); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Libs/Extends/RetryableHttpClient.php: -------------------------------------------------------------------------------- 1 | value; 20 | } 21 | 22 | return parent::request($method, $url, $options); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Libs/Extends/RouterStrategy.php: -------------------------------------------------------------------------------- 1 | implode(', ', $methods), 23 | ]; 24 | 25 | if ('cors' === ag($_SERVER, 'HTTP_SEC_FETCH_MODE')) { 26 | return fn(): iResponse => addCors(api_response(Status::NO_CONTENT, headers: $headers), $headers, $methods); 27 | } 28 | 29 | return fn(): iResponse => api_response(Status::NO_CONTENT, headers: $headers); 30 | } 31 | 32 | /** 33 | * @throws ContainerExceptionInterface 34 | * @throws \ReflectionException 35 | * @throws NotFoundExceptionInterface 36 | */ 37 | public function invokeRouteCallable(Route $route, iRequest $request): iResponse 38 | { 39 | return $this->decorateResponse( 40 | Container::get(ReflectionContainer::class)->call( 41 | callable: $route->getCallable($this->getContainer()), 42 | args: [ 43 | ...$route->getVars(), 44 | iRequest::class => $request, 45 | 'args' => $route->getVars(), 46 | ] 47 | ) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Libs/Extends/StreamableChunks.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class StreamableChunks implements IteratorAggregate 16 | { 17 | /** 18 | * @param iStream $stream The stream to be used. 19 | * @param int $chunkSize The chunk size to be used. 20 | */ 21 | public function __construct(private iStream $stream, private int $chunkSize = 1024 * 8) 22 | { 23 | } 24 | 25 | /** 26 | * Get the iterator. 27 | * 28 | * @return Generator 29 | */ 30 | #[ReturnTypeWillChange] 31 | public function getIterator(): Generator 32 | { 33 | while ('' !== ($chunk = $this->stream->read($this->chunkSize))) { 34 | yield $chunk; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Libs/Mappers/Import/ReadOnlyMapper.php: -------------------------------------------------------------------------------- 1 | isContainer = true; 22 | } 23 | 24 | public function add(iState $entity, array $opts = []): MemoryMapper 25 | { 26 | if (false === $this->isContainer) { 27 | return parent::add($entity, $opts); 28 | } 29 | $this->objects[] = $entity; 30 | $pointer = array_key_last($this->objects); 31 | $this->changed[$pointer] = $pointer; 32 | $this->addPointers($this->objects[$pointer], $pointer); 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * @inheritdoc 39 | */ 40 | public function remove(iState $entity): bool 41 | { 42 | if (false === ($pointer = $this->getPointer($entity))) { 43 | return false; 44 | } 45 | 46 | $this->removePointers($this->objects[$pointer]); 47 | 48 | if (null !== ($this->objects[$pointer] ?? null)) { 49 | unset($this->objects[$pointer]); 50 | } 51 | 52 | if (null !== ($this->changed[$pointer] ?? null)) { 53 | unset($this->changed[$pointer]); 54 | } 55 | 56 | return true; 57 | } 58 | 59 | /** 60 | * @inheritdoc 61 | */ 62 | public function commit(): array 63 | { 64 | $this->reset(); 65 | 66 | return [ 67 | iState::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0], 68 | iState::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0], 69 | ]; 70 | } 71 | 72 | public function __destruct() 73 | { 74 | // -- disabled autocommit. 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Libs/Message.php: -------------------------------------------------------------------------------- 1 | handle($request); 17 | 18 | if (!$response->hasHeader('Access-Control-Allow-Origin')) { 19 | $response = $response->withHeader('Access-Control-Allow-Origin', '*'); 20 | } 21 | 22 | if (!$response->hasHeader('Access-Control-Allow-Credentials')) { 23 | $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); 24 | } 25 | 26 | return $response; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Libs/Middlewares/AddTimingMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request)->withHeader( 17 | 'X-Application-Finished-In', 18 | round(microtime(true) - APP_START, 6) . 's' 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Libs/Middlewares/ExceptionHandlerMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 19 | } catch (\Throwable $e) { 20 | return api_error($e->getMessage(), Status::tryFrom($e->getCode()) ?? Status::INTERNAL_SERVER_ERROR); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Libs/Middlewares/NoAccessLogMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('INTERNAL_REQUEST', false)) { 18 | return $handler->handle($request); 19 | } 20 | 21 | if (true === (bool)Config::get('api.logInternal', false)) { 22 | return $handler->handle($request); 23 | } 24 | 25 | return $handler->handle($request)->withHeader('X-No-AccessLog', '1'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Libs/Middlewares/ParseJsonBodyMiddleware.php: -------------------------------------------------------------------------------- 1 | getMethod()))) { 21 | throw new RuntimeException(r('Invalid HTTP method. "{method}".', [ 22 | 'method' => $request->getMethod() 23 | ]), Status::METHOD_NOT_ALLOWED->value); 24 | } 25 | 26 | if (true === in_array($method, [Method::GET, Method::HEAD, Method::OPTIONS])) { 27 | return $handler->handle($request); 28 | } 29 | 30 | $header = $request->getHeaderLine('Content-Type'); 31 | 32 | if (1 === preg_match('#^application/(|\S+\+)json($|[ ;])#', $header)) { 33 | return $handler->handle($this->parse($request)); 34 | } 35 | 36 | return $handler->handle($request); 37 | } 38 | 39 | private function parse(iRequest $request): iRequest 40 | { 41 | $body = (string)$request->getBody(); 42 | 43 | if ($request->getBody()->isSeekable()) { 44 | $request->getBody()->rewind(); 45 | } 46 | 47 | if (empty($body)) { 48 | return $request; 49 | } 50 | 51 | try { 52 | return $request->withParsedBody(json_decode($body, true, flags: JSON_THROW_ON_ERROR)); 53 | } catch (JsonException $e) { 54 | throw new RuntimeException(r('Error when parsing JSON request body. {error}', [ 55 | 'error' => $e->getMessage() 56 | ]), Status::BAD_REQUEST->value, $e); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Libs/StreamedBody.php: -------------------------------------------------------------------------------- 1 | func = $func; 17 | } 18 | 19 | public static function create(callable $func, bool $isReadable = true): StreamInterface 20 | { 21 | return new self($func, isReadable: $isReadable); 22 | } 23 | 24 | public function __destruct() 25 | { 26 | } 27 | 28 | public function __toString(): string 29 | { 30 | return $this->getContents(); 31 | } 32 | 33 | public function close(): void 34 | { 35 | } 36 | 37 | public function detach(): null 38 | { 39 | return null; 40 | } 41 | 42 | public function getSize(): ?int 43 | { 44 | return null; 45 | } 46 | 47 | public function tell(): int 48 | { 49 | return 0; 50 | } 51 | 52 | public function eof(): bool 53 | { 54 | return false; 55 | } 56 | 57 | public function isSeekable(): bool 58 | { 59 | return false; 60 | } 61 | 62 | public function seek($offset, $whence = \SEEK_SET): void 63 | { 64 | } 65 | 66 | public function rewind(): void 67 | { 68 | } 69 | 70 | public function isWritable(): bool 71 | { 72 | return false; 73 | } 74 | 75 | public function write($string): int 76 | { 77 | throw new RuntimeException('Unable to write to a non-writable stream.'); 78 | } 79 | 80 | public function isReadable(): bool 81 | { 82 | return $this->isReadable; 83 | } 84 | 85 | public function read($length): string 86 | { 87 | return $this->getContents(); 88 | } 89 | 90 | public function getContents(): string 91 | { 92 | return ($this->func)(); 93 | } 94 | 95 | public function getMetadata($key = null): null 96 | { 97 | return null; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Libs/UserContext.php: -------------------------------------------------------------------------------- 1 | mapper = $mapper->withUserContext($this); 37 | } 38 | 39 | public function getPath(): string 40 | { 41 | if (isset($this->data['path'])) { 42 | return $this->data['path']; 43 | } 44 | 45 | return fixPath(Config::get('path') . '/' . ('main' === $this->name ? 'config' : "users/{$this->name}")); 46 | } 47 | 48 | public function getBackendsNames(): string 49 | { 50 | return join(', ', array_keys($this->config->getAll())); 51 | } 52 | 53 | public function add(string $key, mixed $value): self 54 | { 55 | $this->data = ag_set($this->data, $key, $value); 56 | return $this; 57 | } 58 | 59 | public function get(string $key, mixed $default = null): mixed 60 | { 61 | return ag($this->data, $key, $default); 62 | } 63 | 64 | public function has(string $key): bool 65 | { 66 | return ag_exists($this->data, $key); 67 | } 68 | 69 | public function remove(string|array $key): self 70 | { 71 | $this->data = ag_delete($this->data, $key); 72 | return $this; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Model/Base/Enums/ScalarType.php: -------------------------------------------------------------------------------- 1 | start = $start; 18 | 19 | return $this; 20 | } 21 | 22 | public function getStart(): int 23 | { 24 | return $this->start; 25 | } 26 | 27 | public function setPerpage(int $perpage = 15): self 28 | { 29 | $this->perpage = $perpage; 30 | 31 | return $this; 32 | } 33 | 34 | public function getPerpage(): int 35 | { 36 | return $this->perpage; 37 | } 38 | 39 | public function setTotal(int $total = 0): self 40 | { 41 | $this->total = $total; 42 | 43 | return $this; 44 | } 45 | 46 | public function getTotal(): int 47 | { 48 | return $this->total; 49 | } 50 | 51 | public function setAscendingOrder(): self 52 | { 53 | $this->order = 'ASC'; 54 | 55 | return $this; 56 | } 57 | 58 | public function setDescendingOrder(): self 59 | { 60 | $this->order = 'DESC'; 61 | 62 | return $this; 63 | } 64 | 65 | public function setSort($field): self 66 | { 67 | $this->sort = $field; 68 | 69 | return $this; 70 | } 71 | 72 | public function getSort(): string 73 | { 74 | return $this->sort; 75 | } 76 | 77 | public function getOrder(): string 78 | { 79 | return $this->order; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/ArrayTransformer.php: -------------------------------------------------------------------------------- 1 | nullable)(type: $type, data: $data); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/DateTransformer.php: -------------------------------------------------------------------------------- 1 | $class($type, $data); 19 | } 20 | 21 | public function __invoke(TransformType $type, mixed $data): string|null|DateTimeInterface 22 | { 23 | if (null === $data) { 24 | if ($this->nullable) { 25 | return null; 26 | } 27 | 28 | throw new RuntimeException('Date cannot be null'); 29 | } 30 | 31 | $isDate = true === ($data instanceof DateTimeInterface); 32 | 33 | if (false === $isDate && !is_string($data)) { 34 | if (true === ctype_digit((string)$data)) { 35 | $isDate = true; 36 | $data = makeDate($data); 37 | } else { 38 | throw new RuntimeException('Date must be a string or an instance of DateTimeInterface'); 39 | } 40 | } 41 | 42 | return match ($type) { 43 | TransformType::ENCODE => $isDate ? $data->format(DateTimeInterface::ATOM) : (string)$data, 44 | TransformType::DECODE => makeDate($data), 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/EnumTransformer.php: -------------------------------------------------------------------------------- 1 | $enumName The class name of the enum. 12 | */ 13 | public function __construct(private string $enumName) 14 | { 15 | } 16 | 17 | public static function create(string $enumName): callable 18 | { 19 | $class = new self($enumName); 20 | return fn(TransformType $type, mixed $data) => $class($type, $data); 21 | } 22 | 23 | public function __invoke(TransformType $type, mixed $value): mixed 24 | { 25 | return match ($type) { 26 | TransformType::ENCODE => $this->encode($value), 27 | TransformType::DECODE => $this->decode($value), 28 | }; 29 | } 30 | 31 | private function encode(mixed $value): string|int 32 | { 33 | if (is_string($value) || is_int($value)) { 34 | return $value; 35 | } 36 | 37 | return $value instanceof BackedEnum ? $value->value : $value->name; 38 | } 39 | 40 | private function decode(mixed $data): mixed 41 | { 42 | return is_subclass_of($this->enumName, BackedEnum::class) 43 | ? ($this->enumName)::from($data) 44 | : constant($this->enumName . '::' . $data); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/JSONTransformer.php: -------------------------------------------------------------------------------- 1 | $class($type, $data); 26 | } 27 | 28 | public function __invoke(TransformType $type, mixed $data): string|array|object|null 29 | { 30 | if (null === $data) { 31 | if (true === $this->nullable) { 32 | return null; 33 | } 34 | throw new InvalidArgumentException('Data cannot be null'); 35 | } 36 | 37 | return match ($type) { 38 | TransformType::ENCODE => json_encode($data, flags: $this->flags), 39 | TransformType::DECODE => json_decode($data, associative: $this->isAssoc, flags: $this->flags), 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/ScalarTransformer.php: -------------------------------------------------------------------------------- 1 | $class($type, $data); 18 | } 19 | 20 | public function __invoke(TransformType $type, mixed $value): int|string|float|bool 21 | { 22 | return match ($type) { 23 | TransformType::ENCODE => $this->encode($value), 24 | TransformType::DECODE => $this->decode($value), 25 | }; 26 | } 27 | 28 | private function encode(mixed $value): float|bool|int|string 29 | { 30 | return $this->decode($value); 31 | } 32 | 33 | private function decode(mixed $data): float|bool|int|string 34 | { 35 | return match ($this->scalarType) { 36 | ScalarType::STRING => (string)$data, 37 | ScalarType::INT => (int)$data, 38 | ScalarType::FLOAT => (float)$data, 39 | ScalarType::BOOL => (bool)$data, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/SerializeTransformer.php: -------------------------------------------------------------------------------- 1 | unserialize($data, ['allowed_classes' => $allowClasses]); 21 | } 22 | } 23 | 24 | public function __invoke(TransformType $type, mixed $data): mixed 25 | { 26 | return match ($type) { 27 | TransformType::ENCODE => (self::$encode)($data), 28 | TransformType::DECODE => (self::$decode)($data), 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Model/Base/Transformers/TimestampTransformer.php: -------------------------------------------------------------------------------- 1 | $class($type, $data); 19 | } 20 | 21 | public function __invoke(TransformType $type, mixed $data): string|null|DateTimeInterface 22 | { 23 | if (null === $data) { 24 | if ($this->nullable) { 25 | return null; 26 | } 27 | 28 | throw new RuntimeException('Date cannot be null'); 29 | } 30 | 31 | $isDate = true === ($data instanceof DateTimeInterface); 32 | 33 | if (false === $isDate && !ctype_digit($data)) { 34 | if (is_string($data)) { 35 | $isDate = true; 36 | $data = makeDate($data); 37 | } else { 38 | throw new RuntimeException(r("Date must be a integer or DateTime. '{type}('{data}')' given.", [ 39 | 'type' => get_debug_type($data), 40 | 'data' => $data, 41 | ])); 42 | } 43 | } 44 | 45 | return match ($type) { 46 | TransformType::ENCODE => $isDate ? $data->getTimestamp() : $data, 47 | TransformType::DECODE => $isDate ? $data : makeDate($data), 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Model/Events/EventListener.php: -------------------------------------------------------------------------------- 1 | runValidator($object); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Model/Events/EventsTable.php: -------------------------------------------------------------------------------- 1 | null, 10 | iState::COLUMN_TYPE => iState::TYPE_MOVIE, 11 | iState::COLUMN_UPDATED => 1, 12 | iState::COLUMN_WATCHED => 1, 13 | iState::COLUMN_VIA => 'test_plex', 14 | iState::COLUMN_TITLE => 'Movie Title', 15 | iState::COLUMN_YEAR => 2020, 16 | iState::COLUMN_SEASON => null, 17 | iState::COLUMN_EPISODE => null, 18 | iState::COLUMN_PARENT => [], 19 | iState::COLUMN_GUIDS => [ 20 | Guid::GUID_IMDB => 'tt1100', 21 | Guid::GUID_TVDB => '1200', 22 | Guid::GUID_TMDB => '1300', 23 | Guid::GUID_TVMAZE => '1400', 24 | Guid::GUID_TVRAGE => '1500', 25 | Guid::GUID_ANIDB => '1600', 26 | ], 27 | iState::COLUMN_META_DATA => [ 28 | 'test_plex' => [ 29 | iState::COLUMN_ID => 121, 30 | iState::COLUMN_TYPE => iState::TYPE_MOVIE, 31 | iState::COLUMN_WATCHED => 1, 32 | iState::COLUMN_YEAR => '2020', 33 | iState::COLUMN_META_DATA_EXTRA => [ 34 | iState::COLUMN_META_DATA_EXTRA_DATE => '2020-01-03', 35 | ], 36 | iState::COLUMN_META_DATA_PROGRESS => 5000, 37 | iState::COLUMN_META_DATA_ADDED_AT => 1, 38 | iState::COLUMN_META_DATA_PLAYED_AT => 2, 39 | ], 40 | ], 41 | iState::COLUMN_EXTRA => [ 42 | 'test_plex' => [ 43 | iState::COLUMN_EXTRA_EVENT => 'media.scrobble', 44 | iState::COLUMN_EXTRA_DATE => 2, 45 | ], 46 | ], 47 | iState::COLUMN_CREATED_AT => 2, 48 | iState::COLUMN_UPDATED_AT => 2, 49 | ]; 50 | -------------------------------------------------------------------------------- /tests/Fixtures/local_data/fanart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/tests/Fixtures/local_data/fanart.png -------------------------------------------------------------------------------- /tests/Fixtures/local_data/poster.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/tests/Fixtures/local_data/poster.jpg -------------------------------------------------------------------------------- /tests/Fixtures/local_data/test.mkv: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /tests/Fixtures/local_data/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arabcoders/watchstate/5cc1c70bd970a6692aea994d78d222d78dce2416/tests/Fixtures/local_data/test.png -------------------------------------------------------------------------------- /tests/Fixtures/local_data/test.srt: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /tests/Fixtures/meminfo_data.txt: -------------------------------------------------------------------------------- 1 | MemTotal: 131598708 kB 2 | MemFree: 10636272 kB 3 | MemAvailable: 113059644 kB 4 | Buffers: 1851320 kB 5 | Cached: 96874768 kB 6 | SwapCached: 199724 kB 7 | Active: 15252072 kB 8 | Inactive: 96757496 kB 9 | Active(anon): 4046452 kB 10 | Inactive(anon): 11604056 kB 11 | Active(file): 11205620 kB 12 | Inactive(file): 85153440 kB 13 | Unevictable: 43880 kB 14 | Mlocked: 0 kB 15 | SwapTotal: 144758584 kB 16 | SwapFree: 140512824 kB 17 | Zswap: 0 kB 18 | Zswapped: 0 kB 19 | Dirty: 28 kB 20 | Writeback: 0 kB 21 | AnonPages: 12869660 kB 22 | Mapped: 760892 kB 23 | Shmem: 2367028 kB 24 | KReclaimable: 7315620 kB 25 | Slab: 8107296 kB 26 | SReclaimable: 7315620 kB 27 | SUnreclaim: 791676 kB 28 | KernelStack: 42512 kB 29 | PageTables: 78184 kB 30 | SecPageTables: 0 kB 31 | NFS_Unstable: 0 kB 32 | Bounce: 0 kB 33 | WritebackTmp: 0 kB 34 | CommitLimit: 210557936 kB 35 | Committed_AS: 38290840 kB 36 | VmallocTotal: 34359738367 kB 37 | VmallocUsed: 198508 kB 38 | VmallocChunk: 0 kB 39 | Percpu: 37760 kB 40 | HardwareCorrupted: 0 kB 41 | AnonHugePages: 5797888 kB 42 | ShmemHugePages: 47104 kB 43 | ShmemPmdMapped: 0 kB 44 | FileHugePages: 0 kB 45 | FilePmdMapped: 0 kB 46 | HugePages_Total: 0 47 | HugePages_Free: 0 48 | HugePages_Rsvd: 0 49 | HugePages_Surp: 0 50 | Hugepagesize: 2048 kB 51 | Hugetlb: 0 kB 52 | DirectMap4k: 11644304 kB 53 | DirectMap2M: 119123968 kB 54 | DirectMap1G: 3145728 kB 55 | 56 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #f0f0f0; 3 | font-family: Arial, sans-serif; 4 | font-size: 16px; 5 | color: #333; 6 | } 7 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test.js: -------------------------------------------------------------------------------- 1 | const testFunc = () => 'test' 2 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "test" 3 | } 4 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test.md: -------------------------------------------------------------------------------- 1 | # Test markdown 2 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test.woff2: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test/index.html: -------------------------------------------------------------------------------- 1 | test_index.html 2 | -------------------------------------------------------------------------------- /tests/Fixtures/static_data/test2/test.html: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /tests/Fixtures/subtitle.exported.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 00:00:00.498 --> 00:00:02.827 - Johnny, where are you? 00:00:02.827 --> 00:00:06.383 - Over here - Where? 00:00:06.383 --> 00:00:09.427 Oh, there you are! 00:00:09.427 --> 00:00:12.600 Come over here. I want to read to you. 00:00:12.600 --> 00:00:16.900 I have your favorite book: Green Eggs and Ham -------------------------------------------------------------------------------- /tests/Fixtures/subtitle.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": 0.498, 4 | "end": 2.827, 5 | "lines": [ 6 | "- Johnny, where are you?" 7 | ], 8 | "vtt": { 9 | "speakers": [ 10 | "Speaker01" 11 | ] 12 | } 13 | }, 14 | { 15 | "start": 2.827, 16 | "end": 6.383, 17 | "lines": [ 18 | "- Over here", 19 | "- Where?" 20 | ], 21 | "vtt": { 22 | "speakers": [ 23 | "Speaker02", 24 | "Speaker01" 25 | ] 26 | } 27 | }, 28 | { 29 | "start": 6.383, 30 | "end": 9.427, 31 | "lines": [ 32 | "Oh, there you are!" 33 | ] 34 | }, 35 | { 36 | "start": 9.427, 37 | "end": 12.6, 38 | "lines": [ 39 | "Come over here.", 40 | "I want to read to you." 41 | ] 42 | }, 43 | { 44 | "start": 12.6, 45 | "end": 16.9, 46 | "lines": [ 47 | "I have your favorite book:", 48 | "Green Eggs and Ham" 49 | ] 50 | } 51 | ] -------------------------------------------------------------------------------- /tests/Fixtures/subtitle.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | Kind: captions 3 | Language: en-US 4 | Channel: CC1 5 | Station: Online ABC 6 | ProgramID: SH010855880000 7 | ProgramType: TV series 8 | ProgramName: Castle 9 | Title: Law & Murder 10 | Season: 3 11 | Episode: 19 12 | PublishDate: 2011-03-28 13 | ContentAdvisory: TV-14 14 | 15 | STYLE 16 | /* Default cue styling */ 17 | ::cue { 18 | background-image: linear-gradient(to bottom, dimgray, lightgray); 19 | color: blue; 20 | } 21 | /* Classes that can be applied to individual cues or phrases */ 22 | ::cue(.bg-yellow) { 23 | background-color: yellow; 24 | } 25 | ::cue(.green) { 26 | color: green; 27 | } 28 | 29 | 30 | NOTE 31 | Copyright (c) 2016 by XYZ Company 32 | All rights reserved 33 | 34 | NOTE - Revisions 35 | 05/10/2016 09:20 AM - Revision 1.0 - First draft. TBD: Positioning 36 | 05/13/2016 06:13 PM - Revision 1.1 - Positioning completed. 37 | 05/14/2016 12:25 PM - Revision 1.2 - Review completed. Final draft. 38 | 39 | NOTE ==== Beginning of Cues ==== 40 | 41 | 00:00:00.498 --> 00:00:02.827 42 | - Johnny, where are you? 43 | 44 | 00:00:02.827 --> 00:00:06.383 45 | - Over here 46 | - Where? 47 | 48 | 00:00:06.383 --> 00:00:09.427 49 | Oh, there you are! 50 | 51 | 00:00:09.427 --> 00:00:12.600 52 | Come over here. 53 | I want to read to you. 54 | 55 | 00:00:12.600 --> 00:00:16.900 56 | I have your favorite book: 57 | Green Eggs and Ham 58 | 59 | NOTE ==== End of file ==== 60 | -------------------------------------------------------------------------------- /tests/Fixtures/test_env_vars: -------------------------------------------------------------------------------- 1 | WS_TZ=Asia/Kuwait 2 | WS_CRON_IMPORT=1 3 | WS_CRON_EXPORT=0 4 | WS_FOO_BAR=" " 5 | WS_CRON_IMPORT_AT=16 */1 * * * 6 | WS_CRON_EXPORT_AT="30 */3 * * *" 7 | WS_CRON_PUSH_AT='*/10 * * * *' 8 | # Commit_line=foo 9 | # Next is empty line 10 | 11 | # Intentionally left "=" from the string 12 | FOOBAR_KAZ 13 | -------------------------------------------------------------------------------- /tests/Fixtures/test_servers.yaml: -------------------------------------------------------------------------------- 1 | test_plex: 2 | type: plex 3 | url: 'https://plex.example.invalid' 4 | token: t000000000000000000p 5 | user: 11111111 6 | uuid: s00000000000000000000000000000000000000p 7 | export: 8 | enabled: true 9 | lastSync: 1724273445 10 | import: 11 | enabled: true 12 | lastSync: 1724173445 13 | webhook: 14 | match: 15 | user: true 16 | uuid: true 17 | options: 18 | ignore: '22,1,2,3' 19 | LIBRARY_SEGMENT: 1000 20 | ADMIN_TOKEN: plex_admin_token 21 | plex_user_uuid: r00000000000000p 22 | 23 | test_jellyfin: 24 | type: jellyfin 25 | url: 'https://jellyfin.example.invalid' 26 | token: t000000000000000000000000000000j 27 | user: u000000000000000000000000000000j 28 | uuid: s000000000000000000000000000000j 29 | export: 30 | enabled: true 31 | lastSync: null 32 | import: 33 | enabled: true 34 | lastSync: 1724173445 35 | webhook: 36 | match: 37 | user: false 38 | uuid: true 39 | options: 40 | ignore: 'i000000000000000000000000000000j,i100000000000000000000000000000j' 41 | MAX_EPISODE_RANGE: 6 42 | 43 | test_emby: 44 | type: emby 45 | url: 'https://emby.example.invalid' 46 | token: t000000000000000000000000000000e 47 | user: u000000000000000000000000000000e 48 | uuid: s000000000000000000000000000000e 49 | import: 50 | enabled: true 51 | lastSync: 1724173445 52 | export: 53 | enabled: true 54 | lastSync: null 55 | webhook: 56 | match: 57 | user: false 58 | uuid: true 59 | options: { } 60 | -------------------------------------------------------------------------------- /tests/Libs/APIResponseTest.php: -------------------------------------------------------------------------------- 1 | 1, 18 | 'name' => 'test', 19 | ]; 20 | 21 | $json = json_encode($body); 22 | 23 | $response = new APIResponse(Status::OK, headers: [ 24 | 'Content-Length' => strlen($json), 25 | 'Content-Type' => 'application/json', 26 | ], body: $body, stream: Stream::create($json)); 27 | 28 | $this->assertEquals(Status::OK, $response->status, 'Status is not OK'); 29 | $this->assertEquals($body, $response->body, 'Body is not equal'); 30 | $this->assertEquals($json, $response->stream->getContents(), 'Stream is not equal'); 31 | $this->assertEquals('application/json', ag($response->headers, 'Content-Type'), 'Content-Type is not equal'); 32 | $this->assertTrue($response->hasStream(), 'Stream is not available'); 33 | $this->assertTrue($response->hasBody(), 'Body is not available'); 34 | $this->assertTrue($response->hasHeaders(), 'Headers are not available'); 35 | $this->assertEquals(strlen($json), $response->getHeader('Content-Length'), 'Content-Length is not equal'); 36 | $this->assertSame(1, $response->getParam('id'), 'Param id is not equal'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Libs/Enums/Http/MethodTest.php: -------------------------------------------------------------------------------- 1 | fail("HTTP Method '{$method}' not recognized."); 19 | } else { 20 | $this->assertEquals( 21 | $method, 22 | $enum->value, 23 | "The return value of 'Method::{$enum->name}' '{$enum->value}' is not the expected value of '{$method}'." 24 | ); 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Libs/Enums/Http/StatusTest.php: -------------------------------------------------------------------------------- 1 | fail("Important HTTP status code '{$code}' not recognized."); 23 | } else { 24 | $this->assertEquals( 25 | $code, 26 | $enum->value, 27 | "The return value of 'Status::{$enum->name}' '{$enum->value}' is not the expected '{$code}' value." 28 | ); 29 | } 30 | } 31 | } 32 | 33 | /** 34 | * This test checks if the status code is within the boundary of 100 to 599. 35 | * This test is needed to trigger an error if the status code is out of the boundary. As some parts 36 | * of the codebase expect the status code to be within this boundary. 37 | */ 38 | public function test_status_code_boundary() 39 | { 40 | foreach (Status::cases() as $code) { 41 | $this->assertTrue( 42 | $code->value >= 100 && $code->value <= 599, 43 | "HTTP Status Code 'Status::{$code->name}' '{$code->value}' is out of boundary." 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Libs/Middlewares/AddCorsMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | process( 20 | request: $this->getRequest(), 21 | handler: $this->getHandler(new Response(Status::OK)) 22 | ); 23 | 24 | $this->assertTrue( 25 | $result->hasHeader('Access-Control-Allow-Origin'), 26 | 'Access-Control-Allow-Origin is not available' 27 | ); 28 | 29 | $this->assertTrue( 30 | $result->hasHeader('Access-Control-Allow-Credentials'), 31 | 'Access-Control-Allow-Credentials is not available' 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Libs/Middlewares/ExceptionHandlerMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | process( 19 | request: $this->getRequest(), 20 | handler: $this->getHandler( 21 | fn() => throw new \RuntimeException('Test Exception', 404) 22 | ) 23 | ); 24 | 25 | $json = json_decode($result->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); 26 | 27 | $this->assertArrayHasKey('error', $json, 'Error key is not available'); 28 | $this->assertArrayHasKey('message', $json['error'], 'Message key is not available'); 29 | $this->assertArrayHasKey('code', $json['error'], 'Status key is not available'); 30 | 31 | $this->assertSame('Test Exception', $json['error']['message'], 'Message is not equal'); 32 | $this->assertSame(404, $json['error']['code'], 'Status is not equal'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Libs/Middlewares/NoAccessLogMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | process( 21 | request: $this->getRequest(), 22 | handler: $this->getHandler(new Response(Status::OK)) 23 | ); 24 | 25 | $this->assertFalse( 26 | $result->hasHeader('X-No-AccessLog'), 27 | 'If INTERNAL_REQUEST is not set, Logging should be enabled.' 28 | ); 29 | } 30 | 31 | public function test_response_internal_request() 32 | { 33 | Config::save('api.logInternal', true); 34 | 35 | $result = new NoAccessLogMiddleware()->process( 36 | request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true), 37 | handler: $this->getHandler(new Response(Status::OK)) 38 | ); 39 | 40 | $this->assertFalse( 41 | $result->hasHeader('X-No-AccessLog'), 42 | 'If INTERNAL_REQUEST is not set, Logging should be enabled.' 43 | ); 44 | 45 | Config::save('api.logInternal', false); 46 | $result = new NoAccessLogMiddleware()->process( 47 | request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true), 48 | handler: $this->getHandler(new Response(Status::OK)) 49 | ); 50 | 51 | $this->assertTrue( 52 | $result->hasHeader('X-No-AccessLog'), 53 | 'If INTERNAL_REQUEST is set and api.logInternal is true, Logging should be disabled.' 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Libs/VttConverterTest.php: -------------------------------------------------------------------------------- 1 | getData()); 31 | 32 | $this->assertEquals($this->getJSON(), $data, 'Failed to parse VTT file'); 33 | $this->assertEquals( 34 | trim(preg_replace('/\r\n|\r|\n/', "\r\n", $this->getExportedData())), 35 | trim(preg_replace('/\r\n|\r|\n/', "\r\n", VttConverter::export($data))), 36 | 'Failed to export VTT file' 37 | ); 38 | } 39 | 40 | public function test_exceptions() 41 | { 42 | $this->checkException( 43 | closure: function () { 44 | $text = << 00:00:21 48 | test 49 | 50 | VTT; 51 | 52 | return VttConverter::parse($text); 53 | }, 54 | reason: 'Invalid VTT file', 55 | exception: \InvalidArgumentException::class, 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Mappers/Import/DirectMapperTest.php: -------------------------------------------------------------------------------- 1 | logger, $this->db, cache: new Psr16Cache(new NullAdapter())); 18 | $mapper->setOptions(options: ['class' => new StateEntity([])]); 19 | return $mapper; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Mappers/Import/MemoryMapperTest.php: -------------------------------------------------------------------------------- 1 | logger, $this->db, new Psr16Cache(new NullAdapter())); 18 | $mapper->setOptions(options: ['class' => new StateEntity([])]); 19 | 20 | return $mapper; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Server/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/Server/resources/index.php: -------------------------------------------------------------------------------- 1 |