Merge commit '0b54c7e307109903515e64989202381cb5c523db' into stable

This commit is contained in:
Buster Neece 2024-02-20 04:45:09 -06:00
commit a22a6dcb17
No known key found for this signature in database
323 changed files with 55049 additions and 38374 deletions

View File

@ -37,12 +37,12 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
php-version: '8.3'
extensions: intl, xdebug
tools: composer:v2, cs2pr
- name: Cache PHP dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
@ -77,12 +77,12 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
php-version: '8.3'
extensions: intl, xdebug
tools: composer:v2, cs2pr
- name: Cache PHP dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
@ -135,7 +135,7 @@ jobs:
chmod 777 .gitinfo
- name: Upload built static assets and translations
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: assets
if-no-files-found: error
@ -158,7 +158,7 @@ jobs:
- uses: actions/checkout@master
- name: Download built static assets from previous step
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: assets
@ -166,13 +166,13 @@ jobs:
uses: depot/setup-action@v1
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -180,7 +180,7 @@ jobs:
- name: Build Docker Metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
azuracast/azuracast

View File

@ -11,6 +11,52 @@ release channel, you can take advantage of these new features and fixes.
---
# AzuraCast 0.19.5 (Feb 20, 2024)
## New Features/Changes
- **Redesigned Public Podcast Pages**: We've completely overhauled the public-facing podcasts pages to be a single
cohesive experience similar to our On-Demand streaming and other public pages.
- Podcasts and episodes are searchable, sortable and paginated, and the pages now use our built-in player for
playing back the podcasts themselves, ensuring uniform controls across browsers.
- Because it's built in our new frontend, you can also continue listening to your podcast episode as you navigate
around the podcast pages.
- The entire new podcast component is also now an embeddable widget in external pages.
- **Improved Listeners Report**: You can now search and sort through the listeners report, view several additional
fields supplying more information about your listeners, and filter your results to only show listeners with a certain
total connected time or using a certain kind of device.
- **Custom Bitrates**: When configuring Mount Points, Remote Relays, or broadcast recordings, you can now specify a
custom bitrate in kilobits per second (kbps) if you want to use a bitrate outside the default options.
- You can now submit a manual metadata update directly via the station profile page. This is useful in cases where the
metadata does not update correctly (i.e. from a live DJ).
## Code Quality/Technical Changes
- Our Docker image is now built directly on top of the official PHP image, which is powered by Debian Bookworm instead
of Ubuntu 22.04 (Jammy). For a majority of station operators, this change will not impact your station operations at
all, but if you specify custom packages to be installed on startup, you should make sure those packages exist in the
Debian repository as well.
- You can now specify a path when using the `azuracast:media:reprocess` command to only mark items starting with the
given (file or directory) path for reprocessing.
- The list of stations on the home dashboard is now paginated, searchable and sortable.
- The "Reorder" dialog for sequential playlists has been emphasized better and now has a "move to top" and "move to
bottom" button.
- The Upcoming Song Queue page automatically refreshes periodically.
## Bug Fixes
- We're continuing to work with Liquidsoap to resolve known issues relating to crackles and pops on crossfade
transitions and issues with metadata not updating on shorter tracks.
---
# AzuraCast 0.19.4 (Jan 4, 2024)
## New Features/Changes

View File

@ -1,7 +1,7 @@
#
# Golang dependencies build step
#
FROM golang:1.21-bullseye AS go-dependencies
FROM golang:1.21-bookworm AS go-dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl git
@ -10,7 +10,7 @@ RUN go install github.com/jwilder/dockerize@v0.6.1
RUN go install github.com/aptible/supercronic@v0.2.28
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.0
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.2
#
# MariaDB dependencies build step
@ -25,7 +25,7 @@ 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
FROM ghcr.io/azuracast/icecast-kh-ac:2024-02-13 AS icecast
#
# Roadrunner build step
@ -35,9 +35,16 @@ FROM ghcr.io/roadrunner-server/roadrunner:2023.3.8 AS roadrunner
#
# Final build image
#
FROM ubuntu:jammy AS pre-final
FROM php:8.3-fpm-bookworm AS pre-final
ENV TZ="UTC"
ENV TZ="UTC" \
LANGUAGE="en_US.UTF-8" \
LC_ALL="en_US.UTF-8" \
LANG="en_US.UTF-8" \
LC_TYPE="en_US.UTF-8"
# Add PHP extension installer tool
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
# Add Go dependencies
COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin
@ -47,8 +54,6 @@ 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

View File

@ -16,7 +16,7 @@ MYSQL_ROOT_PASSWORD=azur4c457
# Developer options.
# Populate these!
INIT_BASE_URL=http://azuracast.local
INIT_BASE_URL=https://azuracast.local
INIT_INSTANCE_NAME="local development"
INIT_DEMO_API_KEY=
INIT_ADMIN_EMAIL=

View File

@ -12,7 +12,7 @@ $environment = App\AppFactory::buildEnvironment();
$console = new Symfony\Component\Console\Application(
'AzuraCast installer',
App\Version::FALLBACK_VERSION
App\Version::STABLE_VERSION
);
$installCommand = new App\Installer\Command\InstallCommand();

View File

@ -10,7 +10,7 @@
}
],
"require": {
"php": "^8.2",
"php": "^8.3",
"ext-PDO": "*",
"ext-curl": "*",
"ext-ffi": "*",
@ -28,7 +28,7 @@
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"azuracast/nowplaying": "dev-main",
"beberlei/doctrineextensions": "^1.2",
"beberlei/doctrineextensions": "^1.4",
"br33f/php-ga4-mp": "^0.1.2",
"brick/math": "^0.11",
"composer/ca-bundle": "^1.2",
@ -57,7 +57,7 @@
"mezzio/mezzio-session-cache": "^1.7",
"monolog/monolog": "^3",
"myclabs/deep-copy": "^1.10",
"nesbot/carbon": "^2.36",
"nesbot/carbon": "^3",
"pagerfanta/doctrine-collections-adapter": "^4",
"pagerfanta/doctrine-orm-adapter": "^4",
"php-di/php-di": "^7.0.1",

1116
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
use App\Controller\Admin\IndexAction;
use App\Controller\AdminAction;
use App\Enums\GlobalPermissions;
use App\Middleware;
use Slim\Routing\RouteCollectorProxy;
@ -33,11 +33,11 @@ return static function (RouteCollectorProxy $app) {
];
foreach ($routes as $routeName => $routePath) {
$group->get($routePath, IndexAction::class)
$group->get($routePath, AdminAction::class)
->setName($routeName);
}
$group->get('/{routes:.+}', IndexAction::class);
$group->get('/{routes:.+}', AdminAction::class);
}
)->add(Middleware\Module\PanelLayout::class)
->add(Middleware\EnableView::class)

View File

