Merge commit '8d455f1c9bb73bc68055b2f54022405e5f25ad50' into stable
This commit is contained in:
commit
0b4a848f3d
|
@ -900,3 +900,6 @@ ij_yaml_sequence_on_new_line = false
|
|||
ij_yaml_space_before_colon = false
|
||||
ij_yaml_spaces_within_braces = true
|
||||
ij_yaml_spaces_within_brackets = true
|
||||
|
||||
[*.neon]
|
||||
indent_style = tab
|
|
@ -1,4 +1,4 @@
|
|||
# Node Modules
|
||||
# Frontend
|
||||
node_modules
|
||||
|
||||
# Junk/cache files.
|
||||
|
|
42
CHANGELOG.md
42
CHANGELOG.md
|
@ -11,6 +11,48 @@ release channel, you can take advantage of these new features and fixes.
|
|||
|
||||
---
|
||||
|
||||
# AzuraCast 0.19.4 (Jan 4, 2024)
|
||||
|
||||
## New Features/Changes
|
||||
|
||||
- **Passwordless Login with Passkeys**: You can now associate passkeys (also known as WebAuthn) with your account and
|
||||
then automatically log in with those passkeys any time. Popular passkeys include those provided by your operating
|
||||
system (i.e. Windows Hello or Apple's Safari passkey system) or may be provided by third-party software (like
|
||||
Bitwarden). They're a secure alternative to passwords and two-factor authentication. You can register multiple
|
||||
passkeys with a single account, and if you ever misplace your passkey, you can still log in with your regular e-mail
|
||||
address and password (and two-factor auth, if you've set one up).
|
||||
|
||||
## Code Quality/Technical Changes
|
||||
|
||||
- We've even further improved our performance on production installations by switching from PHP-FPM to the Roadrunner
|
||||
PHP application engine and tweaking how we handle caching on things like album art images. These changes should
|
||||
dramatically reduce how often PHP is called, and make it much faster when it is called.
|
||||
|
||||
- Our High-Performance Now Playing updates through Centrifugo now send the current Now Playing data immediately upon
|
||||
connection, meaning you can rely on it exclusively for Now Playing data instead of making another API call to load the
|
||||
initial data. See
|
||||
our [updated code samples](https://www.azuracast.com/docs/developers/now-playing-data/#high-performance-updates) for
|
||||
more information.
|
||||
|
||||
- MariaDB has been updated to 11.2. Databases will automatically be upgraded on the first boot after updating.
|
||||
|
||||
- If you upload media to a folder and that folder is set to auto-assign to a playlist, the media will *instantly* be a
|
||||
part of that playlist, not subject to a sync task delay; this should greatly improve the user experience when using
|
||||
this feature.
|
||||
|
||||
- We've identified several places in our Docker image where caches led to excessive image size. We've reduced our
|
||||
uncompressed Docker image size from 3GB to 1.4GB, and the compressed images from 700MB to under 500MB. This should
|
||||
mean faster update pulls and less disk space used for installation operators.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- An issue causing audio recordings to break the station filesystem was fixed by Liquidsoap, and the fixed version is
|
||||
included in this version of AzuraCast.
|
||||
|
||||
- A bug causing fade/cue/etc. values on media of "0" or "0.0" to not properly disable crossfading/etc. has been fixed.
|
||||
|
||||
---
|
||||
|
||||
# AzuraCast 0.19.3 (Nov 18, 2023)
|
||||
|
||||
## New Features/Changes
|
||||
|
|
210
Dockerfile
210
Dockerfile
|
@ -1,21 +1,36 @@
|
|||
#
|
||||
# Golang dependencies build step
|
||||
#
|
||||
FROM golang:1.20-bullseye AS go-dependencies
|
||||
FROM golang:1.21-bullseye AS go-dependencies
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl git
|
||||
|
||||
RUN go install github.com/jwilder/dockerize@v0.6.1
|
||||
|
||||
RUN go install github.com/aptible/supercronic@v0.2.26
|
||||
RUN go install github.com/aptible/supercronic@v0.2.28
|
||||
|
||||
RUN go install github.com/centrifugal/centrifugo/v5@v5.1.1
|
||||
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.0
|
||||
|
||||
#
|
||||
# MariaDB dependencies build step
|
||||
#
|
||||
FROM mariadb:10.9-jammy AS mariadb
|
||||
FROM mariadb:11.2-jammy AS mariadb
|
||||
|
||||
#
|
||||
# Built-in docs build step
|
||||
#
|
||||
FROM ghcr.io/azuracast/azuracast.com:builtin AS docs
|
||||
|
||||
#
|
||||
# Icecast-KH with AzuraCast customizations build step
|
||||
#
|
||||
FROM ghcr.io/azuracast/icecast-kh-ac:latest AS icecast
|
||||
|
||||
#
|
||||
# Roadrunner build step
|
||||
#
|
||||
FROM ghcr.io/roadrunner-server/roadrunner:2023.3.8 AS roadrunner
|
||||
|
||||
#
|
||||
# Final build image
|
||||
|
@ -32,34 +47,53 @@ COPY --from=go-dependencies /go/bin/centrifugo /usr/local/bin/centrifugo
|
|||
# Add MariaDB dependencies
|
||||
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
|
||||
COPY --from=mariadb /usr/local/bin/docker-entrypoint.sh /usr/local/bin/db_entrypoint.sh
|
||||
COPY --from=mariadb /etc/apt/sources.list.d/mariadb.list /etc/apt/sources.list.d/mariadb.list
|
||||
COPY --from=mariadb /etc/apt/trusted.gpg.d/mariadb.gpg /etc/apt/trusted.gpg.d/mariadb.gpg
|
||||
|
||||
# Add Icecast
|
||||
COPY --from=icecast /usr/local/bin/icecast /usr/local/bin/icecast
|
||||
COPY --from=icecast /usr/local/share/icecast /usr/local/share/icecast
|
||||
|
||||
# Add Roadrunner
|
||||
COPY --from=roadrunner /usr/bin/rr /usr/local/bin/rr
|
||||
|
||||
# Run base build process
|
||||
COPY ./util/docker/common /bd_build/
|
||||
|
||||
RUN bash /bd_build/prepare.sh \
|
||||
&& bash /bd_build/add_user.sh
|
||||
&& bash /bd_build/add_user.sh \
|
||||
&& bash /bd_build/cleanup.sh
|
||||
|
||||
# Add built-in docs
|
||||
COPY --from=docs --chown=azuracast:azuracast /dist /var/azuracast/docs
|
||||
|
||||
# Build each set of dependencies in their own step for cacheability.
|
||||
COPY ./util/docker/supervisor /bd_build/supervisor/
|
||||
RUN bash /bd_build/supervisor/setup.sh
|
||||
RUN bash /bd_build/supervisor/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build/supervisor
|
||||
|
||||
COPY ./util/docker/stations /bd_build/stations/
|
||||
RUN bash /bd_build/stations/setup.sh
|
||||
RUN bash /bd_build/stations/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build/stations
|
||||
|
||||
COPY ./util/docker/web /bd_build/web/
|
||||
RUN bash /bd_build/web/setup.sh
|
||||
RUN bash /bd_build/web/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build/web
|
||||
|
||||
COPY ./util/docker/mariadb /bd_build/mariadb/
|
||||
RUN bash /bd_build/mariadb/setup.sh
|
||||
RUN bash /bd_build/mariadb/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build/mariadb
|
||||
|
||||
COPY ./util/docker/redis /bd_build/redis/
|
||||
RUN bash /bd_build/redis/setup.sh
|
||||
|
||||
COPY ./util/docker/docs /bd_build/docs/
|
||||
RUN bash /bd_build/docs/setup.sh
|
||||
RUN bash /bd_build/redis/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build/redis
|
||||
|
||||
RUN bash /bd_build/chown_dirs.sh \
|
||||
bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build
|
||||
|
||||
USER azuracast
|
||||
|
@ -78,99 +112,12 @@ VOLUME "/var/azuracast/storage/geoip"
|
|||
VOLUME "/var/azuracast/storage/sftpgo"
|
||||
VOLUME "/var/azuracast/storage/acme"
|
||||
|
||||
#
|
||||
# Development Build
|
||||
#
|
||||
FROM pre-final AS development
|
||||
|
||||
# Dev build step
|
||||
COPY ./util/docker/common /bd_build/
|
||||
COPY ./util/docker/dev /bd_build/dev
|
||||
|
||||
RUN bash /bd_build/dev/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build
|
||||
|
||||
USER azuracast
|
||||
|
||||
WORKDIR /var/azuracast/www
|
||||
|
||||
COPY --chown=azuracast:azuracast . .
|
||||
|
||||
RUN composer install --no-ansi --no-interaction
|
||||
|
||||
WORKDIR /var/azuracast/www/frontend
|
||||
|
||||
RUN npm ci --include=dev
|
||||
|
||||
WORKDIR /var/azuracast/www
|
||||
|
||||
USER root
|
||||
|
||||
EXPOSE 80 443 2022
|
||||
EXPOSE 8000-8999
|
||||
|
||||
# Sensible default environment variables.
|
||||
ENV TZ="UTC" \
|
||||
LANG="en_US.UTF-8" \
|
||||
PATH="${PATH}:/var/azuracast/servers/shoutcast2" \
|
||||
DOCKER_IS_STANDALONE="true" \
|
||||
APPLICATION_ENV="development" \
|
||||
MYSQL_HOST="localhost" \
|
||||
MYSQL_PORT=3306 \
|
||||
MYSQL_USER="azuracast" \
|
||||
MYSQL_PASSWORD="azur4c457" \
|
||||
MYSQL_DATABASE="azuracast" \
|
||||
ENABLE_REDIS="true" \
|
||||
REDIS_HOST="localhost" \
|
||||
REDIS_PORT=6379 \
|
||||
REDIS_DB=1 \
|
||||
NGINX_RADIO_PORTS="default" \
|
||||
NGINX_WEBDJ_PORTS="default" \
|
||||
COMPOSER_PLUGIN_MODE="false" \
|
||||
ADDITIONAL_MEDIA_SYNC_WORKER_COUNT=0 \
|
||||
PROFILING_EXTENSION_ENABLED=1 \
|
||||
PROFILING_EXTENSION_ALWAYS_ON=0 \
|
||||
PROFILING_EXTENSION_HTTP_KEY=dev \
|
||||
PROFILING_EXTENSION_HTTP_IP_WHITELIST=* \
|
||||
ENABLE_WEB_UPDATER="false"
|
||||
|
||||
# Entrypoint and default command
|
||||
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
||||
CMD ["--no-main-command"]
|
||||
|
||||
#
|
||||
# Final build (Just environment vars and squishing the FS)
|
||||
#
|
||||
FROM ubuntu:jammy AS final
|
||||
|
||||
COPY --from=pre-final / /
|
||||
|
||||
USER azuracast
|
||||
|
||||
WORKDIR /var/azuracast/www
|
||||
|
||||
COPY --chown=azuracast:azuracast ./composer.json ./composer.lock ./
|
||||
RUN composer install \
|
||||
--no-dev \
|
||||
--no-ansi \
|
||||
--no-autoloader \
|
||||
--no-interaction
|
||||
|
||||
COPY --chown=azuracast:azuracast . .
|
||||
|
||||
RUN composer dump-autoload --optimize --classmap-authoritative
|
||||
|
||||
USER root
|
||||
|
||||
EXPOSE 80 443 2022
|
||||
EXPOSE 8000-8999
|
||||
|
||||
# Sensible default environment variables.
|
||||
ENV TZ="UTC" \
|
||||
LANG="en_US.UTF-8" \
|
||||
PATH="${PATH}:/var/azuracast/servers/shoutcast2" \
|
||||
DOCKER_IS_STANDALONE="true" \
|
||||
ENV LANG="en_US.UTF-8" \
|
||||
PATH="${PATH}:/var/azuracast/storage/shoutcast2" \
|
||||
APPLICATION_ENV="production" \
|
||||
MYSQL_HOST="localhost" \
|
||||
MYSQL_PORT=3306 \
|
||||
|
@ -191,6 +138,63 @@ ENV TZ="UTC" \
|
|||
PROFILING_EXTENSION_HTTP_IP_WHITELIST=* \
|
||||
ENABLE_WEB_UPDATER="true"
|
||||
|
||||
#
|
||||
# Development Build
|
||||
#
|
||||
FROM pre-final AS development
|
||||
|
||||
# Dev build step
|
||||
COPY ./util/docker/common /bd_build/
|
||||
COPY ./util/docker/dev /bd_build/dev
|
||||
|
||||
RUN bash /bd_build/dev/setup.sh \
|
||||
&& bash /bd_build/cleanup.sh \
|
||||
&& rm -rf /bd_build
|
||||
|
||||
USER azuracast
|
||||
|
||||
WORKDIR /var/azuracast/www
|
||||
|
||||
COPY --chown=azuracast:azuracast . .
|
||||
|
||||
RUN composer install --no-ansi --no-interaction \
|
||||
&& composer clear-cache
|
||||
|
||||
WORKDIR /var/azuracast/www/frontend
|
||||
|
||||
RUN npm ci --include=dev \
|
||||
&& npm cache clean --force
|
||||
|
||||
WORKDIR /var/azuracast/www
|
||||
|
||||
USER root
|
||||
|
||||
# Sensible default environment variables.
|
||||
ENV APPLICATION_ENV="development" \
|
||||
PROFILING_EXTENSION_ENABLED=1 \
|
||||
ENABLE_WEB_UPDATER="false"
|
||||
|
||||
# Entrypoint and default command
|
||||
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
||||
CMD ["--no-main-command"]
|
||||
|
||||
#
|
||||
# Final build (Just environment vars and squishing the FS)
|
||||
#
|
||||
FROM pre-final AS final
|
||||
|
||||
USER azuracast
|
||||
|
||||
WORKDIR /var/azuracast/www
|
||||
|
||||
COPY --chown=azuracast:azuracast . .
|
||||
|
||||
RUN composer install --no-dev --no-ansi --no-autoloader --no-interaction \
|
||||
&& composer dump-autoload --optimize --classmap-authoritative \
|
||||
&& composer clear-cache
|
||||
|
||||
USER root
|
||||
|
||||
# Entrypoint and default command
|
||||
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
||||
CMD ["--no-main-command"]
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"br33f/php-ga4-mp": "^0.1.2",
|
||||
"brick/math": "^0.11",
|
||||
"composer/ca-bundle": "^1.2",
|
||||
"doctrine/annotations": "^2",
|
||||
"doctrine/data-fixtures": "^1.3",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/migrations": "^3.0",
|
||||
|
@ -42,8 +41,9 @@
|
|||
"gettext/php-scanner": "^1.3",
|
||||
"gettext/translator": "^1.1",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"intervention/image": "^2.6",
|
||||
"james-heinrich/getid3": "v2.0.0-beta5",
|
||||
"intervention/image": "^3",
|
||||
"james-heinrich/getid3": "v2.0.0-beta6",
|
||||
"lbuchs/webauthn": "^2.1",
|
||||
"league/csv": "^9.6",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/flysystem-sftp-v3": "^3.0",
|
||||
|
@ -61,37 +61,39 @@
|
|||
"pagerfanta/doctrine-collections-adapter": "^4",
|
||||
"pagerfanta/doctrine-orm-adapter": "^4",
|
||||
"php-di/php-di": "^7.0.1",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.0",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.2",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"psr/simple-cache": "^3.0",
|
||||
"ramsey/uuid": "^4.0",
|
||||
"rlanvin/php-ip": "dev-master",
|
||||
"roadrunner-php/centrifugo": "^2.0",
|
||||
"skoerfgen/acmecert": "^3.2",
|
||||
"slim/http": "^1.1",
|
||||
"slim/slim": "^4.2",
|
||||
"spatie/flysystem-dropbox": "^3",
|
||||
"spiral/roadrunner-http": "^3.3",
|
||||
"spomky-labs/otphp": "^11",
|
||||
"supervisorphp/supervisor": "dev-main",
|
||||
"symfony/cache": "^6",
|
||||
"symfony/console": "^6",
|
||||
"symfony/event-dispatcher": "^6",
|
||||
"symfony/filesystem": "^6.0",
|
||||
"symfony/finder": "^6",
|
||||
"symfony/intl": "^6",
|
||||
"symfony/lock": "^6",
|
||||
"symfony/mailer": "^6",
|
||||
"symfony/messenger": "^6",
|
||||
"symfony/process": "^6",
|
||||
"symfony/property-access": "^6",
|
||||
"symfony/rate-limiter": "^6",
|
||||
"symfony/redis-messenger": "^6",
|
||||
"symfony/serializer": "^6",
|
||||
"symfony/validator": "^6",
|
||||
"symfony/yaml": "^6",
|
||||
"symfony/cache": "^7",
|
||||
"symfony/console": "^7",
|
||||
"symfony/event-dispatcher": "^7",
|
||||
"symfony/filesystem": "^7",
|
||||
"symfony/finder": "^7",
|
||||
"symfony/intl": "^7",
|
||||
"symfony/lock": "^7",
|
||||
"symfony/mailer": "^7",
|
||||
"symfony/messenger": "^7",
|
||||
"symfony/process": "^7",
|
||||
"symfony/property-access": "^7",
|
||||
"symfony/rate-limiter": "^7",
|
||||
"symfony/redis-messenger": "^7",
|
||||
"symfony/serializer": "^7",
|
||||
"symfony/validator": "^7",
|
||||
"symfony/yaml": "^7",
|
||||
"vlucas/phpdotenv": "^5.3",
|
||||
"voku/portable-utf8": "^6.0",
|
||||
"wikimedia/composer-merge-plugin": "dev-master",
|
||||
"zircote/swagger-php": "^4.3.0"
|
||||
"zircote/swagger-php": "dev-master"
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-iconv": "1.99",
|
||||
|
@ -121,13 +123,13 @@
|
|||
"phpstan/phpstan-doctrine": "^1",
|
||||
"phpunit/php-timer": "^6",
|
||||
"phpunit/phpunit": "^10",
|
||||
"psy/psysh": "^0.11.0",
|
||||
"psy/psysh": "^0.12",
|
||||
"pyrech/composer-changelogs": "^2",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"slevomat/coding-standard": "^8",
|
||||
"softcreatr/jsonpath": "^0.8",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/var-dumper": "^6"
|
||||
"symfony/var-dumper": "^7"
|
||||
},
|
||||
"config": {
|
||||
"discard-changes": true,
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -92,12 +92,10 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
|
|||
$app->addRoutingMiddleware();
|
||||
|
||||
// Redirects and updates that should happen before system middleware.
|
||||
$app->add(new Middleware\Cache\SetDefaultCache());
|
||||
$app->add(new Middleware\RemoveSlashes());
|
||||
$app->add(new Middleware\ApplyXForwardedProto());
|
||||
|
||||
// Use PSR-7 compatible sessions.
|
||||
$app->add(Middleware\InjectSession::class);
|
||||
|
||||
// Add an error handler for most in-controller/task situations.
|
||||
$errorMiddleware = $app->addErrorMiddleware(
|
||||
$environment->showDetailedErrors(),
|
||||
|
|
|
@ -10,15 +10,41 @@ return static function (App $app) {
|
|||
$app->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
call_user_func(include(__DIR__ . '/routes/base.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/public.php'), $group);
|
||||
}
|
||||
)->add(Middleware\Auth\StandardAuth::class);
|
||||
)->add(Middleware\Auth\PublicAuth::class);
|
||||
|
||||
$app->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
call_user_func(include(__DIR__ . '/routes/api.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/base.php'), $group);
|
||||
}
|
||||
)->add(Middleware\Auth\ApiAuth::class);
|
||||
)->add(Middleware\Auth\StandardAuth::class)
|
||||
->add(Middleware\InjectSession::class);
|
||||
|
||||
$app->group(
|
||||
'/api',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
call_user_func(include(__DIR__ . '/routes/api_public.php'), $group);
|
||||
}
|
||||
)->add(Middleware\Module\Api::class)
|
||||
->add(Middleware\Auth\PublicAuth::class);
|
||||
|
||||
$group->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
call_user_func(include(__DIR__ . '/routes/api_internal.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/api_admin.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/api_frontend.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/api_station.php'), $group);
|
||||
}
|
||||
)
|
||||
->add(Middleware\Module\Api::class)
|
||||
->add(Middleware\Auth\ApiAuth::class)
|
||||
->add(Middleware\InjectSession::class);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,100 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Middleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
return static function (RouteCollectorProxy $app) {
|
||||
$app->group(
|
||||
'/api',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->options(
|
||||
'/{routes:.+}',
|
||||
function (ServerRequest $request, Response $response, ...$params) {
|
||||
return $response
|
||||
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
->withHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'x-api-key, x-requested-with, Content-Type, Accept, Origin, Authorization'
|
||||
)
|
||||
->withHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'',
|
||||
function (ServerRequest $request, Response $response, ...$params): ResponseInterface {
|
||||
return $response->withRedirect('/docs/api/');
|
||||
}
|
||||
)->setName('api:index:index');
|
||||
|
||||
$group->get('/openapi.yml', Controller\Api\OpenApiAction::class)
|
||||
->setName('api:openapi');
|
||||
|
||||
$group->get('/status', Controller\Api\IndexController::class . ':statusAction')
|
||||
->setName('api:index:status');
|
||||
|
||||
$group->get('/time', Controller\Api\IndexController::class . ':timeAction')
|
||||
->setName('api:index:time');
|
||||
|
||||
$group->group(
|
||||
'/internal',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->group(
|
||||
'/{station_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->map(
|
||||
['GET', 'POST'],
|
||||
'/liquidsoap/{action}',
|
||||
Controller\Api\Internal\LiquidsoapAction::class
|
||||
)->setName('api:internal:liquidsoap');
|
||||
|
||||
// Icecast internal auth functions
|
||||
$group->map(
|
||||
['GET', 'POST'],
|
||||
'/listener-auth',
|
||||
Controller\Api\Internal\ListenerAuthAction::class
|
||||
)->setName('api:internal:listener-auth');
|
||||
}
|
||||
)->add(Middleware\GetStation::class);
|
||||
|
||||
$group->post('/sftp-auth', Controller\Api\Internal\SftpAuthAction::class)
|
||||
->setName('api:internal:sftp-auth');
|
||||
|
||||
$group->post('/sftp-event', Controller\Api\Internal\SftpEventAction::class)
|
||||
->setName('api:internal:sftp-event');
|
||||
|
||||
$group->get('/relays', Controller\Api\Internal\RelaysController::class)
|
||||
->setName('api:internal:relays')
|
||||
->add(Middleware\RequireLogin::class);
|
||||
|
||||
$group->post('/relays', Controller\Api\Internal\RelaysController::class . ':updateAction')
|
||||
->add(Middleware\RequireLogin::class);
|
||||
}
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/nowplaying[/{station_id}]',
|
||||
Controller\Api\NowPlayingController::class . ':getAction'
|
||||
)->setName('api:nowplaying:index');
|
||||
|
||||
$group->get(
|
||||
'/nowplaying/{station_id}/art[/{timestamp}.jpg]',
|
||||
Controller\Api\NowPlayingController::class . ':getArtAction'
|
||||
)->setName('api:nowplaying:art');
|
||||
|
||||
$group->get('/stations', Controller\Api\Stations\IndexController::class . ':listAction')
|
||||
->setName('api:stations:list')
|
||||
->add(new Middleware\RateLimit('api'));
|
||||
|
||||
call_user_func(include(__DIR__ . '/api_admin.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/api_frontend.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/api_station.php'), $group);
|
||||
}
|
||||
)->add(Middleware\Module\Api::class);
|
||||
};
|
|
@ -47,6 +47,31 @@ return static function (RouteCollectorProxy $group) {
|
|||
'/api-key/{id}',
|
||||
Controller\Api\Frontend\Account\ApiKeysController::class . ':deleteAction'
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/webauthn/register',
|
||||
Controller\Api\Frontend\Account\WebAuthn\GetRegistrationAction::class
|
||||
)->setName('api:frontend:webauthn:register');
|
||||
|
||||
$group->put(
|
||||
'/webauthn/register',
|
||||
Controller\Api\Frontend\Account\WebAuthn\PutRegistrationAction::class
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/passkeys',
|
||||
Controller\Api\Frontend\Account\PasskeysController::class . ':listAction'
|
||||
)->setName('api:frontend:passkeys');
|
||||
|
||||
$group->get(
|
||||
'/passkey/{id}',
|
||||
Controller\Api\Frontend\Account\PasskeysController::class . ':getAction'
|
||||
)->setName('api:frontend:passkey');
|
||||
|
||||
$group->delete(
|
||||
'/passkey/{id}',
|
||||
Controller\Api\Frontend\Account\PasskeysController::class . ':deleteAction'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller;
|
||||
use App\Middleware;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
// Internal API endpoints (called by other programs hosted on the same machine).
|
||||
return static function (RouteCollectorProxy $group) {
|
||||
$group->group(
|
||||
'/internal',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->group(
|
||||
'/{station_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->map(
|
||||
['GET', 'POST'],
|
||||
'/liquidsoap/{action}',
|
||||
Controller\Api\Internal\LiquidsoapAction::class
|
||||
)->setName('api:internal:liquidsoap');
|
||||
|
||||
// Icecast internal auth functions
|
||||
$group->map(
|
||||
['GET', 'POST'],
|
||||
'/listener-auth',
|
||||
Controller\Api\Internal\ListenerAuthAction::class
|
||||
)->setName('api:internal:listener-auth');
|
||||
}
|
||||
)->add(Middleware\GetStation::class);
|
||||
|
||||
$group->post('/sftp-auth', Controller\Api\Internal\SftpAuthAction::class)
|
||||
->setName('api:internal:sftp-auth');
|
||||
|
||||
$group->post('/sftp-event', Controller\Api\Internal\SftpEventAction::class)
|
||||
->setName('api:internal:sftp-event');
|
||||
|
||||
$group->get('/relays', Controller\Api\Internal\RelaysController::class)
|
||||
->setName('api:internal:relays')
|
||||
->add(Middleware\RequireLogin::class);
|
||||
|
||||
$group->post('/relays', Controller\Api\Internal\RelaysController::class . ':updateAction')
|
||||
->add(Middleware\RequireLogin::class);
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Middleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
// Public-facing API endpoints (unauthenticated).
|
||||
return static function (RouteCollectorProxy $group) {
|
||||
$group->options(
|
||||
'/{routes:.+}',
|
||||
function (ServerRequest $request, Response $response, ...$params) {
|
||||
return $response
|
||||
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
->withHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'x-api-key, x-requested-with, Content-Type, Accept, Origin, Authorization'
|
||||
)
|
||||
->withHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'',
|
||||
function (ServerRequest $request, Response $response, ...$params): ResponseInterface {
|
||||
return $response->withRedirect('/docs/api/');
|
||||
}
|
||||
)->setName('api:index:index');
|
||||
|
||||
$group->get('/openapi.yml', Controller\Api\OpenApiAction::class)
|
||||
->setName('api:openapi')
|
||||
->add(new Middleware\Cache\SetCache(60));
|
||||
|
||||
$group->get('/status', Controller\Api\IndexController::class . ':statusAction')
|
||||
->setName('api:index:status');
|
||||
|
||||
$group->get('/time', Controller\Api\IndexController::class . ':timeAction')
|
||||
->setName('api:index:time')
|
||||
->add(new Middleware\Cache\SetCache(1));
|
||||
|
||||
$group->get(
|
||||
'/nowplaying[/{station_id}]',
|
||||
Controller\Api\NowPlayingAction::class
|
||||
)->setName('api:nowplaying:index')
|
||||
->add(new Middleware\Cache\SetCache(15))
|
||||
->add(Middleware\GetStation::class);
|
||||
|
||||
$group->get(
|
||||
'/nowplaying/{station_id}/art[/{timestamp}.jpg]',
|
||||
Controller\Api\NowPlayingArtAction::class
|
||||
)->setName('api:nowplaying:art')
|
||||
->add(new Middleware\Cache\SetCache(15))
|
||||
->add(Middleware\RequireStation::class)
|
||||
->add(Middleware\GetStation::class);
|
||||
|
||||
$group->get('/stations', Controller\Api\Stations\IndexController::class . ':listAction')
|
||||
->setName('api:stations:list')
|
||||
->add(new Middleware\RateLimit('api'));
|
||||
|
||||
$group->group(
|
||||
'/station/{station_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
// Media Art
|
||||
$group->get(
|
||||
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Art\GetArtAction::class
|
||||
)->setName('api:stations:media:art');
|
||||
|
||||
// Streamer Art
|
||||
$group->get(
|
||||
'/streamer/{id}/art[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Streamers\Art\GetArtAction::class
|
||||
)->setName('api:stations:streamer:art');
|
||||
|
||||
// Podcast and Episode Art
|
||||
$group->group(
|
||||
'/podcast/{podcast_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get(
|
||||
'/art[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:art');
|
||||
|
||||
$group->get(
|
||||
'/episode/{episode_id}/art[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:episode:art');
|
||||
}
|
||||
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
|
||||
}
|
||||
)->add(new Middleware\Cache\SetStaticFileCache())
|
||||
->add(Middleware\RequireStation::class)
|
||||
->add(Middleware\GetStation::class);
|
||||
};
|
|
@ -19,7 +19,7 @@ return static function (RouteCollectorProxy $group) {
|
|||
->setName('api:stations:index')
|
||||
->add(new Middleware\RateLimit('api', 5, 2));
|
||||
|
||||
$group->get('/nowplaying', Controller\Api\NowPlayingController::class . ':getAction');
|
||||
$group->get('/nowplaying', Controller\Api\NowPlayingAction::class . ':getAction');
|
||||
|
||||
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)
|
||||
->setName('api:stations:schedule');
|
||||
|
@ -40,10 +40,12 @@ return static function (RouteCollectorProxy $group) {
|
|||
|
||||
// On-Demand Streaming
|
||||
$group->get('/ondemand', Controller\Api\Stations\OnDemand\ListAction::class)
|
||||
->setName('api:stations:ondemand:list');
|
||||
->setName('api:stations:ondemand:list')
|
||||
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand));
|
||||
|
||||
$group->get('/ondemand/download/{media_id}', Controller\Api\Stations\OnDemand\DownloadAction::class)
|
||||
->setName('api:stations:ondemand:download')
|
||||
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
|
||||
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
||||
|
||||
// Podcast Public Pages
|
||||
|
@ -53,10 +55,7 @@ return static function (RouteCollectorProxy $group) {
|
|||
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
|
||||
->setName('api:stations:podcast');
|
||||
|
||||
$group->get(
|
||||
'/art',
|
||||
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:art');
|
||||
// See ./api_public for podcast art.
|
||||
|
||||
$group->get(
|
||||
'/episodes',
|
||||
|
@ -71,11 +70,6 @@ return static function (RouteCollectorProxy $group) {
|
|||
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
|
||||
)->setName('api:stations:podcast:episode');
|
||||
|
||||
$group->get(
|
||||
'/art',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:episode:art');
|
||||
|
||||
$group->get(
|
||||
'/download',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
|
||||
|
@ -85,18 +79,7 @@ return static function (RouteCollectorProxy $group) {
|
|||
}
|
||||
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
|
||||
|
||||
// Media Art
|
||||
$group->get('/art/{media_id:[a-zA-Z0-9\-]+}.jpg', Controller\Api\Stations\Art\GetArtAction::class)
|
||||
->setName('api:stations:media:art');
|
||||
|
||||
$group->get('/art/{media_id:[a-zA-Z0-9\-]+}', Controller\Api\Stations\Art\GetArtAction::class)
|
||||
->setName('api:stations:media:art-internal');
|
||||
|
||||
// Streamer Art
|
||||
$group->get(
|
||||
'/streamer/{id}/art',
|
||||
Controller\Api\Stations\Streamers\Art\GetArtAction::class
|
||||
)->setName('api:stations:streamer:art');
|
||||
// NOTE: See ./api_public.php for media art public path.
|
||||
|
||||
/*
|
||||
* Authenticated Functions
|
||||
|
@ -254,9 +237,10 @@ return static function (RouteCollectorProxy $group) {
|
|||
->add(new Middleware\Permissions(StationPermissions::View, true));
|
||||
|
||||
$group->get(
|
||||
'/waveform/{media_id:[a-zA-Z0-9\-]+}.json',
|
||||
'/waveform/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.json]',
|
||||
Controller\Api\Stations\Waveform\GetWaveformAction::class
|
||||
)->setName('api:stations:media:waveform');
|
||||
)->setName('api:stations:media:waveform')
|
||||
->add(new Middleware\Cache\SetStaticFileCache());
|
||||
|
||||
$group->post('/art/{media_id:[a-zA-Z0-9]+}', Controller\Api\Stations\Art\PostArtAction::class)
|
||||
->add(new Middleware\Permissions(StationPermissions::Media, true));
|
||||
|
|
|
@ -53,6 +53,11 @@ return static function (RouteCollectorProxy $app) {
|
|||
->setName('account:recover')
|
||||
->add(Middleware\EnableView::class);
|
||||
|
||||
$app->get('/login/webauthn', Controller\Frontend\Account\WebAuthn\GetValidationAction::class)
|
||||
->setName('account:webauthn');
|
||||
|
||||
$app->post('/login/webauthn', Controller\Frontend\Account\WebAuthn\PostValidationAction::class);
|
||||
|
||||
$app->group(
|
||||
'/setup',
|
||||
function (RouteCollectorProxy $group) {
|
||||
|
|
|
@ -38,7 +38,8 @@ return static function (RouteCollectorProxy $app) {
|
|||
->setName('public:manifest');
|
||||
|
||||
$group->get('/embed-requests', Controller\Frontend\PublicPages\RequestsAction::class)
|
||||
->setName('public:embedrequests');
|
||||
->setName('public:embedrequests')
|
||||
->add(new Middleware\StationSupportsFeature(App\Enums\StationFeatures::Requests));
|
||||
|
||||
$group->get('/playlist[.{format}]', Controller\Frontend\PublicPages\PlaylistAction::class)
|
||||
->setName('public:playlist');
|
||||
|
@ -50,7 +51,8 @@ return static function (RouteCollectorProxy $app) {
|
|||
->setName('public:dj');
|
||||
|
||||
$group->get('/ondemand[/{embed:embed}]', Controller\Frontend\PublicPages\OnDemandAction::class)
|
||||
->setName('public:ondemand');
|
||||
->setName('public:ondemand')
|
||||
->add(new Middleware\StationSupportsFeature(App\Enums\StationFeatures::OnDemand));
|
||||
|
||||
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
|
||||
->setName('public:schedule');
|
||||
|
|
|
@ -266,27 +266,12 @@ return [
|
|||
|
||||
Psr\Log\LoggerInterface::class => DI\get(Monolog\Logger::class),
|
||||
|
||||
// Doctrine annotations reader
|
||||
Doctrine\Common\Annotations\Reader::class => static function (
|
||||
Psr\Cache\CacheItemPoolInterface $psr6Cache,
|
||||
Environment $settings
|
||||
) {
|
||||
$proxyCache = new Symfony\Component\Cache\Adapter\ProxyAdapter($psr6Cache, 'annotations.');
|
||||
|
||||
return new Doctrine\Common\Annotations\PsrCachedReader(
|
||||
new Doctrine\Common\Annotations\AnnotationReader(),
|
||||
$proxyCache,
|
||||
!$settings->isProduction()
|
||||
);
|
||||
},
|
||||
|
||||
// Symfony Serializer
|
||||
Symfony\Component\Serializer\Serializer::class => static function (
|
||||
Doctrine\Common\Annotations\Reader $reader,
|
||||
App\Doctrine\ReloadableEntityManagerInterface $em,
|
||||
App\Doctrine\ReloadableEntityManagerInterface $em
|
||||
) {
|
||||
$classMetaFactory = new Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory(
|
||||
new Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader($reader)
|
||||
new Symfony\Component\Serializer\Mapping\Loader\AttributeLoader()
|
||||
);
|
||||
|
||||
$normalizers = [
|
||||
|
@ -309,19 +294,17 @@ return [
|
|||
|
||||
// Symfony Validator
|
||||
Symfony\Component\Validator\Validator\ValidatorInterface::class => static function (
|
||||
Doctrine\Common\Annotations\Reader $reader,
|
||||
Symfony\Component\Validator\ContainerConstraintValidatorFactory $constraintValidatorFactory
|
||||
) {
|
||||
$builder = new Symfony\Component\Validator\ValidatorBuilder();
|
||||
$builder->setConstraintValidatorFactory($constraintValidatorFactory);
|
||||
$builder->enableAnnotationMapping();
|
||||
$builder->setDoctrineAnnotationReader($reader);
|
||||
$builder->enableAttributeMapping();
|
||||
|
||||
return $builder->getValidator();
|
||||
},
|
||||
|
||||
App\MessageQueue\QueueManagerInterface::class => static function (
|
||||
App\Service\RedisFactory $redisFactory,
|
||||
App\Service\RedisFactory $redisFactory
|
||||
) {
|
||||
return ($redisFactory->isSupported())
|
||||
? new App\MessageQueue\QueueManager($redisFactory)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
services:
|
||||
web:
|
||||
image: ghcr.io/azuracast/azuracast:development
|
||||
build:
|
||||
context: .
|
||||
target: development
|
||||
|
|
|
@ -19,9 +19,9 @@ services:
|
|||
# Want to customize the HTTP/S ports? Follow the instructions here:
|
||||
# https://www.azuracast.com/docs/administration/docker/#using-non-standard-ports
|
||||
ports:
|
||||
- '${AZURACAST_HTTP_PORT:-80}:80'
|
||||
- '${AZURACAST_HTTPS_PORT:-443}:443'
|
||||
- '${AZURACAST_SFTP_PORT:-2022}:2022'
|
||||
- '${AZURACAST_HTTP_PORT:-80}:${AZURACAST_HTTP_PORT:-80}'
|
||||
- '${AZURACAST_HTTPS_PORT:-443}:${AZURACAST_HTTPS_PORT:-443}'
|
||||
- '${AZURACAST_SFTP_PORT:-2022}:${AZURACAST_SFTP_PORT:-2022}'
|
||||
- '8000:8000'
|
||||
- '8005:8005'
|
||||
- '8006:8006'
|
||||
|
@ -169,17 +169,9 @@ services:
|
|||
- '8490:8490'
|
||||
- '8495:8495'
|
||||
- '8496:8496'
|
||||
env_file: azuracast.env
|
||||
environment:
|
||||
LANG: ${LANG:-en_US.UTF-8}
|
||||
AZURACAST_DC_REVISION: 14
|
||||
AZURACAST_VERSION: ${AZURACAST_VERSION:-latest}
|
||||
AZURACAST_SFTP_PORT: ${AZURACAST_SFTP_PORT:-2022}
|
||||
NGINX_TIMEOUT: ${NGINX_TIMEOUT:-1800}
|
||||
LETSENCRYPT_HOST: ${LETSENCRYPT_HOST:-}
|
||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-}
|
||||
PUID: ${AZURACAST_PUID:-1000}
|
||||
PGID: ${AZURACAST_PGID:-1000}
|
||||
env_file:
|
||||
- azuracast.env
|
||||
- .env
|
||||
volumes:
|
||||
- station_data:/var/azuracast/stations
|
||||
- backups:/var/azuracast/backups
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -39,12 +39,13 @@
|
|||
"qrcode": "^1.5.3",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"sweetalert2": "11.4.8",
|
||||
"typescript": "^5.3.2",
|
||||
"vue": "^3.2",
|
||||
"vue-axios": "^3.5",
|
||||
"vue-codemirror6": "1.1.0",
|
||||
"vue-codemirror6": "^1",
|
||||
"vue-easy-lightbox": "^1.16",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue3-gettext": "^2.3.4",
|
||||
"vue3-gettext": "3.0.0-beta.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"wavesurfer.js": "^7",
|
||||
"zxcvbn": "^4.4.2"
|
||||
|
@ -57,18 +58,18 @@
|
|||
"@types/qrcode": "^1.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vitejs/plugin-vue": "^5",
|
||||
"@vue/eslint-config-typescript": "^12",
|
||||
"del": "^7",
|
||||
"esbuild": "^0.19.9",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-vue": "^9.8.0",
|
||||
"glob": "^10.2.7",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsdom": "^23",
|
||||
"sass": "^1.39.2",
|
||||
"svg.js": "^2.7.1",
|
||||
"swagger-typescript-api": "^13.0.3",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.6",
|
||||
"vite": "^5",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vue-eslint-parser": "^9.3.1",
|
||||
"vue-tsc": "^1.8.8"
|
||||
|
|
|
@ -1,245 +1,53 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2 class="outside-card-header mb-1">
|
||||
{{ $gettext('My Account') }}
|
||||
</h2>
|
||||
<h2 class="outside-card-header mb-1">
|
||||
{{ $gettext('My Account') }}
|
||||
</h2>
|
||||
|
||||
<div class="row row-of-cards">
|
||||
<div class="col-sm-12 col-md-6 col-lg-5">
|
||||
<card-page
|
||||
header-id="hdr_profile"
|
||||
:title="$gettext('Profile')"
|
||||
>
|
||||
<loading :loading="userLoading">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
v-if="user.avatar.url_128"
|
||||
class="flex-shrink-0 pe-2"
|
||||
>
|
||||
<avatar
|
||||
:url="user.avatar.url_128"
|
||||
:service="user.avatar.service_name"
|
||||
:service-url="user.avatar.service_url"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
<h2
|
||||
v-if="user.name"
|
||||
class="card-title"
|
||||
>
|
||||
{{ user.name }}
|
||||
</h2>
|
||||
<h2
|
||||
v-else
|
||||
class="card-title"
|
||||
>
|
||||
{{ $gettext('AzuraCast User') }}
|
||||
</h2>
|
||||
<h3 class="card-subtitle">
|
||||
{{ user.email }}
|
||||
</h3>
|
||||
<section
|
||||
class="card mb-4"
|
||||
role="region"
|
||||
:aria-label="$gettext('Account Details')"
|
||||
>
|
||||
<user-info-panel ref="$userInfoPanel">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-dark"
|
||||
@click="doEditProfile"
|
||||
>
|
||||
<icon :icon="IconEdit" />
|
||||
<span>
|
||||
{{ $gettext('Edit Profile') }}
|
||||
</span>
|
||||
</button>
|
||||
</user-info-panel>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="user.roles.length > 0"
|
||||
class="mt-2"
|
||||
>
|
||||
<span
|
||||
v-for="role in user.roles"
|
||||
:key="role.id"
|
||||
class="badge text-bg-secondary me-2"
|
||||
>{{ role.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</loading>
|
||||
|
||||
<template #footer_actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doEditProfile"
|
||||
>
|
||||
<icon :icon="IconEdit" />
|
||||
<span>
|
||||
{{ $gettext('Edit Profile') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</card-page>
|
||||
|
||||
<card-page
|
||||
header-id="hdr_security"
|
||||
:title="$gettext('Security')"
|
||||
>
|
||||
<loading :loading="securityLoading">
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
{{ $gettext('Two-Factor Authentication') }}
|
||||
<enabled-badge :enabled="security.twoFactorEnabled" />
|
||||
</h5>
|
||||
|
||||
<p class="card-text mt-2">
|
||||
{{
|
||||
$gettext('Two-factor authentication improves the security of your account by requiring a second one-time access code in addition to your password when you log in.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</loading>
|
||||
|
||||
<template #footer_actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doChangePassword"
|
||||
>
|
||||
<icon :icon="IconVpnKey" />
|
||||
<span>
|
||||
{{ $gettext('Change Password') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="security.twoFactorEnabled"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="disableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLockOpen" />
|
||||
<span>
|
||||
{{ $gettext('Disable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
@click="enableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLock" />
|
||||
<span>
|
||||
{{ $gettext('Enable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</card-page>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6 col-lg-7">
|
||||
<card-page
|
||||
header-id="hdr_api_keys"
|
||||
:title="$gettext('API Keys')"
|
||||
>
|
||||
<template #info>
|
||||
{{
|
||||
$gettext('Use API keys to authenticate with the AzuraCast API using the same permissions as your user account.')
|
||||
}}
|
||||
|
||||
<a
|
||||
href="/api"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('API Documentation') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="createApiKey"
|
||||
>
|
||||
<icon :icon="IconAdd" />
|
||||
<span>
|
||||
{{ $gettext('Add API Key') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<data-table
|
||||
id="account_api_keys"
|
||||
ref="$dataTable"
|
||||
:show-toolbar="false"
|
||||
:fields="apiKeyFields"
|
||||
:api-url="apiKeysApiUrl"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deleteApiKey(row.item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
</div>
|
||||
<div class="row row-of-cards">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<security-panel />
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<api-keys-panel />
|
||||
</div>
|
||||
|
||||
<account-edit-modal
|
||||
ref="$editModal"
|
||||
:user-url="userUrl"
|
||||
:supported-locales="supportedLocales"
|
||||
@reload="reload"
|
||||
/>
|
||||
|
||||
<account-change-password-modal
|
||||
ref="$changePasswordModal"
|
||||
:change-password-url="changePasswordUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
|
||||
<account-two-factor-modal
|
||||
ref="$twoFactorModal"
|
||||
:two-factor-url="twoFactorUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
|
||||
<account-api-key-modal
|
||||
ref="$apiKeyModal"
|
||||
:create-url="apiKeysApiUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<account-edit-modal
|
||||
ref="$editModal"
|
||||
:supported-locales="supportedLocales"
|
||||
@reload="onProfileEdited"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
|
||||
import AccountChangePasswordModal from "./Account/ChangePasswordModal.vue";
|
||||
import AccountApiKeyModal from "./Account/ApiKeyModal.vue";
|
||||
import AccountTwoFactorModal from "./Account/TwoFactorModal.vue";
|
||||
import AccountEditModal from "./Account/EditModal.vue";
|
||||
import Avatar from "~/components/Common/Avatar.vue";
|
||||
import EnabledBadge from "~/components/Common/Badges/EnabledBadge.vue";
|
||||
import {ref} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import {IconAdd, IconEdit, IconLock, IconLockOpen, IconVpnKey} from "~/components/Common/icons";
|
||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import {IconEdit} from "~/components/Common/icons";
|
||||
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
||||
import SecurityPanel from "~/components/Account/SecurityPanel.vue";
|
||||
import ApiKeysPanel from "~/components/Account/ApiKeysPanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
userUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
changePasswordUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
twoFactorUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
apiKeysApiUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
supportedLocales: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
|
@ -248,94 +56,13 @@ const props = defineProps({
|
|||
}
|
||||
});
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {state: user, isLoading: userLoading, execute: reloadUser} = useRefreshableAsyncState(
|
||||
() => axios.get(props.userUrl).then((r) => r.data),
|
||||
{
|
||||
name: null,
|
||||
email: null,
|
||||
avatar: {
|
||||
url_128: null,
|
||||
service_name: null,
|
||||
service_url: null
|
||||
},
|
||||
roles: [],
|
||||
},
|
||||
);
|
||||
|
||||
const {state: security, isLoading: securityLoading, execute: reloadSecurity} = useRefreshableAsyncState(
|
||||
() => axios.get(props.twoFactorUrl).then((r) => {
|
||||
return {
|
||||
twoFactorEnabled: r.data.two_factor_enabled
|
||||
};
|
||||
}),
|
||||
{
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const apiKeyFields: DataTableField[] = [
|
||||
{
|
||||
key: 'comment',
|
||||
isRowHeader: true,
|
||||
label: $gettext('API Key Description/Comments'),
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: $gettext('Actions'),
|
||||
sortable: false,
|
||||
class: 'shrink'
|
||||
}
|
||||
];
|
||||
|
||||
const $dataTable = ref<DataTableTemplateRef>(null);
|
||||
|
||||
const relist = () => {
|
||||
reloadUser();
|
||||
reloadSecurity();
|
||||
$dataTable.value?.relist();
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const $editModal = ref<InstanceType<typeof AccountEditModal> | null>(null);
|
||||
|
||||
const doEditProfile = () => {
|
||||
$editModal.value?.open();
|
||||
};
|
||||
|
||||
const $changePasswordModal = ref<InstanceType<typeof AccountChangePasswordModal> | null>(null);
|
||||
|
||||
const doChangePassword = () => {
|
||||
$changePasswordModal.value?.open();
|
||||
const onProfileEdited = () => {
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const $twoFactorModal = ref<InstanceType<typeof AccountTwoFactorModal> | null>(null);
|
||||
|
||||
const enableTwoFactor = () => {
|
||||
$twoFactorModal.value?.open();
|
||||
};
|
||||
|
||||
const {doDelete: doDisableTwoFactor} = useConfirmAndDelete(
|
||||
$gettext('Disable two-factor authentication?'),
|
||||
relist
|
||||
);
|
||||
const disableTwoFactor = () => doDisableTwoFactor(props.twoFactorUrl);
|
||||
|
||||
const $apiKeyModal = ref<InstanceType<typeof AccountApiKeyModal> | null>(null);
|
||||
|
||||
const createApiKey = () => {
|
||||
$apiKeyModal.value?.create();
|
||||
};
|
||||
|
||||
const {doDelete: deleteApiKey} = useConfirmAndDelete(
|
||||
$gettext('Delete API Key?'),
|
||||
relist
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<card-page
|
||||
header-id="hdr_api_keys"
|
||||
:title="$gettext('API Keys')"
|
||||
>
|
||||
<template #info>
|
||||
{{
|
||||
$gettext('Use API keys to authenticate with the AzuraCast API using the same permissions as your user account.')
|
||||
}}
|
||||
|
||||
<a
|
||||
href="/api"
|
||||
class="alert-link"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('API Documentation') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="createApiKey"
|
||||
>
|
||||
<icon :icon="IconAdd" />
|
||||
<span>
|
||||
{{ $gettext('Add API Key') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<data-table
|
||||
id="account_api_keys"
|
||||
ref="$dataTable"
|
||||
:show-toolbar="false"
|
||||
:fields="apiKeyFields"
|
||||
:api-url="apiKeysApiUrl"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deleteApiKey(row.item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
|
||||
<account-api-key-modal
|
||||
ref="$apiKeyModal"
|
||||
:create-url="apiKeysApiUrl"
|
||||
@relist="relist"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {IconAdd} from "~/components/Common/icons.ts";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import AccountApiKeyModal from "~/components/Account/ApiKeyModal.vue";
|
||||
import {ref} from "vue";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const apiKeysApiUrl = getApiUrl('/frontend/account/api-keys');
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const apiKeyFields: DataTableField[] = [
|
||||
{
|
||||
key: 'comment',
|
||||
isRowHeader: true,
|
||||
label: $gettext('API Key Description/Comments'),
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: $gettext('Actions'),
|
||||
sortable: false,
|
||||
class: 'shrink'
|
||||
}
|
||||
];
|
||||
|
||||
const $apiKeyModal = ref<InstanceType<typeof AccountApiKeyModal> | null>(null);
|
||||
|
||||
const createApiKey = () => {
|
||||
$apiKeyModal.value?.create();
|
||||
};
|
||||
|
||||
const $dataTable = ref<DataTableTemplateRef>(null);
|
||||
const {relist} = useHasDatatable($dataTable);
|
||||
|
||||
const {doDelete: deleteApiKey} = useConfirmAndDelete(
|
||||
$gettext('Delete API Key?'),
|
||||
relist
|
||||
);
|
||||
</script>
|
|
@ -46,16 +46,12 @@ import {ref} from "vue";
|
|||
import {useAxios} from "~/vendor/axios";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
|
||||
const props = defineProps({
|
||||
changePasswordUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const emit = defineEmits(['relist']);
|
||||
|
||||
const changePasswordUrl = getApiUrl('/frontend/account/password');
|
||||
|
||||
const passwordsMatch = (value, siblings) => {
|
||||
return siblings.new_password === value;
|
||||
};
|
||||
|
@ -97,7 +93,7 @@ const {axios} = useAxios();
|
|||
const onSubmit = () => {
|
||||
ifValid(() => {
|
||||
axios
|
||||
.put(props.changePasswordUrl, form.value)
|
||||
.put(changePasswordUrl.value, form.value)
|
||||
.finally(() => {
|
||||
$modal.value?.hide();
|
||||
emit('relist');
|
||||
|
|
|
@ -25,12 +25,10 @@ import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
|||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import {useHasModal} from "~/functions/useHasModal.ts";
|
||||
|
||||
const props = defineProps({
|
||||
userUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
supportedLocales: {
|
||||
type: Object,
|
||||
required: true
|
||||
|
@ -39,6 +37,8 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
const userUrl = getApiUrl('/frontend/account/me');
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
|
@ -64,10 +64,7 @@ const clearContents = () => {
|
|||
};
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
|
||||
const close = () => {
|
||||
$modal.value?.hide();
|
||||
};
|
||||
const {show, hide} = useHasModal($modal);
|
||||
|
||||
const {notifySuccess} = useNotify();
|
||||
const {axios} = useAxios();
|
||||
|
@ -75,13 +72,13 @@ const {axios} = useAxios();
|
|||
const open = () => {
|
||||
clearContents();
|
||||
|
||||
$modal.value?.show();
|
||||
show();
|
||||
|
||||
axios.get(props.userUrl).then((resp) => {
|
||||
axios.get(userUrl.value).then((resp) => {
|
||||
form.value = mergeExisting(form.value, resp.data);
|
||||
loading.value = false;
|
||||
}).catch(() => {
|
||||
close();
|
||||
hide();
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -91,12 +88,12 @@ const doSubmit = () => {
|
|||
|
||||
axios({
|
||||
method: 'PUT',
|
||||
url: props.userUrl,
|
||||
url: userUrl.value,
|
||||
data: form.value
|
||||
}).then(() => {
|
||||
notifySuccess();
|
||||
emit('reload');
|
||||
close();
|
||||
hide();
|
||||
}).catch((error) => {
|
||||
error.value = error.response.data.message;
|
||||
});
|
||||
|
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<modal
|
||||
id="api_keys_modal"
|
||||
ref="$modal"
|
||||
size="md"
|
||||
centered
|
||||
:title="$gettext('Add New Passkey')"
|
||||
no-enforce-focus
|
||||
@hidden="onHidden"
|
||||
>
|
||||
<template #default>
|
||||
<div
|
||||
v-show="error != null"
|
||||
class="alert alert-danger"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="isSupported"
|
||||
class="form vue-form"
|
||||
@submit.prevent="doSubmit"
|
||||
>
|
||||
<form-group-field
|
||||
id="form_name"
|
||||
:field="v$.name"
|
||||
autofocus
|
||||
class="mb-3"
|
||||
:label="$gettext('Passkey Nickname')"
|
||||
/>
|
||||
|
||||
<form-markup id="form_select_passkey">
|
||||
<template #label>
|
||||
{{ $gettext('Select Passkey') }}
|
||||
</template>
|
||||
|
||||
<p class="card-text">
|
||||
{{ $gettext('Click the button below to open your browser window to select a passkey.') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="form.createResponse"
|
||||
class="card-text"
|
||||
>
|
||||
{{ $gettext('A passkey has been selected. Submit this form to add it to your account.') }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="selectPasskey"
|
||||
>
|
||||
{{ $gettext('Select Passkey') }}
|
||||
</button>
|
||||
</div>
|
||||
</form-markup>
|
||||
|
||||
<invisible-submit-button />
|
||||
</form>
|
||||
|
||||
<div v-else>
|
||||
<p class="card-text">
|
||||
{{
|
||||
$gettext('Your browser does not support passkeys. Consider updating your browser to the latest version.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #modal-footer="slotProps">
|
||||
<slot
|
||||
name="modal-footer"
|
||||
v-bind="slotProps"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="hide"
|
||||
>
|
||||
{{ $gettext('Close') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
:class="(v$.$invalid) ? 'btn-danger' : 'btn-primary'"
|
||||
@click="doSubmit"
|
||||
>
|
||||
{{ $gettext('Add New Passkey') }}
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton.vue";
|
||||
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||
import {required} from '@vuelidate/validators';
|
||||
import {ref} from "vue";
|
||||
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import Modal from "~/components/Common/Modal.vue";
|
||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||
import FormMarkup from "~/components/Form/FormMarkup.vue";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import useWebAuthn from "~/functions/useWebAuthn.ts";
|
||||
|
||||
const emit = defineEmits(['relist']);
|
||||
|
||||
const registerWebAuthnUrl = getApiUrl('/frontend/account/webauthn/register');
|
||||
|
||||
const error = ref(null);
|
||||
|
||||
const {form, resetForm, v$, validate} = useVuelidateOnForm(
|
||||
{
|
||||
name: {required},
|
||||
createResponse: {required}
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
createResponse: null
|
||||
}
|
||||
);
|
||||
|
||||
const clearContents = () => {
|
||||
resetForm();
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const $modal = ref<ModalTemplateRef>(null);
|
||||
const {show, hide} = useHasModal($modal);
|
||||
|
||||
const create = () => {
|
||||
clearContents();
|
||||
show();
|
||||
};
|
||||
|
||||
const {isSupported, doRegister, cancel} = useWebAuthn();
|
||||
|
||||
const onHidden = () => {
|
||||
clearContents();
|
||||
cancel();
|
||||
emit('relist');
|
||||
};
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const selectPasskey = async () => {
|
||||
const registerArgs = await axios.get(registerWebAuthnUrl.value).then(r => r.data);
|
||||
|
||||
try {
|
||||
form.value.createResponse = await doRegister(registerArgs);
|
||||
} catch (err) {
|
||||
if (err.name === 'InvalidStateError') {
|
||||
error.value = 'Error: Authenticator was probably already registered by user';
|
||||
} else {
|
||||
error.value = err;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const doSubmit = async () => {
|
||||
const isValid = await validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
|
||||
axios({
|
||||
method: 'PUT',
|
||||
url: registerWebAuthnUrl.value,
|
||||
data: form.value
|
||||
}).then(() => {
|
||||
hide();
|
||||
}).catch((error) => {
|
||||
error.value = error.response.data.message;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
create
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<card-page header-id="hdr_security">
|
||||
<template #header="{id}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-fill">
|
||||
<h3
|
||||
:id="id"
|
||||
class="card-title"
|
||||
>
|
||||
{{ $gettext('Security') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-dark"
|
||||
@click="doChangePassword"
|
||||
>
|
||||
<icon :icon="IconVpnKey" />
|
||||
<span>
|
||||
{{ $gettext('Change Password') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<loading :loading="securityLoading">
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
{{ $gettext('Two-Factor Authentication') }}
|
||||
<enabled-badge :enabled="security.twoFactorEnabled" />
|
||||
</h5>
|
||||
|
||||
<p class="card-text mt-2">
|
||||
{{
|
||||
$gettext('Two-factor authentication improves the security of your account by requiring a second one-time access code in addition to your password when you log in.')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="security.twoFactorEnabled"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="disableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLockOpen" />
|
||||
<span>
|
||||
{{ $gettext('Disable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
@click="enableTwoFactor"
|
||||
>
|
||||
<icon :icon="IconLock" />
|
||||
<span>
|
||||
{{ $gettext('Enable Two-Factor') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</loading>
|
||||
|
||||
<div class="card-body">
|
||||
<h5>
|
||||
{{ $gettext('Passkey Authentication') }}
|
||||
</h5>
|
||||
|
||||
<p class="card-text mt-2">
|
||||
{{
|
||||
$gettext('Using a passkey (like Windows Hello, YubiKey, or your smartphone) allows you to securely log in without needing to enter your password or two-factor code.')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doAddPasskey"
|
||||
>
|
||||
<icon :icon="IconAdd" />
|
||||
<span>
|
||||
{{ $gettext('Add New Passkey') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<data-table
|
||||
id="account_passkeys"
|
||||
ref="$dataTable"
|
||||
:show-toolbar="false"
|
||||
:fields="passkeyFields"
|
||||
:api-url="passkeysApiUrl"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deletePasskey(row.item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
|
||||
<account-change-password-modal ref="$changePasswordModal" />
|
||||
|
||||
<account-two-factor-modal
|
||||
ref="$twoFactorModal"
|
||||
:two-factor-url="twoFactorUrl"
|
||||
@relist="reloadSecurity"
|
||||
/>
|
||||
|
||||
<passkey-modal
|
||||
ref="$passkeyModal"
|
||||
@relist="reloadPasskeys"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {IconAdd, IconLock, IconLockOpen, IconVpnKey} from "~/components/Common/icons.ts";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import EnabledBadge from "~/components/Common/Badges/EnabledBadge.vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import AccountTwoFactorModal from "~/components/Account/TwoFactorModal.vue";
|
||||
import AccountChangePasswordModal from "~/components/Account/ChangePasswordModal.vue";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {ref} from "vue";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import PasskeyModal from "~/components/Account/PasskeyModal.vue";
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const twoFactorUrl = getApiUrl('/frontend/account/two-factor');
|
||||
|
||||
const {state: security, isLoading: securityLoading, execute: reloadSecurity} = useRefreshableAsyncState(
|
||||
() => axios.get(twoFactorUrl.value).then((r) => {
|
||||
return {
|
||||
twoFactorEnabled: r.data.two_factor_enabled
|
||||
};
|
||||
}),
|
||||
{
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const $changePasswordModal = ref<InstanceType<typeof AccountChangePasswordModal> | null>(null);
|
||||
|
||||
const doChangePassword = () => {
|
||||
$changePasswordModal.value?.open();
|
||||
};
|
||||
|
||||
const $twoFactorModal = ref<InstanceType<typeof AccountTwoFactorModal> | null>(null);
|
||||
|
||||
const enableTwoFactor = () => {
|
||||
$twoFactorModal.value?.open();
|
||||
};
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const {doDelete: doDisableTwoFactor} = useConfirmAndDelete(
|
||||
$gettext('Disable two-factor authentication?'),
|
||||
reloadSecurity
|
||||
);
|
||||
const disableTwoFactor = () => doDisableTwoFactor(twoFactorUrl.value);
|
||||
|
||||
const passkeysApiUrl = getApiUrl('/frontend/account/passkeys');
|
||||
|
||||
const passkeyFields: DataTableField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
isRowHeader: true,
|
||||
label: $gettext('Passkey Nickname'),
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: $gettext('Actions'),
|
||||
sortable: false,
|
||||
class: 'shrink'
|
||||
}
|
||||
];
|
||||
|
||||
const $dataTable = ref<DataTableTemplateRef>(null);
|
||||
const {relist: reloadPasskeys} = useHasDatatable($dataTable);
|
||||
|
||||
const {doDelete: deletePasskey} = useConfirmAndDelete(
|
||||
$gettext('Delete Passkey?'),
|
||||
reloadPasskeys
|
||||
);
|
||||
|
||||
const $passkeyModal = ref<InstanceType<typeof PasskeyModal> | null>(null);
|
||||
|
||||
const doAddPasskey = () => {
|
||||
$passkeyModal.value?.create();
|
||||
};
|
||||
|
||||
</script>
|
|
@ -78,6 +78,7 @@ import {useNotify} from "~/functions/useNotify";
|
|||
import {useAxios} from "~/vendor/axios";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
import QrCode from "~/components/Account/QrCode.vue";
|
||||
import {useHasModal} from "~/functions/useHasModal.ts";
|
||||
|
||||
const props = defineProps({
|
||||
twoFactorUrl: {
|
||||
|
@ -118,10 +119,7 @@ const clearContents = () => {
|
|||
};
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
|
||||
const close = () => {
|
||||
$modal.value?.hide();
|
||||
};
|
||||
const {hide, show} = useHasModal($modal);
|
||||
|
||||
const {notifySuccess} = useNotify();
|
||||
const {axios} = useAxios();
|
||||
|
@ -131,13 +129,13 @@ const open = () => {
|
|||
|
||||
loading.value = true;
|
||||
|
||||
$modal.value?.show();
|
||||
show();
|
||||
|
||||
axios.put(props.twoFactorUrl).then((resp) => {
|
||||
totp.value = resp.data;
|
||||
loading.value = false;
|
||||
}).catch(() => {
|
||||
close();
|
||||
hide();
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -155,7 +153,7 @@ const doSubmit = () => {
|
|||
}).then(() => {
|
||||
notifySuccess();
|
||||
emit('relist');
|
||||
close();
|
||||
hide();
|
||||
}).catch((error) => {
|
||||
error.value = error.response.data.message;
|
||||
});
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="card-header text-bg-primary d-flex flex-wrap align-items-center">
|
||||
<avatar
|
||||
v-if="user.avatar.url_128"
|
||||
class="flex-shrink-0 me-3"
|
||||
:url="user.avatar.url_128"
|
||||
:service="user.avatar.service_name"
|
||||
:service-url="user.avatar.service_url"
|
||||
/>
|
||||
|
||||
<div class="flex-fill">
|
||||
<h2
|
||||
v-if="user.name"
|
||||
class="card-title mt-0"
|
||||
>
|
||||
{{ user.name }}
|
||||
</h2>
|
||||
<h2
|
||||
v-else
|
||||
class="card-title"
|
||||
>
|
||||
{{ $gettext('AzuraCast User') }}
|
||||
</h2>
|
||||
<h3 class="card-subtitle">
|
||||
{{ user.email }}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
v-if="user.roles.length > 0"
|
||||
class="mt-2"
|
||||
>
|
||||
<span
|
||||
v-for="role in user.roles"
|
||||
:key="role.id"
|
||||
class="badge text-bg-secondary me-2"
|
||||
>{{ role.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.default"
|
||||
class="flex-md-shrink-0 mt-3 mt-md-0 buttons"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Avatar from "~/components/Common/Avatar.vue";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
|
||||
const slots = defineSlots();
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const userUrl = getApiUrl('/frontend/account/me');
|
||||
|
||||
const {state: user, execute: reload} = useRefreshableAsyncState(
|
||||
() => axios.get(userUrl.value).then((r) => r.data),
|
||||
{
|
||||
name: null,
|
||||
email: null,
|
||||
avatar: {
|
||||
url_128: null,
|
||||
service_name: null,
|
||||
service_url: null
|
||||
},
|
||||
roles: [],
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
reload
|
||||
});
|
||||
</script>
|
|
@ -94,6 +94,7 @@ import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
|||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||
import FormGroupSelect from "~/components/Form/FormGroupSelect.vue";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
import {useHasModal} from "~/functions/useHasModal.ts";
|
||||
|
||||
const props = defineProps({
|
||||
settingsUrl: {
|
||||
|
@ -110,8 +111,6 @@ const emit = defineEmits(['relist']);
|
|||
|
||||
const loading = ref(true);
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
|
||||
const {form, resetForm, v$, ifValid} = useVuelidateOnForm(
|
||||
{
|
||||
'backup_enabled': {},
|
||||
|
@ -154,16 +153,19 @@ const formatOptions = computed(() => {
|
|||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
const {hide, show} = useHasModal($modal);
|
||||
|
||||
const close = () => {
|
||||
emit('relist');
|
||||
$modal.value.hide();
|
||||
hide();
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
loading.value = true;
|
||||
|
||||
$modal.value.show();
|
||||
show();
|
||||
|
||||
axios.get(props.settingsUrl).then((resp) => {
|
||||
form.value = mergeExisting(form.value, resp.data);
|
||||
|
|
|
@ -87,7 +87,12 @@
|
|||
>
|
||||
<template #cell(name)="row">
|
||||
<h5>{{ row.item.task }}</h5>
|
||||
{{ row.item.pattern }}
|
||||
<span v-if="row.item.pattern">
|
||||
{{ row.item.pattern }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $gettext('Custom') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell(actions)="row">
|
||||
<button
|
||||
|
@ -228,7 +233,7 @@
|
|||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
|
||||
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
|
@ -237,12 +242,12 @@ import {useAxios} from "~/vendor/axios";
|
|||
import {useNotify} from "~/functions/useNotify";
|
||||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import {useIntervalFn} from "@vueuse/core";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import {IconRefresh} from "~/components/Common/icons.ts";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
|
||||
|
||||
const listSyncTasksUrl = getApiUrl('/admin/debug/sync-tasks');
|
||||
const listQueueTotalsUrl = getApiUrl('/admin/debug/queues');
|
||||
|
@ -252,14 +257,20 @@ const clearQueuesUrl = getApiUrl('/admin/debug/clear-queue');
|
|||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {state: syncTasks, isLoading: syncTasksLoading, execute: resetSyncTasks} = useRefreshableAsyncState(
|
||||
const {state: syncTasks, isLoading: syncTasksLoading, execute: resetSyncTasks} = useAutoRefreshingAsyncState(
|
||||
() => axios.get(listSyncTasksUrl.value).then(r => r.data),
|
||||
[],
|
||||
{
|
||||
timeout: 60000
|
||||
}
|
||||
);
|
||||
|
||||
const {state: queueTotals, isLoading: queueTotalsLoading, execute: resetQueueTotals} = useRefreshableAsyncState(
|
||||
const {state: queueTotals, isLoading: queueTotalsLoading, execute: resetQueueTotals} = useAutoRefreshingAsyncState(
|
||||
() => axios.get(listQueueTotalsUrl.value).then(r => r.data),
|
||||
[],
|
||||
{
|
||||
timeout: 60000
|
||||
}
|
||||
);
|
||||
|
||||
const {state: stations, isLoading: stationsLoading} = useRefreshableAsyncState(
|
||||
|
@ -267,14 +278,6 @@ const {state: stations, isLoading: stationsLoading} = useRefreshableAsyncState(
|
|||
[],
|
||||
);
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
resetSyncTasks();
|
||||
resetQueueTotals();
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const {timestampToRelative} = useLuxon();
|
||||
|
||||
|
|
|
@ -93,7 +93,6 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import {computed} from "vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {getApiUrl} from "~/router";
|
||||
import {useAdminMenu} from "~/components/Admin/menu";
|
||||
|
@ -102,9 +101,8 @@ import MemoryStatsPanel from "~/components/Admin/Index/MemoryStatsPanel.vue";
|
|||
import DiskUsagePanel from "~/components/Admin/Index/DiskUsagePanel.vue";
|
||||
import ServicesPanel from "~/components/Admin/Index/ServicesPanel.vue";
|
||||
import NetworkStatsPanel from "~/components/Admin/Index/NetworkStatsPanel.vue";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {useIntervalFn} from "@vueuse/core";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
|
||||
|
||||
const statsUrl = getApiUrl('/admin/server/stats');
|
||||
|
||||
|
@ -112,7 +110,7 @@ const menuItems = useAdminMenu();
|
|||
|
||||
const {axiosSilent} = useAxios();
|
||||
|
||||
const {state: stats, isLoading, execute: reloadStats} = useRefreshableAsyncState(
|
||||
const {state: stats, isLoading} = useAutoRefreshingAsyncState(
|
||||
() => axiosSilent.get(statsUrl.value).then(r => r.data),
|
||||
{
|
||||
cpu: {
|
||||
|
@ -154,14 +152,8 @@ const {state: stats, isLoading, execute: reloadStats} = useRefreshableAsyncState
|
|||
network: []
|
||||
},
|
||||
{
|
||||
shallow: true
|
||||
shallow: true,
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
reloadStats()
|
||||
},
|
||||
computed(() => (!document.hidden) ? 5000 : 10000)
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -51,30 +51,22 @@ import RunningBadge from "~/components/Common/Badges/RunningBadge.vue";
|
|||
import {getApiUrl} from "~/router.ts";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {useIntervalFn} from "@vueuse/core";
|
||||
import {computed} from "vue";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
|
||||
|
||||
const servicesUrl = getApiUrl('/admin/services');
|
||||
|
||||
const {axios, axiosSilent} = useAxios();
|
||||
|
||||
const {state: services, isLoading, execute: reloadServices} = useRefreshableAsyncState(
|
||||
const {state: services, isLoading} = useAutoRefreshingAsyncState(
|
||||
() => axiosSilent.get(servicesUrl.value).then(r => r.data),
|
||||
[],
|
||||
{
|
||||
timeout: 5000,
|
||||
shallow: true
|
||||
}
|
||||
);
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
reloadServices()
|
||||
},
|
||||
computed(() => (!document.hidden) ? 5000 : 15000)
|
||||
);
|
||||
|
||||
const {notifySuccess} = useNotify();
|
||||
|
||||
const doRestart = (serviceUrl) => {
|
||||
|
|
|
@ -78,7 +78,7 @@ const doSendTest = () => {
|
|||
}).then(() => {
|
||||
notifySuccess($gettext('Test message sent.'));
|
||||
}).finally(() => {
|
||||
close();
|
||||
hide();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -22,6 +22,7 @@ import {useNotify} from "~/functions/useNotify";
|
|||
import {useAxios} from "~/vendor/axios";
|
||||
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
import {useHasModal} from "~/functions/useHasModal.ts";
|
||||
|
||||
const emit = defineEmits(['relist']);
|
||||
|
||||
|
@ -43,6 +44,8 @@ const {form, resetForm, v$, ifValid} = useVuelidateOnForm(
|
|||
);
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
const {hide, show} = useHasModal($modal);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const create = (stationName, stationCloneUrl) => {
|
||||
|
@ -56,7 +59,7 @@ const create = (stationName, stationCloneUrl) => {
|
|||
error.value = null;
|
||||
cloneUrl.value = stationCloneUrl;
|
||||
|
||||
$modal.value?.show();
|
||||
show();
|
||||
};
|
||||
|
||||
const clearContents = () => {
|
||||
|
@ -64,10 +67,6 @@ const clearContents = () => {
|
|||
cloneUrl.value = null;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
$modal.value?.hide();
|
||||
};
|
||||
|
||||
const {notifySuccess} = useNotify();
|
||||
const {axios} = useAxios();
|
||||
|
||||
|
@ -82,7 +81,7 @@ const doSubmit = () => {
|
|||
}).then(() => {
|
||||
notifySuccess();
|
||||
emit('relist');
|
||||
close();
|
||||
hide();
|
||||
}).catch((error) => {
|
||||
error.value = error.response.data.message;
|
||||
});
|
||||
|
|
|
@ -114,9 +114,10 @@
|
|||
class="col-md-6"
|
||||
:field="v$.backend_config.master_me_loudness_target"
|
||||
input-type="number"
|
||||
:input-attrs="{ min: '-50', max: '-2', step: '1' }"
|
||||
:input-attrs="{ min: '-50', max: '0', step: '1' }"
|
||||
:label="$gettext('Master_me Loudness Target (LUFS)')"
|
||||
:description="$gettext('The average target loudness (measured in LUFS) for the broadcasted stream. Values between -14 and -18 LUFS are common for Internet radio stations.')"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -114,7 +114,7 @@ const showAdminTab = userAllowed(GlobalPermission.Stations);
|
|||
const {form, resetForm, v$, ifValid} = useVuelidateOnForm();
|
||||
|
||||
const isValid = computed(() => {
|
||||
return !v$.value?.$invalid ?? true;
|
||||
return !v$.value?.$invalid;
|
||||
});
|
||||
|
||||
watch(isValid, (newValue) => {
|
||||
|
|
|
@ -16,16 +16,10 @@ import {javascript} from "@codemirror/lang-javascript";
|
|||
import {liquidsoap} from "codemirror-lang-liquidsoap";
|
||||
import useTheme from "~/functions/theme";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const props = defineProps<{
|
||||
modelValue: string | null,
|
||||
mode: string
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
class="card-body bg-info-subtle text-info-emphasis d-flex flex-md-row flex-column align-items-center"
|
||||
class="card-body alert alert-info d-flex flex-md-row flex-column align-items-center"
|
||||
role="alert"
|
||||
aria-live="off"
|
||||
>
|
||||
|
|
|
@ -8,44 +8,25 @@
|
|||
role="region"
|
||||
:aria-label="$gettext('Account Details')"
|
||||
>
|
||||
<div class="card-header text-bg-primary d-flex flex-wrap align-items-center">
|
||||
<avatar
|
||||
v-if="user.avatar.url"
|
||||
class="flex-shrink-0 me-3"
|
||||
:url="user.avatar.url"
|
||||
:service="user.avatar.service"
|
||||
:service-url="user.avatar.serviceUrl"
|
||||
/>
|
||||
|
||||
<div class="flex-fill">
|
||||
<h2 class="card-title mt-0">
|
||||
{{ user.name }}
|
||||
</h2>
|
||||
<h3 class="card-subtitle">
|
||||
{{ user.email }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex-md-shrink-0 mt-3 mt-md-0 buttons">
|
||||
<a
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="profileUrl"
|
||||
>
|
||||
<icon :icon="IconAccountCircle" />
|
||||
<span>{{ $gettext('My Account') }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="showAdmin"
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="adminUrl"
|
||||
>
|
||||
<icon :icon="IconSettings" />
|
||||
<span>{{ $gettext('Administration') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<user-info-panel>
|
||||
<a
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="profileUrl"
|
||||
>
|
||||
<icon :icon="IconAccountCircle" />
|
||||
<span>{{ $gettext('My Account') }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="showAdmin"
|
||||
class="btn btn-dark"
|
||||
role="button"
|
||||
:href="adminUrl"
|
||||
>
|
||||
<icon :icon="IconSettings" />
|
||||
<span>{{ $gettext('Administration') }}</span>
|
||||
</a>
|
||||
</user-info-panel>
|
||||
|
||||
<template v-if="!notificationsLoading && notifications.length > 0">
|
||||
<div
|
||||
|
@ -292,13 +273,11 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import Avatar from '~/components/Common/Avatar.vue';
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useAsyncState, useIntervalFn} from "@vueuse/core";
|
||||
import {useAsyncState} from "@vueuse/core";
|
||||
import {computed, ref} from "vue";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState";
|
||||
import DashboardCharts from "~/components/DashboardCharts.vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
|
@ -308,12 +287,11 @@ import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
|
|||
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox";
|
||||
import useOptionalStorage from "~/functions/useOptionalStorage";
|
||||
import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons";
|
||||
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
|
||||
|
||||
const props = defineProps({
|
||||
userUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
profileUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
|
@ -326,32 +304,24 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
notificationsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showCharts: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
chartsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
manageStationsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
stationsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showAlbumArt: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const notificationsUrl = getApiUrl('/frontend/dashboard/notifications');
|
||||
const chartsUrl = getApiUrl('/frontend/dashboard/charts');
|
||||
const stationsUrl = getApiUrl('/frontend/dashboard/stations');
|
||||
|
||||
const chartsVisible = useOptionalStorage<boolean>('dashboard_show_chart', true);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
@ -364,43 +334,17 @@ const langShowHideCharts = computed(() => {
|
|||
|
||||
const {axios, axiosSilent} = useAxios();
|
||||
|
||||
const {state: user} = useAsyncState(
|
||||
() => axios.get(props.userUrl)
|
||||
.then((resp) => {
|
||||
return {
|
||||
name: resp.data.name,
|
||||
email: resp.data.email,
|
||||
avatar: {
|
||||
url: resp.data.avatar.url_64,
|
||||
service: resp.data.avatar.service_name,
|
||||
serviceUrl: resp.data.avatar.service_url
|
||||
}
|
||||
};
|
||||
}),
|
||||
{
|
||||
name: $gettext('AzuraCast User'),
|
||||
email: null,
|
||||
avatar: {
|
||||
url: null,
|
||||
service: null,
|
||||
serviceUrl: null
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
|
||||
() => axios.get(props.notificationsUrl).then((r) => r.data),
|
||||
() => axios.get(notificationsUrl.value).then((r) => r.data),
|
||||
[]
|
||||
);
|
||||
|
||||
const {state: stations, isLoading: stationsLoading, execute: reloadStations} = useRefreshableAsyncState(
|
||||
() => axiosSilent.get(props.stationsUrl).then((r) => r.data),
|
||||
const {state: stations, isLoading: stationsLoading} = useAutoRefreshingAsyncState(
|
||||
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
|
||||
[],
|
||||
);
|
||||
|
||||
useIntervalFn(
|
||||
reloadStations,
|
||||
computed(() => (!document.hidden) ? 15000 : 30000)
|
||||
{
|
||||
timeout: 15000
|
||||
}
|
||||
);
|
||||
|
||||
const $lightbox = ref<LightboxTemplateRef>(null);
|
||||
|
|
|
@ -10,12 +10,9 @@
|
|||
<script setup lang="ts">
|
||||
import {useVModel} from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const props = defineProps<{
|
||||
modelValue: boolean | null
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const checkboxValue = useVModel(props, 'modelValue', emit);
|
||||
|
|
|
@ -57,9 +57,22 @@
|
|||
</template>
|
||||
|
||||
<template
|
||||
v-if="description || slots.description"
|
||||
v-if="description || slots.description || clearable"
|
||||
#description="slotProps"
|
||||
>
|
||||
<div
|
||||
v-if="clearable"
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
@click.prevent="clear"
|
||||
>
|
||||
{{ $gettext('Clear Field') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
v-bind="slotProps"
|
||||
name="description"
|
||||
|
@ -121,6 +134,10 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
advanced: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
@ -143,18 +160,18 @@ const filteredModel = computed({
|
|||
},
|
||||
set(newValue) {
|
||||
if ((isNumeric.value || props.inputEmptyIsNull) && '' === newValue) {
|
||||
newValue = null;
|
||||
}
|
||||
model.value = null;
|
||||
} else {
|
||||
if (props.inputTrim && null !== newValue) {
|
||||
newValue = newValue.replace(/^\s+|\s+$/gm, '');
|
||||
}
|
||||
|
||||
if (props.inputTrim && null !== newValue) {
|
||||
newValue = newValue.replace(/^\s+|\s+$/gm, '');
|
||||
}
|
||||
if (isNumeric.value) {
|
||||
newValue = Number(newValue);
|
||||
}
|
||||
|
||||
if (isNumeric.value) {
|
||||
newValue = Number(newValue);
|
||||
model.value = newValue;
|
||||
}
|
||||
|
||||
model.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -164,6 +181,10 @@ const focus = () => {
|
|||
$input.value?.focus();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
filteredModel.value = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
|
|
|
@ -96,7 +96,6 @@ const onUpdateDuration = (newValue: number) => {
|
|||
};
|
||||
|
||||
const onUpdateCurrentTime = (newValue: number) => {
|
||||
console.log(newValue);
|
||||
currentTime.value = newValue;
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
<template>
|
||||
<div class="public-page">
|
||||
<div class="card p-2">
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm">
|
||||
<h2
|
||||
v-if="hideProductName"
|
||||
class="card-title text-center"
|
||||
>
|
||||
{{ $gettext('Welcome!') }}
|
||||
</h2>
|
||||
<h2
|
||||
v-else
|
||||
class="card-title text-center"
|
||||
>
|
||||
{{ $gettext('Welcome to AzuraCast!') }}
|
||||
</h2>
|
||||
<h3
|
||||
v-if="instanceName"
|
||||
class="card-subtitle text-center text-muted"
|
||||
>
|
||||
{{ instanceName }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="login-form"
|
||||
action=""
|
||||
method="post"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label
|
||||
for="username"
|
||||
class="mb-2 d-flex align-items-center gap-2"
|
||||
>
|
||||
<icon :icon="IconMail" />
|
||||
<strong>
|
||||
{{ $gettext('E-mail Address') }}
|
||||
</strong>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="email"
|
||||
name="username"
|
||||
class="form-control"
|
||||
autocomplete="username webauthn"
|
||||
:placeholder="$gettext('name@example.com')"
|
||||
:aria-label="$gettext('E-mail Address')"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<label
|
||||
for="password"
|
||||
class="mb-2 d-flex align-items-center gap-2"
|
||||
>
|
||||
<icon :icon="IconVpnKey" />
|
||||
<strong>{{ $gettext('Password') }}</strong>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
autocomplete="current-password"
|
||||
:placeholder="$gettext('Enter your password')"
|
||||
:aria-label="$gettext('Password')"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input
|
||||
id="frm_remember_me"
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
value="1"
|
||||
class="toggle-switch custom-control-input"
|
||||
>
|
||||
<label
|
||||
for="frm_remember_me"
|
||||
class="custom-control-label"
|
||||
>
|
||||
{{ $gettext('Remember me') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-buttons mt-3 mb-3">
|
||||
<button
|
||||
type="submit"
|
||||
role="button"
|
||||
:title="$gettext('Sign In')"
|
||||
class="btn btn-login btn-primary"
|
||||
>
|
||||
{{ $gettext('Sign In') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-if="passkeySupported"
|
||||
id="webauthn-form"
|
||||
ref="$webAuthnForm"
|
||||
:action="webAuthnUrl"
|
||||
method="post"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="validateData"
|
||||
:value="validateData"
|
||||
>
|
||||
|
||||
<div class="block-buttons mb-3">
|
||||
<button
|
||||
type="button"
|
||||
role="button"
|
||||
:title="$gettext('Sign In with Passkey')"
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="logInWithPasskey"
|
||||
>
|
||||
{{ $gettext('Sign In with Passkey') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-center m-0">
|
||||
{{ $gettext('Please log in to continue.') }}
|
||||
|
||||
<a :href="forgotPasswordUrl">
|
||||
{{ $gettext('Forgot your password?') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import {IconMail, IconVpnKey} from "~/components/Common/icons.ts";
|
||||
import useWebAuthn from "~/functions/useWebAuthn.ts";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import {nextTick, onMounted, ref} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
hideProductName: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
instanceName: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
forgotPasswordUrl: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
webAuthnUrl: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
isSupported: passkeySupported,
|
||||
isConditionalSupported: passkeyConditionalSupported,
|
||||
doValidate
|
||||
} = useWebAuthn();
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const $webAuthnForm = ref<HTMLFormElement | null>(null);
|
||||
|
||||
const validateArgs = ref<object | null>(null);
|
||||
const validateData = ref<string | null>(null);
|
||||
|
||||
const handleValidationResponse = async (validateResp) => {
|
||||
validateData.value = JSON.stringify(validateResp);
|
||||
await nextTick();
|
||||
$webAuthnForm.value?.submit();
|
||||
}
|
||||
|
||||
const logInWithPasskey = async () => {
|
||||
if (validateArgs.value === null) {
|
||||
validateArgs.value = await axios.get(props.webAuthnUrl).then(r => r.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const validateResp = await doValidate(validateArgs.value, false);
|
||||
await handleValidationResponse(validateResp);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const isConditionalSupported = await passkeyConditionalSupported();
|
||||
if (!isConditionalSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call WebAuthn authentication
|
||||
validateArgs.value = await axios.get(props.webAuthnUrl).then(r => r.data);
|
||||
|
||||
try {
|
||||
const validateResp = await doValidate(validateArgs.value, true);
|
||||
await handleValidationResponse(validateResp);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import {useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
||||
import {nextTick, onMounted} from "vue";
|
||||
|
||||
useProvidePlayerStore('global');
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
document.dispatchEvent(new CustomEvent("vue-ready"));
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -177,7 +177,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useSlots, watch} from "vue";
|
||||
import {nextTick, onMounted, useSlots, watch} from "vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import useTheme from "~/functions/theme";
|
||||
import {
|
||||
|
@ -253,4 +253,9 @@ watch(
|
|||
);
|
||||
|
||||
useProvidePlayerStore('global');
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
document.dispatchEvent(new CustomEvent("vue-ready"));
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -53,10 +53,9 @@
|
|||
/>
|
||||
|
||||
<request-modal
|
||||
v-if="enableRequests"
|
||||
ref="$requestModal"
|
||||
:show-album-art="showAlbumArt"
|
||||
:request-list-uri="requestListUri"
|
||||
:custom-fields="customFields"
|
||||
v-bind="pickProps(props, requestsProps)"
|
||||
/>
|
||||
|
||||
<lightbox ref="$lightbox" />
|
||||
|
@ -73,9 +72,11 @@ import {pickProps} from "~/functions/pickProps";
|
|||
import Lightbox from "~/components/Common/Lightbox.vue";
|
||||
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox";
|
||||
import {IconDownload, IconHelp, IconHistory} from "~/components/Common/icons";
|
||||
import requestsProps from "~/components/Public/Requests/requestsProps.ts";
|
||||
|
||||
const props = defineProps({
|
||||
...playerProps,
|
||||
...requestsProps,
|
||||
stationName: {
|
||||
type: String,
|
||||
required: true
|
||||
|
@ -88,15 +89,6 @@ const props = defineProps({
|
|||
type: String,
|
||||
required: true
|
||||
},
|
||||
requestListUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const history = ref({});
|
||||
|
|
|
@ -7,9 +7,7 @@
|
|||
hide-footer
|
||||
>
|
||||
<song-request
|
||||
:show-album-art="showAlbumArt"
|
||||
:request-list-uri="requestListUri"
|
||||
:custom-fields="customFields"
|
||||
v-bind="props"
|
||||
@submitted="hide"
|
||||
/>
|
||||
</modal>
|
||||
|
@ -20,21 +18,10 @@ import SongRequest from '../Requests.vue';
|
|||
import {ref} from "vue";
|
||||
import Modal from "~/components/Common/Modal.vue";
|
||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||
import requestsProps from "~/components/Public/Requests/requestsProps.ts";
|
||||
|
||||
const props = defineProps({
|
||||
requestListUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showAlbumArt: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
...requestsProps
|
||||
});
|
||||
|
||||
const $modal = ref<ModalTemplateRef>(null);
|
||||
|
|
|
@ -1,18 +1,11 @@
|
|||
<template>
|
||||
<section
|
||||
id="content"
|
||||
class="full-height-wrapper"
|
||||
role="main"
|
||||
class="d-flex align-items-stretch"
|
||||
style="height: 100vh;"
|
||||
>
|
||||
<div
|
||||
class="container pt-5 pb-5 h-100"
|
||||
style="flex: 1;"
|
||||
>
|
||||
<div
|
||||
class="card"
|
||||
style="height: 100%;"
|
||||
>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header text-bg-primary">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink">
|
||||
|
@ -76,7 +69,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import InlinePlayer from '../InlinePlayer.vue';
|
||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
||||
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||
import {forEach} from 'lodash';
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
|
@ -112,8 +105,8 @@ const props = defineProps({
|
|||
const {$gettext} = useTranslate();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{key: 'download_url', label: ' '},
|
||||
{key: 'art', label: $gettext('Art')},
|
||||
{key: 'download_url', label: ' ', class: 'shrink'},
|
||||
{key: 'art', label: $gettext('Art'), class: 'shrink'},
|
||||
{
|
||||
key: 'title',
|
||||
label: $gettext('Title'),
|
||||
|
@ -152,39 +145,3 @@ forEach(props.customFields.slice(), (field) => {
|
|||
const $lightbox = ref<LightboxTemplateRef>(null);
|
||||
useProvideLightbox($lightbox);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ondemand.embed {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
#public_on_demand {
|
||||
.datatable-main {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table.table {
|
||||
thead tr th:nth-child(1),
|
||||
tbody tr td:nth-child(1) {
|
||||
padding-right: 0.75rem;
|
||||
width: 3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead tr th:nth-child(2),
|
||||
tbody tr td:nth-child(2) {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
thead tr th:nth-child(3),
|
||||
tbody tr td:nth-child(3) {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -140,7 +140,7 @@
|
|||
<script setup lang="ts">
|
||||
import AudioPlayer from '~/components/Common/AudioPlayer.vue';
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import {computed, onMounted, ref, shallowRef, watch} from "vue";
|
||||
import {computed, nextTick, onMounted, ref, shallowRef, watch} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import useNowPlaying from "~/functions/useNowPlaying";
|
||||
import playerProps from "~/components/Public/playerProps";
|
||||
|
@ -149,6 +149,7 @@ import AlbumArt from "~/components/Common/AlbumArt.vue";
|
|||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import usePlayerVolume from "~/functions/usePlayerVolume";
|
||||
import {usePlayerStore} from "~/functions/usePlayerStore.ts";
|
||||
import {useEventListener} from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
...playerProps
|
||||
|
@ -177,16 +178,20 @@ const currentStream = shallowRef<CurrentStreamDescriptor>({
|
|||
hls: false,
|
||||
});
|
||||
|
||||
const enable_hls = computed(() => {
|
||||
const enableHls = computed(() => {
|
||||
return props.showHls && np.value?.station?.hls_enabled;
|
||||
});
|
||||
|
||||
const hlsIsDefault = computed(() => {
|
||||
return enableHls.value && np.value?.station?.hls_is_default;
|
||||
});
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const streams = computed<CurrentStreamDescriptor[]>(() => {
|
||||
const allStreams = [];
|
||||
|
||||
if (enable_hls.value) {
|
||||
if (enableHls.value) {
|
||||
allStreams.push({
|
||||
name: $gettext('HLS'),
|
||||
url: np.value?.station?.hls_url,
|
||||
|
@ -238,12 +243,17 @@ const switchStream = (new_stream: CurrentStreamDescriptor) => {
|
|||
});
|
||||
};
|
||||
|
||||
if (props.autoplay) {
|
||||
const stop = useEventListener(document, "now-playing", async () => {
|
||||
await nextTick();
|
||||
|
||||
switchStream(currentStream.value);
|
||||
stop();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.dispatchEvent(new CustomEvent("player-ready"));
|
||||
|
||||
if (props.autoplay) {
|
||||
switchStream(currentStream.value);
|
||||
}
|
||||
});
|
||||
|
||||
const onNowPlayingUpdated = (np_new) => {
|
||||
|
@ -254,7 +264,7 @@ const onNowPlayingUpdated = (np_new) => {
|
|||
let $currentStream = currentStream.value;
|
||||
|
||||
if ($currentStream.url === '' && $streams.length > 0) {
|
||||
if (props.hlsIsDefault && enable_hls.value) {
|
||||
if (hlsIsDefault.value) {
|
||||
currentStream.value = $streams[0];
|
||||
} else {
|
||||
$currentStream = null;
|
||||
|
|
|
@ -1,145 +1,11 @@
|
|||
<template>
|
||||
<div style="overflow-x: hidden">
|
||||
<data-table
|
||||
id="public_requests"
|
||||
ref="datatable"
|
||||
paginated
|
||||
select-fields
|
||||
:page-options="pageOptions"
|
||||
:fields="fields"
|
||||
:api-url="requestListUri"
|
||||
>
|
||||
<template #cell(name)="row">
|
||||
<div class="d-flex align-items-center">
|
||||
<album-art
|
||||
v-if="showAlbumArt"
|
||||
:src="row.item.song.art"
|
||||
:width="40"
|
||||
class="flex-shrink-1 pe-3"
|
||||
/>
|
||||
<div class="flex-fill">
|
||||
{{ row.item.song.title }}<br>
|
||||
<small>{{ row.item.song.artist }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(actions)="row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="doSubmitRequest(row.item.request_url)"
|
||||
>
|
||||
{{ $gettext('Request') }}
|
||||
</button>
|
||||
</template>
|
||||
</data-table>
|
||||
<div class="container-fluid">
|
||||
<requests-data-table v-bind="props" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
||||
import {forEach} from 'lodash';
|
||||
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
||||
import {computed} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import RequestsDataTable from "~/components/Public/Requests/RequestsDataTable.vue";
|
||||
import requestsProps from "~/components/Public/Requests/requestsProps.ts";
|
||||
|
||||
const props = defineProps({
|
||||
requestListUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showAlbumArt: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submitted']);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const fields = computed<DataTableField[]>(() => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'name',
|
||||
isRowHeader: true,
|
||||
label: $gettext('Name'),
|
||||
sortable: false,
|
||||
selectable: true
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: $gettext('Title'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.title
|
||||
},
|
||||
{
|
||||
key: 'artist',
|
||||
label: $gettext('Artist'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.artist
|
||||
},
|
||||
{
|
||||
key: 'album',
|
||||
label: $gettext('Album'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.album
|
||||
},
|
||||
{
|
||||
key: 'genre',
|
||||
label: $gettext('Genre'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.genre
|
||||
}
|
||||
];
|
||||
|
||||
forEach({...props.customFields}, (field) => {
|
||||
fields.push({
|
||||
key: 'custom_field_' + field.id,
|
||||
label: field.name,
|
||||
sortable: false,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.custom_fields[field.short_name]
|
||||
});
|
||||
});
|
||||
|
||||
fields.push(
|
||||
{key: 'actions', label: $gettext('Actions'), class: 'shrink', sortable: false}
|
||||
);
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
const pageOptions = [10, 25];
|
||||
|
||||
const {notifySuccess, notifyError} = useNotify();
|
||||
const {axios} = useAxios();
|
||||
|
||||
const doSubmitRequest = (url) => {
|
||||
axios.post(url).then((resp) => {
|
||||
if (resp.data.success) {
|
||||
notifySuccess(resp.data.message);
|
||||
} else {
|
||||
notifyError(resp.data.message);
|
||||
}
|
||||
}).finally(() => {
|
||||
emit('submitted');
|
||||
});
|
||||
};
|
||||
const props = defineProps(requestsProps);
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<data-table
|
||||
id="public_requests"
|
||||
ref="datatable"
|
||||
paginated
|
||||
select-fields
|
||||
:page-options="pageOptions"
|
||||
:fields="fields"
|
||||
:api-url="requestListUri"
|
||||
>
|
||||
<template #cell(name)="row">
|
||||
<div class="d-flex align-items-center">
|
||||
<album-art
|
||||
v-if="showAlbumArt"
|
||||
:src="row.item.song.art"
|
||||
:width="40"
|
||||
class="flex-shrink-1 pe-3"
|
||||
/>
|
||||
<div class="flex-fill">
|
||||
{{ row.item.song.title }}<br>
|
||||
<small>{{ row.item.song.artist }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(actions)="row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="doSubmitRequest(row.item.request_url)"
|
||||
>
|
||||
{{ $gettext('Request') }}
|
||||
</button>
|
||||
</template>
|
||||
</data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||
import {forEach} from 'lodash';
|
||||
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
||||
import {computed} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import requestsProps from "~/components/Public/Requests/requestsProps.ts";
|
||||
|
||||
const props = defineProps({
|
||||
...requestsProps
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submitted']);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const fields = computed<DataTableField[]>(() => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'name',
|
||||
isRowHeader: true,
|
||||
label: $gettext('Name'),
|
||||
sortable: false,
|
||||
selectable: true
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: $gettext('Title'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.title
|
||||
},
|
||||
{
|
||||
key: 'artist',
|
||||
label: $gettext('Artist'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.artist
|
||||
},
|
||||
{
|
||||
key: 'album',
|
||||
label: $gettext('Album'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.album
|
||||
},
|
||||
{
|
||||
key: 'genre',
|
||||
label: $gettext('Genre'),
|
||||
sortable: true,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.genre
|
||||
}
|
||||
];
|
||||
|
||||
forEach({...props.customFields}, (field) => {
|
||||
fields.push({
|
||||
key: 'custom_field_' + field.id,
|
||||
label: field.name,
|
||||
sortable: false,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => item.song.custom_fields[field.short_name]
|
||||
});
|
||||
});
|
||||
|
||||
fields.push(
|
||||
{key: 'actions', label: $gettext('Actions'), class: 'shrink', sortable: false}
|
||||
);
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
const pageOptions = [10, 25];
|
||||
|
||||
const {notifySuccess, notifyError} = useNotify();
|
||||
const {axios} = useAxios();
|
||||
|
||||
const doSubmitRequest = (url) => {
|
||||
axios.post(url).then((resp) => {
|
||||
if (resp.data.success) {
|
||||
notifySuccess(resp.data.message);
|
||||
} else {
|
||||
notifyError(resp.data.message);
|
||||
}
|
||||
}).finally(() => {
|
||||
emit('submitted');
|
||||
});
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,15 @@
|
|||
export default {
|
||||
requestListUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showAlbumArt: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
}
|
|
@ -1,18 +1,11 @@
|
|||
<template>
|
||||
<section
|
||||
id="content"
|
||||
class="full-height-wrapper"
|
||||
role="main"
|
||||
class="d-flex align-items-stretch"
|
||||
style="height: 100vh;"
|
||||
>
|
||||
<div
|
||||
class="container pt-5 pb-5 h-100"
|
||||
style="flex: 1;"
|
||||
>
|
||||
<div
|
||||
class="card"
|
||||
style="height: 100%;"
|
||||
>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header text-bg-primary">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink">
|
||||
|
@ -60,14 +53,7 @@ const props = defineProps({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.schedule.embed {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#station-schedule-calendar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
|
@ -6,10 +6,6 @@ export default {
|
|||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
hlsIsDefault: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showAlbumArt: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
|
|
|
@ -64,6 +64,7 @@ import {useNotify} from "~/functions/useNotify";
|
|||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import {ModalFormTemplateRef} from "~/functions/useBaseEditModal.ts";
|
||||
import {useHasModal} from "~/functions/useHasModal.ts";
|
||||
|
||||
const props = defineProps({
|
||||
customFields: {
|
||||
|
@ -148,10 +149,7 @@ const resetForm = () => {
|
|||
};
|
||||
|
||||
const $modal = ref<ModalFormTemplateRef>(null);
|
||||
|
||||
const close = () => {
|
||||
$modal.value?.hide();
|
||||
};
|
||||
const {hide, show} = useHasModal($modal);
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
|
@ -164,7 +162,7 @@ const open = (newRecordUrl, newAlbumArtUrl, newAudioUrl, newWaveformUrl) => {
|
|||
audioUrl.value = newAudioUrl;
|
||||
waveformUrl.value = newWaveformUrl;
|
||||
|
||||
$modal.value?.show();
|
||||
show();
|
||||
|
||||
axios.get(newRecordUrl).then((resp) => {
|
||||
const d = resp.data;
|
||||
|
@ -195,7 +193,7 @@ const open = (newRecordUrl, newAlbumArtUrl, newAudioUrl, newWaveformUrl) => {
|
|||
|
||||
form.value = newForm;
|
||||
}).catch(() => {
|
||||
close();
|
||||
hide();
|
||||
}).finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
|
@ -210,7 +208,7 @@ const doEdit = () => {
|
|||
axios.put(recordUrl.value, form.value).then(() => {
|
||||
notifySuccess();
|
||||
emit('relist');
|
||||
close();
|
||||
hide();
|
||||
}).catch((error) => {
|
||||
error.value = error.response.data.message;
|
||||
});
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
:input-attrs="{ step: '0.1' }"
|
||||
:label="$gettext('Amplify: Amplification (dB)')"
|
||||
:description="$gettext('The volume in decibels to amplify the track with. Leave blank to use the system default.')"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<form-group-field
|
||||
|
@ -25,6 +26,7 @@
|
|||
:input-attrs="{ step: '0.1' }"
|
||||
:label="$gettext('Custom Fading: Overlap Time (seconds)')"
|
||||
:description="$gettext('The time that this song should overlap its surrounding songs when fading. Leave blank to use the system default.')"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<form-group-field
|
||||
|
@ -35,6 +37,7 @@
|
|||
:input-attrs="{ step: '0.1' }"
|
||||
:label="$gettext('Custom Fading: Fade-In Time (seconds)')"
|
||||
:description="$gettext('The time period that the song should fade in. Leave blank to use the system default.')"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<form-group-field
|
||||
|
@ -45,6 +48,7 @@
|
|||
:input-attrs="{ step: '0.1' }"
|
||||
:label="$gettext('Custom Fading: Fade-Out Time (seconds)')"
|
||||
:description="$gettext('The time period that the song should fade out. Leave blank to use the system default.')"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<form-group-field
|
||||
|
@ -55,6 +59,7 @@
|
|||
:input-attrs="{ step: '0.1' }"
|
||||
:label="$gettext('Custom Cues: Cue-In Point (seconds)')"
|
||||
:description="$gettext('Seconds from the start of the song that the AutoDJ should start playing.')"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<form-group-field
|
||||
|
@ -65,6 +70,7 @@
|
|||
:input-attrs="{ step: '0.1' }"
|
||||
:label="$gettext('Custom Cues: Cue-Out Point (seconds)')"
|
||||
:description="$gettext('Seconds from the start of the song that the AutoDJ should stop playing.')"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
class="btn btn-primary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<icon :icon="IconClearAll" />
|
||||
|
@ -187,15 +188,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {forEach, intersection, map} from 'lodash';
|
||||
import {intersection, map} from 'lodash';
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import '~/vendor/sweetalert';
|
||||
import {h, ref, toRef, watch} from "vue";
|
||||
import {ref, toRef, watch} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||
import {IconClearAll, IconDelete, IconFolder, IconMoreHoriz, IconMove} from "~/components/Common/icons";
|
||||
import useHandleBatchResponse from "~/components/Stations/Media/useHandleBatchResponse.ts";
|
||||
|
||||
const props = defineProps({
|
||||
currentDirectory: {
|
||||
|
@ -227,9 +228,6 @@ const emit = defineEmits(['relist', 'add-playlist', 'move-files', 'create-direct
|
|||
const checkedPlaylists = ref([]);
|
||||
const newPlaylist = ref('');
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const langErrors = $gettext('The request could not be processed.');
|
||||
|
||||
watch(toRef(props, 'selectedItems'), (items) => {
|
||||
// Get all playlists that are active on ALL selected items.
|
||||
const playlistsForItems = map(items.all, (item) => {
|
||||
|
@ -248,41 +246,20 @@ watch(newPlaylist, (text) => {
|
|||
}
|
||||
});
|
||||
|
||||
const {notifySuccess, notifyError} = useNotify();
|
||||
const {$gettext} = useTranslate();
|
||||
const {axios} = useAxios();
|
||||
|
||||
const notifyNoFiles = () => {
|
||||
notifyError($gettext('No files selected.'));
|
||||
};
|
||||
const {notifyNoFiles, handleBatchResponse} = useHandleBatchResponse();
|
||||
|
||||
const doBatch = (action, notifyMessage) => {
|
||||
const doBatch = (action, successMessage, errorMessage) => {
|
||||
if (props.selectedItems.all.length) {
|
||||
axios.put(props.batchUrl, {
|
||||
'do': action,
|
||||
'current_directory': props.currentDirectory,
|
||||
'files': props.selectedItems.files,
|
||||
'dirs': props.selectedItems.directories
|
||||
}).then((resp) => {
|
||||
if (resp.data.success) {
|
||||
const allItemNodes = [];
|
||||
forEach(props.selectedItems.all, (item) => {
|
||||
allItemNodes.push(h('div', {}, item.path_short));
|
||||
});
|
||||
|
||||
notifySuccess(allItemNodes, {
|
||||
title: notifyMessage
|
||||
});
|
||||
} else {
|
||||
const errorNodes = [];
|
||||
forEach(resp.data.errors, (error) => {
|
||||
errorNodes.push(h('div', {}, error));
|
||||
});
|
||||
|
||||
notifyError(errorNodes, {
|
||||
title: langErrors
|
||||
});
|
||||
}
|
||||
|
||||
}).then(({data}) => {
|
||||
handleBatchResponse(data, successMessage, errorMessage);
|
||||
emit('relist');
|
||||
});
|
||||
} else {
|
||||
|
@ -291,15 +268,27 @@ const doBatch = (action, notifyMessage) => {
|
|||
};
|
||||
|
||||
const doImmediateQueue = () => {
|
||||
doBatch('immediate', $gettext('Files played immediately:'));
|
||||
doBatch(
|
||||
'immediate',
|
||||
$gettext('Files played immediately:'),
|
||||
$gettext('Error queueing files:')
|
||||
);
|
||||
};
|
||||
|
||||
const doQueue = () => {
|
||||
doBatch('queue', $gettext('Files queued for playback:'));
|
||||
doBatch(
|
||||
'queue',
|
||||
$gettext('Files queued for playback:'),
|
||||
$gettext('Error queueing files:')
|
||||
);
|
||||
};
|
||||
|
||||
const doReprocess = () => {
|
||||
doBatch('reprocess', $gettext('Files marked for reprocessing:'));
|
||||
doBatch(
|
||||
'reprocess',
|
||||
$gettext('Files marked for reprocessing:'),
|
||||
$gettext('Error reprocessing files:')
|
||||
);
|
||||
};
|
||||
|
||||
const {confirmDelete} = useSweetAlert();
|
||||
|
@ -315,7 +304,11 @@ const doDelete = () => {
|
|||
title: buttonConfirmText,
|
||||
}).then((result) => {
|
||||
if (result.value) {
|
||||
doBatch('delete', $gettext('Files removed:'));
|
||||
doBatch(
|
||||
'delete',
|
||||
$gettext('Files removed:'),
|
||||
$gettext('Error removing files:')
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -329,38 +322,24 @@ const setPlaylists = () => {
|
|||
'currentDirectory': props.currentDirectory,
|
||||
'files': props.selectedItems.files,
|
||||
'dirs': props.selectedItems.directories
|
||||
}).then((resp) => {
|
||||
if (resp.data.success) {
|
||||
if (resp.data.record) {
|
||||
emit('add-playlist', resp.data.record);
|
||||
}).then(({data}) => {
|
||||
if (data.success) {
|
||||
if (data.record) {
|
||||
emit('add-playlist', data.record);
|
||||
}
|
||||
|
||||
const notifyMessage = (checkedPlaylists.value.length > 0)
|
||||
? $gettext('Playlists updated for selected files:')
|
||||
: $gettext('Playlists cleared for selected files:');
|
||||
|
||||
const allItemNodes = [];
|
||||
forEach(props.selectedItems.all, (item) => {
|
||||
allItemNodes.push(h('div', {}, item.path_short));
|
||||
});
|
||||
|
||||
notifySuccess(allItemNodes, {
|
||||
title: notifyMessage
|
||||
});
|
||||
|
||||
checkedPlaylists.value = [];
|
||||
newPlaylist.value = '';
|
||||
} else {
|
||||
const errorNodes = [];
|
||||
forEach(resp.data.errors, (error) => {
|
||||
errorNodes.push(h('div', {}, error));
|
||||
});
|
||||
|
||||
notifyError(errorNodes, {
|
||||
title: langErrors
|
||||
});
|
||||
}
|
||||
|
||||
handleBatchResponse(
|
||||
data,
|
||||
(checkedPlaylists.value.length > 0)
|
||||
? $gettext('Playlists updated for selected files:')
|
||||
: $gettext('Playlists cleared for selected files:'),
|
||||
$gettext('Error updating playlists:')
|
||||
);
|
||||
|
||||
emit('relist');
|
||||
});
|
||||
} else {
|
||||
|
|
|
@ -76,16 +76,15 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||
import {forEach} from 'lodash';
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import {computed, h, ref} from "vue";
|
||||
import {computed, ref} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import Modal from "~/components/Common/Modal.vue";
|
||||
import {IconChevronLeft, IconFolder} from "~/components/Common/icons";
|
||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||
import useHandleBatchResponse from "~/components/Stations/Media/useHandleBatchResponse.ts";
|
||||
|
||||
const props = defineProps({
|
||||
selectedItems: {
|
||||
|
@ -132,9 +131,10 @@ const onHidden = () => {
|
|||
destinationDirectory.value = '';
|
||||
}
|
||||
|
||||
const {notifySuccess} = useNotify();
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {handleBatchResponse} = useHandleBatchResponse();
|
||||
|
||||
const doMove = () => {
|
||||
(props.selectedItems.all.length) && axios.put(props.batchUrl, {
|
||||
'do': 'move',
|
||||
|
@ -142,16 +142,12 @@ const doMove = () => {
|
|||
'directory': destinationDirectory.value,
|
||||
'files': props.selectedItems.files,
|
||||
'dirs': props.selectedItems.directories
|
||||
}).then(() => {
|
||||
const notifyMessage = $gettext('Files moved:');
|
||||
const itemNameNodes = [];
|
||||
forEach(props.selectedItems.all, (item) => {
|
||||
itemNameNodes.push(h('div', {}, item.path_short));
|
||||
});
|
||||
|
||||
notifySuccess(itemNameNodes, {
|
||||
title: notifyMessage
|
||||
});
|
||||
}).then(({data}) => {
|
||||
handleBatchResponse(
|
||||
data,
|
||||
$gettext('Files moved:'),
|
||||
$gettext('Error moving files:')
|
||||
);
|
||||
}).finally(() => {
|
||||
hide();
|
||||
emit('relist');
|
||||
|
|
|
@ -99,7 +99,7 @@ const doMkdir = () => {
|
|||
notifySuccess($gettext('New directory created.'));
|
||||
}).finally(() => {
|
||||
emit('relist');
|
||||
close();
|
||||
hide();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -98,7 +98,7 @@ const doRename = () => {
|
|||
file: file.value,
|
||||
...form.value
|
||||
}).finally(() => {
|
||||
close();
|
||||
hide();
|
||||
emit('relist');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import {forEach} from "lodash";
|
||||
import {h} from "vue";
|
||||
import {useNotify} from "~/functions/useNotify.ts";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
|
||||
interface BatchResponse {
|
||||
success: bool,
|
||||
dirs: string[],
|
||||
files: string[],
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export default function useHandleBatchResponse() {
|
||||
const {notifySuccess, notifyError} = useNotify();
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const notifyNoFiles = () => {
|
||||
notifyError($gettext('No files selected.'));
|
||||
};
|
||||
|
||||
const handleBatchResponse = (
|
||||
data: BatchResponse,
|
||||
successMessage: string,
|
||||
errorMessage: string
|
||||
): void => {
|
||||
if (data.success) {
|
||||
const itemNameNodes = [];
|
||||
forEach(data.dirs, (item) => {
|
||||
itemNameNodes.push(h('div', {}, item));
|
||||
});
|
||||
forEach(data.files, (item) => {
|
||||
itemNameNodes.push(h('div', {}, item));
|
||||
});
|
||||
|
||||
notifySuccess(itemNameNodes, {
|
||||
title: successMessage
|
||||
});
|
||||
} else {
|
||||
const itemErrorNodes = [];
|
||||
forEach(data.errors, (err) => {
|
||||
itemErrorNodes.push(h('div', {}, err));
|
||||
})
|
||||
|
||||
notifyError(itemErrorNodes, {
|
||||
title: errorMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notifyNoFiles,
|
||||
handleBatchResponse
|
||||
};
|
||||
}
|
|
@ -90,11 +90,10 @@ import publicPagesPanelProps from "./publicPagesPanelProps";
|
|||
import requestsPanelProps from "./requestsPanelProps";
|
||||
import streamersPanelProps from "./streamersPanelProps";
|
||||
import {pickProps} from "~/functions/pickProps";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState";
|
||||
import {useIntervalFn} from "@vueuse/core";
|
||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
|
||||
|
||||
const props = defineProps({
|
||||
...backendPanelProps,
|
||||
|
@ -129,7 +128,7 @@ const hasActiveBackend = computed(() => {
|
|||
|
||||
const {axios, axiosSilent} = useAxios();
|
||||
|
||||
const {state: profileInfo, execute: reloadProfile} = useRefreshableAsyncState(
|
||||
const {state: profileInfo} = useAutoRefreshingAsyncState(
|
||||
() => axiosSilent.get(props.profileApiUri).then((r) => r.data),
|
||||
{
|
||||
station: {
|
||||
|
@ -142,14 +141,12 @@ const {state: profileInfo, execute: reloadProfile} = useRefreshableAsyncState(
|
|||
needs_restart: false
|
||||
},
|
||||
schedule: []
|
||||
},
|
||||
{
|
||||
timeout: 15000
|
||||
}
|
||||
);
|
||||
|
||||
useIntervalFn(
|
||||
reloadProfile,
|
||||
computed(() => (!document.hidden) ? 15000 : 30000)
|
||||
);
|
||||
|
||||
const {showAlert} = useSweetAlert();
|
||||
const {notify} = useNotify();
|
||||
const {$gettext} = useTranslate();
|
||||
|
|
|
@ -153,7 +153,7 @@
|
|||
style="line-height: 1;"
|
||||
>
|
||||
{{ np.playing_next.song.title }}<br>
|
||||
<small>{{ np.playing_next.song.artist }}</small>
|
||||
<small class="text-muted">{{ np.playing_next.song.artist }}</small>
|
||||
</h6>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<tabs
|
||||
<tab
|
||||
:label="$gettext('AutoDJ')"
|
||||
:item-header-class="tabClass"
|
||||
>
|
||||
|
@ -83,7 +83,7 @@
|
|||
</template>
|
||||
</form-group-checkbox>
|
||||
</div>
|
||||
</tabs>
|
||||
</tab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
@ -94,7 +94,7 @@ import {computed} from "vue";
|
|||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||
import {useVModel} from "@vueuse/core";
|
||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
<p class="card-text">
|
||||
{{ $gettext('Stereo Tool is an industry standard for software audio processing. For more information on how to configure it, please refer to the') }}
|
||||
<a
|
||||
class="alert-link"
|
||||
href="https://www.thimeo.com/stereo-tool/"
|
||||
target="_blank"
|
||||
>
|
||||
|
|
|
@ -12,13 +12,8 @@
|
|||
{{ $gettext('Streamer/DJ Accounts') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-md-6 text-end text-muted">
|
||||
{{
|
||||
$gettext(
|
||||
'This station\'s time zone is currently %{tz}.',
|
||||
{tz: timezone}
|
||||
)
|
||||
}}
|
||||
<div class="col-md-6 text-end">
|
||||
<time-zone />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -116,7 +111,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
||||
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||
import EditModal from './Streamers/EditModal.vue';
|
||||
import BroadcastsModal from './Streamers/BroadcastsModal.vue';
|
||||
import Schedule from '~/components/Common/ScheduleView.vue';
|
||||
|
@ -133,6 +128,7 @@ import {getStationApiUrl} from "~/router";
|
|||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import AddButton from "~/components/Common/AddButton.vue";
|
||||
import TimeZone from "~/components/Stations/Common/TimeZone.vue";
|
||||
|
||||
const props = defineProps({
|
||||
connectionInfo: {
|
||||
|
|
|
@ -445,6 +445,11 @@ export interface ApiNowPlayingStation {
|
|||
* @example true
|
||||
*/
|
||||
hls_enabled?: boolean;
|
||||
/**
|
||||
* If the HLS stream should be the default one for the station.
|
||||
* @example true
|
||||
*/
|
||||
hls_is_default?: boolean;
|
||||
/**
|
||||
* The full URL to listen to the HLS stream for the station.
|
||||
* @example "https://example.com/hls/azuratest_radio/live.m3u8"
|
||||
|
@ -1264,10 +1269,9 @@ export type StationMedia = HasAutoIncrementId &
|
|||
isrc?: string | null;
|
||||
/**
|
||||
* The song duration in seconds.
|
||||
* @format float
|
||||
* @example 240
|
||||
*/
|
||||
length?: number | null;
|
||||
length?: string | null;
|
||||
/**
|
||||
* The formatted song duration (in mm:ss format)
|
||||
* @example "4:00"
|
||||
|
@ -1285,40 +1289,34 @@ export type StationMedia = HasAutoIncrementId &
|
|||
mtime?: number | null;
|
||||
/**
|
||||
* The amount of amplification (in dB) to be applied to the radio source (liq_amplify)
|
||||
* @format float
|
||||
* @example -14
|
||||
*/
|
||||
amplify?: number | null;
|
||||
amplify?: string | null;
|
||||
/**
|
||||
* The length of time (in seconds) before the next song starts in the fade (liq_start_next)
|
||||
* @format float
|
||||
* @example 2
|
||||
*/
|
||||
fade_overlap?: number | null;
|
||||
fade_overlap?: string | null;
|
||||
/**
|
||||
* The length of time (in seconds) to fade in the next track (liq_fade_in)
|
||||
* @format float
|
||||
* @example 3
|
||||
*/
|
||||
fade_in?: number | null;
|
||||
fade_in?: string | null;
|
||||
/**
|
||||
* The length of time (in seconds) to fade out the previous track (liq_fade_out)
|
||||
* @format float
|
||||
* @example 3
|
||||
*/
|
||||
fade_out?: number | null;
|
||||
fade_out?: string | null;
|
||||
/**
|
||||
* The length of time (in seconds) from the start of the track to start playing (liq_cue_in)
|
||||
* @format float
|
||||
* @example 30
|
||||
*/
|
||||
cue_in?: number | null;
|
||||
cue_in?: string | null;
|
||||
/**
|
||||
* The length of time (in seconds) from the CUE-IN of the track to stop playing (liq_cue_out)
|
||||
* @format float
|
||||
* @example 30
|
||||
*/
|
||||
cue_out?: number | null;
|
||||
cue_out?: string | null;
|
||||
/**
|
||||
* The latest time (UNIX timestamp) when album art was updated.
|
||||
* @example 1609480800
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {Pausable, UseAsyncStateOptions, UseAsyncStateReturn, useIntervalFn} from "@vueuse/core";
|
||||
import {computed} from "vue";
|
||||
|
||||
interface AutoRefreshingAsyncStateOptions<Shallow extends boolean, D = any>
|
||||
extends UseAsyncStateOptions<Shallow, D> {
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
interface AutoRefreshingAsyncStateReturn<Data, Params extends any[], Shallow extends boolean>
|
||||
extends UseAsyncStateReturn<Data, Params, Shallow>, Pausable {
|
||||
}
|
||||
|
||||
export default function useAutoRefreshingAsyncState<Data, Params extends any[] = [], Shallow extends boolean = true>(
|
||||
promise: Promise<Data> | ((...args: Params) => Promise<Data>),
|
||||
initialState: Data,
|
||||
options: AutoRefreshingAsyncStateOptions<Shallow, Data> = {}
|
||||
): AutoRefreshingAsyncStateReturn<Data, Params, Shallow> {
|
||||
const {
|
||||
timeout = 15000
|
||||
} = options ?? {}
|
||||
|
||||
const {
|
||||
state,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
execute
|
||||
} = useRefreshableAsyncState(
|
||||
promise,
|
||||
initialState,
|
||||
{
|
||||
throwError: true,
|
||||
...options
|
||||
}
|
||||
);
|
||||
|
||||
const intervalDelay = computed(() =>
|
||||
(!document.hidden) ? timeout : (timeout * 2)
|
||||
);
|
||||
|
||||
const {isActive, pause, resume} = useIntervalFn(
|
||||
async () => {
|
||||
try {
|
||||
await execute();
|
||||
} catch (e) {
|
||||
pause();
|
||||
}
|
||||
},
|
||||
intervalDelay
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
execute,
|
||||
isActive,
|
||||
pause,
|
||||
resume
|
||||
};
|
||||
}
|
|
@ -1,48 +1,41 @@
|
|||
import NowPlaying from '~/entities/NowPlaying';
|
||||
import {computed, onMounted, ref, shallowRef, watch} from "vue";
|
||||
import {computed, onMounted, Ref, ref, ShallowRef, shallowRef, watch} from "vue";
|
||||
import {useEventSource, useIntervalFn} from "@vueuse/core";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {has} from "lodash";
|
||||
import formatTime from "~/functions/formatTime";
|
||||
import {ApiNowPlaying} from "~/entities/ApiInterfaces.ts";
|
||||
import {getApiUrl} from "~/router.ts";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import formatTime from "~/functions/formatTime.ts";
|
||||
|
||||
export const nowPlayingProps = {
|
||||
nowPlayingUri: {
|
||||
stationShortName: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
useStatic: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
useSse: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
sseUri: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
initialNowPlaying: {
|
||||
type: Object,
|
||||
default() {
|
||||
return NowPlaying;
|
||||
}
|
||||
},
|
||||
timeUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
};
|
||||
|
||||
export default function useNowPlaying(props) {
|
||||
const np = shallowRef(props.initialNowPlaying);
|
||||
const np: ShallowRef<ApiNowPlaying> = shallowRef(NowPlaying);
|
||||
const npTimestamp: Ref<number> = ref(0);
|
||||
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000));
|
||||
const currentTrackDuration = ref(0);
|
||||
const currentTrackElapsed = ref(0);
|
||||
const currentTime: Ref<number> = ref(Math.floor(Date.now() / 1000));
|
||||
const currentTrackDuration: Ref<number> = ref(0);
|
||||
const currentTrackElapsed: Ref<number> = ref(0);
|
||||
|
||||
const setNowPlaying = (np_new) => {
|
||||
const setNowPlaying = (np_new: ApiNowPlaying) => {
|
||||
np.value = np_new;
|
||||
npTimestamp.value = Date.now();
|
||||
|
||||
currentTrackDuration.value = np_new?.now_playing?.duration ?? 0;
|
||||
currentTrackDuration.value = np_new.now_playing.duration ?? 0;
|
||||
|
||||
// Update the browser metadata for browsers that support it (i.e. Mobile Chrome)
|
||||
if ('mediaSession' in navigator) {
|
||||
|
@ -60,33 +53,64 @@ export default function useNowPlaying(props) {
|
|||
}));
|
||||
}
|
||||
|
||||
// Trigger initial NP set.
|
||||
setNowPlaying(np.value);
|
||||
|
||||
if (props.useSse) {
|
||||
const {data} = useEventSource(props.sseUri);
|
||||
watch(data, (data_raw) => {
|
||||
const json_data = JSON.parse(data_raw);
|
||||
const json_data_np = json_data?.pub?.data ?? {};
|
||||
const sseBaseUri = getApiUrl('/live/nowplaying/sse');
|
||||
const sseUriParams = new URLSearchParams({
|
||||
"cf_connect": JSON.stringify({
|
||||
"subs": {
|
||||
[`station:${props.stationShortName}`]: {},
|
||||
"global:time": {},
|
||||
}
|
||||
}),
|
||||
});
|
||||
const sseUri = sseBaseUri.value + '?' + sseUriParams.toString();
|
||||
|
||||
if (has(json_data_np, 'np')) {
|
||||
setTimeout(() => {
|
||||
setNowPlaying(json_data_np.np);
|
||||
}, 3000);
|
||||
} else if (has(json_data_np, 'time')) {
|
||||
currentTime.value = json_data_np.time;
|
||||
const handleSseData = (ssePayload) => {
|
||||
const jsonData = ssePayload?.pub?.data ?? {};
|
||||
if (ssePayload.channel === 'global:time') {
|
||||
currentTime.value = jsonData.time;
|
||||
} else {
|
||||
if (npTimestamp.value === 0) {
|
||||
setNowPlaying(jsonData.np);
|
||||
} else {
|
||||
// SSE events often dispatch *too quickly* relative to the delays involved in
|
||||
// Liquidsoap and Icecast, so we delay these changes from showing up to better
|
||||
// approximate when listeners will really hear the track change.
|
||||
setTimeout(() => {
|
||||
setNowPlaying(jsonData.np);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {data} = useEventSource(sseUri);
|
||||
watch(data, (dataRaw: string) => {
|
||||
const jsonData = JSON.parse(dataRaw);
|
||||
if ('connect' in jsonData) {
|
||||
const initialData = jsonData.connect.data ?? [];
|
||||
initialData.forEach((initialRow) => handleSseData(initialRow));
|
||||
} else if ('channel' in jsonData) {
|
||||
handleSseData(jsonData);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const {axios} = useAxios();
|
||||
const nowPlayingUri = props.useStatic
|
||||
? getApiUrl(`/nowplaying_static/${props.stationShortName}.json`)
|
||||
: getApiUrl(`/nowplaying/${props.stationShortName}`);
|
||||
|
||||
const timeUri = getApiUrl('/time');
|
||||
const {axiosSilent} = useAxios();
|
||||
|
||||
const axiosNoCacheConfig = {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
}
|
||||
};
|
||||
|
||||
const checkNowPlaying = () => {
|
||||
axios.get(props.nowPlayingUri, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
}
|
||||
}).then((response) => {
|
||||
axiosSilent.get(nowPlayingUri.value, axiosNoCacheConfig).then((response) => {
|
||||
setNowPlaying(response.data);
|
||||
|
||||
setTimeout(checkNowPlaying, (!document.hidden) ? 15000 : 30000);
|
||||
|
@ -96,13 +120,7 @@ export default function useNowPlaying(props) {
|
|||
};
|
||||
|
||||
const checkTime = () => {
|
||||
axios.get(props.timeUri, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
}
|
||||
}).then((response) => {
|
||||
axiosSilent.get(timeUri.value, axiosNoCacheConfig).then((response) => {
|
||||
currentTime.value = response.data.timestamp;
|
||||
}).finally(() => {
|
||||
setTimeout(checkTime, (!document.hidden) ? 300000 : 600000);
|
||||
|
@ -110,8 +128,8 @@ export default function useNowPlaying(props) {
|
|||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(checkTime, 5000);
|
||||
setTimeout(checkNowPlaying, 5000);
|
||||
checkTime();
|
||||
checkNowPlaying();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -135,39 +153,30 @@ export default function useNowPlaying(props) {
|
|||
});
|
||||
|
||||
const currentTrackPercent = computed(() => {
|
||||
const $currentTrackElapsed = currentTrackElapsed.value;
|
||||
const $currentTrackDuration = currentTrackDuration.value;
|
||||
|
||||
if (!$currentTrackDuration) {
|
||||
if (!currentTrackDuration.value) {
|
||||
return 0;
|
||||
}
|
||||
if ($currentTrackElapsed > $currentTrackDuration) {
|
||||
if (currentTrackElapsed.value > currentTrackDuration.value) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return ($currentTrackElapsed / $currentTrackDuration) * 100;
|
||||
return (currentTrackElapsed.value / currentTrackDuration.value) * 100;
|
||||
});
|
||||
|
||||
const currentTrackDurationDisplay = computed(() => {
|
||||
const $currentTrackDuration = currentTrackDuration.value;
|
||||
return ($currentTrackDuration) ? formatTime($currentTrackDuration) : null;
|
||||
return (currentTrackDuration.value) ? formatTime(currentTrackDuration.value) : null;
|
||||
});
|
||||
|
||||
const currentTrackElapsedDisplay = computed(() => {
|
||||
let $currentTrackElapsed = currentTrackElapsed.value;
|
||||
const $currentTrackDuration = currentTrackDuration.value;
|
||||
|
||||
if (!$currentTrackDuration) {
|
||||
if (!currentTrackDuration.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($currentTrackElapsed > $currentTrackDuration) {
|
||||
$currentTrackElapsed = $currentTrackDuration;
|
||||
}
|
||||
|
||||
return formatTime($currentTrackElapsed);
|
||||
return (currentTrackElapsed.value <= currentTrackDuration.value)
|
||||
? formatTime(currentTrackElapsed.value)
|
||||
: currentTrackDurationDisplay.value;
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
np,
|
||||
currentTime,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {useAsyncState} from "@vueuse/core";
|
||||
import {useAsyncState, UseAsyncStateOptions, UseAsyncStateReturn} from "@vueuse/core";
|
||||
import syncOnce from "~/functions/syncOnce";
|
||||
import {Ref} from "vue";
|
||||
|
||||
/**
|
||||
* Just like useAsyncState, except with settings changed:
|
||||
|
@ -8,12 +9,18 @@ import syncOnce from "~/functions/syncOnce";
|
|||
*
|
||||
* @see useAsyncState
|
||||
*/
|
||||
export default function useRefreshableAsyncState(
|
||||
promise,
|
||||
initialState,
|
||||
options = {}
|
||||
) {
|
||||
const {state, isLoading: allIsLoading, execute} = useAsyncState(
|
||||
export default function useRefreshableAsyncState<Data, Params extends any[] = [], Shallow extends boolean = true>(
|
||||
promise: Promise<Data> | ((...args: Params) => Promise<Data>),
|
||||
initialState: Data,
|
||||
options: UseAsyncStateOptions<Shallow, Data> = {}
|
||||
): UseAsyncStateReturn<Data, Params, Shallow> {
|
||||
const {
|
||||
state,
|
||||
isReady,
|
||||
isLoading: allIsLoading,
|
||||
error,
|
||||
execute
|
||||
} = useAsyncState(
|
||||
promise,
|
||||
initialState,
|
||||
{
|
||||
|
@ -22,11 +29,13 @@ export default function useRefreshableAsyncState(
|
|||
}
|
||||
);
|
||||
|
||||
const isLoading = syncOnce(allIsLoading);
|
||||
const isLoading: Ref<boolean> = syncOnce(allIsLoading);
|
||||
|
||||
return {
|
||||
state,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
execute
|
||||
};
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ export function useVuelidateOnForm(validations = {}, blankForm = {}, options = {
|
|||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
return !v$.value.$invalid ?? true;
|
||||
return !v$.value.$invalid;
|
||||
});
|
||||
|
||||
const validate = () => {
|
||||
|
|
|
@ -6,7 +6,7 @@ export function useVuelidateOnFormTab(validations, form, blankForm = {}, vuelida
|
|||
const v$ = useVuelidate(validations, form, vuelidateOptions);
|
||||
|
||||
const isValid = computed(() => {
|
||||
return !v$.value.$invalid ?? true;
|
||||
return !v$.value.$invalid;
|
||||
});
|
||||
|
||||
const tabClass = computed(() => {
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
import {cloneDeep} from "lodash";
|
||||
|
||||
export default function useWebAuthn() {
|
||||
let abortController = null;
|
||||
const abortAndCreateNew = (message: string) => {
|
||||
if (abortController) {
|
||||
const abortError = new Error(message);
|
||||
abortError.name = 'AbortError';
|
||||
abortController.abort(abortError);
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
return abortController.signal;
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (abortController) {
|
||||
const abortError = new Error('Operation cancelled.');
|
||||
abortError.name = 'AbortError';
|
||||
abortController.abort(abortError);
|
||||
}
|
||||
}
|
||||
|
||||
const recursiveBase64StrToArrayBuffer = (obj) => {
|
||||
const prefix = '=?BINARY?B?';
|
||||
const suffix = '?=';
|
||||
if (typeof obj === 'object') {
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === 'string') {
|
||||
let str = obj[key];
|
||||
if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) {
|
||||
str = str.substring(prefix.length, str.length - suffix.length);
|
||||
|
||||
const binary_string = window.atob(str);
|
||||
const len = binary_string.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary_string.charCodeAt(i);
|
||||
}
|
||||
obj[key] = bytes.buffer;
|
||||
}
|
||||
} else {
|
||||
recursiveBase64StrToArrayBuffer(obj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBufferToBase64 = (buffer) => {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
const isSupported: boolean =
|
||||
window?.PublicKeyCredential !== undefined &&
|
||||
typeof window.PublicKeyCredential === 'function';
|
||||
|
||||
const isConditionalSupported = async (): Promise<boolean> => {
|
||||
if (!isSupported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await PublicKeyCredential.isConditionalMediationAvailable();
|
||||
};
|
||||
|
||||
const processServerArgs = (serverArgs) => {
|
||||
const newArgs = cloneDeep(serverArgs);
|
||||
recursiveBase64StrToArrayBuffer(newArgs);
|
||||
return newArgs;
|
||||
};
|
||||
|
||||
// Registration (private creation)
|
||||
const processRegisterResponse = (cred) => {
|
||||
return {
|
||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
|
||||
};
|
||||
}
|
||||
|
||||
const doRegister = async (rawArgs: object) => {
|
||||
const registerArgs = processServerArgs(rawArgs);
|
||||
|
||||
const signal = abortAndCreateNew('New registration started.');
|
||||
|
||||
const options = {
|
||||
...registerArgs,
|
||||
signal: signal
|
||||
};
|
||||
|
||||
const rawResp = await navigator.credentials.create(options);
|
||||
return processRegisterResponse(rawResp);
|
||||
};
|
||||
|
||||
// Validation (public login)
|
||||
const processValidateResponse = (cred) => {
|
||||
return {
|
||||
id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
|
||||
signature: cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null,
|
||||
userHandle: cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null
|
||||
};
|
||||
};
|
||||
|
||||
const doValidate = async (rawArgs: object, isConditional: boolean = false) => {
|
||||
const validateArgs = processServerArgs(rawArgs);
|
||||
|
||||
const mediation = (isConditional) ? {
|
||||
mediation: 'conditional'
|
||||
} : {};
|
||||
|
||||
const signal = abortAndCreateNew('New validation started.');
|
||||
|
||||
const options = {
|
||||
...validateArgs,
|
||||
...mediation,
|
||||
signal: signal
|
||||
};
|
||||
|
||||
const rawResp = await navigator.credentials.get(options);
|
||||
return processValidateResponse(rawResp);
|
||||
};
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isConditionalSupported,
|
||||
doValidate,
|
||||
doRegister,
|
||||
cancel
|
||||
};
|
||||
}
|
|
@ -37,6 +37,29 @@ ready(() => {
|
|||
const toast = new bootstrap.Toast(el);
|
||||
toast.show();
|
||||
});
|
||||
|
||||
// If in a frame, notify the parent frame of the frame dimensions.
|
||||
if (window.self !== window.top) {
|
||||
let docHeight = 0;
|
||||
let docWidth = 0;
|
||||
|
||||
const postSizeToParent = () => {
|
||||
if (document.body.scrollHeight !== docHeight || document.body.scrollWidth !== docWidth) {
|
||||
docHeight = document.body.scrollHeight;
|
||||
docWidth = document.body.scrollWidth;
|
||||
|
||||
const message = {height: docHeight, width: docWidth};
|
||||
window.top.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
||||
postSizeToParent();
|
||||
document.addEventListener("vue-ready", postSizeToParent);
|
||||
|
||||
const mainElem = document.querySelector('main');
|
||||
const resizeObserver = new ResizeObserver(postSizeToParent);
|
||||
resizeObserver.observe(mainElem);
|
||||
}
|
||||
});
|
||||
|
||||
export default bootstrap;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import initApp from "~/layout";
|
||||
import useMinimalLayout from "~/layouts/MinimalLayout";
|
||||
import Login from "~/components/Login.vue";
|
||||
|
||||
initApp(useMinimalLayout(Login));
|
|
@ -17,7 +17,7 @@
|
|||
--public-page-bg: url('/static/img/hexbg_dark.webp');
|
||||
--scrollbar-color: #{$gray-800};
|
||||
--bs-info-bg-subtle: #bbdefb;
|
||||
--bs-info-text-emphasis: #181d20;
|
||||
--bs-info-text-emphasis: #2C363D;
|
||||
|
||||
.table-striped {
|
||||
--bs-table-striped-bg: #2b2e37;
|
||||
|
|
|
@ -2,10 +2,6 @@ body.embed {
|
|||
background: transparent !important;
|
||||
color-scheme: unset;
|
||||
min-height: auto;
|
||||
|
||||
&.ondemand {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
body.embed-social {
|
||||
|
|
|
@ -37,4 +37,36 @@ body.page-minimal {
|
|||
.might-overflow {
|
||||
@include might-overflow();
|
||||
}
|
||||
|
||||
&.embed {
|
||||
.full-height-wrapper {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.embed) {
|
||||
.full-height-wrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100vh;
|
||||
|
||||
.container {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
|
||||
.card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.datatable-main {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,8 +37,9 @@ export default function installAxios(vueApp: App) {
|
|||
let notifyMessage = $gettext('An error occurred and your request could not be completed.');
|
||||
if (error.response) {
|
||||
// Request made and server responded
|
||||
notifyMessage = error.response.data.message;
|
||||
console.error(notifyMessage);
|
||||
const responseJson = error.response.data ?? {};
|
||||
notifyMessage = responseJson.message ?? notifyMessage;
|
||||
console.error(responseJson);
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
console.error(error.request);
|
||||
|
|
|
@ -37,5 +37,8 @@
|
|||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,77 +1,2 @@
|
|||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$criteria of method Doctrine\\\\DBAL\\\\Connection\\:\\:update\\(\\) expects array\\<string, mixed\\>, array\\<int, int\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Migration/Version20180412055024.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$criteria of method Doctrine\\\\DBAL\\\\Connection\\:\\:update\\(\\) expects array\\<string, mixed\\>, array\\<int, int\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Migration/Version20180429013130.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$criteria of method Doctrine\\\\DBAL\\\\Connection\\:\\:update\\(\\) expects array\\<string, mixed\\>, array\\<int, int\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Migration/Version20180818223558.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$criteria of method Doctrine\\\\DBAL\\\\Connection\\:\\:update\\(\\) expects array\\<string, mixed\\>, array\\<int, int\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Migration/Version20190513163051.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$criteria of method Doctrine\\\\DBAL\\\\Connection\\:\\:delete\\(\\) expects array\\<string, mixed\\>, array\\<int, int\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Migration/Version20201204043539.php
|
||||
|
||||
-
|
||||
message: "#^Cannot cast Symfony\\\\Component\\\\Validator\\\\ConstraintViolationListInterface to string\\.$#"
|
||||
count: 1
|
||||
path: src/Exception/ValidationException.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Normalizer\\\\DoctrineEntityNormalizer\\:\\:getAllowedAttributes\\(\\) should return array\\|false but returns array\\<string\\|Symfony\\\\Component\\\\Serializer\\\\Mapping\\\\AttributeMetadataInterface\\>\\|bool\\.$#"
|
||||
count: 1
|
||||
path: src/Normalizer/DoctrineEntityNormalizer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$className of method Doctrine\\\\ORM\\\\EntityManagerInterface\\:\\:getRepository\\(\\) expects class\\-string\\<object\\>, class\\-string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: src/Validator/Constraints/UniqueEntityValidator.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$depth\\.$#"
|
||||
count: 1
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$hasAttributes\\.$#"
|
||||
count: 1
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$isEmptyElement\\.$#"
|
||||
count: 1
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$localName\\.$#"
|
||||
count: 1
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$name\\.$#"
|
||||
count: 1
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$nodeType\\.$#"
|
||||
count: 1
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property XMLReader\\:\\:\\$value\\.$#"
|
||||
count: 2
|
||||
path: src/Xml/Reader.php
|
||||
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
includes:
|
||||
- phpstan-baseline.neon
|
||||
- vendor/phpstan/phpstan-doctrine/extension.neon
|
||||
- vendor/phpstan/phpstan-doctrine/rules.neon
|
||||
|
||||
parameters:
|
||||
level: 8
|
||||
|
||||
checkMissingIterableValueType: false
|
||||
|
||||
doctrine:
|
||||
objectManagerLoader: util/phpstan-doctrine.php
|
||||
|
||||
paths:
|
||||
- bin
|
||||
- config
|
||||
|
|
11
src/Acl.php
11
src/Acl.php
|
@ -11,10 +11,10 @@ use App\Entity\User;
|
|||
use App\Enums\GlobalPermissions;
|
||||
use App\Enums\PermissionInterface;
|
||||
use App\Enums\StationPermissions;
|
||||
use App\Exception\InvalidRequestAttribute;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Traits\RequestAwareTrait;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
|
@ -106,9 +106,12 @@ final class Acl
|
|||
array|string|PermissionInterface $action,
|
||||
Station|int $stationId = null
|
||||
): bool {
|
||||
if ($this->request instanceof ServerRequestInterface) {
|
||||
$user = $this->request->getAttribute(ServerRequest::ATTR_USER);
|
||||
return $this->userAllowed($user, $action, $stationId);
|
||||
if ($this->request instanceof ServerRequest) {
|
||||
try {
|
||||
$user = $this->request->getUser();
|
||||
return $this->userAllowed($user, $action, $stationId);
|
||||
} catch (InvalidRequestAttribute) {
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
@ -6,8 +6,7 @@ namespace App;
|
|||
|
||||
use App\Console\Application;
|
||||
use App\Enums\SupportedLocales;
|
||||
use App\Http\Factory\ResponseFactory;
|
||||
use App\Http\Factory\ServerRequestFactory;
|
||||
use App\Http\HttpFactory;
|
||||
use App\Utilities\Logger as AppLogger;
|
||||
use DI;
|
||||
use Monolog\ErrorHandler;
|
||||
|
@ -21,34 +20,40 @@ use Slim\Handlers\Strategies\RequestResponse;
|
|||
final class AppFactory
|
||||
{
|
||||
public static function createApp(
|
||||
array $appEnvironment = [],
|
||||
array $diDefinitions = []
|
||||
array $appEnvironment = []
|
||||
): App {
|
||||
$di = self::buildContainer($appEnvironment, $diDefinitions);
|
||||
$environment = self::buildEnvironment($appEnvironment);
|
||||
$diBuilder = self::createContainerBuilder($environment);
|
||||
$di = self::buildContainer($diBuilder);
|
||||
return self::buildAppFromContainer($di);
|
||||
}
|
||||
|
||||
public static function createCli(
|
||||
array $appEnvironment = [],
|
||||
array $diDefinitions = []
|
||||
array $appEnvironment = []
|
||||
): Application {
|
||||
$di = self::buildContainer($appEnvironment, $diDefinitions);
|
||||
$environment = self::buildEnvironment($appEnvironment);
|
||||
$diBuilder = self::createContainerBuilder($environment);
|
||||
$di = self::buildContainer($diBuilder);
|
||||
|
||||
// Some CLI commands require the App to be injected for routing.
|
||||
self::buildAppFromContainer($di);
|
||||
|
||||
$env = $di->get(Environment::class);
|
||||
|
||||
SupportedLocales::createForCli($env);
|
||||
SupportedLocales::createForCli($environment);
|
||||
|
||||
return $di->get(Application::class);
|
||||
}
|
||||
|
||||
public static function buildAppFromContainer(DI\Container $container): App
|
||||
{
|
||||
public static function buildAppFromContainer(
|
||||
DI\Container $container,
|
||||
?HttpFactory $httpFactory = null
|
||||
): App {
|
||||
$httpFactory ??= new HttpFactory();
|
||||
|
||||
ServerRequestCreatorFactory::setSlimHttpDecoratorsAutomaticDetection(false);
|
||||
ServerRequestCreatorFactory::setServerRequestCreator(new ServerRequestFactory());
|
||||
ServerRequestCreatorFactory::setServerRequestCreator($httpFactory);
|
||||
|
||||
$app = new App(
|
||||
responseFactory: new ResponseFactory(),
|
||||
responseFactory: $httpFactory,
|
||||
container: $container,
|
||||
);
|
||||
$container->set(App::class, $app);
|
||||
|
@ -67,18 +72,19 @@ final class AppFactory
|
|||
return $app;
|
||||
}
|
||||
|
||||
public static function buildContainer(
|
||||
array $appEnvironment = [],
|
||||
array $diDefinitions = []
|
||||
): DI\Container {
|
||||
$environment = self::buildEnvironment($appEnvironment);
|
||||
/**
|
||||
* @return DI\ContainerBuilder<DI\Container>
|
||||
*/
|
||||
public static function createContainerBuilder(
|
||||
Environment $environment
|
||||
): DI\ContainerBuilder {
|
||||
$diDefinitions = [
|
||||
Environment::class => $environment,
|
||||
];
|
||||
|
||||
Environment::setInstance($environment);
|
||||
|
||||
self::applyPhpSettings($environment);
|
||||
|
||||
// Override DI definitions for settings.
|
||||
$diDefinitions[Environment::class] = $environment;
|
||||
|
||||
$plugins = new Plugins($environment->getBaseDirectory() . '/plugins');
|
||||
|
||||
$diDefinitions[Plugins::class] = $plugins;
|
||||
|
@ -96,6 +102,16 @@ final class AppFactory
|
|||
|
||||
$containerBuilder->addDefinitions(dirname(__DIR__) . '/config/services.php');
|
||||
|
||||
return $containerBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DI\ContainerBuilder<DI\Container> $containerBuilder
|
||||
* @return DI\Container
|
||||
*/
|
||||
public static function buildContainer(
|
||||
DI\ContainerBuilder $containerBuilder
|
||||
): DI\Container {
|
||||
$di = $containerBuilder->build();
|
||||
|
||||
// Monolog setup
|
||||
|
@ -109,14 +125,17 @@ final class AppFactory
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $environment
|
||||
* @param array<string, mixed> $rawEnvironment
|
||||
*/
|
||||
public static function buildEnvironment(array $environment = []): Environment
|
||||
public static function buildEnvironment(array $rawEnvironment = []): Environment
|
||||
{
|
||||
$_ENV = getenv();
|
||||
$environment = array_merge(array_filter($_ENV), $environment);
|
||||
$rawEnvironment = array_merge(array_filter($_ENV), $rawEnvironment);
|
||||
$environment = new Environment($rawEnvironment);
|
||||
|
||||
return new Environment($environment);
|
||||
self::applyPhpSettings($environment);
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
private static function applyPhpSettings(Environment $environment): void
|
||||
|
@ -141,6 +160,9 @@ final class AppFactory
|
|||
: $environment->getTempDirectory() . '/php_errors.log'
|
||||
);
|
||||
|
||||
mb_internal_encoding('UTF-8');
|
||||
ini_set('default_charset', 'utf-8');
|
||||
|
||||
if (!headers_sent()) {
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
|
|
|
@ -4,13 +4,18 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use Intervention\Image\Interfaces\EncoderInterface;
|
||||
|
||||
abstract class AbstractMultiPatternCustomAsset extends AbstractCustomAsset
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{string, EncoderInterface}>
|
||||
*/
|
||||
abstract protected function getPatterns(): array;
|
||||
|
||||
protected function getPattern(): string
|
||||
{
|
||||
return $this->getPatterns()['default'];
|
||||
return $this->getPatterns()['default'][0];
|
||||
}
|
||||
|
||||
protected function getPathForPattern(string $pattern): string
|
||||
|
@ -22,26 +27,26 @@ abstract class AbstractMultiPatternCustomAsset extends AbstractCustomAsset
|
|||
public function getPath(): string
|
||||
{
|
||||
$patterns = $this->getPatterns();
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach ($patterns as [$pattern, $encoder]) {
|
||||
$path = $this->getPathForPattern($pattern);
|
||||
if (is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getPathForPattern($patterns['default']);
|
||||
return $this->getPathForPattern($patterns['default'][0]);
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
foreach ($this->getPatterns() as $pattern) {
|
||||
foreach ($this->getPatterns() as [$pattern, $encoder]) {
|
||||
@unlink($this->getPathForPattern($pattern));
|
||||
}
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
foreach ($this->getPatterns() as $pattern) {
|
||||
foreach ($this->getPatterns() as [$pattern, $encoder]) {
|
||||
$path = $this->getPathForPattern($pattern);
|
||||
|
||||
if (is_file($path)) {
|
||||
|
|
|
@ -4,17 +4,28 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use Intervention\Image\Constraint;
|
||||
use Intervention\Image\Image;
|
||||
use Intervention\Image\Encoders\JpegEncoder;
|
||||
use Intervention\Image\Encoders\PngEncoder;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\Interfaces\ImageInterface;
|
||||
|
||||
final class AlbumArtCustomAsset extends AbstractMultiPatternCustomAsset
|
||||
{
|
||||
protected function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
'default' => 'album_art%s.jpg',
|
||||
'image/png' => 'album_art%s.png',
|
||||
'image/webp' => 'album_art%s.webp',
|
||||
'default' => [
|
||||
'album_art%s.jpg',
|
||||
new JpegEncoder(90),
|
||||
],
|
||||
'image/png' => [
|
||||
'album_art%s.png',
|
||||
new PngEncoder(),
|
||||
],
|
||||
'image/webp' => [
|
||||
'album_art%s.webp',
|
||||
new WebpEncoder(90),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -23,23 +34,19 @@ final class AlbumArtCustomAsset extends AbstractMultiPatternCustomAsset
|
|||
return $this->environment->getAssetUrl() . '/img/generic_song.jpg';
|
||||
}
|
||||
|
||||
public function upload(Image $image): void
|
||||
public function upload(ImageInterface $image, string $mimeType): void
|
||||
{
|
||||
$newImage = clone $image;
|
||||
$newImage->resize(1500, 1500, function (Constraint $constraint) {
|
||||
$constraint->upsize();
|
||||
});
|
||||
$newImage->resizeDown(1500, 1500);
|
||||
|
||||
$this->delete();
|
||||
|
||||
$patterns = $this->getPatterns();
|
||||
$mimeType = $newImage->mime();
|
||||
|
||||
$pattern = $patterns[$mimeType] ?? $patterns['default'];
|
||||
[$pattern, $encoder] = $patterns[$mimeType] ?? $patterns['default'];
|
||||
|
||||
$destPath = $this->getPathForPattern($pattern);
|
||||
$this->ensureDirectoryExists(dirname($destPath));
|
||||
|
||||
$newImage->save($destPath, 90);
|
||||
$newImage->encode($encoder)->save($destPath);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,17 +4,28 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use Intervention\Image\Constraint;
|
||||
use Intervention\Image\Image;
|
||||
use Intervention\Image\Encoders\JpegEncoder;
|
||||
use Intervention\Image\Encoders\PngEncoder;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\Interfaces\ImageInterface;
|
||||
|
||||
final class BackgroundCustomAsset extends AbstractMultiPatternCustomAsset
|
||||
{
|
||||
protected function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
'default' => 'background%s.jpg',
|
||||
'image/png' => 'background%s.png',
|
||||
'image/webp' => 'background%s.webp',
|
||||
'default' => [
|
||||
'background%s.jpg',
|
||||
new JpegEncoder(90),
|
||||
],
|
||||
'image/png' => [
|
||||
'background%s.png',
|
||||
new PngEncoder(),
|
||||
],
|
||||
'image/webp' => [
|
||||
'background%s.webp',
|
||||
new WebpEncoder(90),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -23,23 +34,19 @@ final class BackgroundCustomAsset extends AbstractMultiPatternCustomAsset
|
|||
return $this->environment->getAssetUrl() . '/img/hexbg.png';
|
||||
}
|
||||
|
||||
public function upload(Image $image): void
|
||||
public function upload(ImageInterface $image, string $mimeType): void
|
||||
{
|
||||
$newImage = clone $image;
|
||||
$newImage->resize(3264, 2160, function (Constraint $constraint) {
|
||||
$constraint->upsize();
|
||||
});
|
||||
$newImage->resizeDown(3264, 2160);
|
||||
|
||||
$this->delete();
|
||||
|
||||
$patterns = $this->getPatterns();
|
||||
$mimeType = $newImage->mime();
|
||||
|
||||
$pattern = $patterns[$mimeType] ?? $patterns['default'];
|
||||
[$pattern, $encoder] = $patterns[$mimeType] ?? $patterns['default'];
|
||||
|
||||
$destPath = $this->getPathForPattern($pattern);
|
||||
$this->ensureDirectoryExists(dirname($destPath));
|
||||
|
||||
$newImage->save($destPath, 90);
|
||||
$newImage->encode($encoder)->save($destPath);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use Intervention\Image\Image;
|
||||
use Intervention\Image\Interfaces\ImageInterface;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
final class BrowserIconCustomAsset extends AbstractCustomAsset
|
||||
|
@ -38,7 +38,7 @@ final class BrowserIconCustomAsset extends AbstractCustomAsset
|
|||
return $assetUrl . '/icons/' . $this->environment->getAppEnvironmentEnum()->value . '/original.png';
|
||||
}
|
||||
|
||||
public function upload(Image $image): void
|
||||
public function upload(ImageInterface $image, string $mimeType): void
|
||||
{
|
||||
$this->delete();
|
||||
|
||||
|
@ -47,12 +47,12 @@ final class BrowserIconCustomAsset extends AbstractCustomAsset
|
|||
|
||||
$newImage = clone $image;
|
||||
$newImage->resize(256, 256);
|
||||
$newImage->save($uploadsDir . '/original.png');
|
||||
$newImage->toPng()->save($uploadsDir . '/original.png');
|
||||
|
||||
foreach (self::ICON_SIZES as $iconSize) {
|
||||
$newImage = clone $image;
|
||||
$newImage->resize($iconSize, $iconSize);
|
||||
$newImage->save($uploadsDir . '/' . $iconSize . '.png');
|
||||
$newImage->toPng()->save($uploadsDir . '/' . $iconSize . '.png');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use Intervention\Image\Image;
|
||||
use Intervention\Image\Interfaces\ImageInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
interface CustomAssetInterface
|
||||
|
@ -19,7 +19,7 @@ interface CustomAssetInterface
|
|||
|
||||
public function getUri(): UriInterface;
|
||||
|
||||
public function upload(Image $image): void;
|
||||
public function upload(ImageInterface $image, string $mimeType): void;
|
||||
|
||||
public function delete(): void;
|
||||
}
|
||||
|
|
|
@ -172,9 +172,14 @@ final class Auth
|
|||
*
|
||||
* @param User $user
|
||||
*/
|
||||
public function setUser(User $user): void
|
||||
public function setUser(User $user, ?bool $isLoginComplete = null): void
|
||||
{
|
||||
$this->session->set(self::SESSION_IS_LOGIN_COMPLETE_KEY, null === $user->getTwoFactorSecret());
|
||||
$this->session->set(
|
||||
self::SESSION_IS_LOGIN_COMPLETE_KEY,
|
||||
(null !== $isLoginComplete)
|
||||
? $isLoginComplete
|
||||
: null === $user->getTwoFactorSecret()
|
||||
);
|
||||
$this->session->set(self::SESSION_USER_ID_KEY, $user->getId());
|
||||
$this->session->regenerate();
|
||||
|
||||
|
|
|
@ -60,7 +60,7 @@ abstract class AbstractDatabaseCommand extends CommandAbstract
|
|||
|
||||
$this->passThruProcess(
|
||||
$io,
|
||||
'mysqldump ' . implode(' ', $commandFlags) . ' $DB_DATABASE > $DB_DEST',
|
||||
'mariadb-dump ' . implode(' ', $commandFlags) . ' $DB_DATABASE > $DB_DEST',
|
||||
dirname($path),
|
||||
$commandEnvVars
|
||||
);
|
||||
|
@ -89,7 +89,7 @@ abstract class AbstractDatabaseCommand extends CommandAbstract
|
|||
|
||||
$this->passThruProcess(
|
||||
$io,
|
||||
'mysql ' . implode(' ', $commandFlags) . ' $DB_DATABASE < $DB_DUMP',
|
||||
'mariadb ' . implode(' ', $commandFlags) . ' $DB_DATABASE < $DB_DUMP',
|
||||
dirname($path),
|
||||
$commandEnvVars
|
||||
);
|
||||
|
|
|
@ -9,6 +9,7 @@ use App\Entity\Enums\StorageLocationTypes;
|
|||
use App\Entity\Repository\StorageLocationRepository;
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StorageLocation;
|
||||
use App\Utilities\Types;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
@ -45,16 +46,14 @@ final class BackupCommand extends AbstractDatabaseCommand
|
|||
$io = new SymfonyStyle($input, $output);
|
||||
$fsUtils = new Filesystem();
|
||||
|
||||
$path = $input->getArgument('path');
|
||||
$excludeMedia = (bool)$input->getOption('exclude-media');
|
||||
$storageLocationId = $input->getOption('storage-location-id');
|
||||
$path = Types::stringOrNull($input->getArgument('path'), true)
|
||||
?? 'manual_backup_' . gmdate('Ymd_Hi') . '.zip';
|
||||
|
||||
$excludeMedia = Types::bool($input->getOption('exclude-media'));
|
||||
$storageLocationId = Types::intOrNull($input->getOption('storage-location-id'));
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
if (empty($path)) {
|
||||
$path = 'manual_backup_' . gmdate('Ymd_Hi') . '.zip';
|
||||
}
|
||||
|
||||
$fileExt = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if (Path::isAbsolute($path)) {
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace App\Console\Command\Backup;
|
|||
|
||||
use App\Console\Command\AbstractDatabaseCommand;
|
||||
use App\Entity\StorageLocation;
|
||||
use App\Utilities\Types;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
@ -34,7 +35,7 @@ final class RestoreCommand extends AbstractDatabaseCommand
|
|||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$path = $input->getArgument('path');
|
||||
$path = Types::stringOrNull($input->getArgument('path'), true);
|
||||
$startTime = microtime(true);
|
||||
|
||||
$io->title('AzuraCast Restore');
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Console\Command\MessageQueue;
|
|||
use App\Console\Command\CommandAbstract;
|
||||
use App\MessageQueue\QueueManagerInterface;
|
||||
use App\MessageQueue\QueueNames;
|
||||
use App\Utilities\Types;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
@ -35,9 +36,9 @@ final class ClearCommand extends CommandAbstract
|
|||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$queueName = $input->getArgument('queue');
|
||||
$queueName = Types::stringOrNull($input->getArgument('queue'), true);
|
||||
|
||||
if (!empty($queueName)) {
|
||||
if (null !== $queueName) {
|
||||
$queue = QueueNames::tryFrom($queueName);
|
||||
|
||||
if (null !== $queue) {
|
||||
|
|
|
@ -13,6 +13,7 @@ use App\MessageQueue\LogWorkerExceptionSubscriber;
|
|||
use App\MessageQueue\QueueManagerInterface;
|
||||
use App\MessageQueue\ResetArrayCacheSubscriber;
|
||||
use App\Service\HighAvailability;
|
||||
use App\Utilities\Types;
|
||||
use Psr\Log\LogLevel;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
@ -55,8 +56,8 @@ final class ProcessCommand extends AbstractSyncCommand
|
|||
{
|
||||
$this->logToExtraFile('app_worker.log');
|
||||
|
||||
$runtime = (int)$input->getArgument('runtime');
|
||||
$workerName = $input->getOption('worker-name');
|
||||
$runtime = Types::int($input->getArgument('runtime'));
|
||||
$workerName = Types::stringOrNull($input->getOption('worker-name'), true);
|
||||
|
||||
if (!$this->highAvailability->isActiveServer()) {
|
||||
$this->logger->error('This instance is not the current active instance.');
|
||||
|
|
|
@ -8,6 +8,7 @@ use App\Container\EntityManagerAwareTrait;
|
|||
use App\Entity\Repository\StationRepository;
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Utilities\Types;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
@ -37,11 +38,11 @@ final class ReprocessMediaCommand extends CommandAbstract
|
|||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$stationName = $input->getArgument('station-name');
|
||||
$stationName = Types::stringOrNull($input->getArgument('station-name'), true);
|
||||
|
||||
$io->title('Manually Reprocess Media');
|
||||
|
||||
if (empty($stationName)) {
|
||||
if (null === $stationName) {
|
||||
$io->section('Reprocessing media for all stations...');
|
||||
|
||||
$storageLocation = null;
|
||||
|
|
|
@ -8,6 +8,7 @@ use App\Entity\Repository\StationRepository;
|
|||
use App\Entity\Station;
|
||||
use App\Nginx\Nginx;
|
||||
use App\Radio\Configuration;
|
||||
use App\Utilities\Types;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
@ -45,8 +46,8 @@ final class RestartRadioCommand extends CommandAbstract
|
|||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$stationName = $input->getArgument('station-name');
|
||||
$noSupervisorRestart = (bool)$input->getOption('no-supervisor-restart');
|
||||
$stationName = Types::stringOrNull($input->getArgument('station-name'));
|
||||
$noSupervisorRestart = Types::bool($input->getOption('no-supervisor-restart'));
|
||||
|
||||
if (!empty($stationName)) {
|
||||
$station = $this->stationRepo->findByIdentifier($stationName);
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Console\Command;
|
|||
use App\Container\ContainerAwareTrait;
|
||||
use App\Container\EnvironmentAwareTrait;
|
||||
use App\Entity\Attributes\StableMigration;
|
||||
use App\Utilities\Types;
|
||||
use Exception;
|
||||
use FilesystemIterator;
|
||||
use InvalidArgumentException;
|
||||
|
@ -44,7 +45,7 @@ final class RollbackDbCommand extends AbstractDatabaseCommand
|
|||
|
||||
// Pull migration corresponding to the stable version specified.
|
||||
try {
|
||||
$version = $input->getArgument('version');
|
||||
$version = Types::string($input->getArgument('version'));
|
||||
$migrationVersion = $this->findMigration($version);
|
||||
} catch (Throwable $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace App\Console\Command\Settings;
|
|||
|
||||
use App\Console\Command\CommandAbstract;
|
||||
use App\Container\SettingsAwareTrait;
|
||||
use App\Utilities\Types;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
@ -30,8 +31,8 @@ final class SetCommand extends CommandAbstract
|
|||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$settingKey = $input->getArgument('setting-key');
|
||||
$settingValue = $input->getArgument('setting-value');
|
||||
$settingKey = Types::string($input->getArgument('setting-key'));
|
||||
$settingValue = Types::string($input->getArgument('setting-value'));
|
||||
|
||||
$io->title('AzuraCast Settings');
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ use App\Entity\Repository\StationRepository;
|
|||
use App\Entity\Station;
|
||||
use App\Sync\NowPlaying\Task\BuildQueueTask;
|
||||
use App\Sync\NowPlaying\Task\NowPlayingTask;
|
||||
use App\Utilities\Types;
|
||||
use Monolog\LogRecord;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
@ -43,7 +44,7 @@ final class NowPlayingPerStationCommand extends AbstractSyncCommand
|
|||
$this->logToExtraFile('app_nowplaying.log');
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$stationName = $input->getArgument('station');
|
||||
$stationName = Types::string($input->getArgument('station'));
|
||||
|
||||
$station = $this->stationRepo->findByIdentifier($stationName);
|
||||
if (!($station instanceof Station)) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue