├── .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 |
13 |
14 |
15 | {{ title }}
16 |
17 |
18 | {{ title }}
19 |
20 |
21 | {{ message }}
22 |
23 |
24 |
25 |
26 |
27 |
68 |
--------------------------------------------------------------------------------
/frontend/components/Overlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
48 |
--------------------------------------------------------------------------------
/frontend/components/UserSelection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Browse as
5 |
6 |
7 |
8 |
9 | {{ user }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | Browse the WebUI as the selected user. This feature is new and not all endpoints supports it yet, over time we
20 | plan to add support for this feature to all endpoints. If the endpoint doesn't support this feature, the main
21 | user will be used instead.
22 |
23 |
24 |
25 |
26 |
27 | Reload
28 |
29 |
30 |
31 |
32 |
33 |
69 |
--------------------------------------------------------------------------------
/frontend/composables/useToast.js:
--------------------------------------------------------------------------------
1 | import {useToast} from "vue-toastification";
2 |
3 | export default () => useToast()
4 |
--------------------------------------------------------------------------------
/frontend/error.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ props.error.statusCode }} - {{ props.error.statusMessage }}
8 |
9 |
10 |
11 |
12 |
13 |
{{ props.error.message }}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Stack trace
25 |
26 |
27 |
28 |
{{ props.error.stack }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Back to Home
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
48 |
--------------------------------------------------------------------------------
/frontend/layouts/guest.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
2 |
7 |
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 |