@ -68,31 +68,67 @@ return static function (RouteCollectorProxy $group) {
$group->get(
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
Controller\Api\Stations\Art\GetArtAction::class
)->setName('api:stations:media:art');
)->setName('api:stations:media:art')
->add(new Middleware\Cache\SetStaticFileCache());
// Streamer Art
$group->get(
'/streamer/{id}/art[-{timestamp}.jpg]',
Controller\Api\Stations\Streamers\Art\GetArtAction::class
)->setName('api:stations:streamer:art');
)->setName('api:stations:streamer:art')
->add(new Middleware\Cache\SetStaticFileCache());
// Podcast and Episode Art
$group->group(
'/podcast/{podcast_id}',
'/public',
function (RouteCollectorProxy $group) {
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:podcast:art');
// Podcast Public Pages
$group->get('/podcasts', Controller\Api\Stations\Podcasts\ListPodcastsAction::class)
->setName('api:stations:public:podcasts');
$group->get(
'/episode/{episode_id}/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:podcast:episode:art');
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\Podcasts\GetPodcastAction::class)
->setName('api:stations:public:podcast');
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:public:podcast:art')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/episodes',
Controller\Api\Stations\Podcasts\Episodes\ListEpisodesAction::class
)->setName('api:stations:public:podcast:episodes');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\Podcasts\Episodes\GetEpisodeAction::class
)->setName('api:stations:public:podcast:episode')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:public:podcast:episode:art')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:public:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class)
->add(Middleware\GetAndRequirePodcast::class);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
);
}
)->add(new Middleware\Cache\SetStaticFileCache())
->add(Middleware\RequireStation::class)
)->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\NowPlayingAction::class . ':getAction');
$group->get('/nowplaying', Controller\Api\NowPlayingAction::class);
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)
->setName('api:stations:schedule');
@ -48,38 +48,7 @@ return static function (RouteCollectorProxy $group) {
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
->add(new Middleware\RateLimit('ondemand', 1, 2));
// Podcast Public Pages
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
// See ./api_public for podcast art.
$group->get(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
)->setName('api:stations:podcast:episodes');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
)->setName('api:stations:podcast:episode');
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
// NOTE: See ./api_public.php for media art public path.
// NOTE: See ./api_public.php for podcast public pages.
/*
* Authenticated Functions
@ -159,6 +128,9 @@ return static function (RouteCollectorProxy $group) {
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
$group->delete(
@ -166,16 +138,26 @@ return static function (RouteCollectorProxy $group) {
Controller\Api\Stations\PodcastsController::class . ':deleteAction'
);
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:podcast:art');
$group->post(
'/art',
Controller\Api\Stations\Podcasts\Art\PostArtAction::class
)->setName('api:stations:podcast:art-internal');
);
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
);
$group->get(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
)->setName('api:stations:podcast:episodes');
$group->post(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
@ -194,6 +176,11 @@ return static function (RouteCollectorProxy $group) {
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
)->setName('api:stations:podcast:episode');
$group->put(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
@ -205,20 +192,30 @@ return static function (RouteCollectorProxy $group) {
. ':deleteAction'
);
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:podcast:episode:art');
$group->post(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
)->setName('api:stations:podcast:episode:art-internal');
);
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
);
$group->get(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:media');
$group->post(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
)->setName('api:stations:podcast:episode:media-internal');
);
$group->delete(
'/media',
@ -227,7 +224,7 @@ return static function (RouteCollectorProxy $group) {
}
);
}
);
)->add(Middleware\GetAndRequirePodcast::class);
}
)->add(new Middleware\Permissions(StationPermissions::Podcasts, true));

View File

@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) {
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
->setName('public:schedule');
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class)
->setName('public:podcasts');
$routes = [
'public:podcasts' => '/podcasts',
'public:podcast' => '/podcast/{podcast_id}',
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
];
$group->get(
'/podcast/{podcast_id}/episodes',
Controller\Frontend\PublicPages\PodcastEpisodesAction::class
)
->setName('public:podcast:episodes');
$group->get(
'/podcast/{podcast_id}/episode/{episode_id}',
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
)
->setName('public:podcast:episode');
foreach ($routes as $routeName => $routePath) {
$group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
->setName($routeName);
}
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
->setName('public:podcast:feed');

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
use App\Controller\Stations\IndexAction;
use App\Controller\StationsAction;
use App\Enums\StationPermissions;
use App\Middleware;
use Slim\Routing\RouteCollectorProxy;
@ -23,6 +23,7 @@ return static function (RouteCollectorProxy $app) {
'stations:logs' => '/logs',
'stations:playlists:index' => '/playlists',
'stations:podcasts:index' => '/podcasts',
'stations:podcast:episodes' => '/podcast/{podcast_id}',
'stations:mounts:index' => '/mounts',
'stations:profile:index' => '/profile',
'stations:profile:edit' => '/profile/edit',
@ -40,11 +41,11 @@ return static function (RouteCollectorProxy $app) {
];
foreach ($routes as $routeName => $routePath) {
$group->get($routePath, IndexAction::class)
$group->get($routePath, StationsAction::class)
->setName($routeName);
}
$group->get('/{routes:.+}', IndexAction::class);
$group->get('/{routes:.+}', StationsAction::class);
}
)->add(Middleware\Module\PanelLayout::class)
->add(new Middleware\Permissions(StationPermissions::View, true))

View File

@ -155,7 +155,7 @@ return [
$cacheInterface = new Symfony\Component\Cache\Adapter\ArrayAdapter();
} elseif ($redisFactory->isSupported()) {
$cacheInterface = new Symfony\Component\Cache\Adapter\RedisAdapter(
$redisFactory->createInstance(),
$redisFactory->getInstance(),
marshaller: new Symfony\Component\Cache\Marshaller\DefaultMarshaller(
$environment->isProduction() ? null : false
)
@ -190,7 +190,7 @@ return [
Environment $environment,
App\Service\RedisFactory $redisFactory
) => ($redisFactory->isSupported())
? new Symfony\Component\Lock\Store\RedisStore($redisFactory->createInstance())
? new Symfony\Component\Lock\Store\RedisStore($redisFactory->getInstance())
: new Symfony\Component\Lock\Store\FlockStore($environment->getTempDirectory()),
// Console
@ -330,8 +330,13 @@ return [
// Register plugin-provided message queue receivers
$receivers = $plugins->registerMessageQueueReceivers($receivers);
/**
* @var class-string $messageClass
* @var class-string $handlerClass
*/
foreach ($receivers as $messageClass => $handlerClass) {
$handlers[$messageClass][] = static function ($message) use ($handlerClass, $di) {
/** @var callable $obj */
$obj = $di->get($handlerClass);
return $obj($message);
};

View File

@ -8,6 +8,9 @@ services:
- "127.0.0.1:3306:3306" # MariaDB
- "127.0.0.1:6025:6025" # Centrifugo
- "127.0.0.1:6379:6379" # Redis
environment:
VAR_DUMPER_FORMAT: server
VAR_DUMPER_SERVER: host.docker.internal:9912
volumes:
- $PWD/util/local_ssl/default.crt:/var/azuracast/storage/acme/ssl.crt:ro
- $PWD/util/local_ssl/default.key:/var/azuracast/storage/acme/ssl.key:ro

View File

@ -536,16 +536,8 @@ install-dev() {
.env --file .env set AZURACAST_PODMAN_MODE=true
fi
chmod 777 ./frontend/ ./web/ ./vendor/ \
./web/static/ ./web/static/api/ \
./web/static/dist/ ./web/static/img/
dc build
dc run --rm web -- azuracast_install "$@"
dc -p azuracast_frontend -f docker-compose.frontend.yml build
dc -p azuracast_frontend -f docker-compose.frontend.yml run --rm frontend npm run build
dc run --rm web -- azuracast_dev_install "$@"
dc up -d
exit
}

View File

@ -9,8 +9,8 @@ import * as url from 'url';
import {JSDOM} from "jsdom";
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
const outputPath = path.resolve(__dirname, './vue/components/Common/icons.ts');
const iconsPath = path.resolve(__dirname, './icons');
const outputPath = path.resolve(__dirname, './src/components/Common/icons.ts');
const iconsPath = path.resolve(__dirname, './src/icons');
const materialIconsViewBox = '0 -960 960 960';
const bootstrapIconsViewBox = '0 0 16 16';
@ -64,7 +64,7 @@ function genIconComponents() {
svgViewBox = `'${svgViewBox}'`;
}
const svgContents = svgInner.innerHTML.trim().replace(
const svgContents = svgInner.innerHTML.trim().replace("\n", "").replace(
' xmlns="http://www.w3.org/2000/svg"',
''
);

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
"scripts": {
"build": "vite build",
"serve": "vite",
"generate-icons": "node genicons.mjs",
"generate-locales": "vue-gettext-extract",
"generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client"
},
@ -42,7 +43,7 @@
"typescript": "^5.3.2",
"vue": "^3.2",
"vue-axios": "^3.5",
"vue-codemirror6": "^1",
"vue-codemirror6": "1.2.0",
"vue-easy-lightbox": "^1.16",
"vue-router": "^4.2.4",
"vue3-gettext": "3.0.0-beta.4",
@ -61,11 +62,11 @@
"@vitejs/plugin-vue": "^5",
"@vue/eslint-config-typescript": "^12",
"del": "^7",
"esbuild": "^0.19.9",
"esbuild": "^0.20",
"eslint": "^8.45.0",
"eslint-plugin-vue": "^9.8.0",
"glob": "^10.2.7",
"jsdom": "^23",
"jsdom": "^24",
"sass": "^1.39.2",
"svg.js": "^2.7.1",
"swagger-typescript-api": "^13.0.3",

View File

@ -151,7 +151,7 @@
<script setup lang="ts">
import Icon from "~/components/Common/Icon.vue";
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import AdminBackupsLastOutputModal from "./Backups/LastOutputModal.vue";
import formatFileSize from "~/functions/formatFileSize";
import AdminBackupsConfigureModal from "~/components/Admin/Backups/ConfigureModal.vue";
@ -196,7 +196,7 @@ const settings = ref({...blankSettings});
const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const {DateTime} = useLuxon();
const {DateTime, timestampToRelative} = useLuxon();
const fields: DataTableField[] = [
{
@ -250,8 +250,6 @@ const relist = () => {
onMounted(relist);
const {timestampToRelative} = useLuxon();
const $lastOutputModal = ref<InstanceType<typeof AdminBackupsLastOutputModal> | null>(null);
const showLastOutput = () => {
$lastOutputModal.value?.show();

View File

@ -28,7 +28,6 @@
class="col-md-6"
:field="v$.backup_time_code"
:label="$gettext('Scheduled Backup Time')"
:description="$gettext('If the end time is before the start time, the playlist will play overnight.')"
>
<template #default="slotProps">
<time-code

View File

@ -41,6 +41,14 @@
>
{{ $gettext('Clone') }}
</button>
<button
type="button"
class="btn btn-sm"
:class="(row.item.is_enabled) ? 'btn-warning' : 'btn-success'"
@click="doToggle(row.item)"
>
{{ (row.item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
</button>
<button
type="button"
class="btn btn-primary"
@ -74,7 +82,7 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import AdminStationsEditModal from "./Stations/EditModal.vue";
import {get} from "lodash";
import AdminStationsCloneModal from "./Stations/CloneModal.vue";
@ -89,6 +97,9 @@ import CardPage from "~/components/Common/CardPage.vue";
import {getApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import CloneModal from "~/components/Admin/Stations/CloneModal.vue";
import {useSweetAlert} from "~/vendor/sweetalert.ts";
import {useNotify} from "~/functions/useNotify.ts";
import {useAxios} from "~/vendor/axios.ts";
const props = defineProps({
...stationFormProps,
@ -149,6 +160,29 @@ const doClone = (stationName, url) => {
$cloneModal.value.create(stationName, url);
};
const {showAlert} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doToggle = (station) => {
const title = (station.is_enabled)
? $gettext('Disable station?')
: $gettext('Enable station?');
showAlert({
title: title
}).then((result) => {
if (result.value) {
axios.put(station.links.self, {
is_enabled: !station.is_enabled
}).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
const {doDelete} = useConfirmAndDelete(
$gettext('Delete Station?'),
relist

View File

@ -50,7 +50,7 @@
class="col-md-4"
:field="v$.backend_config.hls_segment_length"
input-type="number"
:input-attrs="{ min: '0', max: '60' }"
:input-attrs="{ min: '0', max: '9999' }"
advanced
:label="$gettext('Segment Length (Seconds)')"
/>

View File

@ -43,13 +43,10 @@
:label="$gettext('Live Broadcast Recording Format')"
/>
<form-group-multi-check
<bitrate-options
id="edit_form_backend_record_streams_bitrate"
class="col-md-6"
:field="v$.backend_config.record_streams_bitrate"
:options="recordBitrateOptions"
stacked
radio
:label="$gettext('Live Broadcast Recording Bitrate (kbps)')"
/>
</div>
@ -140,6 +137,7 @@ import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import {numeric} from "@vuelidate/validators";
import {useAzuraCast} from "~/vendor/azuracast";
import Tab from "~/components/Common/Tab.vue";
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
const props = defineProps({
form: {
@ -250,17 +248,4 @@ const recordStreamsOptions = computed(() => {
}
];
});
const recordBitrateOptions = computed(() => {
return [
{text: '32', value: 32},
{text: '48', value: 48},
{text: '64', value: 64},
{text: '96', value: 96},
{text: '128', value: 128},
{text: '192', value: 192},
{text: '256', value: 256},
{text: '320', value: 320}
];
});
</script>

View File

@ -1,7 +1,8 @@
import {getApiUrl} from "~/router.ts";
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
import {RouteRecordRaw} from "vue-router";
export default function useAdminRoutes() {
export default function useAdminRoutes(): RouteRecordRaw[] {
return [
{
path: '/',

View File

@ -0,0 +1,153 @@
<template>
<form-group
v-bind="$attrs"
:id="id"
>
<template
v-if="label || slots.label"
#label="slotProps"
>
<form-label
:is-required="isRequired"
:advanced="advanced"
>
<slot
name="label"
v-bind="slotProps"
>
{{ label }}
</slot>
</form-label>
</template>
<template #default>
<form-multi-check
:id="id"
v-model="radioField"
:name="name || id"
:options="bitrateOptions"
radio
stacked
>
<template
v-for="(_, slot) of useSlotsExcept(['default', 'label', 'description'])"
#[slot]="scope"
>
<slot
:name="slot"
v-bind="scope"
/>
</template>
<template #label(custom)>
{{ $gettext('Custom') }}
<input
:id="id+'_custom'"
v-model="customField"
class="form-control form-control-sm"
type="number"
min="1"
max="4096"
step="1"
>
</template>
</form-multi-check>
</template>
<template
v-if="description || slots.description"
#description="slotProps"
>
<slot
v-bind="slotProps"
name="description"
>
{{ description }}
</slot>
</template>
</form-group>
</template>
<script setup lang="ts">
import {formFieldProps, useFormField} from "~/components/Form/useFormField";
import {computed, ComputedRef, useSlots} from "vue";
import {includes, map} from "lodash";
import useSlotsExcept from "~/functions/useSlotsExcept.ts";
import FormMultiCheck from "~/components/Form/FormMultiCheck.vue";
import FormLabel from "~/components/Form/FormLabel.vue";
import FormGroup from "~/components/Form/FormGroup.vue";
const props = defineProps({
...formFieldProps,
id: {
type: String,
required: true
},
name: {
type: String,
default: null,
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
advanced: {
type: Boolean,
default: false
}
});
const slots = useSlots();
const emit = defineEmits(['update:modelValue']);
const {model} = useFormField(props, emit);
const radioBitrates = [
32, 48, 64, 96, 128, 192, 256, 320
];
const customField: ComputedRef<number | null> = computed({
get() {
return includes(radioBitrates, model.value)
? ''
: model.value;
},
set(newValue) {
model.value = newValue;
}
});
const radioField: ComputedRef<number | string | null> = computed({
get() {
return includes(radioBitrates, model.value)
? model.value
: 'custom';
},
set(newValue) {
if (newValue !== 'custom') {
model.value = newValue;
}
}
});
const bitrateOptions = map(
radioBitrates,
(val) => {
return {
value: val,
text: val
};
}
);
bitrateOptions.push({
value: 'custom',
text: 'Custom'
});
</script>

View File

@ -1,7 +1,7 @@
<template>
<div
:id="id"
style="display: contents"
class="datatable-wrapper"
>
<div
v-if="showToolbar"
@ -110,7 +110,7 @@
<div class="px-3 py-1">
<form-multi-check
id="field_select"
v-model="settings.visibleFieldKeys"
v-model="visibleFieldKeys"
:options="selectableFieldOptions"
stacked
/>
@ -184,7 +184,7 @@
</tr>
</thead>
<tbody>
<template v-if="isLoading">
<template v-if="isLoading && hideOnLoading">
<tr>
<td
:colspan="columnCount"
@ -247,6 +247,7 @@
:name="'cell('+column.key+')'"
:column="column"
:item="row"
:is-active="isActiveDetailRow(row)"
:toggle-details="() => toggleDetails(row)"
>
{{ getColumnValue(column, row) }}
@ -285,7 +286,7 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="Row extends object">
import {filter, forEach, get, includes, indexOf, isEmpty, map, reverse, slice, some} from 'lodash';
import Icon from './Icon.vue';
import {computed, onMounted, ref, shallowRef, toRaw, toRef, useSlots, watch} from "vue";
@ -298,71 +299,43 @@ import useOptionalStorage from "~/functions/useOptionalStorage";
import {IconArrowDropDown, IconArrowDropUp, IconFilterList, IconRefresh, IconSearch} from "~/components/Common/icons";
import {useAzuraCast} from "~/vendor/azuracast.ts";
const props = defineProps({
id: {
type: String,
default: null
},
apiUrl: {
type: String,
default: null
},
items: {
type: Array,
default: null
},
responsive: {
type: [Boolean, String],
default: true
},
paginated: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
showToolbar: {
type: Boolean,
default: true
},
pageOptions: {
type: Array<number>,
default: () => [10, 25, 50, 100, 250, 500, 0]
},
defaultPerPage: {
type: Number,
default: 10
},
fields: {
type: Array<DataTableField>,
required: true
},
selectable: {
type: Boolean,
default: false
},
detailed: {
type: Boolean,
default: false
},
selectFields: {
type: Boolean,
default: false
},
handleClientSide: {
type: Boolean,
default: false
},
requestConfig: {
type: Function,
default: null
},
requestProcess: {
type: Function,
default: null
}
export interface DataTableProps {
id?: string,
fields: DataTableField[],
apiUrl?: string, // URL to fetch for server-side data
items?: Row[], // Array of items for client-side data
responsive?: boolean | string, // Make table responsive (boolean or CSS class for specific responsiveness width)
paginated?: boolean, // Enable pagination.
loading?: boolean, // Pass to override the "loading" property for this table.
hideOnLoading?: boolean, // Replace the table contents with a loading animation when data is being retrieved.
showToolbar?: boolean, // Show the header "Toolbar" with search, refresh, per-page, etc.
pageOptions?: number[],
defaultPerPage?: number,
selectable?: boolean, // Allow selecting individual rows with checkboxes at the side of each row
detailed?: boolean, // Allow showing "Detail" panel for selected rows.
selectFields?: boolean, // Allow selecting which columns are visible.
handleClientSide?: boolean, // Handle searching, sorting and pagination client-side without API calls.
requestConfig?(config: object): object, // Custom server-side request configuration (pre-request)
requestProcess?(rawData: object[]): Row[], // Custom server-side request result processing (post-request)
}
const props = withDefaults(defineProps<DataTableProps>(), {
id: null,
apiUrl: null,
items: null,
responsive: () => true,
paginated: false,
loading: false,
hideOnLoading: true,
showToolbar: true,
pageOptions: () => [10, 25, 50, 100, 250, 500, 0],
defaultPerPage: 10,
selectable: false,
detailed: false,
selectFields: false,
handleClientSide: false,
requestConfig: undefined,
requestProcess: undefined
});
const slots = useSlots();
@ -375,7 +348,7 @@ const emit = defineEmits([
'data-loaded'
]);
const selectedRows = shallowRef([]);
const selectedRows = shallowRef<Row[]>([]);
const searchPhrase = ref<string>('');
const currentPage = ref<number>(1);
@ -390,10 +363,10 @@ watch(toRef(props, 'loading'), (newLoading: boolean) => {
isLoading.value = newLoading;
});
const visibleItems = shallowRef([]);
const visibleItems = shallowRef<Row[]>([]);
const totalRows = ref(0);
const activeDetailsRow = shallowRef(null);
const activeDetailsRow = shallowRef<Row>(null);
export interface DataTableField {
key: string,
@ -453,15 +426,25 @@ const settings = useOptionalStorage(
}
);
const visibleFieldKeys = computed(() => {
if (!isEmpty(settings.value.visibleFieldKeys)) {
return settings.value.visibleFieldKeys;
}
const visibleFieldKeys = computed({
get: () => {
const settingsKeys = toRaw(settings.value.visibleFieldKeys);
if (!isEmpty(settingsKeys)) {
return settingsKeys;
}
return map(defaultSelectableFields.value, (field) => field.key);
return map(defaultSelectableFields.value, (field) => field.key);
},
set: (newValue) => {
if (isEmpty(newValue)) {
newValue = map(defaultSelectableFields.value, (field) => field.key);
}
settings.value.visibleFieldKeys = newValue;
}
});
const perPage = computed(() => {
const perPage = computed<number>(() => {
if (!props.paginated) {
return -1;
}
@ -487,15 +470,15 @@ const visibleFields = computed<DataTableField[]>(() => {
});
});
const getPerPageLabel = (num) => {
const getPerPageLabel = (num): string => {
return (num === 0) ? 'All' : num.toString();
};
const perPageLabel = computed(() => {
const perPageLabel = computed<string>(() => {
return getPerPageLabel(perPage.value);
});
const showPagination = computed(() => {
const showPagination = computed<boolean>(() => {
return props.paginated && perPage.value !== 0;
});
@ -683,7 +666,7 @@ const isAllChecked = computed<boolean>(() => {
});
});
const isRowChecked = (row) => {
const isRowChecked = (row: Row) => {
return indexOf(selectedRows.value, row) >= 0;
};
@ -711,7 +694,7 @@ const sort = (column: DataTableField) => {
refresh();
};
const checkRow = (row) => {
const checkRow = (row: Row) => {
const newSelectedRows = selectedRows.value;
if (isRowChecked(row)) {
@ -740,11 +723,11 @@ const checkAll = () => {
selectedRows.value = newSelectedRows;
};
const isActiveDetailRow = (row) => {
const isActiveDetailRow = (row: Row) => {
return activeDetailsRow.value === row;
};
const toggleDetails = (row) => {
const toggleDetails = (row: Row) => {
activeDetailsRow.value = isActiveDetailRow(row)
? null
: row;
@ -758,7 +741,7 @@ const responsiveClass = computed(() => {
return (props.responsive ? 'table-responsive' : '');
});
const getColumnValue = (field: DataTableField, row: object): string => {
const getColumnValue = (field: DataTableField, row: Row): string => {
const columnValue = get(row, field.key, null);
return (field.formatter)

View File

@ -52,21 +52,37 @@ export const IconCheck: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M378-235 142-471l52-52 184 184 388-388 52 52-440 440Z"/>'
};
export const IconChevronBarDown: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 4.146a.5.5 0 0 1 .708 0L8 7.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 11.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5"/>'
};
export const IconChevronBarUp: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 11.854a.5.5 0 0 0 .708 0L8 8.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708M2.4 5.2c0 .22.18.4.4.4h10.4a.4.4 0 0 0 0-.8H2.8a.4.4 0 0 0-.4.4"/>'
};
export const IconChevronDoubleDown: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M1.646 6.646a.5.5 0 0 1 .708 0L8 12.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/> <path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" d="M1.646 2.646a.5.5 0 0 1 .708 0L8 8.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>'
};
export const IconChevronDoubleUp: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M7.646 2.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 3.707 2.354 9.354a.5.5 0 1 1-.708-.708z"/> <path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" d="M7.646 6.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 7.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>'
};
export const IconChevronDown: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/>'
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>'
};
export const IconChevronLeft: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/>'
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>'
};
export const IconChevronRight: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/>'
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>'
};
export const IconChevronUp: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/>'
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>'
};
export const IconClearAll: Icon = {
viewBox: materialIconsViewBox,
@ -80,6 +96,10 @@ export const IconCode: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M318-232 68-483l253-253 52 53-200 200 198 198-53 53Zm322 2-52-53 201-201-198-198 51-51 251 250-253 253Z"/>'
};
export const IconContract: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 14.854a.5.5 0 0 0 .708 0L8 11.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708m0-13.708a.5.5 0 0 1 .708 0L8 4.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8"/>'
};
export const IconCopy: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M305-185q-29.725 0-51.363-22.137Q232-229.275 232-258v-564q0-28.725 21.637-50.862Q275.275-895 305-895h444q28.725 0 50.862 22.138Q822-850.725 822-822v564q0 28.725-22.138 50.863Q777.725-185 749-185H305ZM172-52q-29.725 0-51.363-22.138Q99-96.275 99-125v-637h73v637h517v73H172Z"/>'
@ -112,6 +132,10 @@ export const IconExitToApp: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M177-104q-28.725 0-50.863-22.137Q104-148.275 104-177v-194h73v194h606v-606H177v193h-73v-193q0-28.725 22.137-50.862Q148.275-856 177-856h606q28.725 0 50.862 22.138Q856-811.725 856-783v606q0 28.725-22.138 50.863Q811.725-104 783-104H177Zm235-166-54-56 118-118H104v-73h372L358-635l54-55 209 209-209 211Z"/>'
};
export const IconExpand: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708m0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708"/>'
};
export const IconFastForward: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M78-221v-518l375 259L78-221Zm431 0v-518l375 259-375 259Z"/>'
@ -260,6 +284,10 @@ export const IconRouter: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M160-84q-28.725 0-50.863-21.637Q87-127.275 87-157v-209q0-28.725 22.137-50.862Q131.275-439 160-439h460v-178h73v178h99q28.725 0 50.862 22.138Q865-394.725 865-366v209q0 29.725-22.138 51.363Q820.725-84 792-84H160Zm142-177.105Q302-280 288.895-293q-13.106-13-32-13Q238-306 225-292.895q-13 13.106-13 32Q212-241 225.105-228.5q13.106 12.5 32 12.5Q276-216 289-228.605q13-12.606 13-32.5Zm150 0Q452-280 438.895-293q-13.106-13-32-13Q387-306 374.5-292.895q-12.5 13.106-12.5 32Q362-241 374.605-228.5q12.606 12.5 32.5 12.5Q426-216 439-228.605q13-12.606 13-32.5ZM556.105-216Q575-216 588-228.605q13-12.606 13-32.5Q601-280 587.895-293q-13.106-13-32-13Q537-306 524-292.895q-13 13.106-13 32Q511-241 524.105-228.5q13.106 12.5 32 12.5ZM566-664l-50-50q28-27 62.989-43.5 34.988-16.5 78.068-16.5 42.08 0 77.012 16.5Q769-741 797-714l-49 50q-17-16-40.409-26.5t-50-10.5Q630-701 606-690.5T566-664ZM466-764l-53-53q39.2-39.638 103.1-68.819Q580-915 657-915q76 0 139.5 29T901-817l-54 53q-31-33-79.5-55.5T657-842q-63 0-111 22.5T466-764Z"/>'
};
export const IconRss: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1 0-2m0 4a6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1 0-2m.5 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>'
};
export const IconSearch: Icon = {
viewBox: materialIconsViewBox,
contents: '<path d="M801-108 537-372q-31 26-72.959 40t-86.603 14q-114.6 0-192.519-78Q107-474 107-586t78-190q78-78 190-78t190 78q78 78 78 190.15 0 43.85-13.5 84.35Q616-461 588-425l266 264-53 53ZM375.5-391q81.75 0 138.125-56.792Q570-504.583 570-586t-56.287-138.208Q457.426-781 375.588-781q-82.671 0-139.13 56.792Q180-667.417 180-586t56.458 138.208Q292.917-391 375.5-391Z"/>'

View File

@ -143,126 +143,98 @@
</div>
</template>
<loading :loading="stationsLoading">
<div class="table-responsive">
<table
id="station_dashboard"
class="table table-striped"
>
<colgroup>
<col width="5%">
<col width="30%">
<col width="10%">
<col width="40%">
<col width="15%">
</colgroup>
<thead>
<tr>
<th class="pe-3">
&nbsp;
</th>
<th class="ps-2">
{{ $gettext('Station Name') }}
</th>
<th class="text-center">
{{ $gettext('Listeners') }}
</th>
<th>{{ $gettext('Now Playing') }}</th>
<th class="text-end" />
</tr>
</thead>
<tbody>
<tr
v-for="item in stations"
:key="item.station.id"
class="align-middle"
>
<td class="text-center pe-1">
<play-button
class="file-icon btn-lg"
:url="item.station.listen_url"
is-stream
/>
</td>
<td class="ps-2">
<div class="h5 m-0">
{{ item.station.name }}
</div>
<div v-if="item.station.is_public">
<a
:href="item.links.public"
target="_blank"
>
{{ $gettext('Public Page') }}
</a>
</div>
</td>
<td class="text-center">
<span class="pe-1">
<icon
class="sm align-middle"
:icon="IconHeadphones"
/>
</span>
<template v-if="item.links.listeners">
<a
:href="item.links.listeners"
:aria-label="$gettext('View Listener Report')"
>
{{ item.listeners.total }}
</a>
</template>
<template v-else>
{{ item.listeners.total }}
</template>
</td>
<td>
<div class="d-flex align-items-center">
<album-art
v-if="showAlbumArt"
:src="item.now_playing.song.art"
class="flex-shrink-0 pe-3"
/>
<data-table
id="dashboard_stations"
ref="$datatable"
:fields="stationFields"
:api-url="stationsUrl"
paginated
responsive
show-toolbar
:hide-on-loading="false"
>
<template #cell(play_button)="{ item }">
<play-button
class="file-icon btn-lg"
:url="item.station.listen_url"
is-stream
/>
</template>
<template #cell(name)="{ item }">
<div class="h5 m-0">
{{ item.station.name }}
</div>
<div v-if="item.station.is_public">
<a
:href="item.links.public"
target="_blank"
>
{{ $gettext('Public Page') }}
</a>
</div>
</template>
<template #cell(listeners)="{ item }">
<span class="pe-1">
<icon
class="sm align-middle"
:icon="IconHeadphones"
/>
</span>
<template v-if="item.links.listeners">
<a
:href="item.links.listeners"
:aria-label="$gettext('View Listener Report')"
>
{{ item.listeners.total }}
</a>
</template>
<template v-else>
{{ item.listeners.total }}
</template>
</template>
<template #cell(now_playing)="{ item }">
<div class="d-flex align-items-center">
<album-art
v-if="showAlbumArt"
:src="item.now_playing.song.art"
class="flex-shrink-0 pe-3"
/>
<div
v-if="!item.is_online"
class="flex-fill text-muted"
>
{{ $gettext('Station Offline') }}
</div>
<div
v-else-if="item.now_playing.song.title !== ''"
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.title }}
</span></strong><br>
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
</div>
<div
v-else
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.text }}
</span></strong>
</div>
</div>
</td>
<td class="text-end">
<a
class="btn btn-primary"
:href="item.links.manage"
role="button"
>
{{ $gettext('Manage') }}
</a>
</td>
</tr>
</tbody>
</table>
</div>
</loading>
<div
v-if="!item.is_online"
class="flex-fill text-muted"
>
{{ $gettext('Station Offline') }}
</div>
<div
v-else-if="item.now_playing.song.title !== ''"
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.title }}
</span></strong><br>
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
</div>
<div
v-else
class="flex-fill"
>
<strong><span class="nowplaying-title">
{{ item.now_playing.song.text }}
</span></strong>
</div>
</div>
</template>
<template #cell(actions)="{ item }">
<a
class="btn btn-primary"
:href="item.links.manage"
role="button"
>
{{ $gettext('Manage') }}
</a>
</template>
</data-table>
</card-page>
</div>
@ -276,11 +248,10 @@ import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import {useAxios} from "~/vendor/axios";
import {useAsyncState} from "@vueuse/core";
import {useAsyncState, useIntervalFn} from "@vueuse/core";
import {computed, ref} from "vue";
import DashboardCharts from "~/components/DashboardCharts.vue";
import {useTranslate} from "~/vendor/gettext";
import Loading from "~/components/Common/Loading.vue";
import Lightbox from "~/components/Common/Lightbox.vue";
import CardPage from "~/components/Common/CardPage.vue";
import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
@ -289,7 +260,8 @@ 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";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
const props = defineProps({
profileUrl: {
@ -332,19 +304,47 @@ const langShowHideCharts = computed(() => {
: $gettext('Show Charts')
});
const {axios, axiosSilent} = useAxios();
const {axios} = useAxios();
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
() => axios.get(notificationsUrl.value).then((r) => r.data),
[]
);
const {state: stations, isLoading: stationsLoading} = useAutoRefreshingAsyncState(
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
[],
const stationFields: DataTableField[] = [
{
timeout: 15000
key: 'play_button',
sortable: false,
class: 'shrink'
},
{
key: 'name',
label: $gettext('Station Name'),
sortable: true,
},
{
key: 'listeners',
label: $gettext('Listeners'),
sortable: true
},
{
key: 'now_playing',
label: $gettext('Now Playing'),
sortable: true
},
{
key: 'actions',
sortable: false,
class: 'shrink'
}
];
const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
useIntervalFn(
refresh,
computed(() => (document.hidden) ? 30000 : 15000)
);
const $lightbox = ref<LightboxTemplateRef>(null);

View File

@ -19,7 +19,7 @@
class="form-check-label"
:for="id+'_'+option.value"
>
<slot :name="`label(${option.value})`">
<slot :name="'label('+option.value+')'">
<template v-if="option.description">
<strong>{{ option.text }}</strong>
<br>

View File

@ -0,0 +1,32 @@
<template>
<section
id="content"
class="full-height-wrapper"
role="main"
>
<div class="container">
<div class="card">
<div class="card-header text-bg-primary">
<slot name="header">
<h2 class="card-title py-2">
<slot name="title" />
</h2>
</slot>
</div>
<slot name="default" />
</div>
</div>
</section>
<lightbox ref="$lightbox" />
</template>
<script setup lang="ts">
import Lightbox from "~/components/Common/Lightbox.vue";
import {ref} from "vue";
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox.ts";
const $lightbox = ref<LightboxTemplateRef>(null);
useProvideLightbox($lightbox);
</script>

View File

@ -1,70 +1,62 @@
<template>
<section
id="content"
class="full-height-wrapper"
role="main"
>
<div class="container">
<div class="card">
<div class="card-header text-bg-primary">
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<template v-if="stationName">
{{ stationName }}
</template>
<template v-else>
{{ $gettext('On-Demand Media') }}
</template>
</h2>
</div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div>
</div>
<data-table
id="public_on_demand"
ref="datatable"
paginated
select-fields
:fields="fields"
:api-url="listUrl"
>
<template #cell(download_url)="row">
<play-button
class="btn-lg"
:url="row.item.download_url"
/>
<template v-if="showDownloadButton">
<a
class="name btn btn-lg p-0 ms-2"
:href="row.item.download_url"
target="_blank"
:title="$gettext('Download')"
>
<icon :icon="IconDownload" />
</a>
</template>
</template>
<template #cell(art)="row">
<album-art :src="row.item.media.art" />
</template>
<template #cell(size)="row">
<template v-if="!row.item.size">
&nbsp;
<full-height-card>
<template #header>
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<template v-if="stationName">
{{ stationName }}
</template>
<template v-else>
{{ formatFileSize(row.item.size) }}
{{ $gettext('On-Demand Media') }}
</template>
</template>
</data-table>
</h2>
</div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div>
</div>
</section>
</template>
<lightbox ref="$lightbox" />
<template #default>
<data-table
id="public_on_demand"
ref="datatable"
paginated
select-fields
:fields="fields"
:api-url="listUrl"
>
<template #cell(download_url)="row">
<play-button
class="btn-lg"
:url="row.item.download_url"
/>
<template v-if="showDownloadButton">
<a
class="name btn btn-lg p-0 ms-2"
:href="row.item.download_url"
target="_blank"
:title="$gettext('Download')"
>
<icon :icon="IconDownload" />
</a>
</template>
</template>
<template #cell(art)="row">
<album-art :src="row.item.media.art" />
</template>
<template #cell(size)="row">
<template v-if="!row.item.size">
&nbsp;
</template>
<template v-else>
{{ formatFileSize(row.item.size) }}
</template>
</template>
</data-table>
</template>
</full-height-card>
</template>
<script setup lang="ts">
@ -74,12 +66,9 @@ import {forEach} from 'lodash';
import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue";
import {useTranslate} from "~/vendor/gettext";
import formatFileSize from "../../functions/formatFileSize";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import Lightbox from "~/components/Common/Lightbox.vue";
import {ref} from "vue";
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox";
import {IconDownload} from "~/components/Common/icons";
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
const props = defineProps({
listUrl: {
@ -141,7 +130,4 @@ forEach(props.customFields.slice(), (field) => {
formatter: (_value, _key, item) => item.media.custom_fields[field.key]
});
});
const $lightbox = ref<LightboxTemplateRef>(null);
useProvideLightbox($lightbox);
</script>

View File

@ -0,0 +1,170 @@
<template>
<div class="full-height-scrollable">
<loading
:loading="isLoading"
lazy
>
<div class="card-body">
<div class="d-flex">
<div class="flex-fill">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcasts'}">
{{ $gettext('Podcasts') }}
</router-link>
</li>
<li class="breadcrumb-item">
{{ podcast.title }}
</li>
</ol>
</nav>
<h4 class="card-title mb-1">
{{ podcast.title }}
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+podcast.email"
target="_blank"
>{{ podcast.author }}</a>
</small>
</h4>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ podcast.language_name }}
</span>
<span
v-for="category in podcast.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ podcast.description }}
</p>
<div class="buttons">
<a
class="btn btn-warning btn-sm"
:href="podcast.links.public_feed"
target="_blank"
>
<icon :icon="IconRss" />
{{ $gettext('RSS') }}
</a>
</div>
</div>
<div class="flex-shrink ps-3">
<album-art
:src="podcast.art"
:width="128"
/>
</div>
</div>
</div>
</loading>
<data-table
id="podcast-episodes"
ref="$datatable"
paginated
:fields="fields"
:api-url="episodesUrl"
>
<template #cell(play_button)="{item}">
<play-button
icon-class="lg"
:url="item.links.download"
/>
</template>
<template #cell(art)="{item}">
<album-art
:src="item.art"
:width="64"
/>
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
<router-link
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
>
{{ item.title }}
</router-link>
</h5>
<div class="badges my-2">
<span
v-if="item.publish_at"
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(item.publish_at) }}
</span>
<span
v-else
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(item.created_at) }}
</span>
<span
v-if="item.explicit"
class="badge text-bg-danger"
>
{{ $gettext('Explicit') }}
</span>
</div>
<p class="card-text">
{{ item.description_short }}
</p>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<router-link
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
class="btn btn-primary"
>
{{ $gettext('Details') }}
</router-link>
</div>
</template>
</data-table>
</div>
</template>
<script setup lang="ts">
import {getStationApiUrl} from "~/router.ts";
import {useRoute} from "vue-router";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
import {useAxios} from "~/vendor/axios.ts";
import Loading from "~/components/Common/Loading.vue";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
import PlayButton from "~/components/Common/PlayButton.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading} = useRefreshableAsyncState(
() => axios.get(podcastUrl.value).then((r) => r.data),
{},
);
const episodesUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episodes`);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'play_button', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Episode'), sortable: true},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
</script>

View File

@ -0,0 +1,148 @@
<template>
<div class="full-height-scrollable">
<div class="card-body">
<loading
:loading="podcastLoading || episodeLoading"
lazy
>
<nav aria-label="breadcrumb">
<ol class="breadcrumb m-0">
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcasts'}">
{{ $gettext('Podcasts') }}
</router-link>
</li>
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcast', params: {podcast_id: podcast.id}}">
{{ podcast.title }}
</router-link>
</li>
<li class="breadcrumb-item">
{{ episode.title }}
</li>
</ol>
</nav>
</loading>
</div>
<div
class="card-body alert alert-secondary"
aria-live="polite"
>
<loading
:loading="podcastLoading"
lazy
>
<h4 class="card-title mb-1">
{{ podcast.title }}
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+podcast.email"
class="alert-link"
target="_blank"
>{{ podcast.author }}</a>
</small>
</h4>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ podcast.language_name }}
</span>
<span
v-for="category in podcast.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ podcast.description }}
</p>
</loading>
</div>
<div class="card-body">
<loading
:loading="episodeLoading"
lazy
>
<div class="d-flex">
<div class="flex-shrink-0 pe-3">
<play-button
icon-class="xl"
:url="episode.links.download"
/>
</div>
<div class="flex-fill">
<h4 class="card-title mb-1">
{{ episode.title }}
</h4>
<div class="badges my-2">
<span
v-if="episode.publish_at"
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(episode.publish_at) }}
</span>
<span
v-else
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(episode.created_at) }}
</span>
<span
v-if="episode.explicit"
class="badge text-bg-danger"
>
{{ $gettext('Explicit') }}
</span>
</div>
<p class="card-text">
{{ episode.description }}
</p>
</div>
<div class="flex-shrink-0 ps-3">
<album-art
:src="episode.art"
:width="96"
/>
</div>
</div>
</loading>
</div>
</div>
</template>
<script setup lang="ts">
import Loading from "~/components/Common/Loading.vue";
import {useRoute} from "vue-router";
import {getStationApiUrl} from "~/router.ts";
import {useAxios} from "~/vendor/axios.ts";
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import PlayButton from "~/components/Common/PlayButton.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
const episodeUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episode/${params.episode_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading: podcastLoading} = useRefreshableAsyncState(
() => axios.get(podcastUrl.value).then((r) => r.data),
{},
);
const {state: episode, isLoading: episodeLoading} = useRefreshableAsyncState(
() => axios.get(episodeUrl.value).then((r) => r.data),
{},
);
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
</script>

View File

@ -0,0 +1,85 @@
<template>
<data-table
id="podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="apiUrl"
>
<template #cell(art)="{item}">
<album-art
:src="item.art"
:width="96"
/>
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
<router-link
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
>
{{ item.title }}
</router-link>
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+item.email"
target="_blank"
>{{ item.author }}</a>
</small>
</h5>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ item.language_name }}
</span>
<span
v-for="category in item.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ item.description_short }}
</p>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<router-link
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
class="btn btn-primary"
>
{{ $gettext('Episodes') }}
</router-link>
<a
class="btn btn-warning"
:href="item.links.public_feed"
target="_blank"
>
<icon :icon="IconRss" />
{{ $gettext('RSS') }}
</a>
</div>
</template>
</data-table>
</template>
<script setup lang="ts">
import AlbumArt from "~/components/Common/AlbumArt.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import {getStationApiUrl} from "~/router.ts";
import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const apiUrl = getStationApiUrl('/public/podcasts');
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: true},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
</script>

View File

@ -0,0 +1,32 @@
<template>
<minimal-layout>
<full-height-card>
<template #header>
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<slot name="title">
{{ name }}
</slot>
</h2>
</div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div>
</template>
<template #default>
<router-view />
</template>
</full-height-card>
</minimal-layout>
</template>
<script setup lang="ts">
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
import InlinePlayer from "~/components/InlinePlayer.vue";
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
import MinimalLayout from "~/components/MinimalLayout.vue";
const {name} = useAzuraCastStation();
</script>

View File

@ -0,0 +1,21 @@
import {RouteRecordRaw} from "vue-router";
export default function usePodcastRoutes(): RouteRecordRaw[] {
return [
{
path: '/podcasts',
component: () => import('~/components/Public/Podcasts/PodcastList.vue'),
name: 'public:podcasts'
},
{
path: '/podcast/:podcast_id',
component: () => import('~/components/Public/Podcasts/Podcast.vue'),
name: 'public:podcast'
},
{
path: '/podcast/:podcast_id/episode/:episode_id',
component: () => import('~/components/Public/Podcasts/PodcastEpisode.vue'),
name: 'public:podcast:episode'
}
];
}

View File

@ -1,41 +1,30 @@
<template>
<section
id="content"
class="full-height-wrapper"
role="main"
>
<div class="container">
<div class="card">
<div class="card-header text-bg-primary">
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<template v-if="stationName">
{{ stationName }}
</template>
<template v-else>
{{ $gettext('Schedule') }}
</template>
</h2>
</div>
</div>
</div>
<div id="station-schedule-calendar">
<schedule
ref="schedule"
:timezone="stationTimeZone"
:schedule-url="scheduleUrl"
:station-time-zone="stationTimeZone"
/>
</div>
<full-height-card>
<template #title>
<template v-if="stationName">
{{ stationName }}
</template>
<template v-else>
{{ $gettext('Schedule') }}
</template>
</template>
<template #default>
<div id="station-schedule-calendar">
<schedule
ref="schedule"
:timezone="stationTimeZone"
:schedule-url="scheduleUrl"
:station-time-zone="stationTimeZone"
/>
</div>
</div>
</section>
</template>
</full-height-card>
</template>
<script setup lang="ts">
import Schedule from '~/components/Common/ScheduleView.vue';
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
const props = defineProps({
scheduleUrl: {

View File

@ -241,7 +241,7 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import MediaToolbar from './Media/MediaToolbar.vue';
import Breadcrumb from './Media/Breadcrumb.vue';
import FileUpload from './Media/FileUpload.vue';
@ -256,14 +256,13 @@ import PlayButton from "~/components/Common/PlayButton.vue";
import {useTranslate} from "~/vendor/gettext";
import {computed, ref, watch} from "vue";
import {forEach, map, partition} from "lodash";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import formatFileSize from "../../functions/formatFileSize";
import InfoCard from "~/components/Common/InfoCard.vue";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router";
import {useRoute, useRouter} from "vue-router";
import {IconFile, IconFolder, IconImage} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const props = defineProps({
initialPlaylists: {
@ -300,9 +299,8 @@ const renameUrl = getStationApiUrl('/files/rename');
const quotaUrl = getStationApiUrl('/quota/station_media');
const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const fields = computed<DataTableField[]>(() => {
const fields: DataTableField[] = [
@ -337,15 +335,7 @@ const fields = computed<DataTableField[]>(() => {
key: 'timestamp',
label: $gettext('Modified'),
sortable: true,
formatter: (value) => {
if (!value) {
return '';
}
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
},
formatter: (value) => formatTimestampAsDateTime(value),
selectable: true,
visible: true
},

View File

@ -37,7 +37,6 @@
<script setup lang="ts">
import {ref, toRef, watch} from "vue";
import {syncRef} from "@vueuse/core";
import {useAxios} from "~/vendor/axios";
import FormGroup from "~/components/Form/FormGroup.vue";
import FormFile from "~/components/Form/FormFile.vue";
@ -49,9 +48,7 @@ const props = defineProps({
}
});
const albumArtSrc = ref(null);
syncRef(toRef(props, 'albumArtUrl'), albumArtSrc, {direction: 'ltr'});
const albumArtSrc = ref(props.albumArtUrl);
const reloadArt = () => {
albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
}

View File

@ -27,14 +27,11 @@
:label="$gettext('AutoDJ Format')"
/>
<form-group-multi-check
<bitrate-options
v-if="formatSupportsBitrateOptions"
id="edit_form_autodj_bitrate"
class="col-md-6"
:field="v$.autodj_bitrate"
:options="bitrateOptions"
stacked
radio
:label="$gettext('AutoDJ Bitrate (kbps)')"
/>
</div>
@ -43,12 +40,12 @@
<script setup lang="ts">
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {map} from "lodash";
import {computed} from "vue";
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import Tab from "~/components/Common/Tab.vue";
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
const props = defineProps({
form: {
@ -101,17 +98,7 @@ const formatOptions = [
}
];
const bitrateOptions = map(
[32, 48, 64, 96, 128, 192, 256, 320],
(val) => {
return {
value: val,
text: val
};
}
);
const formatSupportsBitrateOptions = computed(() => {
return (props.form.autodj_format.$model !== 'flac');
return (props.form.autodj_format !== 'flac');
});
</script>

View File

@ -45,38 +45,12 @@
:api-url="listUrl"
detailed
>
<template #cell(actions)="{ item, toggleDetails }">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
class="btn btn-sm btn-secondary"
type="button"
@click="toggleDetails()"
>
{{ $gettext('More') }}
</button>
</div>
</template>
<template #cell(name)="row">
<h5 class="m-0">
{{ row.item.name }}
</h5>
<div>
<span class="badge text-bg-secondary me-1">
<div class="badges">
<span class="badge text-bg-secondary">
<template v-if="row.item.source === 'songs'">
{{ $gettext('Song-based') }}
</template>
@ -86,31 +60,31 @@
</span>
<span
v-if="row.item.is_jingle"
class="badge text-bg-primary me-1"
class="badge text-bg-primary"
>
{{ $gettext('Jingle Mode') }}
</span>
<span
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
class="badge text-bg-info me-1"
class="badge text-bg-info"
>
{{ $gettext('Sequential') }}
</span>
<span
v-if="row.item.include_in_on_demand"
class="badge text-bg-info me-1"
class="badge text-bg-info"
>
{{ $gettext('On-Demand') }}
</span>
<span
v-if="row.item.include_in_automation"
class="badge text-bg-success me-1"
v-if="row.item.schedule_items.length > 0"
class="badge text-bg-info"
>
{{ $gettext('Auto-Assigned') }}
{{ $gettext('Scheduled') }}
</span>
<span
v-if="!row.item.is_enabled"
class="badge text-bg-danger me-1"
class="badge text-bg-danger"
>
{{ $gettext('Disabled') }}
</span>
@ -171,18 +145,54 @@
&nbsp;
</template>
</template>
<template #cell(actions)="{ item, isActive, toggleDetails }">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
class="btn btn-sm btn-secondary"
type="button"
@click="toggleDetails()"
>
<icon :icon="isActive ? IconContract : IconExpand" />
{{ $gettext('More') }}
</button>
</div>
</template>
<template #detail="{ item }">
<div
class="buttons"
style="line-height: 2.5;"
>
<button
v-if="item.links.order"
type="button"
class="btn btn-sm btn-primary"
@click="doReorder(item.links.order)"
>
{{ $gettext('Reorder') }}
</button>
<button
type="button"
class="btn btn-sm"
:class="toggleButtonClass(item)"
:class="(item.is_enabled) ? 'btn-warning' : 'btn-success'"
@click="doModify(item.links.toggle)"
>
{{ langToggleButton(item) }}
{{ (item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
</button>
<button
v-if="item.links.empty"
@ -208,14 +218,6 @@
>
{{ $gettext('Import from PLS/M3U') }}
</button>
<button
v-if="item.links.order"
type="button"
class="btn btn-sm btn-secondary"
@click="doReorder(item.links.order)"
>
{{ $gettext('Reorder') }}
</button>
<button
v-if="item.links.queue"
type="button"
@ -300,7 +302,7 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import Schedule from '~/components/Common/ScheduleView.vue';
import EditModal from './Playlists/EditModal.vue';
import ReorderModal from './Playlists/ReorderModal.vue';
@ -322,6 +324,8 @@ import TimeZone from "~/components/Stations/Common/TimeZone.vue";
import Tabs from "~/components/Common/Tabs.vue";
import Tab from "~/components/Common/Tab.vue";
import AddButton from "~/components/Common/AddButton.vue";
import {IconContract, IconExpand} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const props = defineProps({
useManualAutoDj: {
@ -344,18 +348,6 @@ const fields: DataTableField[] = [
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const toggleButtonClass = (record) => {
return (record.is_enabled)
? 'btn-warning'
: 'btn-success';
}
const langToggleButton = (record) => {
return (record.is_enabled)
? $gettext('Disable')
: $gettext('Enable');
};
const {Duration} = useLuxon();
const formatLength = (length) => {

View File

@ -50,11 +50,20 @@
<td>{{ element.media.album }}</td>
<td>
<div class="btn-group btn-group-sm">
<button
v-if="index+1 < media.length"
type="button"
class="btn btn-secondary"
:title="$gettext('Move to Bottom')"
@click.prevent="moveToBottom(index)"
>
<icon :icon="IconChevronBarDown" />
</button>
<button
v-if="index+1 < media.length"
type="button"
class="btn btn-primary"
:title="$gettext('Down')"
:title="$gettext('Move Down')"
@click.prevent="moveDown(index)"
>
<icon :icon="IconChevronDown" />
@ -63,11 +72,20 @@
v-if="index > 0"
type="button"
class="btn btn-primary"
:title="$gettext('Up')"
:title="$gettext('Move Up')"
@click.prevent="moveUp(index)"
>
<icon :icon="IconChevronUp" />
</button>
<button
v-if="index > 0"
type="button"
class="btn btn-secondary"
:title="$gettext('Move to Top')"
@click.prevent="moveToTop(index)"
>
<icon :icon="IconChevronBarUp" />
</button>
</div>
</td>
</tr>
@ -87,7 +105,7 @@ import {useAxios} from "~/vendor/axios";
import {useNotify} from "~/functions/useNotify";
import {useTranslate} from "~/vendor/gettext";
import Modal from "~/components/Common/Modal.vue";
import {IconChevronDown, IconChevronUp} from "~/components/Common/icons";
import {IconChevronBarDown, IconChevronBarUp, IconChevronDown, IconChevronUp} from "~/components/Common/icons";
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
@ -129,12 +147,26 @@ const save = () => {
};
const moveDown = (index) => {
media.value.splice(index + 1, 0, media.value.splice(index, 1)[0]);
const currentItem = media.value.splice(index, 1)[0];
media.value.splice(index + 1, 0, currentItem);
save();
};
const moveToBottom = (index) => {
const currentItem = media.value.splice(index, 1)[0];
media.value.splice(media.value.length, 0, currentItem);
save();
};
const moveUp = (index) => {
media.value.splice(index - 1, 0, media.value.splice(index, 1)[0]);
const currentItem = media.value.splice(index, 1)[0];
media.value.splice(index - 1, 0, currentItem);
save();
};
const moveToTop = (index) => {
const currentItem = media.value.splice(index, 1)[0];
media.value.splice(0, 0, currentItem);
save();
};

View File

@ -30,16 +30,14 @@
</div>
<div class="card-body buttons">
<button
type="button"
<router-link
class="btn btn-secondary"
@click="doClearPodcast()"
:to="{name: 'stations:podcasts:index'}"
>
<icon :icon="IconChevronLeft" />
<span>
{{ $gettext('All Podcasts') }}
</span>
</button>
{{ $gettext('All Podcasts') }}
</router-link>
<add-button
:text="$gettext('Add Episode')"
@click="doCreate"
@ -50,48 +48,67 @@
id="station_podcast_episodes"
ref="$datatable"
paginated
select-fields
:fields="fields"
:api-url="podcast.links.episodes"
>
<template #cell(art)="row">
<album-art :src="row.item.art" />
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="row">
<template #cell(title)="{item}">
<h5 class="m-0">
{{ row.item.title }}
{{ item.title }}
</h5>
<a
:href="row.item.links.public"
target="_blank"
>{{ $gettext('Public Page') }}</a>
<div v-if="item.is_published">
<a
:href="item.links.public"
target="_blank"
>{{ $gettext('Public Page') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
{{ $gettext('Unpublished') }}
</span>
</div>
</template>
<template #cell(podcast_media)="row">
<template v-if="row.item.media">
<span>{{ row.item.media.original_name }}</span>
<template #cell(podcast_media)="{item}">
<template v-if="item.media">
<span>{{ item.media.original_name }}</span>
<br>
<small>{{ row.item.media.length_text }}</small>
<small>{{ item.media.length_text }}</small>
</template>
</template>
<template #cell(explicit)="row">
<template #cell(is_published)="{item}">
<span v-if="item.is_published">
{{ $gettext('Yes') }}
</span>
<span v-else>
{{ $gettext('No') }}
</span>
</template>
<template #cell(explicit)="{item}">
<span
v-if="row.item.explicit"
v-if="item.explicit"
class="badge text-bg-danger"
>{{ $gettext('Explicit') }}</span>
<span v-else>&nbsp;</span>
</template>
<template #cell(actions)="row">
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(row.item.links.self)"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(row.item.links.self)"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
@ -111,79 +128,93 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import EditModal from './EpisodeEditModal.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './Podcasts/EpisodeEditModal.vue';
import Icon from '~/components/Common/Icon.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import AddButton from "~/components/Common/AddButton.vue";
import {IconChevronLeft} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import {getStationApiUrl} from "~/router.ts";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
import {ApiPodcast} from "~/entities/ApiInterfaces.ts";
import useHasEditModal from "~/functions/useHasEditModal.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const props = defineProps({
quotaUrl: {
type: String,
required: true
},
podcast: {
type: Object,
required: true
}
});
const props = defineProps<{
podcast: ApiPodcast
}>();
const emit = defineEmits(['clear-podcast']);
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
const {$gettext} = useTranslate();
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Episode'), sortable: false},
{key: 'podcast_media', label: $gettext('File Name'), sortable: false},
{key: 'explicit', label: $gettext('Explicit'), sortable: false},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
{
key: 'art',
label: $gettext('Art'),
sortable: false,
class: 'shrink pe-0',
selectable: true
},
{
key: 'title',
label: $gettext('Episode'),
sortable: false
},
{
key: 'podcast_media',
label: $gettext('File Name'),
sortable: false
},
{
key: 'is_published',
label: $gettext('Is Published'),
visible: false,
sortable: true,
selectable: true
},
{
key: 'publish_at',
label: $gettext('Publish At'),
formatter: (_col, _key, item) => formatTimestampAsDateTime(item.publish_at),
sortable: true,
selectable: true
},
{
key: 'explicit',
label: $gettext('Explicit'),
sortable: true,
selectable: true
},
{
key: 'actions',
label: $gettext('Actions'),
sortable: false,
class: 'shrink'
}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
const relist = () => {
$quota.value?.update();
$datatable.value?.refresh();
refresh();
};
const $editEpisodeModal = ref<InstanceType<typeof EditModal> | null>(null);
const {doCreate, doEdit} = useHasEditModal($editEpisodeModal);
const doCreate = () => {
$editEpisodeModal.value?.create();
};
const doEdit = (url) => {
$editEpisodeModal.value?.edit(url);
};
const doClearPodcast = () => {
emit('clear-podcast');
};
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doDelete = (url) => {
confirmDelete({
title: $gettext('Delete Episode?'),
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
const {doDelete} = useConfirmAndDelete(
$gettext('Delete Episode?'),
() => relist()
);
</script>

View File

@ -1,39 +1,154 @@
<template>
<episodes-view
v-if="activePodcast"
:podcast="activePodcast"
:quota-url="quotaUrl"
@clear-podcast="onClearPodcast"
/>
<list-view
v-else
v-bind="pickProps(props, listViewProps)"
:quota-url="quotaUrl"
@select-podcast="onSelectPodcast"
<card-page>
<template #header>
<div class="row align-items-center">
<div class="col-md-7">
<h2 class="card-title">
{{ $gettext('Podcasts') }}
</h2>
</div>
<div class="col-md-5 text-end">
<stations-common-quota
ref="$quota"
:quota-url="quotaUrl"
/>
</div>
</div>
</template>
<template #actions>
<add-button
:text="$gettext('Add Podcast')"
@click="doCreate"
/>
</template>
<data-table
id="station_podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
{{ item.title }}
</h5>
<div v-if="item.is_published">
<a
:href="item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
{{ $gettext('Unpublished') }}
</span>
</div>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<router-link
class="btn btn-secondary"
:to="{name: 'stations:podcast:episodes', params: {podcast_id: item.id}}"
>
{{ $gettext('Episodes') }}
</router-link>
</div>
</template>
</data-table>
</card-page>
<edit-modal
ref="$editPodcastModal"
:create-url="listUrl"
:new-art-url="newArtUrl"
:language-options="languageOptions"
:categories-options="categoriesOptions"
@relist="relist"
/>
</template>
<script setup lang="ts">
import EpisodesView from './Podcasts/EpisodesView.vue';
import ListView from './Podcasts/ListView.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './Podcasts/PodcastEditModal.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import listViewProps from "./Podcasts/listViewProps";
import {pickProps} from "~/functions/pickProps";
import {getStationApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import CardPage from "~/components/Common/CardPage.vue";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
import useHasEditModal from "~/functions/useHasEditModal.ts";
const props = defineProps({
...listViewProps
languageOptions: {
type: Object,
required: true
},
categoriesOptions: {
type: Object,
required: true
},
});
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
const listUrl = getStationApiUrl('/podcasts');
const newArtUrl = getStationApiUrl('/podcasts/art');
const activePodcast = ref(null);
const {$gettext} = useTranslate();
const onSelectPodcast = (podcast) => {
activePodcast.value = podcast;
const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: false},
{
key: 'episodes',
label: $gettext('# Episodes'),
sortable: false,
},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
const relist = () => {
$quota.value?.update();
refresh();
};
const onClearPodcast = () => {
activePodcast.value = null;
}
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
const {doCreate, doEdit} = useHasEditModal($editPodcastModal);
const {doDelete} = useConfirmAndDelete(
$gettext('Delete Podcast?'),
() => relist()
);
</script>

View File

@ -45,7 +45,7 @@
</template>
<script setup lang="ts">
import {computed, ref, toRef} from "vue";
import {computed, ref, toRef, watch} from "vue";
import {useAxios} from "~/vendor/axios";
import FormGroup from "~/components/Form/FormGroup.vue";
import FormFile from "~/components/Form/FormFile.vue";
@ -54,15 +54,11 @@ import Tab from "~/components/Common/Tab.vue";
const props = defineProps({
modelValue: {
type: Object,
required: true
default: null
},
artworkSrc: {
type: String,
required: true
},
editArtUrl: {
type: String,
required: true
default: null
},
newArtUrl: {
type: String,
@ -72,7 +68,12 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
const artworkSrc = toRef(props, 'artworkSrc');
const artworkSrc = ref(props.artworkSrc);
const reloadArt = () => {
artworkSrc.value = props.artworkSrc + '?' + Math.floor(Date.now() / 1000);
}
watch(toRef(props, 'artworkSrc'), reloadArt);
const localSrc = ref(null);
const src = computed(() => {
@ -92,21 +93,24 @@ const uploaded = (file) => {
}, false);
fileReader.readAsDataURL(file);
const url = (props.editArtUrl) ? props.editArtUrl : props.newArtUrl;
const url = (props.artworkSrc) ? props.artworkSrc : props.newArtUrl;
const formData = new FormData();
formData.append('art', file);
axios.post(url, formData).then((resp) => {
emit('update:modelValue', resp.data);
reloadArt();
});
};
const deleteArt = () => {
if (props.editArtUrl) {
axios.delete(props.editArtUrl).then(() => {
if (props.artworkSrc) {
axios.delete(props.artworkSrc).then(() => {
reloadArt();
localSrc.value = null;
});
} else {
reloadArt();
localSrc.value = null;
}
}

View File

@ -18,14 +18,12 @@
:record-has-media="record.has_media"
:new-media-url="newMediaUrl"
:edit-media-url="record.links.media"
:download-url="record.links.download"
/>
<podcast-common-artwork
v-model="form.artwork_file"
:artwork-src="record.art"
:artwork-src="record.links.art"
:new-art-url="newArtUrl"
:edit-art-url="record.links.art"
/>
</tabs>
</modal-form>

View File

@ -32,9 +32,9 @@
<template v-if="hasMedia">
<div class="block-buttons pt-3">
<a
v-if="downloadUrl"
v-if="editMediaUrl"
class="btn btn-block btn-dark"
:href="downloadUrl"
:href="editMediaUrl"
target="_blank"
>
{{ $gettext('Download') }}
@ -74,10 +74,6 @@ const props = defineProps({
type: Boolean,
required: true
},
downloadUrl: {
type: String,
required: true
},
editMediaUrl: {
type: String,
required: true

View File

@ -1,172 +0,0 @@
<template>
<section
class="card"
role="region"
>
<div class="card-header text-bg-primary">
<div class="row align-items-center">
<div class="col-md-7">
<h2 class="card-title">
{{ $gettext('Podcasts') }}
</h2>
</div>
<div class="col-md-5 text-end">
<stations-common-quota
ref="$quota"
:quota-url="quotaUrl"
/>
</div>
</div>
</div>
<div class="card-body buttons">
<add-button
:text="$gettext('Add Podcast')"
@click="doCreate"
/>
</div>
<data-table
id="station_podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="row">
<album-art :src="row.item.art" />
</template>
<template #cell(title)="row">
<h5 class="m-0">
{{ row.item.title }}
</h5>
<a
:href="row.item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="row.item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</template>
<template #cell(actions)="row">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(row.item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(row.item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
type="button"
class="btn btn-secondary"
@click="doSelectPodcast(row.item)"
>
{{ $gettext('Episodes') }}
</button>
</div>
</template>
</data-table>
</section>
<edit-modal
ref="$editPodcastModal"
:create-url="listUrl"
:new-art-url="newArtUrl"
:language-options="languageOptions"
:categories-options="categoriesOptions"
@relist="relist"
/>
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import EditModal from './PodcastEditModal.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import listViewProps from "./listViewProps";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {getStationApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
const props = defineProps({
...listViewProps,
quotaUrl: {
type: String,
required: true
}
});
const listUrl = getStationApiUrl('/podcasts');
const newArtUrl = getStationApiUrl('/podcasts/art');
const emit = defineEmits(['select-podcast']);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: false},
{
key: 'episodes',
label: $gettext('# Episodes'),
sortable: false,
formatter: (val) => {
return val?.length ?? 0;
}
},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const relist = () => {
$quota.value?.update();
$datatable.value?.refresh();
};
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
const doCreate = () => {
$editPodcastModal.value?.create();
};
const doEdit = (url) => {
$editPodcastModal.value?.edit(url);
};
const doSelectPodcast = (podcast) => {
emit('select-podcast', podcast);
};
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doDelete = (url) => {
confirmDelete({
title: $gettext('Delete Podcast?'),
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
</script>

View File

@ -17,9 +17,8 @@
<podcast-common-artwork
v-model="form.artwork_file"
:artwork-src="record.art"
:artwork-src="record.links.art"
:new-art-url="newArtUrl"
:edit-art-url="record.links.art"
/>
</tabs>
</modal-form>
@ -35,6 +34,7 @@ import {useResettableRef} from "~/functions/useResettableRef";
import {useTranslate} from "~/vendor/gettext";
import ModalForm from "~/components/Common/ModalForm.vue";
import Tabs from "~/components/Common/Tabs.vue";
import {map} from "lodash";
const props = defineProps({
...baseEditModalProps,
@ -59,7 +59,9 @@ const $modal = ref<ModalFormTemplateRef>(null);
const {record, reset} = useResettableRef({
has_custom_art: false,
art: null,
links: {}
links: {
art: null
}
});
const {
@ -87,6 +89,11 @@ const {
reset();
},
populateForm: (data, formRef) => {
data.categories = map(
data.categories,
(row) => row.category
);
record.value = data;
formRef.value = mergeExisting(formRef.value, data);
},

View File

@ -1,10 +0,0 @@
export default {
languageOptions: {
type: Object,
required: true
},
categoriesOptions: {
type: Object,
required: true
}
}

View File

@ -120,6 +120,10 @@ const types = computed(() => {
value: 'history',
text: $gettext('History')
},
{
value: 'podcasts',
text: $gettext('Podcasts')
},
{
value: 'schedule',
text: $gettext('Schedule')
@ -174,6 +178,9 @@ const baseEmbedUrl = computed(() => {
case 'schedule':
return props.publicScheduleEmbedUri;
case 'podcasts':
return props.publicPodcastsEmbedUri;
case 'player':
default:
return props.publicPageEmbedUri;
@ -181,14 +188,17 @@ const baseEmbedUrl = computed(() => {
});
const embedUrl = computed(() => {
return (selectedTheme.value !== "browser")
? baseEmbedUrl.value + '?theme=' + selectedTheme.value
: baseEmbedUrl.value;
const baseUrl = new URL(baseEmbedUrl.value);
if (selectedTheme.value !== 'browser') {
baseUrl.searchParams.set('theme', selectedTheme.value);
}
return baseUrl.toString();
});
const embedHeight = computed(() => {
switch (selectedType.value) {
case 'ondemand':
case 'podcasts':
return '400px';
case 'requests':

View File

@ -223,14 +223,29 @@
{{ $gettext('Disconnect Streamer') }}
</span>
</button>
<button
id="btn_update_metadata"
type="button"
class="btn btn-link text-secondary"
@click="updateMetadata()"
>
<icon :icon="IconUpdate" />
<span>
{{ $gettext('Update Metadata') }}
</span>
</button>
</template>
</card-page>
<template v-if="isLiquidsoap && userAllowedForStation(StationPermission.Broadcasting)">
<update-metadata-modal ref="$updateMetadataModal" />
</template>
</template>
<script setup lang="ts">
import {BackendAdapter} from '~/entities/RadioAdapters';
import Icon from '~/components/Common/Icon.vue';
import {computed} from "vue";
import {computed, Ref, ref} from "vue";
import {useTranslate} from "~/vendor/gettext";
import nowPlayingPanelProps from "~/components/Stations/Profile/nowPlayingPanelProps";
import useNowPlaying from "~/functions/useNowPlaying";
@ -239,7 +254,16 @@ import CardPage from "~/components/Common/CardPage.vue";
import {useLightbox} from "~/vendor/lightbox";
import {StationPermission, userAllowedForStation} from "~/acl";
import {useAzuraCastStation} from "~/vendor/azuracast";
import {IconHeadphones, IconLogs, IconMic, IconMusicNote, IconSkipNext, IconVolumeOff} from "~/components/Common/icons";
import {
IconHeadphones,
IconLogs,
IconMic,
IconMusicNote,
IconSkipNext,
IconUpdate,
IconVolumeOff
} from "~/components/Common/icons";
import UpdateMetadataModal from "~/components/Stations/Profile/UpdateMetadataModal.vue";
const props = defineProps({
...nowPlayingPanelProps,
@ -275,4 +299,9 @@ const {vLightbox} = useLightbox();
const makeApiCall = (uri) => {
emit('api-call', uri);
};
const $updateMetadataModal: Ref<InstanceType<typeof UpdateMetadataModal> | null> = ref(null);
const updateMetadata = () => {
$updateMetadataModal.value?.open();
}
</script>

View File

@ -45,9 +45,9 @@
<script setup lang="ts">
import {map} from "lodash";
import {computed} from "vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import CardPage from "~/components/Common/CardPage.vue";
import {useLuxon} from "~/vendor/luxon";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({
scheduleItems: {
@ -56,39 +56,35 @@ const props = defineProps({
}
});
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const {
now,
timestampToDateTime,
formatDateTime
} = useStationDateTimeFormatter();
const processedScheduleItems = computed(() => {
const now = DateTime.now().setZone(timezone);
const nowTz = now();
return map(props.scheduleItems, (row) => {
const start_moment = DateTime.fromSeconds(row.start_timestamp).setZone(timezone);
const end_moment = DateTime.fromSeconds(row.end_timestamp).setZone(timezone);
const startMoment = timestampToDateTime(row.start_timestamp);
const endMoment = timestampToDateTime(row.end_timestamp);
row.time_until = start_moment.toRelative();
row.time_until = startMoment.toRelative();
if (start_moment.hasSame(now, 'day')) {
row.start_formatted = start_moment.toLocaleString(
{...DateTime.TIME_SIMPLE, ...timeConfig}
);
} else {
row.start_formatted = start_moment.toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
}
row.start_formatted = formatDateTime(
startMoment,
startMoment.hasSame(nowTz, 'day')
? DateTime.TIME_SIMPLE
: DateTime.DATETIME_MED
);
if (end_moment.hasSame(start_moment, 'day')) {
row.end_formatted = end_moment.toLocaleString(
{...DateTime.TIME_SIMPLE, ...timeConfig}
);
} else {
row.end_formatted = end_moment.toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
}
row.end_formatted = formatDateTime(
endMoment,
endMoment.hasSame(startMoment, 'day')
? DateTime.TIME_SIMPLE
: DateTime.DATETIME_MED
);
return row;
});

View File

@ -0,0 +1,115 @@
<template>
<modal
id="update_metadata"
ref="$modal"
centered
:title="$gettext('Update Metadata')"
@hidden="onHidden"
@shown="onShown"
>
<info-card>
{{
$gettext('Use this form to send a manual metadata update. Note that this will override any existing metadata on the stream.')
}}
</info-card>
<form @submit.prevent="doUpdateMetadata">
<div class="row g-3">
<form-group-field
id="update_metadata_title"
ref="$field"
class="col-12"
:field="v$.title"
:label="$gettext('Title')"
/>
<form-group-field
id="update_metadata_artist"
class="col-12"
:field="v$.artist"
:label="$gettext('Artist')"
/>
</div>
<invisible-submit-button />
</form>
<template #modal-footer>
<button
type="button"
class="btn btn-secondary"
@click="hide"
>
{{ $gettext('Close') }}
</button>
<button
type="button"
class="btn"
:class="(v$.$invalid) ? 'btn-danger' : 'btn-primary'"
@click="doUpdateMetadata"
>
{{ $gettext('Update Metadata') }}
</button>
</template>
</modal>
</template>
<script setup lang="ts">
import {required} from '@vuelidate/validators';
import FormGroupField from "~/components/Form/FormGroupField.vue";
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
import {nextTick, ref} from "vue";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {useTranslate} from "~/vendor/gettext";
import Modal from "~/components/Common/Modal.vue";
import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton.vue";
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
import {getStationApiUrl} from "~/router.ts";
import InfoCard from "~/components/Common/InfoCard.vue";
const updateMetadataUrl = getStationApiUrl('/nowplaying/update');
const {form, v$, resetForm, ifValid} = useVuelidateOnForm(
{
title: {required},
artist: {}
},
{
title: null,
artist: null
}
);
const $modal = ref<ModalTemplateRef>(null);
const {hide, show: open} = useHasModal($modal);
const onHidden = () => {
resetForm();
}
const $field = ref<InstanceType<typeof FormGroupField> | null>(null);
const onShown = () => {
nextTick(() => {
$field.value?.focus();
})
};
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const {$gettext} = useTranslate();
const doUpdateMetadata = () => {
ifValid(() => {
axios.post(updateMetadataUrl.value, form.value).then(() => {
notifySuccess($gettext('Metadata updated.'));
}).finally(() => {
hide();
});
});
};
defineExpose({
open
});
</script>

View File

@ -42,5 +42,9 @@ export default {
publicScheduleEmbedUri: {
type: String,
required: true
},
publicPodcastsEmbedUri: {
type: String,
required: true
}
}

View File

@ -18,6 +18,7 @@
ref="$datatable"
:fields="fields"
:api-url="listUrl"
:hide-on-loading="false"
>
<template #cell(actions)="row">
<div class="btn-group btn-group-sm">
@ -52,8 +53,8 @@
</div>
</template>
<template #cell(played_at)="row">
{{ formatTime(row.item.played_at) }}<br>
<small>{{ formatRelativeTime(row.item.played_at) }}</small>
{{ formatTimestampAsTime(row.item.played_at) }}<br>
<small>{{ formatTimestampAsRelative(row.item.played_at) }}</small>
</template>
<template #cell(source)="row">
<div v-if="row.item.is_request">
@ -70,21 +71,21 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '../Common/DataTable.vue';
import DataTable, {DataTableField} from '../Common/DataTable.vue';
import QueueLogsModal from './Queue/LogsModal.vue';
import Icon from "~/components/Common/Icon.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import {computed, ref} from "vue";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {useSweetAlert} from "~/vendor/sweetalert";
import CardPage from "~/components/Common/CardPage.vue";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router";
import {IconRemove} from "~/components/Common/icons";
import {useIntervalFn} from "@vueuse/core";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const listUrl = getStationApiUrl('/queue');
const clearUrl = getStationApiUrl('/queue/clear');
@ -98,24 +99,19 @@ const fields: DataTableField[] = [
{key: 'source', label: $gettext('Source'), sortable: false}
];
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const getDateTime = (timestamp) =>
DateTime.fromSeconds(timestamp).setZone(timezone);
const {timeConfig} = useAzuraCast();
const formatTime = (time) => getDateTime(time).toLocaleString(
{...DateTime.TIME_WITH_SECONDS, ...timeConfig}
);
const formatRelativeTime = (time) => getDateTime(time).toRelative();
const {
formatTimestampAsTime,
formatTimestampAsRelative
} = useStationDateTimeFormatter();
const $datatable = ref<DataTableTemplateRef>(null);
const {relist} = useHasDatatable($datatable);
useIntervalFn(
relist,
computed(() => (document.hidden) ? 60000 : 30000)
);
const $logsModal = ref<InstanceType<typeof QueueLogsModal> | null>(null);
const doShowLogs = (logs) => {
$logsModal.value?.show(logs);

View File

@ -27,14 +27,11 @@
:label="$gettext('AutoDJ Format')"
/>
<form-group-multi-check
<bitrate-options
v-if="formatSupportsBitrateOptions"
id="edit_form_autodj_bitrate"
class="col-md-6"
:field="v$.autodj_bitrate"
:options="bitrateOptions"
stacked
radio
:label="$gettext('AutoDJ Bitrate (kbps)')"
/>
@ -89,12 +86,12 @@
<script setup lang="ts">
import FormGroupField from "~/components/Form/FormGroupField.vue";
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {map} from "lodash";
import {computed} from "vue";
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import Tab from "~/components/Common/Tab.vue";
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
const props = defineProps({
form: {
@ -153,16 +150,6 @@ const formatOptions = [
}
];
const bitrateOptions = map(
[32, 48, 64, 96, 128, 192, 256, 320],
(val) => {
return {
value: val,
text: val
};
}
);
const formatSupportsBitrateOptions = computed(() => {
return form.value?.autodj_format !== 'flac';
});

View File

@ -75,67 +75,79 @@
<div id="map">
<StationReportsListenersMap
:listeners="listeners"
:listeners="filteredListeners"
/>
</div>
<div>
<div class="card-body row">
<div class="col-md-4">
<h5>
<div class="card-body">
<div class="row row-cols-md-auto align-items-center">
<div class="col-12 text-start text-md-end h5">
{{ $gettext('Unique Listeners') }}
<br>
<small>
{{ $gettext('for selected period') }}
</small>
</h5>
<h3>{{ listeners.length }}</h3>
</div>
<div class="col-md-4">
<h5>
</div>
<div class="col-12 h3">
{{ listeners.length }}
</div>
<div class="col-12 text-start text-md-end h5">
{{ $gettext('Total Listener Hours') }}
<br>
<small>
{{ $gettext('for selected period') }}
</small>
</h5>
<h3>{{ totalListenerHours }}</h3>
</div>
<div class="col-12 h3">
{{ totalListenerHours }}
</div>
<div class="col-12">
<listener-filters-bar v-model:filters="filters" />
</div>
</div>
</div>
<data-table
id="station_playlists"
id="station_listeners"
ref="$datatable"
paginated
handle-client-side
:fields="fields"
:items="listeners"
:items="filteredListeners"
select-fields
@refresh-clicked="updateListeners()"
>
<template #cell(time)="row">
{{ formatTime(row.item.connected_time) }}
</template>
<template #cell(time_sec)="row">
{{ row.item.connected_time }}
</template>
<template #cell(user_agent)="row">
<div>
<span v-if="row.item.is_mobile">
<icon :icon="IconSmartphone" />
<span class="visually-hidden">
{{ $gettext('Mobile Device') }}
<!-- eslint-disable-next-line -->
<template #cell(device.client)="row">
<div class="d-flex align-items-center">
<div class="flex-shrink-0 pe-2">
<span v-if="row.item.device.is_bot">
<icon :icon="IconRouter" />
<span class="visually-hidden">
{{ $gettext('Bot/Crawler') }}
</span>
</span>
</span>
<span v-else>
<icon :icon="IconDesktopWindows" />
<span class="visually-hidden">
{{ $gettext('Desktop Device') }}
<span v-else-if="row.item.device.is_mobile">
<icon :icon="IconSmartphone" />
<span class="visually-hidden">
{{ $gettext('Mobile') }}
</span>
</span>
</span>
{{ row.item.user_agent }}
</div>
<div v-if="row.item.device.client">
<small>{{ row.item.device.client }}</small>
<span v-else>
<icon :icon="IconDesktopWindows" />
<span class="visually-hidden">
{{ $gettext('Desktop') }}
</span>
</span>
</div>
<div class="flex-fill">
<div v-if="row.item.device.client">
{{ row.item.device.client }}
</div>
<div class="small">
{{ row.item.user_agent }}
</div>
</div>
</div>
</template>
<template #cell(stream)="row">
@ -174,17 +186,21 @@
<script setup lang="ts">
import StationReportsListenersMap from "./Listeners/Map.vue";
import Icon from "~/components/Common/Icon.vue";
import formatTime from "~/functions/formatTime";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
import {computed, nextTick, onMounted, ref, shallowRef, watch} from "vue";
import {computed, ComputedRef, nextTick, onMounted, Ref, ref, ShallowRef, shallowRef, watch} from "vue";
import {useTranslate} from "~/vendor/gettext";
import {useAxios} from "~/vendor/axios";
import {useAzuraCastStation} from "~/vendor/azuracast";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router";
import {IconDesktopWindows, IconDownload, IconSmartphone} from "~/components/Common/icons";
import {IconDesktopWindows, IconDownload, IconRouter, IconSmartphone} from "~/components/Common/icons";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
import {ListenerFilters, ListenerTypeFilter} from "~/components/Stations/Reports/Listeners/listenerFilters.ts";
import {filter} from "lodash";
import formatTime from "~/functions/formatTime.ts";
import ListenerFiltersBar from "./Listeners/FiltersBar.vue";
import {ApiListener} from "~/entities/ApiInterfaces.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({
attribution: {
@ -196,12 +212,15 @@ const props = defineProps({
const apiUrl = getStationApiUrl('/listeners');
const isLive = ref<boolean>(true);
const listeners = shallowRef([]);
const {timezone} = useAzuraCastStation();
const listeners: ShallowRef<ApiListener[]> = shallowRef([]);
const {DateTime} = useLuxon();
const nowTz = DateTime.now().setZone(timezone);
const {
now,
formatTimestampAsDateTime
} = useStationDateTimeFormatter();
const nowTz = now();
const minDate = nowTz.minus({years: 5}).toJSDate();
const maxDate = nowTz.plus({days: 5}).toJSDate();
@ -211,38 +230,107 @@ const dateRange = ref({
endDate: nowTz.toJSDate()
});
const filters: Ref<ListenerFilters> = ref({
minLength: null,
maxLength: null,
type: ListenerTypeFilter.All,
});
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'ip', label: $gettext('IP'), sortable: false},
{key: 'time', label: $gettext('Time'), sortable: false},
{key: 'time_sec', label: $gettext('Time (sec)'), sortable: false},
{key: 'user_agent', isRowHeader: true, label: $gettext('User Agent'), sortable: false},
{key: 'stream', label: $gettext('Stream'), sortable: false},
{key: 'location', label: $gettext('Location'), sortable: false}
{
key: 'ip', label: $gettext('IP'), sortable: false,
selectable: true,
visible: true
},
{
key: 'connected_time',
label: $gettext('Time'),
sortable: true,
formatter: (_col, _key, item) => {
return formatTime(item.connected_time)
},
selectable: true,
visible: true
},
{
key: 'connected_time_sec',
label: $gettext('Time (sec)'),
sortable: false,
formatter: (_col, _key, item) => {
return item.connected_time;
},
selectable: true,
visible: false
},
{
key: 'connected_on',
label: $gettext('Start Time'),
sortable: true,
formatter: (_col, _key, item) => formatTimestampAsDateTime(
item.connected_on,
DateTime.DATETIME_SHORT
),
selectable: true,
visible: false
},
{
key: 'connected_until',
label: $gettext('End Time'),
sortable: true,
formatter: (_col, _key, item) => formatTimestampAsDateTime(
item.connected_until,
DateTime.DATETIME_SHORT
),
selectable: true,
visible: false
},
{
key: 'device.client',
isRowHeader: true,
label: $gettext('User Agent'),
sortable: true,
selectable: true,
visible: true
},
{
key: 'stream',
label: $gettext('Stream'),
sortable: true,
selectable: true,
visible: true
},
{
key: 'location',
label: $gettext('Location'),
sortable: true,
selectable: true,
visible: true
}
];
const exportUrl = computed(() => {
const exportUrl = new URL(apiUrl.value, document.location.href);
const exportUrlParams = exportUrl.searchParams;
exportUrlParams.set('format', 'csv');
exportUrlParams.set('format', 'csv');
if (!isLive.value) {
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
}
if (!isLive.value) {
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
}
return exportUrl.toString();
return exportUrl.toString();
});
const totalListenerHours = computed(() => {
let tlh_seconds = 0;
listeners.value.forEach(function (listener) {
tlh_seconds += listener.connected_time;
});
let tlh_seconds = 0;
filteredListeners.value.forEach(function (listener) {
tlh_seconds += listener.connected_time;
});
const tlh_hours = tlh_seconds / 3600;
return Math.round((tlh_hours + 0.00001) * 100) / 100;
const tlh_hours = tlh_seconds / 3600;
return Math.round((tlh_hours + 0.00001) * 100) / 100;
});
const {axios} = useAxios();
@ -250,6 +338,40 @@ const {axios} = useAxios();
const $datatable = ref<DataTableTemplateRef>(null);
const {navigate} = useHasDatatable($datatable);
const hasFilters: ComputedRef<boolean> = computed(() => {
return null !== filters.value.minLength
|| null !== filters.value.maxLength
|| ListenerTypeFilter.All !== filters.value.type;
});
const filteredListeners: ComputedRef<ApiListener[]> = computed(() => {
if (!hasFilters.value) {
return listeners.value;
}
return filter(
listeners.value,
(row: ApiListener) => {
const connectedTime: number = row.connected_time;
if (null !== filters.value.minLength && connectedTime < filters.value.minLength) {
return false;
}
if (null !== filters.value.maxLength && connectedTime > filters.value.maxLength) {
return false;
}
if (ListenerTypeFilter.All !== filters.value.type) {
if (ListenerTypeFilter.Mobile === filters.value.type && !row.device.is_mobile) {
return false;
} else if (ListenerTypeFilter.Desktop === filters.value.type && row.device.is_mobile) {
return false;
}
}
return true;
}
);
});
const updateListeners = () => {
const params: {
[key: string]: any

View File

@ -0,0 +1,85 @@
<template>
<div class="row row-cols-md-auto g-3 align-items-center">
<div class="col-12">
<label for="minLength">{{ $gettext('Min. Connected Time') }}</label>
<div class="input-group input-group-sm">
<input
id="minLength"
v-model="filters.minLength"
type="number"
class="form-control"
min="0"
step="1"
>
</div>
</div>
<div class="col-12">
<label for="maxLength">{{ $gettext('Max. Connected Time') }}</label>
<div class="input-group input-group-sm">
<input
id="maxLength"
v-model="filters.maxLength"
type="number"
class="form-control"
min="0"
step="1"
>
</div>
</div>
<div class="col-12">
<label for="type">{{ $gettext('Listener Type') }}</label>
<div class="input-group input-group-sm">
<select
v-model="filters.type"
class="form-select form-select-sm"
>
<option :value="ListenerTypeFilter.All">
{{ $gettext('All Types') }}
</option>
<option :value="ListenerTypeFilter.Mobile">
{{ $gettext('Mobile') }}
</option>
<option :value="ListenerTypeFilter.Desktop">
{{ $gettext('Desktop') }}
</option>
<option :value="ListenerTypeFilter.Bot">
{{ $gettext('Bot/Crawler') }}
</option>
</select>
</div>
</div>
<div class="col-12">
<button
type="button"
class="btn btn-sm btn-secondary"
@click="clearFilters"
>
<icon :icon="IconClearAll" />
<span>
{{ $gettext('Clear Filters') }}
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {ListenerFilters, ListenerTypeFilter} from "./listenerFilters.ts";
import {useVModel} from "@vueuse/core";
import {WritableComputedRef} from "vue";
import {IconClearAll} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const props = defineProps<{
filters: ListenerFilters
}>();
const emit = defineEmits(['update:filters']);
const filters: WritableComputedRef<ListenerFilters> = useVModel(props, 'filters', emit);
const clearFilters = () => {
filters.value.minLength = null;
filters.value.maxLength = null;
filters.value.type = ListenerTypeFilter.All;
}
</script>

View File

@ -0,0 +1,12 @@
export enum ListenerTypeFilter {
All = 'all',
Mobile = 'mobile',
Desktop = 'desktop',
Bot = 'bot'
}
export interface ListenerFilters {
type: ListenerTypeFilter,
minLength: number | null,
maxLength: number | null
}

View File

@ -92,11 +92,10 @@ import StreamsTab from "./Overview/StreamsTab.vue";
import ClientsTab from "./Overview/ClientsTab.vue";
import ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab.vue";
import {ref} from "vue";
import {useAzuraCastStation} from "~/vendor/azuracast";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router";
import Tabs from "~/components/Common/Tabs.vue";
import Tab from "~/components/Common/Tab.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const props = defineProps({
showFullAnalytics: {
@ -113,11 +112,9 @@ const byCountryUrl = getStationApiUrl('/reports/overview/by-country');
const byClientUrl = getStationApiUrl('/reports/overview/by-client');
const listeningTimeUrl = getStationApiUrl('/reports/overview/by-listening-time');
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const nowTz = DateTime.now().setZone(timezone);
const {now} = useStationDateTimeFormatter();
const nowTz = now();
const dateRange = ref({
startDate: nowTz.minus({days: 13}).toJSDate(),
endDate: nowTz.toJSDate(),

View File

@ -56,14 +56,14 @@
:api-url="listUrlForType"
>
<template #cell(timestamp)="row">
{{ formatTime(row.item.timestamp) }}
{{ formatTimestampAsDateTime(row.item.timestamp) }}
</template>
<template #cell(played_at)="row">
<span v-if="row.item.played_at === 0">
{{ $gettext('Not Played') }}
</span>
<span v-else>
{{ formatTime(row.item.played_at) }}
{{ formatTimestampAsDateTime(row.item.played_at) }}
</span>
</template>
<template #cell(song_title)="row">
@ -93,18 +93,17 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import Icon from "~/components/Common/Icon.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {computed, nextTick, ref} from "vue";
import {useTranslate} from "~/vendor/gettext";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router";
import {IconRemove} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const listUrl = getStationApiUrl('/reports/requests');
const clearUrl = getStationApiUrl('/reports/requests/clear');
@ -147,16 +146,7 @@ const setType = (type) => {
nextTick(relist);
};
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const formatTime = (time) => {
return DateTime.fromSeconds(time).setZone(timezone).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
};
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();

View File

@ -114,16 +114,14 @@ import FormFieldset from "~/components/Form/FormFieldset.vue";
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
import {getStationApiUrl} from "~/router";
import {useLuxon} from "~/vendor/luxon";
import {useAzuraCastStation} from "~/vendor/azuracast";
import CardPage from "~/components/Common/CardPage.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const apiUrl = getStationApiUrl('/reports/soundexchange');
const {DateTime} = useLuxon();
const {timezone} = useAzuraCastStation();
const {now} = useStationDateTimeFormatter();
const lastMonth = DateTime.now().setZone(timezone).minus({months: 1});
const lastMonth = now().minus({months: 1});
const {v$} = useVuelidateOnForm(
{

View File

@ -86,22 +86,28 @@
<script setup lang="ts">
import Icon from "~/components/Common/Icon.vue";
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {computed, ref, watch} from "vue";
import {useTranslate} from "~/vendor/gettext";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router";
import {IconDownload, IconTrendingDown, IconTrendingUp} from "~/components/Common/icons";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
const baseApiUrl = getStationApiUrl('/history');
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const {
now,
formatDateTimeAsDateTime,
formatTimestampAsDateTime
} = useStationDateTimeFormatter();
const nowTz = DateTime.now().setZone(timezone);
const nowTz = now();
const dateRange = ref(
{
@ -111,7 +117,6 @@ const dateRange = ref(
);
const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const fields: DataTableField[] = [
{
@ -119,29 +124,22 @@ const fields: DataTableField[] = [
label: $gettext('Date/Time (Browser)'),
selectable: true,
sortable: false,
formatter: (value) => {
return DateTime.fromSeconds(
value,
{zone: 'system'}
).toLocaleString(
{...DateTime.DATETIME_SHORT, ...timeConfig}
);
}
visible: false,
formatter: (value) => formatDateTimeAsDateTime(
DateTime.fromSeconds(value, {zone: 'system'}),
DateTime.DATETIME_SHORT
)
},
{
key: 'played_at_station',
label: $gettext('Date/Time (Station)'),
sortable: false,
selectable: true,
visible: false,
formatter: (_value, _key, item) => {
return DateTime.fromSeconds(
item.played_at,
{zone: timezone}
).toLocaleString(
{...DateTime.DATETIME_SHORT, ...timeConfig}
);
}
visible: true,
formatter: (_value, _key, item) => formatTimestampAsDateTime(
item.played_at,
DateTime.DATETIME_SHORT
)
},
{
key: 'listeners_start',

View File

@ -13,7 +13,7 @@
</template>
<data-table
id="station_remotes"
id="station_sftp_users"
ref="$datatable"
:show-toolbar="false"
:fields="fields"
@ -75,7 +75,7 @@
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import SftpUsersEditModal from "./SftpUsers/EditModal.vue";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";

View File

@ -64,14 +64,15 @@
import {ref} from "vue";
import Icon from "~/components/Common/Icon.vue";
import SidebarMenu from "~/components/Common/SidebarMenu.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {useAzuraCastStation} from "~/vendor/azuracast";
import {useEventBus, useIntervalFn} from "@vueuse/core";
import {useStationsMenu} from "~/components/Stations/menu";
import {StationPermission, userAllowedForStation} from "~/acl";
import {useAxios} from "~/vendor/axios.ts";
import {getStationApiUrl} from "~/router.ts";
import {useLuxon} from "~/vendor/luxon.ts";
import {IconEdit} from "~/components/Common/icons.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({
station: {
@ -82,17 +83,15 @@ const props = defineProps({
const menuItems = useStationsMenu();
const {timeConfig} = useAzuraCast();
const {name, timezone} = useAzuraCastStation();
const {name} = useAzuraCastStation();
const {DateTime} = useLuxon();
const {now, formatDateTimeAsTime} = useStationDateTimeFormatter();
const clock = ref('');
useIntervalFn(() => {
clock.value = DateTime.now().setZone(timezone).toLocaleString({
...DateTime.TIME_WITH_SHORT_OFFSET,
...timeConfig
})
clock.value = formatDateTimeAsTime(now(), DateTime.TIME_WITH_SHORT_OFFSET);
}, 1000, {
immediate: true,
immediateCallback: true

View File

@ -63,24 +63,23 @@ import InlinePlayer from '~/components/InlinePlayer.vue';
import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue";
import '~/vendor/sweetalert';
import {useAzuraCast} from "~/vendor/azuracast";
import {ref} from "vue";
import {useTranslate} from "~/vendor/gettext";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import Modal from "~/components/Common/Modal.vue";
import {useLuxon} from "~/vendor/luxon";
import {IconDownload} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const listUrl = ref(null);
const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const {DateTime} = useLuxon();
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const fields: DataTableField[] = [
{
@ -93,11 +92,7 @@ const fields: DataTableField[] = [
key: 'timestampStart',
label: $gettext('Start Time'),
sortable: false,
formatter: (value) => {
return DateTime.fromSeconds(value).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
},
formatter: (value) => formatTimestampAsDateTime(value),
class: 'ps-3'
},
{
@ -105,13 +100,9 @@ const fields: DataTableField[] = [
label: $gettext('End Time'),
sortable: false,
formatter: (value) => {
if (value === 0) {
return $gettext('Live');
}
return DateTime.fromSeconds(value).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
return value === 0
? $gettext('Live')
: formatTimestampAsDateTime(value);
}
},
{

View File

@ -1,7 +1,9 @@
import {getStationApiUrl} from "~/router.ts";
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
import {RouteRecordRaw} from "vue-router";
import {useAxios} from "~/vendor/axios.ts";
export default function useStationsRoutes() {
export default function useStationsRoutes(): RouteRecordRaw[] {
return [
{
path: '/',
@ -61,6 +63,22 @@ export default function useStationsRoutes() {
name: 'stations:podcasts:index',
...populateComponentRemotely(getStationApiUrl('/vue/podcasts'))
},
{
path: '/podcast/:podcast_id',
component: () => import('~/components/Stations/PodcastEpisodes.vue'),
name: 'stations:podcast:episodes',
beforeEnter: async (to, _, next) => {
const apiUrl = getStationApiUrl(`/podcast/${to.params.podcast_id}`);
const {axios} = useAxios();
to.meta.state = {
podcast: await axios.get(apiUrl.value).then(r => r.data)
};
next();
},
props: (to) => {
return to.meta.state;
}
},
{
path: '/profile',
name: 'stations:profile:index',

View File

@ -269,10 +269,76 @@ export interface ApiListener {
* @example 30
*/
connected_time?: number;
/** Device metadata, if available */
device?: any[];
/** Location metadata, if available */
location?: any[];
device?: ApiListenerDevice;
location?: ApiListenerLocation;
}
export interface ApiListenerDevice {
/**
* If the listener device is likely a browser.
* @example true
*/
is_browser?: boolean;
/**
* If the listener device is likely a mobile device.
* @example true
*/
is_mobile?: boolean;
/**
* If the listener device is likely a crawler.
* @example true
*/
is_bot?: boolean;
/**
* Summary of the listener client.
* @example "Firefox 121.0, Windows"
*/
client?: string | null;
/**
* Summary of the listener browser family.
* @example "Firefox"
*/
browser_family?: string | null;
/**
* Summary of the listener OS family.
* @example "Windows"
*/
os_family?: string | null;
}
export interface ApiListenerLocation {
/**
* The approximate city of the listener.
* @example "Austin"
*/
city?: string | null;
/**
* The approximate region/state of the listener.
* @example "Texas"
*/
region?: string | null;
/**
* The approximate country of the listener.
* @example "United States"
*/
country?: string | null;
/**
* A description of the location.
* @example "Austin, Texas, US"
*/
description?: string;
/**
* Latitude.
* @format float
* @example "30.000000"
*/
lat?: number | null;
/**
* Latitude.
* @format float
* @example "-97.000000"
*/
lon?: number | null;
}
export type ApiNewRecord = ApiStatus & {
@ -408,6 +474,11 @@ export interface ApiNowPlayingStation {
* @example "liquidsoap"
*/
backend?: string;
/**
* The station's IANA time zone
* @example "America/Chicago"
*/
timezone?: string;
/**
* The full URL to listen to the default mount of the station
* @example "http://localhost:8000/radio.mp3"
@ -531,26 +602,39 @@ export interface ApiNowPlayingStationRemote {
}
export type ApiPodcast = HasLinks & {
id?: string | null;
storage_location_id?: number | null;
title?: string | null;
id?: string;
storage_location_id?: number;
title?: string;
link?: string | null;
description?: string | null;
language?: string | null;
author?: string | null;
email?: string | null;
description?: string;
description_short?: string;
language?: string;
language_name?: string;
author?: string;
email?: string;
has_custom_art?: boolean;
art?: string | null;
art?: string;
art_updated_at?: number;
categories?: string[];
episodes?: string[];
is_published?: boolean;
episodes?: number;
categories?: ApiPodcastCategory[];
};
export interface ApiPodcastCategory {
category?: string;
text?: string;
title?: string;
subtitle?: string | null;
}
export type ApiPodcastEpisode = HasLinks & {
id?: string | null;
title?: string | null;
description?: string | null;
id?: string;
title?: string;
description?: string;
description_short?: string;
explicit?: boolean;
created_at?: number;
is_published?: boolean;
publish_at?: number | null;
has_media?: boolean;
media?: ApiPodcastMedia;
@ -583,32 +667,32 @@ export interface ApiSong {
* The song artist.
* @example "Chet Porter"
*/
artist?: string;
artist?: string | null;
/**
* The song title.
* @example "Aluko River"
*/
title?: string;
title?: string | null;
/**
* The song album.
* @example "Moving Castle"
*/
album?: string;
album?: string | null;
/**
* The song genre.
* @example "Rock"
*/
genre?: string;
genre?: string | null;
/**
* The International Standard Recording Code (ISRC) of the file.
* @example "US28E1600021"
*/
isrc?: string;
isrc?: string | null;
/**
* Lyrics to the song.
* @example ""
*/
lyrics?: string;
lyrics?: string | null;
/**
* URL to the album artwork (if available).
* @example "https://picsum.photos/1200/1200"

View File

@ -1,4 +1,4 @@
export default function (seconds: string | number) {
export default function (seconds: string | number): string {
seconds = Math.floor(Number(seconds));
const d: number = Math.floor(seconds / 86400),

View File

@ -1,3 +1,3 @@
export default function (volume: number) {
export default function (volume: number): number {
return Math.min((Math.exp(volume / 100) - 1) / (Math.E - 1), 1);
}

View File

@ -1,4 +1,4 @@
export default function isObject(value) {
export default function isObject(value: any): boolean {
return typeof value === "object"
&& (Object(value) === value)
&& !Array.isArray(value);

View File

@ -1,7 +1,10 @@
import {reactivePick} from "@vueuse/core";
import {keys} from "lodash";
export function pickProps(props, subset) {
export function pickProps<T extends object, K extends T>(
props: T,
subset: K
): Pick<T, keyof K> {
return reactivePick(
props,
...keys(subset)

View File

@ -1,5 +1,5 @@
import {resolveRef, watchOnce} from "@vueuse/core";
import {ref} from "vue";
import {watchOnce} from "@vueuse/core";
import {ref, toRef} from "vue";
/**
* Creates a ref that syncs with its "source" value only once.
@ -7,7 +7,7 @@ import {ref} from "vue";
* subsequent refreshes.
*/
export default function syncOnce(sourceMaybeRef) {
const sourceRef = resolveRef(sourceMaybeRef);
const sourceRef = toRef(sourceMaybeRef);
const newRef = ref(sourceRef.value);
watchOnce(sourceRef, (newVal) => {

View File

@ -3,7 +3,7 @@ import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
export default function useConfirmAndDelete(
confirmMessage,
confirmMessage: string,
onSuccess = null
) {
const {confirmDelete} = useSweetAlert();

View File

@ -4,22 +4,41 @@ import {Ref} from "vue";
export type DataTableTemplateRef = InstanceType<typeof DataTable> | null;
export default function useHasDatatable($datatableRef: Ref<DataTableTemplateRef>) {
/**
* Reset selected rows, active row, and trigger data reload.
*/
const refresh = () => {
return $datatableRef.value?.refresh();
};
/**
* Refresh, but clearing the cache where relevant.
* @see refresh
*/
const relist = () => {
return $datatableRef.value?.relist();
}
/**
* Clear search phrase and current page, then call refresh().
* @see relist
*/
const navigate = () => {
return $datatableRef.value?.navigate();
}
/**
* Set the current search filer string.
* @param newTerm The new search term.
*/
const setFilter = (newTerm: string) => {
return $datatableRef.value?.setFilter(newTerm);
}
/**
* Either set the specified row as active, or disable it if it already is active.
* @param row
*/
const toggleDetails = (row) => {
return $datatableRef.value?.toggleDetails(row);
};

View File

@ -8,7 +8,6 @@ interface EditModalCompatible {
export type EditModalTemplateRef = InstanceType<EditModalCompatible> | null;
export default function useHasEditModal($modalRef: Ref<EditModalTemplateRef>) {
const doCreate = (): void => {
$modalRef.value?.create();

View File

@ -0,0 +1,69 @@
import {useLuxon} from "~/vendor/luxon.ts";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
import {DateTimeMaybeValid} from "luxon";
export default function useStationDateTimeFormatter() {
const {DateTime} = useLuxon();
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation();
const now = (): DateTimeMaybeValid =>
DateTime.local({zone: timezone});
const timestampToDateTime = (value): DateTimeMaybeValid =>
DateTime.fromSeconds(value, {zone: timezone});
const formatDateTime = (
value: DateTimeMaybeValid,
format: Intl.DateTimeFormatOptions
) => value.toLocaleString(
{...format, ...timeConfig}
);
const formatDateTimeAsDateTime = (
value: DateTimeMaybeValid,
format: Intl.DateTimeFormatOptions | null = null
) => formatDateTime(value, format ?? DateTime.DATETIME_MED);
const formatDateTimeAsTime = (
value: DateTimeMaybeValid,
format: Intl.DateTimeFormatOptions | null = null
) => formatDateTime(value, format ?? DateTime.TIME_WITH_SECONDS);
const formatDateTimeAsRelative = (
value: DateTimeMaybeValid
) => value.toRelative();
const formatTimestampAsDateTime = (
value: any,
format: Intl.DateTimeFormatOptions | null = null
) =>
(value)
? formatDateTimeAsDateTime(timestampToDateTime(value), format)
: ''
const formatTimestampAsTime = (
value: any,
format: Intl.DateTimeFormatOptions | null = null
) =>
(value)
? formatDateTimeAsTime(timestampToDateTime(value), format)
: ''
const formatTimestampAsRelative = (value) =>
(value)
? formatDateTimeAsRelative(timestampToDateTime(value))
: '';
return {
now,
timestampToDateTime,
formatDateTime,
formatDateTimeAsDateTime,
formatDateTimeAsTime,
formatDateTimeAsRelative,
formatTimestampAsDateTime,
formatTimestampAsTime,
formatTimestampAsRelative
};
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 4.146a.5.5 0 0 1 .708 0L8 7.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 11.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 11.854a.5.5 0 0 0 .708 0L8 8.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708M2.4 5.2c0 .22.18.4.4.4h10.4a.4.4 0 0 0 0-.8H2.8a.4.4 0 0 0-.4.4"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-double-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 6.646a.5.5 0 0 1 .708 0L8 12.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
<path fill-rule="evenodd" d="M1.646 2.646a.5.5 0 0 1 .708 0L8 8.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-double-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 2.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 3.707 2.354 9.354a.5.5 0 1 1-.708-.708z"/>
<path fill-rule="evenodd" d="M7.646 6.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 7.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 289 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 288 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 290 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-contract" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 14.854a.5.5 0 0 0 .708 0L8 11.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708m0-13.708a.5.5 0 0 1 .708 0L8 4.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708m0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rss-fill" viewBox="0 0 16 16">
<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1 0-2m0 4a6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1 0-2m.5 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1,24 @@
import initApp from "~/layout";
import {h} from "vue";
import {createRouter, createWebHistory} from "vue-router";
import {useAzuraCast} from "~/vendor/azuracast";
import {installRouter} from "~/vendor/router";
import PodcastsLayout from "~/components/Public/Podcasts/PodcastsLayout.vue";
import usePodcastRoutes from "~/components/Public/Podcasts/routes";
initApp({
render() {
return h(PodcastsLayout);
}
}, async (vueApp) => {
const routes = usePodcastRoutes();
const {componentProps} = useAzuraCast();
installRouter(
createRouter({
history: createWebHistory(componentProps.baseUrl),
routes
}),
vueApp
);
});

View File

@ -0,0 +1,9 @@
.badges {
& > * {
margin-right: .25rem;
}
& > *:last-child {
margin-right: 0;
}
}

View File

@ -23,7 +23,7 @@
margin-bottom: 0;
}
& > .alert {
.card-body.alert {
border-left: 0;
border-right: 0;
border-top: 0;

View File

@ -1,3 +1,7 @@
div.datatable-wrapper {
display: contents;
}
div.datatable-toolbar-top,
div.datatable-toolbar-bottom {
&:empty {

View File

@ -61,10 +61,21 @@ body.page-minimal {
.card {
height: 100%;
}
.datatable-main {
overflow-y: auto;
.card-body {
flex: 0 1 auto;
}
// Only make the datatable scrollable if it's the only element in the card.
& > .datatable-wrapper {
.datatable-main {
overflow-y: auto;
}
}
& > .full-height-scrollable {
overflow-y: auto;
}
}
}
}

View File

@ -28,6 +28,7 @@
// Overrides for the Daemonite Material theme
@import "root";
@import 'overrides/badges';
@import 'overrides/body';
@import 'overrides/buttons';
@import 'overrides/card';

View File

@ -11,7 +11,7 @@ export function useTranslate(): Language {
export async function installTranslate(vueApp: App): Promise<void> {
const {locale} = useAzuraCast();
const translations = import.meta.glob('../../../translations/**/translations.json', {as: 'json'});
const translations = import.meta.glob('../../../translations/**/translations.json', {query: '?json'});
const localePath = '../../../translations/' + locale + '.UTF-8/translations.json';
gettext = createGettext({

View File

@ -31,6 +31,10 @@ parameters:
scanDirectories:
- ./vendor/zircote/swagger-php/src/Annotations
stubFiles:
- util/phpstan_di.stub
- util/phpstan_phpdi.stub
universalObjectCratesClasses:
- App\Session\NamespaceInterface
- App\View

View File

@ -19,12 +19,30 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use function in_array;
use function is_array;
/**
* @phpstan-type PermissionsArray array{
* global: array<string, string>,
* station: array<string, string>
* }
*/
final class Acl
{
use RequestAwareTrait;
/**
* @var PermissionsArray
*/
private array $permissions;
/**
* @var null|array<
* int,
* array{
* stations?: array<int, array<string>>,
* global?: array<string>
* }
* >
*/
private ?array $actions;
public function __construct(
@ -41,12 +59,12 @@ final class Acl
{
$sql = $this->em->createQuery(
<<<'DQL'
SELECT rp FROM App\Entity\RolePermission rp
SELECT rp.station_id, rp.role_id, rp.action_name FROM App\Entity\RolePermission rp
DQL
);
$this->actions = [];
foreach ($sql->getArrayResult() as $row) {
foreach ($sql->toIterable() as $row) {
if ($row['station_id']) {
$this->actions[$row['role_id']]['stations'][$row['station_id']][] = $row['action_name'];
} else {
@ -69,12 +87,11 @@ final class Acl
}
/**
* @return mixed[]
* @return array
*/
public function listPermissions(): array
{
if (!isset($this->permissions)) {
/** @var array<string,array<string, string>> $permissions */
$permissions = [
'global' => [],
'station' => [],

View File

@ -8,6 +8,7 @@ use App\Container\EnvironmentAwareTrait;
use App\Entity\Repository\UserRepository;
use App\Entity\User;
use App\Exception\NotLoggedInException;
use App\Utilities\Types;
use Mezzio\Session\SessionInterface;
final class Auth
@ -83,7 +84,7 @@ final class Auth
if (!$this->session->has(self::SESSION_MASQUERADE_USER_ID_KEY)) {
$this->masqueraded_user = false;
} else {
$maskUserId = (int)$this->session->get(self::SESSION_MASQUERADE_USER_ID_KEY);
$maskUserId = Types::int($this->session->get(self::SESSION_MASQUERADE_USER_ID_KEY));
if (0 !== $maskUserId) {
$user = $this->userRepo->getRepository()->find($maskUserId);
} else {
@ -125,7 +126,7 @@ final class Auth
*/
public function isLoginComplete(): bool
{
return $this->session->get(self::SESSION_IS_LOGIN_COMPLETE_KEY, false) ?? false;
return Types::bool($this->session->get(self::SESSION_IS_LOGIN_COMPLETE_KEY, false));
}
/**
@ -136,7 +137,7 @@ final class Auth
public function getUser(): ?User
{
if (null === $this->user) {
$userId = (int)$this->session->get(self::SESSION_USER_ID_KEY);
$userId = Types::int($this->session->get(self::SESSION_USER_ID_KEY));
if (0 === $userId) {
$this->user = false;
@ -238,7 +239,7 @@ final class Auth
$user = $this->getUser();
if (!($user instanceof User)) {
throw new NotLoggedInException();
throw NotLoggedInException::create();
}
if ($user->verifyTwoFactor($otp)) {

View File

@ -6,9 +6,17 @@ namespace App\Cache;
use App\Entity\Api\NowPlaying\NowPlaying;
use App\Entity\Station;
use App\Utilities\Types;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
/**
* @phpstan-type LookupRow array{
* short_name: string,
* is_public: bool,
* updated_at: int
* }
*/
final class NowPlayingCache
{
private const NOWPLAYING_CACHE_TTL = 180;
@ -41,9 +49,13 @@ final class NowPlayingCache
$stationCacheItem = $this->getStationCache($station);
return ($stationCacheItem->isHit())
? $stationCacheItem->get()
: null;
if (!$stationCacheItem->isHit()) {
return null;
}
$np = $stationCacheItem->get();
assert($np instanceof NowPlaying);
return $np;
}
/**
@ -58,6 +70,8 @@ final class NowPlayingCache
}
$np = [];
/** @var LookupRow[] $lookupCache */
$lookupCache = (array)$lookupCacheItem->get();
foreach ($lookupCache as $stationInfo) {
@ -78,11 +92,14 @@ final class NowPlayingCache
return $np;
}
/**
* @return array<int, LookupRow>
*/
public function getLookup(): array
{
$lookupCacheItem = $this->getLookupCache();
return $lookupCacheItem->isHit()
? (array)$lookupCacheItem->get()
? Types::array($lookupCacheItem->get())
: [];
}
@ -114,7 +131,7 @@ final class NowPlayingCache
$lookupCacheItem = $this->getLookupCache();
$lookupCache = $lookupCacheItem->isHit()
? (array)$lookupCacheItem->get()
? Types::array($lookupCacheItem->get())
: [];
$lookupCache[$station->getIdRequired()] = [
@ -131,12 +148,9 @@ final class NowPlayingCache
private function getStationCache(string $identifier): CacheItemInterface
{
if (is_numeric($identifier)) {
$lookupCacheItem = $this->getLookupCache();
$lookupCache = $lookupCacheItem->isHit()
? (array)$lookupCacheItem->get()
: [];
$lookupCache = $this->getLookup();
$identifier = (int)$identifier;
$identifier = Types::int($identifier);
if (isset($lookupCache[$identifier])) {
$identifier = $lookupCache[$identifier]['short_name'];
}

Some files were not shown because too many files have changed in this diff Show More