Merge commit '8d455f1c9bb73bc68055b2f54022405e5f25ad50' into stable

This commit is contained in:
Buster Neece 2024-01-04 19:17:45 -06:00
commit 0b4a848f3d
No known key found for this signature in database
412 changed files with 54640 additions and 79777 deletions

View File

@ -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

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
# Node Modules
# Frontend
node_modules
# Junk/cache files.

View File

@ -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

View File

@ -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"]

View File

@ -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,

2550
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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(),

View File

@ -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);
}
);
};

View File

@ -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);
};

View File

@ -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'
);
}
);

View File

@ -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);
}
);
};

View File

@ -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);
};

View File

@ -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));

View File

@ -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) {

View File

@ -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');

View File

@ -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)

View File

@ -1,5 +1,6 @@
services:
web:
image: ghcr.io/azuracast/azuracast:development
build:
context: .
target: development

View File

@ -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

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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');

View File

@ -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;
});

View File

@ -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>

View File

@ -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>

View File

@ -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;
});

View File

@ -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>

View File

@ -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);

View File

@ -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();

View File

@ -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>

View File

@ -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) => {

View File

@ -78,7 +78,7 @@ const doSendTest = () => {
}).then(() => {
notifySuccess($gettext('Test message sent.'));
}).finally(() => {
close();
hide();
});
});
};

View File

@ -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;
});

View File

@ -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>

View File

@ -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) => {

View File

@ -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']);

View File

@ -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"
>

View File

@ -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);

View File

@ -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);

View File

@ -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(() => {

View File

@ -96,7 +96,6 @@ const onUpdateDuration = (newValue: number) => {
};
const onUpdateCurrentTime = (newValue: number) => {
console.log(newValue);
currentTime.value = newValue;
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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({});

View File

@ -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);

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,15 @@
export default {
requestListUri: {
type: String,
required: true
},
showAlbumArt: {
type: Boolean,
default: true
},
customFields: {
type: Array,
required: false,
default: () => []
}
}

View File

@ -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;
}

View File

@ -6,10 +6,6 @@ export default {
type: Boolean,
default: true
},
hlsIsDefault: {
type: Boolean,
default: true
},
showAlbumArt: {
type: Boolean,
default: true

View File

@ -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;
});

View File

@ -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>

View File

@ -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 {

View File

@ -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');

View File

@ -99,7 +99,7 @@ const doMkdir = () => {
notifySuccess($gettext('New directory created.'));
}).finally(() => {
emit('relist');
close();
hide();
});
});
};

View File

@ -98,7 +98,7 @@ const doRename = () => {
file: file.value,
...form.value
}).finally(() => {
close();
hide();
emit('relist');
});
});

View File

@ -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
};
}

View File

@ -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();

View File

@ -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>

View File

@ -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: {

View File

@ -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"
>

View File

@ -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: {

View File

@ -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

View File

@ -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
};
}

View File

@ -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,

View File

@ -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
};
}

View File

@ -42,7 +42,7 @@ export function useVuelidateOnForm(validations = {}, blankForm = {}, options = {
}
const isValid = computed(() => {
return !v$.value.$invalid ?? true;
return !v$.value.$invalid;
});
const validate = () => {

View File

@ -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(() => {

View File

@ -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
};
}

View File

@ -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;

View File

@ -0,0 +1,5 @@
import initApp from "~/layout";
import useMinimalLayout from "~/layouts/MinimalLayout";
import Login from "~/components/Login.vue";
initApp(useMinimalLayout(Login));

View File

@ -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;

View File

@ -2,10 +2,6 @@ body.embed {
background: transparent !important;
color-scheme: unset;
min-height: auto;
&.ondemand {
overflow: hidden;
}
}
body.embed-social {

View File

@ -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;
}
}
}
}
}

View File

@ -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);

View File

@ -37,5 +37,8 @@
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": [
"node_modules"
]
}

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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');

View File

@ -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)) {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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;
}

View File

@ -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();

View File

@ -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
);

View File

@ -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)) {

View File

@ -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');

View File

@ -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) {

View File

@ -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.');

View File

@ -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;

View File

@ -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);

View File

@ -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());

View File

@ -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');

View File

@ -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