mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-17 17:38:02 +00:00
feat: podcasts
This commit is contained in:
4
Makefile
4
Makefile
@@ -181,13 +181,13 @@ lint:
|
||||
.PHONY: php-cs-fixer
|
||||
php-cs-fixer:
|
||||
@echo "\x1b[33mFixing PHP files...\x1b[0m"
|
||||
@FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$$'); \
|
||||
@FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$$' | grep -v '^gen/'); \
|
||||
if [ -z "$$FILES" ]; then \
|
||||
echo "No PHP files staged."; \
|
||||
else \
|
||||
echo "Running CS fixer on:" $$FILES; \
|
||||
php -l $$FILES || exit 1; \
|
||||
php vendor-bin/cs-fixer/vendor/php-cs-fixer/shim/php-cs-fixer.phar --config=.php-cs-fixer.dist.php fix $$FILES || exit 1; \
|
||||
PHP_CS_FIXER_IGNORE_ENV=true php vendor-bin/cs-fixer/vendor/php-cs-fixer/shim/php-cs-fixer.phar --config=.php-cs-fixer.dist.php fix $$FILES || exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: format
|
||||
|
||||
@@ -40,13 +40,14 @@ It supports music files, podcasts (with gPodder sync), audiobooks, YouTube video
|
||||
<nextcloud min-version="29" max-version="31"/>
|
||||
</dependencies>
|
||||
|
||||
<!-- <background-jobs>
|
||||
<job>OCA\Jukebox\Cron\FetchCurrenciesJob</job>
|
||||
</background-jobs> -->
|
||||
<background-jobs>
|
||||
<job>OCA\Jukebox\Cron\FetchPodcastEpisodesTask</job>
|
||||
</background-jobs>
|
||||
|
||||
<commands>
|
||||
<command>OCA\Jukebox\Command\ScanMusic</command>
|
||||
<command>OCA\Jukebox\Command\ImportRadioStations</command>
|
||||
<command>OCA\Jukebox\Command\PodcastFetchEpisodes</command>
|
||||
</commands>
|
||||
|
||||
<settings>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"bamarni/composer-bin-plugin": "^1.8",
|
||||
"james-heinrich/getid3": "^1.9"
|
||||
"james-heinrich/getid3": "^1.9",
|
||||
"simplepie/simplepie": "^1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"nextcloud/ocp": "dev-stable29",
|
||||
|
||||
@@ -14,6 +14,7 @@ use OCP\AppFramework\OCSController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class {{pascalCase name}}Controller extends OCSController {
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ class {{pascalCase name}}Controller extends OCSController {
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ use OCP\AppFramework\Db\Entity;
|
||||
class {{pascalCase name}} extends Entity implements JsonSerializable {
|
||||
// protected $fieldName;
|
||||
|
||||
public function __construct() {
|
||||
// $this->addType('fieldName', 'type');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
// 'field_name' => $this->fieldName,
|
||||
// 'field_name' => $this->getFieldName(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Command;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxRadioStationMapper;
|
||||
use OCA\Jukebox\Db\RadioStationMapper;
|
||||
use OCA\Jukebox\Service\RadioSourcesService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
class ImportRadioStations extends Command {
|
||||
public function __construct(
|
||||
private RadioSourcesService $service,
|
||||
private JukeboxRadioStationMapper $stationMapper,
|
||||
private RadioStationMapper $stationMapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
73
lib/Command/PodcastFetchEpisodes.php
Normal file
73
lib/Command/PodcastFetchEpisodes.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Jukebox\Command;
|
||||
|
||||
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
|
||||
use OCA\Jukebox\Service\PodcastEpisodeWriterService;
|
||||
use OCA\Jukebox\Service\PodcastFeedParserService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class PodcastFetchEpisodes extends Command {
|
||||
public function __construct(
|
||||
private PodcastSubscriptionMapper $subMapper,
|
||||
private PodcastFeedParserService $parser,
|
||||
private PodcastEpisodeWriterService $writer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('jukebox:podcast-fetch-episodes')
|
||||
->setDescription('Fetch new podcast episodes for all or specific subscriptions')
|
||||
->addArgument('userId', InputArgument::OPTIONAL, 'User ID')
|
||||
->addArgument('subscriptionId', InputArgument::OPTIONAL, 'Subscription ID');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$userId = $input->getArgument('userId');
|
||||
$subscriptionId = $input->getArgument('subscriptionId');
|
||||
|
||||
$output->writeln('<info>Running FetchPodcastEpisodesTask</info>');
|
||||
if ($userId && $subscriptionId) {
|
||||
$output->writeln("Arguments received: userId={$userId}, subscriptionId={$subscriptionId}");
|
||||
$sub = $this->subMapper->find($userId, $subscriptionId);
|
||||
$allSubs = $sub ? [$sub] : [];
|
||||
} else {
|
||||
$output->writeln('<info>No specific arguments received. Fetching all subscribed feeds.</info>');
|
||||
$allSubs = $this->subMapper->findAllSubscribed();
|
||||
}
|
||||
|
||||
foreach ($allSubs as $sub) {
|
||||
$userId = $sub->getUserId();
|
||||
$url = $sub->getUrl();
|
||||
|
||||
if (!$userId || !$url) {
|
||||
$output->writeln("<comment>Skipping sub {$sub->getId()} due to missing userId or url</comment>");
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeln("<info>Fetching episodes for user {$userId} from {$url}</info>");
|
||||
|
||||
try {
|
||||
$episodes = $this->parser->parseEpisodes($url);
|
||||
$this->writer->storeEpisodes($userId, $sub, $episodes);
|
||||
$output->writeln('<info>Fetched ' . count($episodes) . ' episodes</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln("<error>Failed to fetch episodes for {$url}: {$e->getMessage()}</error>");
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxMusicMapper;
|
||||
use OCA\Jukebox\Db\TrackMapper;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
@@ -29,7 +30,7 @@ class MusicController extends OCSController {
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private JukeboxMusicMapper $musicMapper,
|
||||
private TrackMapper $musicMapper,
|
||||
private IUserSession $userSession,
|
||||
private IRootFolder $rootFolder,
|
||||
private LoggerInterface $logger,
|
||||
@@ -44,6 +45,7 @@ class MusicController extends OCSController {
|
||||
*
|
||||
* 200: List of media tracks for current user
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/tracks')]
|
||||
public function listTracks(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -70,8 +72,9 @@ class MusicController extends OCSController {
|
||||
* 403: Track does not belong to current user
|
||||
* 404: Track file or record not found
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/tracks/{id}/stream')]
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/tracks/{id}/stream')]
|
||||
public function streamTrack(int $id): FileDisplayResponse|JSONResponse {
|
||||
$this->logger->info('Received request to stream track with ID: ' . $id);
|
||||
|
||||
@@ -112,6 +115,7 @@ class MusicController extends OCSController {
|
||||
*
|
||||
* 200: Grouped albums and their tracks
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/albums')]
|
||||
public function listAlbums(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -162,6 +166,7 @@ class MusicController extends OCSController {
|
||||
*
|
||||
* 200: Album and its tracks
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/albums/{artist}/{album}')]
|
||||
public function getAlbumById(string $artist, string $album): JSONResponse {
|
||||
try {
|
||||
@@ -217,6 +222,7 @@ class MusicController extends OCSController {
|
||||
*
|
||||
* 200: List of unique artists
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/artists')]
|
||||
public function listArtists(): JSONResponse {
|
||||
try {
|
||||
@@ -248,6 +254,7 @@ class MusicController extends OCSController {
|
||||
*
|
||||
* 200: Artist details, their albums and tracks
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/music/artists/{id}')]
|
||||
public function getArtistById(string $id): JSONResponse {
|
||||
$this->logger->info('Received request to get artist by ID: ' . $id);
|
||||
|
||||
474
lib/Controller/PodcastController.php
Normal file
474
lib/Controller/PodcastController.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\Cron\FetchPodcastEpisodesTask;
|
||||
use OCA\Jukebox\Db\PodcastEpisode;
|
||||
use OCA\Jukebox\Db\PodcastEpisodeMapper;
|
||||
use OCA\Jukebox\Db\PodcastEpisodePlay;
|
||||
use OCA\Jukebox\Db\PodcastEpisodePlayMapper;
|
||||
use OCA\Jukebox\Db\PodcastSubscription;
|
||||
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
|
||||
use OCA\Jukebox\Service\PodcastFeedParserService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class PodcastController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private LoggerInterface $logger,
|
||||
private IUserSession $userSession,
|
||||
private PodcastSubscriptionMapper $subMapper,
|
||||
private PodcastFeedParserService $parser,
|
||||
private PodcastEpisodePlayMapper $playMapper,
|
||||
private PodcastEpisodeMapper $epMapper,
|
||||
private IJobList $jobList,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all podcast subscriptions for the current user
|
||||
*
|
||||
* @return JSONResponse<Http::STATUS_OK, list<array{
|
||||
* id: int,
|
||||
* url: string,
|
||||
* subscribed: bool,
|
||||
* updated: string
|
||||
* }>, array{}>
|
||||
*
|
||||
* 200: Subscriptions listed
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/podcasts/subscriptions')]
|
||||
public function getSubscriptions(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$subscriptions = $this->subMapper->findAllBySubscribed($user->getUID(), true);
|
||||
$data = array_map(static fn (PodcastSubscription $s) => $s->jsonSerialize(), $subscriptions);
|
||||
|
||||
return new JSONResponse(['subscriptions' => $data], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a podcast by URL
|
||||
*
|
||||
* @param string $url The podcast feed URL
|
||||
* @return JSONResponse<Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_OK, array{
|
||||
* subscription: array{
|
||||
* id: int,
|
||||
* url: string,
|
||||
* subscribed: bool,
|
||||
* updated: string,
|
||||
* title: string,
|
||||
* author: string,
|
||||
* description: string,
|
||||
* image: string
|
||||
* }}, array{}>
|
||||
*
|
||||
* 201: Subscription created
|
||||
* 200: Subscription updated
|
||||
* 400: Invalid request
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/podcasts/subscriptions')]
|
||||
public function subscribe(string $url): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
$userId = $user->getUID();
|
||||
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$this->logger->error('Invalid podcast URL provided', ['url' => $url, 'userId' => $userId]);
|
||||
return new JSONResponse(['error' => 'Invalid URL'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = $this->subMapper->findByUrl($userId, $url);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
$existing = null;
|
||||
}
|
||||
|
||||
$now = new \DateTime();
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->setSubscribed(true);
|
||||
$existing->setUpdated($now);
|
||||
$this->subMapper->update($existing);
|
||||
|
||||
$this->logger->info('Podcast subscription updated', ['url' => $url, 'userId' => $userId]);
|
||||
return new JSONResponse(['subscription' => $existing], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
try {
|
||||
$feed = $this->parser->parseSubscriptionMetadata($url);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->logger->error('Failed to parse podcast feed', [
|
||||
'url' => $url,
|
||||
'userId' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return new JSONResponse(['error' => 'Failed to parse feed'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$imageBase64 = null;
|
||||
if (!empty($feed['imageUrl']) && filter_var($feed['imageUrl'], FILTER_VALIDATE_URL)) {
|
||||
try {
|
||||
$imageData = @file_get_contents($feed['imageUrl']);
|
||||
if ($imageData !== false) {
|
||||
$mimeType = finfo_buffer(finfo_open(), $imageData, FILEINFO_MIME_TYPE);
|
||||
$imageBase64 = 'data:' . $mimeType . ';base64,' . base64_encode($imageData);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Failed to fetch or encode podcast image', [
|
||||
'url' => $feed['imageUrl'],
|
||||
'userId' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$subscription = new PodcastSubscription();
|
||||
$subscription->setUrl($url);
|
||||
$subscription->setSubscribed(true);
|
||||
$subscription->setUpdated($now);
|
||||
$subscription->setUserId($userId);
|
||||
$subscription->setTitle($feed['title']);
|
||||
$subscription->setAuthor($feed['author']);
|
||||
$subscription->setDescription($feed['description']);
|
||||
$subscription->setImage($imageBase64);
|
||||
|
||||
$this->subMapper->insert($subscription);
|
||||
$this->logger->info('Podcast subscription created', ['url' => $url, 'userId' => $userId]);
|
||||
$this->jobList->add(FetchPodcastEpisodesTask::class, ['userId' => $userId, 'subscriptionId' => $subscription->getId()]);
|
||||
return new JSONResponse(['subscription' => $subscription->jsonSerialize()], Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a podcast playback action
|
||||
*
|
||||
* @param int $id Episode ID
|
||||
* @param string $guid Episode GUID
|
||||
* @param string $action e.g. "play", "pause", "complete"
|
||||
* @param int $timestamp UNIX timestamp
|
||||
* @param int|null $position Position in seconds
|
||||
* @param int|null $total Duration in seconds
|
||||
* @param string|null $device Device name or ID
|
||||
*
|
||||
* @return JSONResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST, array{}, array{}>
|
||||
*
|
||||
* 200: Action logged
|
||||
* 400: Invalid input
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/podcasts/track')]
|
||||
public function trackAction(
|
||||
int $id,
|
||||
string $guid,
|
||||
string $action,
|
||||
int $timestamp,
|
||||
?int $position = null,
|
||||
?int $total = null,
|
||||
?string $device = null,
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!in_array($action, ['play', 'pause', 'complete', 'resume'], true)) {
|
||||
return new JSONResponse(['error' => 'Invalid action'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$entry = new PodcastEpisodePlay();
|
||||
$entry->setUserId($user->getUID());
|
||||
$entry->setEpisodeId($id);
|
||||
$entry->setEpisodeGuid($guid);
|
||||
$entry->setAction($action);
|
||||
$entry->setTimestamp($timestamp);
|
||||
$entry->setPosition($position);
|
||||
$entry->setTotal($total);
|
||||
$entry->setDevice($device);
|
||||
|
||||
$this->playMapper->insert($entry);
|
||||
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next unfinished episode per podcast
|
||||
*
|
||||
* @return JSONResponse<Http::STATUS_OK, array{
|
||||
* episodes: list<array{
|
||||
* id: int,
|
||||
* title: string|null,
|
||||
* guid: string|null,
|
||||
* pub_date: string|null,
|
||||
* duration: int|null,
|
||||
* media_url: string|null,
|
||||
* description: string|null,
|
||||
* subscription_data_id: int
|
||||
* }>
|
||||
* }, array{}>
|
||||
*
|
||||
* 200: Next episodes listed
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/podcasts/next')]
|
||||
public function getNextEpisodes(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
$userId = $user->getUID();
|
||||
|
||||
$subs = $this->subMapper->findAllBySubscribed($userId, true);
|
||||
$results = [];
|
||||
|
||||
foreach ($subs as $sub) {
|
||||
$episodes = $this->epMapper->findBySubscription($sub->getId());
|
||||
|
||||
usort($episodes, fn (PodcastEpisode $a, PodcastEpisode $b) =>
|
||||
($a->getPubDate()?->getTimestamp() ?? 0) <=> ($b->getPubDate()?->getTimestamp() ?? 0)
|
||||
);
|
||||
|
||||
foreach ($episodes as $ep) {
|
||||
$duration = $ep->getDuration();
|
||||
if (!$duration || $duration < 60) {
|
||||
continue; // skip if no valid duration
|
||||
}
|
||||
|
||||
$play = $this->playMapper->findLatestPlay($userId, (string)$ep->getGuid());
|
||||
$progress = $play && $play->getPosition() !== null
|
||||
? ($play->getPosition() / $duration)
|
||||
: 0;
|
||||
|
||||
if ($progress < 0.98) {
|
||||
$results[] = [
|
||||
'id' => $ep->getId(),
|
||||
'title' => $ep->getTitle(),
|
||||
'guid' => $ep->getGuid(),
|
||||
'pub_date' => $ep->getPubDate()?->format(DATE_ATOM),
|
||||
'duration' => $duration,
|
||||
'media_url' => $ep->getMediaUrl(),
|
||||
'description' => $ep->getDescription(),
|
||||
'subscription_id' => $ep->getSubscriptionId(),
|
||||
];
|
||||
break; // only the first unfinished one per subscription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new JSONResponse(['episodes' => $results], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single podcast subscription
|
||||
*
|
||||
* @param int $id the subscription ID
|
||||
* @return JSONResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, array{
|
||||
* subscription: array<string, mixed>
|
||||
* }, array{}>
|
||||
*
|
||||
* 200: Subscription found
|
||||
* 404: Subscription not found
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/podcasts/subscriptions/{id}')]
|
||||
public function getSubscription(int $id): JSONResponse {
|
||||
$this->logger->debug('Fetching podcast subscription', ['id' => $id]);
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$sub = $this->subMapper->find($user->getUID(), $id);
|
||||
return new JSONResponse(['subscription' => $sub->jsonSerialize()], Http::STATUS_OK);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
$this->logger->error('Podcast subscription not found', ['id' => $id, 'userId' => $user->getUID()]);
|
||||
return new JSONResponse(['error' => 'Not found'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all episodes for a podcast subscription
|
||||
*
|
||||
* @param int $id the subscription ID
|
||||
* @return JSONResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, array{
|
||||
* episodes: list<array<string, mixed>>
|
||||
* }, array{}>
|
||||
*
|
||||
* 200: Episodes listed
|
||||
* 404: Subscription not found
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/podcasts/subscriptions/{id}/episodes')]
|
||||
public function getEpisodesForSubscription(int $id): JSONResponse {
|
||||
$this->logger->debug('Fetching podcast episodes for subscription', ['id' => $id]);
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->subMapper->find($user->getUID(), $id);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
$this->logger->error('Podcast subscription not found', ['id' => $id, 'userId' => $user->getUID()]);
|
||||
return new JSONResponse(['error' => 'Subscription not found'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
$episodes = $this->epMapper->findBySubscription($user->getUID(), $id);
|
||||
|
||||
usort($episodes, fn ($a, $b) =>
|
||||
($b->getPubDate()?->getTimestamp() ?? 0) <=> ($a->getPubDate()?->getTimestamp() ?? 0)
|
||||
);
|
||||
|
||||
return new JSONResponse(['episodes' => array_map(fn ($ep) => $ep->jsonSerialize(), $episodes)], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a podcast episode
|
||||
*
|
||||
* @param int $id Episode ID
|
||||
* @param string|null $range Optional HTTP Range header for seeking support
|
||||
*
|
||||
* @return StreamResponse<Http::STATUS_OK, mixed>
|
||||
* @return StreamResponse<Http::STATUS_PARTIAL_CONTENT, mixed>
|
||||
* @return JSONResponse<Http::STATUS_UNAUTHORIZED, array{ message: string }, array{}>
|
||||
* @return JSONResponse<Http::STATUS_NOT_FOUND, array{ message: string }, array{}>
|
||||
* @return JSONResponse<Http::STATUS_BAD_REQUEST, array{ message: string }, array{}>
|
||||
* @return JSONResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{ message: string }, array{}>
|
||||
*
|
||||
* 200: Full content stream returned
|
||||
* 206: Partial content stream returned
|
||||
* 400: Invalid or missing media URL
|
||||
* 401: User is not authenticated
|
||||
* 404: Episode not found
|
||||
* 500: Error occurred while streaming
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/podcasts/episodes/{id}/stream')]
|
||||
public function streamEpisode(
|
||||
int $id,
|
||||
?string $range = null,
|
||||
): Response {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
$episode = $this->epMapper->find($user->getUID(), $id);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Episode not found'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
$url = $episode->getMediaUrl();
|
||||
if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Invalid media URL'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rangeHeader = $this->request->getHeader('range');
|
||||
$headers = [];
|
||||
if ($rangeHeader !== null) {
|
||||
$headers[] = 'range: ' . $rangeHeader;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $headerSize === false) {
|
||||
$this->logger->error('Failed to stream podcast episode via cURL', [
|
||||
'userId' => $user->getUID(),
|
||||
'episodeId' => $id,
|
||||
]);
|
||||
return new JSONResponse(['message' => 'Stream failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$headersText = substr($response, 0, $headerSize);
|
||||
$body = substr($response, $headerSize);
|
||||
|
||||
$lines = explode("\r\n", $headersText);
|
||||
$statusLine = array_shift($lines);
|
||||
|
||||
// Start clean output
|
||||
header_remove();
|
||||
foreach ($lines as $line) {
|
||||
if (stripos($line, 'Content-Type:') === 0 ||
|
||||
stripos($line, 'Content-Range:') === 0 ||
|
||||
stripos($line, 'Content-Length:') === 0 ||
|
||||
stripos($line, 'Accept-Ranges:') === 0) {
|
||||
header($line, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Always allow browser cache
|
||||
header('Cache-Control: public, max-age=31536000');
|
||||
header('Content-Transfer-Encoding: binary');
|
||||
|
||||
http_response_code($statusCode);
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last known playback position for a podcast episode
|
||||
*
|
||||
* @param int $id Episode ID
|
||||
*
|
||||
* @return JSONResponse<Http::STATUS_OK, array{ position: int }, array{}>
|
||||
* @return JSONResponse<Http::STATUS_UNAUTHORIZED, array{ message: string }, array{}>
|
||||
* @return JSONResponse<Http::STATUS_NOT_FOUND, array{ message: string }, array{}>
|
||||
*
|
||||
* 200: Playback position returned
|
||||
* 401: User not authenticated
|
||||
* 404: Episode not found
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/podcasts/episodes/{id}/position')]
|
||||
public function getEpisodePosition(int $id): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$position = $this->playMapper->getPositionForEpisode($user->getUID(), $id);
|
||||
|
||||
return new JSONResponse(['position' => $position ?? 0]);
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxRadioStation;
|
||||
use OCA\Jukebox\Db\JukeboxRadioStationMapper;
|
||||
use OCA\Jukebox\Db\RadioStation;
|
||||
use OCA\Jukebox\Db\RadioStationMapper;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IAppConfig;
|
||||
@@ -30,7 +30,7 @@ class RadioController extends OCSController {
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private JukeboxRadioStationMapper $stationMapper,
|
||||
private RadioStationMapper $stationMapper,
|
||||
private IUserSession $userSession,
|
||||
private IClientService $httpClientService,
|
||||
private LoggerInterface $logger,
|
||||
@@ -47,6 +47,7 @@ class RadioController extends OCSController {
|
||||
*
|
||||
* 200: List of radio stations returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/stations')]
|
||||
public function index(int $offset = 0, int $limit = 50): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -67,6 +68,7 @@ class RadioController extends OCSController {
|
||||
*
|
||||
* 200: List of radio stations returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/favorites')]
|
||||
public function favorites(int $offset = 0, int $limit = 50): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -86,6 +88,7 @@ class RadioController extends OCSController {
|
||||
*
|
||||
* 200: Matching radio stations returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/search/{name}')]
|
||||
public function search(string $name): JSONResponse {
|
||||
try {
|
||||
@@ -102,7 +105,7 @@ class RadioController extends OCSController {
|
||||
if (!isset($item['stationuuid'])) {
|
||||
continue;
|
||||
}
|
||||
$station = new JukeboxRadioStation();
|
||||
$station = new RadioStation();
|
||||
$station->setRemoteUuid($item['stationuuid']);
|
||||
$station->setName($item['name'] ?? '');
|
||||
$station->setStreamUrl($item['url_resolved'] ?? '');
|
||||
@@ -134,6 +137,7 @@ class RadioController extends OCSController {
|
||||
*
|
||||
* 200: Radio station returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/{uuid}')]
|
||||
public function getByUuid(string $uuid): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -153,6 +157,7 @@ class RadioController extends OCSController {
|
||||
*
|
||||
* 200: Station was added successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/radio/stations')]
|
||||
public function addByUuid(array $station): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -161,7 +166,7 @@ class RadioController extends OCSController {
|
||||
}
|
||||
|
||||
try {
|
||||
$stationEntity = new JukeboxRadioStation();
|
||||
$stationEntity = new RadioStation();
|
||||
$stationEntity->setUserId($user->getUID());
|
||||
$stationEntity->setRemoteUuid($station['remoteUuid']);
|
||||
$stationEntity->setName($station['name'] ?? '');
|
||||
@@ -198,6 +203,7 @@ class RadioController extends OCSController {
|
||||
*
|
||||
* 200: Station was added successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/radio/stations/{uuid}')]
|
||||
public function updateByUuid(string $uuid, array $station): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -279,6 +285,7 @@ class RadioController extends OCSController {
|
||||
* 401: Unauthenticated
|
||||
* 404: Station not found
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/radio/stations/{uuid}')]
|
||||
public function deleteByUuid(string $uuid): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -301,38 +308,6 @@ class RadioController extends OCSController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user to the actual radio stream URL
|
||||
*
|
||||
* @param string $uuid Remote UUID of the radio station
|
||||
* @return RedirectResponse|JSONResponse
|
||||
*
|
||||
* 302: Redirect to stream URL
|
||||
* 401: Unauthenticated
|
||||
* 404: Station not found
|
||||
*/
|
||||
// #[NoCSRFRequired]
|
||||
// #[ApiRoute(verb: 'GET', url: '/api/radio/{uuid}/stream')]
|
||||
// public function streamByUuid(string $uuid): \OCP\AppFramework\Http\Response {
|
||||
// $user = $this->userSession->getUser();
|
||||
// if (!$user) {
|
||||
// return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED, []);
|
||||
// }
|
||||
//
|
||||
// $station = $this->stationMapper->findByRemoteUuid($user->getUID(), $uuid);
|
||||
// if (!$station) {
|
||||
// return new JSONResponse(['message' => 'Station not found'], Http::STATUS_NOT_FOUND, []);
|
||||
// }
|
||||
//
|
||||
// $streamUrl = $station->getStreamUrl();
|
||||
// if (!$streamUrl) {
|
||||
// return new JSONResponse(['message' => 'Station has no stream URL'], Http::STATUS_BAD_REQUEST, []);
|
||||
// }
|
||||
//
|
||||
// return new RedirectResponse($streamUrl);
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* Stream a radio station by its UUID
|
||||
*
|
||||
@@ -344,6 +319,7 @@ class RadioController extends OCSController {
|
||||
* 200: Streaming audio content
|
||||
* 500: Internal error while fetching stream
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/radio/{uuid}/stream')]
|
||||
public function streamByUuid(string $uuid): \OCP\AppFramework\Http\Response {
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace OCA\Jukebox\Controller;
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
@@ -40,6 +41,7 @@ class SettingsController extends OCSController {
|
||||
*
|
||||
* 200: Settings saved
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/settings')]
|
||||
public function saveSettings(mixed $data): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
@@ -63,6 +65,7 @@ class SettingsController extends OCSController {
|
||||
*
|
||||
* 200: Current settings
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/settings')]
|
||||
public function getSettings(): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
|
||||
77
lib/Cron/FetchPodcastEpisodesTask.php
Normal file
77
lib/Cron/FetchPodcastEpisodesTask.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Cron;
|
||||
|
||||
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
|
||||
use OCA\Jukebox\Service\PodcastEpisodeWriterService;
|
||||
use OCA\Jukebox\Service\PodcastFeedParserService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class FetchPodcastEpisodesTask extends TimedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private LoggerInterface $logger,
|
||||
private PodcastSubscriptionMapper $subMapper,
|
||||
private PodcastFeedParserService $parser,
|
||||
private PodcastEpisodeWriterService $writer,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
|
||||
// Run once an hour
|
||||
$this->setInterval(3600);
|
||||
}
|
||||
|
||||
protected function run($arguments): void {
|
||||
$this->logger->info('Running FetchPodcastEpisodesTask', [
|
||||
'arguments' => $arguments,
|
||||
]);
|
||||
if (isset($arguments['userId']) && isset($arguments['subscriptionId'])) {
|
||||
$sub = $this->subMapper->find($arguments['userId'], $arguments['subscriptionId']);
|
||||
$allSubs = $sub ? [$sub] : [];
|
||||
} else {
|
||||
$allSubs = $this->subMapper->findAllSubscribed();
|
||||
}
|
||||
|
||||
foreach ($allSubs as $sub) {
|
||||
$userId = $sub->getUserId();
|
||||
$url = $sub->getUrl();
|
||||
|
||||
if (!$userId || !$url) {
|
||||
$this->logger->warning('Skipping podcast subscription with missing userId or url', [
|
||||
'subscriptionId' => $sub->getId(),
|
||||
'userId' => $userId,
|
||||
'url' => $url,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->info('Fetching podcast episodes', [
|
||||
'userId' => $userId,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
try {
|
||||
$parsed = $this->parser->parseEpisodes($url);
|
||||
$this->writer->storeEpisodes($userId, $sub, $parsed['episodes']);
|
||||
$this->logger->info('Fetched podcast episodes', [
|
||||
'userId' => $userId,
|
||||
'url' => $url,
|
||||
'episodesCount' => count($parsed['episodes']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to fetch podcast episodes', [
|
||||
'userId' => $userId,
|
||||
'url' => $url,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
lib/Db/GpodderPodcastEpisodeAction.php
Normal file
61
lib/Db/GpodderPodcastEpisodeAction.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method string getPodcast()
|
||||
* @method void setPodcast(string $podcast)
|
||||
* @method string getEpisode()
|
||||
* @method void setEpisode(string $episode)
|
||||
* @method string getAction()
|
||||
* @method void setAction(string $action)
|
||||
* @method int getPosition()
|
||||
* @method void setPosition(int $position)
|
||||
* @method int getStarted()
|
||||
* @method void setStarted(int $started)
|
||||
* @method int getTotal()
|
||||
* @method void setTotal(int $total)
|
||||
* @method string getTimestamp()
|
||||
* @method void setTimestamp(string $timestamp)
|
||||
* @method int getTimestampEpoch()
|
||||
* @method void setTimestampEpoch(int $timestampEpoch)
|
||||
* @method string|null getGuid()
|
||||
* @method void setGuid(?string $guid)
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $userId)
|
||||
*/
|
||||
class GpodderPodcastEpisodeAction extends Entity implements JsonSerializable {
|
||||
protected string $podcast;
|
||||
protected string $episode;
|
||||
protected string $action;
|
||||
protected int $position = -1;
|
||||
protected int $started = -1;
|
||||
protected int $total = -1;
|
||||
protected string $timestamp;
|
||||
protected int $timestampEpoch;
|
||||
protected ?string $guid = null;
|
||||
protected string $userId;
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'podcast' => $this->getPodcast(),
|
||||
'episode' => $this->getEpisode(),
|
||||
'action' => $this->getAction(),
|
||||
'position' => $this->getPosition(),
|
||||
'started' => $this->getStarted(),
|
||||
'total' => $this->getTotal(),
|
||||
'timestamp' => $this->getTimestamp(),
|
||||
'timestampEpoch' => $this->getTimestampEpoch(),
|
||||
'guid' => $this->getGuid(),
|
||||
'userId' => $this->getUserId(),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
lib/Db/GpodderPodcastEpisodeActionMapper.php
Normal file
54
lib/Db/GpodderPodcastEpisodeActionMapper.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<GpoddePodcastEpisodeAction>
|
||||
*/
|
||||
class GpoddePodcastEpisodeActionMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, 'gpodder_episode_action', GpoddePodcastEpisodeAction::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $userId, string $id): GpoddePodcastEpisodeAction {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()
|
||||
->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $projectId
|
||||
* @return array<GpoddePodcastEpisodeAction>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
44
lib/Db/GpodderPodcastSubscription.php
Normal file
44
lib/Db/GpodderPodcastSubscription.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method string getUserId()
|
||||
* @method void setUserId($value)
|
||||
* @method string getUrl()
|
||||
* @method void setUrl($value)
|
||||
* @method bool getSubscribed()
|
||||
* @method void setSubscribed($value)
|
||||
* @method int getUpdated()
|
||||
* @method void setUpdated($value)
|
||||
*/
|
||||
class GpoddePodcastSubscription extends Entity implements JsonSerializable {
|
||||
protected $userId = '';
|
||||
protected $url = '';
|
||||
protected $subscribed = false;
|
||||
protected $updated = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('userId', 'string');
|
||||
$this->addType('url', 'string');
|
||||
$this->addType('subscribed', 'bool');
|
||||
$this->addType('updated', 'int');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'url' => $this->getUrl(),
|
||||
'subscribed' => $this->getSubscribed(),
|
||||
'updated' => $this->getUpdated(),
|
||||
];
|
||||
}
|
||||
}
|
||||
99
lib/Db/GpodderPodcastSubscriptionMapper.php
Normal file
99
lib/Db/GpodderPodcastSubscriptionMapper.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<GpoddePodcastSubscription>
|
||||
*/
|
||||
class GpoddePodcastSubscriptionMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, 'gpodder_subscriptions', GpoddePodcastSubscription::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $id): GpoddePodcastSubscription {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function findByUrl(string $userId, string $url): ?GpoddePodcastSubscription {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('url', $qb->createNamedParameter($url, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()
|
||||
->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function remove(string $id): void {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param bool $subscribed
|
||||
* @return array<GpoddePodcastSubscription>
|
||||
*/
|
||||
public function findAllBySubscribed(string $userId, bool $subscribed): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('subscribed', $qb->createNamedParameter($subscribed, IQueryBuilder::PARAM_BOOL))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()
|
||||
->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $projectId
|
||||
* @return array<GpoddePodcastSubscription>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
67
lib/Db/PodcastEpisode.php
Normal file
67
lib/Db/PodcastEpisode.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int|null getActionId()
|
||||
* @method void setActionId(?int $id)
|
||||
* @method int getSubscriptionId()
|
||||
* @method void setSubscriptionId(int $id)
|
||||
* @method string|null getTitle()
|
||||
* @method void setTitle(?string $title)
|
||||
* @method string|null getGuid()
|
||||
* @method void setGuid(?string $guid)
|
||||
* @method \DateTimeInterface|null getPubDate()
|
||||
* @method void setPubDate(?\DateTimeInterface $date)
|
||||
* @method int|null getDuration()
|
||||
* @method void setDuration(?int $duration)
|
||||
* @method string|null getMediaUrl()
|
||||
* @method void setMediaUrl(?string $url)
|
||||
* @method string|null getDescription()
|
||||
* @method void setDescription(?string $desc)
|
||||
* @method string|null getUserId()
|
||||
* @method void setUserId(?string $uid)
|
||||
*/
|
||||
class PodcastEpisode extends Entity implements JsonSerializable {
|
||||
protected ?int $actionId = null;
|
||||
protected ?int $subscriptionId = null;
|
||||
protected ?string $title = null;
|
||||
protected ?string $guid = null;
|
||||
protected ?\DateTimeInterface $pubDate = null;
|
||||
protected ?int $duration = null;
|
||||
protected ?string $mediaUrl = null;
|
||||
protected ?string $description = null;
|
||||
protected ?string $userId = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('actionId', 'integer');
|
||||
$this->addType('subscriptionId', 'integer');
|
||||
$this->addType('title', 'string');
|
||||
$this->addType('guid', 'string');
|
||||
$this->addType('pubDate', 'datetime');
|
||||
$this->addType('duration', 'integer');
|
||||
$this->addType('mediaUrl', 'string');
|
||||
$this->addType('description', 'string');
|
||||
$this->addType('userId', 'string');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'action_id' => $this->getActionId(),
|
||||
'subscription_id' => $this->getSubscriptionId(),
|
||||
'title' => $this->getTitle(),
|
||||
'guid' => $this->getGuid(),
|
||||
'pub_date' => $this->getPubDate()?->format(DATE_ATOM),
|
||||
'duration' => $this->getDuration(),
|
||||
'media_url' => $this->getMediaUrl(),
|
||||
'description' => $this->getDescription(),
|
||||
'user_id' => $this->getUserId(),
|
||||
];
|
||||
}
|
||||
}
|
||||
84
lib/Db/PodcastEpisodeMapper.php
Normal file
84
lib/Db/PodcastEpisodeMapper.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<PodcastEpisode>
|
||||
*/
|
||||
class PodcastEpisodeMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('podcast_eps'), PodcastEpisode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
* @return PodcastEpisode
|
||||
*/
|
||||
public function findByGpodderId(string $userId, int $gpodderActionId): PodcastEpisode {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('action_id', $qb->createNamedParameter($gpodderActionId)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
* @return PodcastEpisode
|
||||
*/
|
||||
public function findByGuid(string $userId, string $guid): ?PodcastEpisode {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('guid', $qb->createNamedParameter($guid)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
* @return PodcastEpisode
|
||||
*/
|
||||
public function find(string $userId, int $id): PodcastEpisode {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return PodcastEpisode[]
|
||||
*/
|
||||
public function findAll(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param int $subscriptionId
|
||||
* @return PodcastEpisode[]
|
||||
*/
|
||||
public function findBySubscription(string $userId, int $subscriptionId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('subscription_id', $qb->createNamedParameter($subscriptionId)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
68
lib/Db/PodcastEpisodePlay.php
Normal file
68
lib/Db/PodcastEpisodePlay.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getId()
|
||||
* @method void setId(int $id)
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $userId)
|
||||
* @method int getEpisodeId()
|
||||
* @method void setEpisodeId(int $episodeGuid)
|
||||
* @method string getEpisodeGuid()
|
||||
* @method void setEpisodeGuid(string $episodeGuid)
|
||||
* @method string getAction()
|
||||
* @method void setAction(string $action)
|
||||
* @method int getTimestamp()
|
||||
* @method void setTimestamp(int $timestamp)
|
||||
* @method int|null getPosition()
|
||||
* @method void setPosition(?int $position)
|
||||
* @method int|null getTotal()
|
||||
* @method void setTotal(?int $total)
|
||||
* @method string|null getDevice()
|
||||
* @method void setDevice(?string $device)
|
||||
*/
|
||||
class PodcastEpisodePlay extends Entity implements JsonSerializable {
|
||||
protected string $userId = '';
|
||||
protected string $episodeGuid = '';
|
||||
protected int $episodeId = 0;
|
||||
protected string $action = '';
|
||||
protected int $timestamp = 0;
|
||||
protected ?int $position = null;
|
||||
protected ?int $total = null;
|
||||
protected ?string $device = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', 'int');
|
||||
$this->addType('userId', 'string');
|
||||
$this->addType('episodeGuid', 'string');
|
||||
$this->addType('episodeId', 'string');
|
||||
$this->addType('action', 'string');
|
||||
$this->addType('timestamp', 'int');
|
||||
$this->addType('position', 'int');
|
||||
$this->addType('total', 'int');
|
||||
$this->addType('device', 'string');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'userId' => $this->getUserId(),
|
||||
'episodeId' => $this->getEpisodeGuid(),
|
||||
'episodeGuid' => $this->getEpisodeGuid(),
|
||||
'action' => $this->getAction(),
|
||||
'timestamp' => $this->getTimestamp(),
|
||||
'position' => $this->getPosition(),
|
||||
'total' => $this->getTotal(),
|
||||
'device' => $this->getDevice(),
|
||||
];
|
||||
}
|
||||
}
|
||||
97
lib/Db/PodcastEpisodePlayMapper.php
Normal file
97
lib/Db/PodcastEpisodePlayMapper.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<PodcastEpisodePlay>
|
||||
*/
|
||||
class PodcastEpisodePlayMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('podcast_ep_plays'), PodcastEpisodePlay::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find play for a given user and episode ID
|
||||
*
|
||||
* @param string $userId
|
||||
* @param int $id
|
||||
* @return PodcastEpisodePlay
|
||||
*/
|
||||
public function findOneByEpisodeId(string $userId, int $episodeId): PodcastEpisodePlay {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('episode_id', $qb->createNamedParameter($episodeId)))
|
||||
->orderBy('timestamp', 'DESC')
|
||||
->setMaxResults(1);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of a podcast episode play for a user and episode ID
|
||||
*
|
||||
* @param string $userId
|
||||
* @param int $episodeId
|
||||
* @return ?int
|
||||
*/
|
||||
public function getPositionForEpisode(string $userId, int $episodeId): ?int {
|
||||
try {
|
||||
$row = $this->findOneByEpisodeId($userId, $episodeId);
|
||||
|
||||
return $row?->getPosition();
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get position for episode play: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plays for a given user and episode GUID
|
||||
*
|
||||
* @param string $userId
|
||||
* @param string $guid
|
||||
* @return PodcastEpisodePlay[]
|
||||
*/
|
||||
public function findByUserAndGuid(string $userId, string $guid): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('episode_guid', $qb->createNamedParameter($guid)));
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest playback for a user and episode (e.g. for resume)
|
||||
*
|
||||
* @param string $userId
|
||||
* @param string $guid
|
||||
* @return PodcastEpisodePlay|null
|
||||
*/
|
||||
public function findLatestPlay(string $userId, string $guid): ?PodcastEpisodePlay {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('episode_guid', $qb->createNamedParameter($guid)))
|
||||
->orderBy('timestamp', 'DESC')
|
||||
->setMaxResults(1);
|
||||
|
||||
return $this->findEntity($qb, true);
|
||||
}
|
||||
}
|
||||
67
lib/Db/PodcastSubscription.php
Normal file
67
lib/Db/PodcastSubscription.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int|null getSubscriptionId()
|
||||
* @method void setSubscriptionId(?int $id)
|
||||
* @method string|null getTitle()
|
||||
* @method void setTitle(?string $title)
|
||||
* @method string|null getAuthor()
|
||||
* @method void setAuthor(?string $author)
|
||||
* @method string|null getDescription()
|
||||
* @method void setDescription(?string $desc)
|
||||
* @method string|null getUrl()
|
||||
* @method void setUrl(?string $url)
|
||||
* @method string|null getUserId()
|
||||
* @method void setUserId(?string $uid)
|
||||
* @method string|null getImage()
|
||||
* @method void setImage(?string $image)
|
||||
* @method bool getSubscribed()
|
||||
* @method void setSubscribed(bool $value)
|
||||
* @method \DateTimeInterface getUpdated()
|
||||
* @method void setUpdated(\DateTimeInterface $dt)
|
||||
*/
|
||||
class PodcastSubscription extends Entity implements JsonSerializable {
|
||||
protected ?int $subscriptionId = null;
|
||||
protected ?string $title = null;
|
||||
protected ?string $author = null;
|
||||
protected ?string $description = null;
|
||||
protected ?string $url = null;
|
||||
protected ?string $userId = null;
|
||||
protected ?string $image = null;
|
||||
protected bool $subscribed = true;
|
||||
protected ?\DateTimeInterface $updated = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('subscriptionId', 'integer');
|
||||
$this->addType('title', 'string');
|
||||
$this->addType('author', 'string');
|
||||
$this->addType('description', 'string');
|
||||
$this->addType('url', 'string');
|
||||
$this->addType('userId', 'string');
|
||||
$this->addType('image', 'string');
|
||||
$this->addType('subscribed', 'boolean');
|
||||
$this->addType('updated', 'datetime');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'subscription_id' => $this->getSubscriptionId(),
|
||||
'title' => $this->getTitle(),
|
||||
'author' => $this->getAuthor(),
|
||||
'description' => $this->getDescription(),
|
||||
'url' => $this->getUrl(),
|
||||
'user_id' => $this->getUserId(),
|
||||
'subscribed' => $this->getSubscribed(),
|
||||
'image' => $this->getImage(),
|
||||
'updated' => $this->getUpdated()?->format(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
96
lib/Db/PodcastSubscriptionMapper.php
Normal file
96
lib/Db/PodcastSubscriptionMapper.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Db;
|
||||
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<PodcastSubscription>
|
||||
*/
|
||||
class PodcastSubscriptionMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('podcast_subs'), PodcastSubscription::class);
|
||||
}
|
||||
|
||||
public function findByGpodderId(string $userId, int $gpodderId): PodcastSubscription {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('subscription_id', $qb->createNamedParameter($gpodderId)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $userId, int $id): PodcastSubscription {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return PodcastSubscription[]
|
||||
*/
|
||||
public function findAll(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all subscribed podcast metadata entries for a user
|
||||
*
|
||||
* @param string $userId
|
||||
* @param bool $subscribed
|
||||
* @return PodcastSubscription[]
|
||||
*/
|
||||
public function findAllBySubscribed(string $userId, bool $subscribed): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('subscribed', $qb->createNamedParameter($subscribed, IQueryBuilder::PARAM_BOOL)));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all subscribed podcast metadata entries
|
||||
*
|
||||
* @return PodcastSubscription[]
|
||||
*/
|
||||
public function findAllSubscribed(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('subscribed', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a podcast metadata entry by URL
|
||||
*
|
||||
* @param string $userId
|
||||
* @param string $url
|
||||
* @return PodcastSubscription|null
|
||||
*/
|
||||
public function findByUrl(string $userId, string $url): ?PodcastSubscription {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('url', $qb->createNamedParameter($url)));
|
||||
|
||||
return $this->findEntity($qb, true);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method bool isFavorited()
|
||||
* @method void setFavorited(bool $favorited)
|
||||
*/
|
||||
class JukeboxRadioStation extends Entity implements JsonSerializable {
|
||||
class RadioStation extends Entity implements JsonSerializable {
|
||||
protected string $remoteUuid = '';
|
||||
protected string $name = '';
|
||||
protected string $streamUrl = '';
|
||||
@@ -13,20 +13,20 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<JukeboxRadioStation>
|
||||
* @template-extends QBMapper<RadioStation>
|
||||
*/
|
||||
class JukeboxRadioStationMapper extends QBMapper {
|
||||
class RadioStationMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('radio_stations'), JukeboxRadioStation::class);
|
||||
parent::__construct($db, Application::tableName('radio_stations'), RadioStation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(int $id): JukeboxRadioStation {
|
||||
public function find(int $id): RadioStation {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
@@ -35,7 +35,7 @@ class JukeboxRadioStationMapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<JukeboxRadioStation>
|
||||
* @return array<RadioStation>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -45,9 +45,9 @@ class JukeboxRadioStationMapper extends QBMapper {
|
||||
|
||||
/**
|
||||
* @param string $remoteUuid
|
||||
* @return JukeboxRadioStation|null
|
||||
* @return RadioStation|null
|
||||
*/
|
||||
public function findByRemoteUuid(string $userId, string $remoteUuid): ?JukeboxRadioStation {
|
||||
public function findByRemoteUuid(string $userId, string $remoteUuid): ?RadioStation {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
@@ -64,7 +64,7 @@ class JukeboxRadioStationMapper extends QBMapper {
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return array<JukeboxRadioStation>
|
||||
* @return array<RadioStation>
|
||||
*/
|
||||
public function findByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -77,7 +77,7 @@ class JukeboxRadioStationMapper extends QBMapper {
|
||||
|
||||
/**
|
||||
* @param string $userId
|
||||
* @return array<JukeboxRadioStation>
|
||||
* @return array<RadioStation>
|
||||
*/
|
||||
public function findFavoritesByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -109,7 +109,7 @@ class JukeboxRadioStationMapper extends QBMapper {
|
||||
* @param string $userId
|
||||
* @param int $offset
|
||||
* @param int $limit
|
||||
* @return array<JukeboxRadioStation>
|
||||
* @return array<RadioStation>
|
||||
*/
|
||||
public function findPaginatedByUserId(string $userId, int $offset, int $limit): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -45,7 +45,7 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method bool isFavorited()
|
||||
* @method void setFavorited(bool $favorited)
|
||||
*/
|
||||
class JukeboxMusic extends Entity implements JsonSerializable {
|
||||
class Track extends Entity implements JsonSerializable {
|
||||
protected string $path = '';
|
||||
protected ?string $title = null;
|
||||
protected ?int $trackNumber = null;
|
||||
@@ -14,21 +14,21 @@ use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<JukeboxMusic>
|
||||
* @template-extends QBMapper<Track>
|
||||
*/
|
||||
class JukeboxMusicMapper extends QBMapper {
|
||||
class TrackMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('music'), JukeboxMusic::class);
|
||||
parent::__construct($db, Application::tableName('music'), Track::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $userId, string $id): JukeboxMusic {
|
||||
public function find(string $userId, string $id): Track {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
@@ -45,7 +45,7 @@ class JukeboxMusicMapper extends QBMapper {
|
||||
* Find all music entries for a specific user
|
||||
*
|
||||
* @param string $userId
|
||||
* @return array<JukeboxMusic>
|
||||
* @return array<Track>
|
||||
*/
|
||||
public function findByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -59,7 +59,7 @@ class JukeboxMusicMapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<JukeboxMusic>
|
||||
* @return array<Track>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -70,7 +70,7 @@ class JukeboxMusicMapper extends QBMapper {
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param string $query
|
||||
* @return array<JukeboxMusic>
|
||||
* @return array<Track>
|
||||
*/
|
||||
public function searchMusic(string $userId, string $query): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -98,7 +98,7 @@ class JukeboxMusicMapper extends QBMapper {
|
||||
* @param string $userId
|
||||
* @param string $artist
|
||||
* @param string $album
|
||||
* @return array<JukeboxMusic>
|
||||
* @return array<Track>
|
||||
*/
|
||||
public function findByAlbum(string $userId, string $artist, string $album): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -117,7 +117,7 @@ class JukeboxMusicMapper extends QBMapper {
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param string $artist
|
||||
* @return array<JukeboxMusic>
|
||||
* @return array<Track>
|
||||
*/
|
||||
public function findByArtist(string $userId, string $artist): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
@@ -140,9 +140,9 @@ class JukeboxMusicMapper extends QBMapper {
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param string $path
|
||||
* @return JukeboxMusic|null
|
||||
* @return Track|null
|
||||
*/
|
||||
public function findByUserIdAndPath(string $userId, string $path): ?JukeboxMusic {
|
||||
public function findByUserIdAndPath(string $userId, string $path): ?Track {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
@@ -28,8 +28,17 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
|
||||
if ($schema->hasTable('jukebox_radio_stations')) {
|
||||
$schema->dropTable('jukebox_radio_stations');
|
||||
}
|
||||
if ($schema->hasTable('jukebox_podcast_subs')) {
|
||||
$schema->dropTable('jukebox_podcast_subs');
|
||||
}
|
||||
if ($schema->hasTable('jukebox_podcast_eps')) {
|
||||
$schema->dropTable('jukebox_podcast_eps');
|
||||
}
|
||||
if ($schema->hasTable('jukebox_podcast_ep_plays')) {
|
||||
$schema->dropTable('jukebox_podcast_ep_plays');
|
||||
}
|
||||
|
||||
// 🎵 Music Table
|
||||
// Music Table
|
||||
$media = $schema->createTable('jukebox_music');
|
||||
|
||||
$media->addColumn('id', 'integer', [
|
||||
@@ -103,7 +112,7 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
|
||||
$media->addIndex(['user_id'], 'media_user_idx');
|
||||
$media->addIndex(['path'], 'media_path_idx');
|
||||
|
||||
// 📻 Radio Table
|
||||
// Radio Table
|
||||
$radio = $schema->createTable('jukebox_radio_stations');
|
||||
|
||||
$radio->addColumn('id', 'integer', [
|
||||
@@ -177,6 +186,156 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
|
||||
$radio->addUniqueIndex(['remote_uuid', 'user_id'], 'radio_remote_uuid_user_id_idx');
|
||||
$radio->addIndex(['user_id'], 'radio_user_idx');
|
||||
|
||||
// Podcast Subscription Metadata Table
|
||||
$subs = $schema->createTable('jukebox_podcast_subs');
|
||||
|
||||
$subs->addColumn('id', 'integer', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$subs->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
'default' => '',
|
||||
]);
|
||||
$subs->addColumn('subscription_id', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Optional link to gpodder_subscriptions.id',
|
||||
]);
|
||||
$subs->addColumn('title', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 512,
|
||||
]);
|
||||
$subs->addColumn('author', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
$subs->addColumn('description', 'text', [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$subs->addColumn('image', 'blob', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Cover image binary data',
|
||||
]);
|
||||
$subs->addColumn('url', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 1024,
|
||||
]);
|
||||
|
||||
$subs->addColumn('subscribed', 'boolean', [
|
||||
'notnull' => false,
|
||||
'default' => true,
|
||||
]);
|
||||
|
||||
$subs->addColumn('updated', 'datetime', [
|
||||
'notnull' => true,
|
||||
'default' => '1970-01-01 00:00:00',
|
||||
]);
|
||||
|
||||
$subs->setPrimaryKey(['id']);
|
||||
$subs->addUniqueIndex(['subscription_id'], 'podcast_sub_id_idx');
|
||||
|
||||
// Podcast Episode Metadata Table
|
||||
$eps = $schema->createTable('jukebox_podcast_eps');
|
||||
|
||||
$eps->addColumn('id', 'integer', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$eps->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
'default' => '',
|
||||
]);
|
||||
$eps->addColumn('action_id', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Optional link to gpodder_episode_action.id',
|
||||
]);
|
||||
$eps->addColumn('subscription_id', 'integer', [
|
||||
'notnull' => true,
|
||||
'comment' => 'FK to jukebox_podcast_subs.id',
|
||||
]);
|
||||
$eps->addColumn('title', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 512,
|
||||
]);
|
||||
$eps->addColumn('guid', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 512,
|
||||
]);
|
||||
$eps->addColumn('pub_date', 'datetime', [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$eps->addColumn('duration', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Duration in seconds',
|
||||
]);
|
||||
$eps->addColumn('media_url', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 1024,
|
||||
]);
|
||||
$eps->addColumn('description', 'text', [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$eps->setPrimaryKey(['id']);
|
||||
$eps->addIndex(['subscription_id'], 'podcast_ep_data_sub_id_idx');
|
||||
$eps->addUniqueIndex(['action_id'], 'podcast_ep_data_action_id_idx');
|
||||
|
||||
// Podcast Episode Playbacks Table
|
||||
$epPlays = $schema->createTable('jukebox_podcast_ep_plays');
|
||||
|
||||
$epPlays->addColumn('id', 'integer', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('episode_id', 'integer', [
|
||||
'notnull' => true,
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('episode_guid', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 512,
|
||||
'comment' => 'Matches the episode GUID, used like in gpodder actions',
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('action', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
'default' => 'play',
|
||||
'comment' => 'Playback action type (e.g. play, resume, complete)',
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('timestamp', 'bigint', [
|
||||
'notnull' => true,
|
||||
'comment' => 'Unix timestamp of when the action occurred',
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('position', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Position in seconds when stopped or finished',
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('total', 'integer', [
|
||||
'notnull' => false,
|
||||
'comment' => 'Total duration of the episode in seconds',
|
||||
]);
|
||||
|
||||
$epPlays->addColumn('device', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
'comment' => 'Optional device ID or label',
|
||||
]);
|
||||
|
||||
$epPlays->setPrimaryKey(['id'], 'ep_plays_pk');
|
||||
$epPlays->addIndex(['user_id', 'episode_guid'], 'play_user_guid_idx');
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
|
||||
119
lib/Service/GpodderSyncService.php
Normal file
119
lib/Service/GpodderSyncService.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use OCA\Jukebox\Db\PodcastEpisode;
|
||||
use OCA\Jukebox\Db\PodcastEpisodeMapper;
|
||||
use OCA\Jukebox\Db\PodcastSubscription;
|
||||
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class GpodderSyncService {
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private IDBConnection $db,
|
||||
private IAppManager $appManager,
|
||||
private PodcastSubscriptionMapper $subMapper,
|
||||
private PodcastEpisodeMapper $epMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gpoddersync app is installed and enabled
|
||||
*/
|
||||
public function isAvailable(): bool {
|
||||
return $this->appManager->isEnabledForUser('gpoddersync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all gpodder subscriptions into jukebox metadata
|
||||
*
|
||||
* @param string $userId
|
||||
* @return int Number of imported subscriptions
|
||||
*/
|
||||
public function importSubscriptions(string $userId): int {
|
||||
if (!$this->isAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id', 'url')
|
||||
->from('gpodder_subscriptions')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
$results = $qb->executeQuery()->fetchAll();
|
||||
$count = 0;
|
||||
|
||||
foreach ($results as $row) {
|
||||
try {
|
||||
// Skip if already linked
|
||||
$this->subMapper->findByGpodderId($userId, (int)$row['id']);
|
||||
continue;
|
||||
} catch (DoesNotExistException) {
|
||||
// Not yet imported, continue
|
||||
}
|
||||
|
||||
$entity = new PodcastSubscription();
|
||||
$entity->setUserId($userId);
|
||||
$entity->setSubscriptionId((int)$row['id']);
|
||||
$entity->setTitle(null); // You can fetch metadata later via parser
|
||||
$entity->setUrl($row['url']);
|
||||
$this->subMapper->insert($entity);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all gpodder episode actions into jukebox metadata
|
||||
*
|
||||
* @param string $userId
|
||||
* @return int Number of imported episodes
|
||||
*/
|
||||
public function importEpisodes(string $userId): int {
|
||||
if (!$this->isAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id', 'podcast', 'episode', 'guid', 'timestamp')
|
||||
->from('gpodder_episode_action')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
$results = $qb->executeQuery()->fetchAll();
|
||||
$count = 0;
|
||||
|
||||
foreach ($results as $row) {
|
||||
try {
|
||||
// Skip if already imported
|
||||
$this->epMapper->findByGpodderId($userId, (int)$row['id']);
|
||||
continue;
|
||||
} catch (DoesNotExistException) {
|
||||
// Not yet imported
|
||||
}
|
||||
|
||||
$entity = new PodcastEpisode();
|
||||
$entity->setUserId($userId);
|
||||
$entity->setActionId((int)$row['id']);
|
||||
$entity->setGuid($row['guid'] ?? $row['episode']);
|
||||
$entity->setTitle($row['episode']);
|
||||
$entity->setPubDate(
|
||||
$row['timestamp'] ? new \DateTimeImmutable($row['timestamp']) : null
|
||||
);
|
||||
|
||||
// You can link to a jukebox_podcast_subscription_data row if podcast URL matches
|
||||
// For now, leave subscriptionDataId unset
|
||||
|
||||
$this->epMapper->insert($entity);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ namespace OCA\Jukebox\Service;
|
||||
|
||||
use getID3;
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCA\Jukebox\Db\JukeboxMusic;
|
||||
use OCA\Jukebox\Db\JukeboxMusicMapper;
|
||||
use OCA\Jukebox\Db\Track;
|
||||
use OCA\Jukebox\Db\TrackMapper;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
@@ -31,7 +31,7 @@ class MusicScannerService {
|
||||
IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
private IAppConfig $appConfig,
|
||||
private JukeboxMusicMapper $musicMapper,
|
||||
private TrackMapper $musicMapper,
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
$this->rootFolder = $rootFolder;
|
||||
@@ -170,7 +170,7 @@ class MusicScannerService {
|
||||
|
||||
// Check for existing
|
||||
$existing = $this->musicMapper->findByUserIdAndPath($userId, $path);
|
||||
$media = $existing ?? new JukeboxMusic();
|
||||
$media = $existing ?? new Track();
|
||||
|
||||
$media->setUserId($userId);
|
||||
$media->setPath($path);
|
||||
|
||||
70
lib/Service/PodcastEpisodeWriterService.php
Normal file
70
lib/Service/PodcastEpisodeWriterService.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use DateTimeInterface;
|
||||
use OCA\Jukebox\Db\PodcastEpisode;
|
||||
use OCA\Jukebox\Db\PodcastEpisodeMapper;
|
||||
use OCA\Jukebox\Db\PodcastSubscription;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class PodcastEpisodeWriterService {
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private PodcastEpisodeMapper $epMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update episodes for a given podcast subscription
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param PodcastSubscription $sub The podcast subscription
|
||||
* @param array<int, array{
|
||||
* guid: string|null,
|
||||
* title: string|null,
|
||||
* pubDate: DateTimeInterface|null,
|
||||
* duration: int|null,
|
||||
* mediaUrl: string|null,
|
||||
* description: string|null
|
||||
* }> $episodes Parsed episode data
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function storeEpisodes(string $userId, PodcastSubscription $sub, array $episodes): void {
|
||||
foreach ($episodes as $data) {
|
||||
if (empty($data['guid'])) {
|
||||
$this->logger->debug('Skipping episode without GUID', ['subscriptionId' => $sub->getId()]);
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var PodcastEpisode $existing */
|
||||
$ep = new PodcastEpisode();
|
||||
/** @var PodcastEpisode|null $existing */
|
||||
$existing = null;
|
||||
|
||||
try {
|
||||
$existing = $this->epMapper->findByGuid($userId, (string)$data['guid']);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
|
||||
//
|
||||
}
|
||||
|
||||
$ep->setUserId($userId);
|
||||
$ep->setSubscriptionDataId((int)$sub->getId());
|
||||
$ep->setGuid((string)$data['guid']);
|
||||
$ep->setTitle($data['title']);
|
||||
$ep->setPubDate($data['pubDate'] instanceof \DateTimeInterface ? \DateTime::createFromInterface($data['pubDate']) : null);
|
||||
$ep->setDuration($data['duration']);
|
||||
$ep->setMediaUrl($data['mediaUrl']);
|
||||
$ep->setDescription($data['description']);
|
||||
|
||||
if ($existing) {
|
||||
$this->epMapper->update($ep);
|
||||
} else {
|
||||
$this->epMapper->insert($ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
lib/Service/PodcastFeedParserService.php
Normal file
107
lib/Service/PodcastFeedParserService.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SimplePie\Item;
|
||||
use SimplePie\SimplePie;
|
||||
|
||||
class PodcastFeedParserService {
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse basic subscription metadata (title, image, etc.)
|
||||
*
|
||||
* @param string $url
|
||||
* @return array{
|
||||
* title: string|null,
|
||||
* description: string|null,
|
||||
* link: string|null,
|
||||
* author: string|null,
|
||||
* imageUrl: string|null
|
||||
* }
|
||||
*
|
||||
* @throws \RuntimeException if feed is invalid
|
||||
*/
|
||||
public function parseSubscriptionMetadata(string $url): array {
|
||||
$feed = $this->loadFeed($url);
|
||||
|
||||
return [
|
||||
'title' => $feed->get_title(),
|
||||
'description' => $feed->get_description(),
|
||||
'link' => $feed->get_link(),
|
||||
'author' => $feed->get_author()?->get_name() ?? null,
|
||||
'imageUrl' => $feed->get_image_url(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse episode entries only
|
||||
*
|
||||
* @param string $feedUrl
|
||||
* @return array<int, array{
|
||||
* guid: string|null,
|
||||
* title: string|null,
|
||||
* pubDate: \DateTimeInterface|null,
|
||||
* duration: int|null,
|
||||
* mediaUrl: string|null,
|
||||
* description: string|null
|
||||
* }>
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function parseEpisodes(string $feedUrl): array {
|
||||
$feed = $this->loadFeed($feedUrl);
|
||||
$episodes = [];
|
||||
|
||||
foreach ($feed->get_items() as $item) {
|
||||
$enclosure = $item->get_enclosure();
|
||||
|
||||
$episodes[] = [
|
||||
'guid' => $item->get_id(),
|
||||
'title' => $item->get_title(),
|
||||
'pubDate' => ($date = $item->get_date('c')) ? new \DateTimeImmutable($date) : null,
|
||||
'duration' => $this->parseDuration($item),
|
||||
'mediaUrl' => $enclosure?->get_link(),
|
||||
'description' => $item->get_description(),
|
||||
];
|
||||
}
|
||||
|
||||
return $episodes;
|
||||
}
|
||||
|
||||
private function loadFeed(string $url): SimplePie {
|
||||
$feed = new SimplePie();
|
||||
$feed->set_feed_url($url);
|
||||
$feed->enable_cache(false);
|
||||
$feed->init();
|
||||
|
||||
if ($feed->error()) {
|
||||
$this->logger->warning("Failed to parse feed: {$feed->error()}");
|
||||
throw new \RuntimeException("Failed to parse podcast feed: {$feed->error()}");
|
||||
}
|
||||
|
||||
return $feed;
|
||||
}
|
||||
|
||||
private function parseDuration(Item $item): ?int {
|
||||
$durationTag = $item->get_item_tags('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration');
|
||||
$raw = $durationTag[0]['data'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = array_reverse(explode(':', $raw));
|
||||
$seconds = 0;
|
||||
foreach ($parts as $i => $part) {
|
||||
$seconds += ((int)$part) * (60 ** $i);
|
||||
}
|
||||
return $seconds;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxRadioStation;
|
||||
use OCA\Jukebox\Db\JukeboxRadioStationMapper;
|
||||
use OCA\Jukebox\Db\RadioStation;
|
||||
use OCA\Jukebox\Db\RadioStationMapper;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -18,7 +18,7 @@ class RadioSourcesService {
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private JukeboxRadioStationMapper $stationMapper,
|
||||
private RadioStationMapper $stationMapper,
|
||||
private IClientService $httpClientService,
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
@@ -69,7 +69,7 @@ class RadioSourcesService {
|
||||
}
|
||||
|
||||
$existing = $this->stationMapper->findByRemoteUuid($uuid);
|
||||
$station = $existing ?? new JukeboxRadioStation();
|
||||
$station = $existing ?? new RadioStation();
|
||||
|
||||
$station->setRemoteUuid($uuid);
|
||||
$station->setName($data['name'] ?? '');
|
||||
|
||||
918
openapi.json
918
openapi.json
File diff suppressed because it is too large
Load Diff
92
src/App.vue
92
src/App.vue
@@ -31,7 +31,10 @@
|
||||
<Tag :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Podcasts" :to="{ path: '/podcasts' }">
|
||||
<NcAppNavigationItem
|
||||
name="Podcasts"
|
||||
:to="{ path: '/podcasts' }"
|
||||
:active="isPrefixRoute('/podcasts')">
|
||||
<template #icon>
|
||||
<Podcast :size="20" />
|
||||
</template>
|
||||
@@ -65,51 +68,7 @@
|
||||
<router-view v-else />
|
||||
</div>
|
||||
<!-- Media Player -->
|
||||
<footer class="jukebox-player">
|
||||
<div class="controls">
|
||||
<NcButton variant="tertiary" aria-label="Previous" size="normal" @click="playback.prev">
|
||||
<template #icon>
|
||||
<SkipPrevious :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
class="play-button"
|
||||
variant="primary"
|
||||
aria-label="Play/Pause"
|
||||
size="normal"
|
||||
@click="playback.togglePlay">
|
||||
<template #icon>
|
||||
<Play :size="24" v-if="!isPlaying" />
|
||||
<Pause :size="24" v-else />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton variant="tertiary" aria-label="Next" size="normal" @click="playback.next">
|
||||
<template #icon>
|
||||
<SkipNext :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<QueuePopover v-model:shown="showQueue" :queue="queue">
|
||||
<template #trigger>
|
||||
<NcButton variant="tertiary" aria-label="Queue" size="normal" @click="toggleQueue">
|
||||
<template #icon>
|
||||
<PlaylistMusic :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</QueuePopover>
|
||||
</div>
|
||||
<div class="seekbar-row">
|
||||
<span class="time">{{ formattedCurrentTime }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="seek"
|
||||
@input="setSeek(Number(($event.target as HTMLInputElement).value))"
|
||||
class="seekbar" />
|
||||
<span class="time">{{ formattedDuration }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<MediaControls />
|
||||
</NcAppContent>
|
||||
</NcContent>
|
||||
</template>
|
||||
@@ -126,8 +85,8 @@
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
|
||||
import QueuePopover from '@/components/media/QueuePopover.vue'
|
||||
import { type Media } from '@/models/media'
|
||||
import MediaControls from '@/components/media/MediaControls.vue'
|
||||
import { type Track } from '@/models/media'
|
||||
|
||||
import SkipPrevious from '@icons/SkipPrevious.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
@@ -155,7 +114,7 @@
|
||||
NcAppNavigationSearch,
|
||||
NcLoadingIcon,
|
||||
NcButton,
|
||||
QueuePopover,
|
||||
MediaControls,
|
||||
SkipPrevious,
|
||||
SkipNext,
|
||||
Play,
|
||||
@@ -178,40 +137,19 @@
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isPrefixRoute = (prefix: string) => route.path.startsWith(prefix)
|
||||
const isRouterLoading = ref(true)
|
||||
|
||||
router.beforeEach(() => (isRouterLoading.value = true))
|
||||
router.afterEach(() => (isRouterLoading.value = false))
|
||||
const isPrefixRoute = (prefix: string) => route.path.startsWith(prefix)
|
||||
|
||||
const showQueue = ref(false)
|
||||
|
||||
function toggleQueue() {
|
||||
if (!showQueue.value) {
|
||||
showQueue.value = !showQueue.value
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formattedCurrentTime = computed(() => formatTime(playback.currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(playback.duration.value))
|
||||
router.beforeEach(() => {
|
||||
isRouterLoading.value = true
|
||||
})
|
||||
router.afterEach(() => {
|
||||
isRouterLoading.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
searchValue: '',
|
||||
seek: computed(() => playback.seek.value),
|
||||
setSeek: playback.setSeek,
|
||||
playback,
|
||||
queue: computed(() => playback.queue.value),
|
||||
isPlaying: computed(() => playback.isPlaying.value),
|
||||
showQueue,
|
||||
toggleQueue,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
isRouterLoading,
|
||||
isPrefixRoute,
|
||||
}
|
||||
|
||||
108
src/components/media/AddPodcastModal.vue
Normal file
108
src/components/media/AddPodcastModal.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<h3>Add Podcast via RSS</h3>
|
||||
|
||||
<NcTextField v-model="rssUrl" label="RSS Feed URL" placeholder="https://example.com/feed.xml" :autofocus="true" />
|
||||
|
||||
<div class="actions">
|
||||
<NcButton :disabled="!isValid" @click="submit">
|
||||
<template #icon>
|
||||
<Plus :size="16" />
|
||||
</template>
|
||||
Add
|
||||
</NcButton>
|
||||
<NcButton @click="close" class="cancel">
|
||||
Cancel
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import Plus from '@icons/Plus.vue'
|
||||
import { axios } from '@/axios'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AddPodcastModal',
|
||||
emits: ['add-subscription', 'close'],
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Plus,
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const rssUrl = ref('')
|
||||
|
||||
const isValid = computed(() => {
|
||||
try {
|
||||
new URL(rssUrl.value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (isValid.value) {
|
||||
const res = await axios.post('/podcasts/subscriptions', {
|
||||
url: rssUrl.value.trim(),
|
||||
})
|
||||
emit('add-subscription', res.data.subscription)
|
||||
rssUrl.value = ''
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
return {
|
||||
rssUrl,
|
||||
isValid,
|
||||
submit,
|
||||
close,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--box-shadow-dialog);
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
gap: 1rem;
|
||||
|
||||
.cancel {
|
||||
background: transparent;
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -31,7 +31,7 @@ import type { Album } from '@/models/media'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AlbumCardItem from '@/components/media/AlbumCardItem.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AlbumListItem',
|
||||
@@ -70,7 +70,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const playTrack = (index: number) => {
|
||||
overwriteQueue([...props.album.tracks], index)
|
||||
overwriteQueue(props.album.tracks.map(trackToPlayable), index)
|
||||
}
|
||||
|
||||
const trackListStyle = computed(() => {
|
||||
|
||||
166
src/components/media/MediaControls.vue
Normal file
166
src/components/media/MediaControls.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<footer class="jukebox-player">
|
||||
<div class="controls">
|
||||
<NcButton variant="tertiary" aria-label="Previous" size="normal" @click="playback.prev" :disabled="isLoading">
|
||||
<template #icon>
|
||||
<SkipPrevious :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcButton class="play-button" variant="primary" aria-label="Play/Pause" size="normal" @click="playback.togglePlay"
|
||||
:disabled="isLoading">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="isLoading" :size="24" />
|
||||
<Play v-else-if="!isPlaying" :size="24" />
|
||||
<Pause v-else :size="24" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcButton variant="tertiary" aria-label="Next" size="normal" @click="playback.next" :disabled="isLoading">
|
||||
<template #icon>
|
||||
<SkipNext :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<QueuePopover v-model:shown="showQueue" :queue="queue">
|
||||
<template #trigger>
|
||||
<NcButton variant="tertiary" aria-label="Queue" size="normal" @click="toggleQueue">
|
||||
<template #icon>
|
||||
<PlaylistMusic :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</QueuePopover>
|
||||
</div>
|
||||
|
||||
<div class="seekbar-row">
|
||||
<span class="time">{{ formattedCurrentTime }}</span>
|
||||
<input type="range" min="0" max="100" :value="seek" @input="onSeek" class="seekbar" :disabled="isLoading" />
|
||||
<span class="time">{{ formattedDuration }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import QueuePopover from '@/components/media/QueuePopover.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
|
||||
import SkipPrevious from '@icons/SkipPrevious.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import Pause from '@icons/Pause.vue'
|
||||
import PlaylistMusic from '@icons/PlaylistMusic.vue'
|
||||
|
||||
import type { Track } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MediaControls',
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
QueuePopover,
|
||||
SkipPrevious,
|
||||
SkipNext,
|
||||
Play,
|
||||
Pause,
|
||||
PlaylistMusic,
|
||||
},
|
||||
setup() {
|
||||
const showQueue = ref(false)
|
||||
|
||||
function toggleQueue() {
|
||||
if (!showQueue.value) {
|
||||
showQueue.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const queue = computed(() => playback.queue.value as unknown[] as Track[])
|
||||
const isPlaying = computed(() => playback.isPlaying.value)
|
||||
const seek = computed(() => playback.seek.value)
|
||||
const formattedCurrentTime = computed(() => formatTime(playback.currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(playback.duration.value))
|
||||
|
||||
const isLoading = computed(() => playback.loading.value)
|
||||
|
||||
function onSeek(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
playback.setSeek(Number(target.value))
|
||||
}
|
||||
|
||||
return {
|
||||
showQueue,
|
||||
toggleQueue,
|
||||
playback,
|
||||
queue,
|
||||
isPlaying,
|
||||
seek,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
onSeek,
|
||||
isLoading,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.jukebox-player {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-background-light);
|
||||
z-index: 1;
|
||||
height: 160px;
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
button {
|
||||
border-radius: 50%;
|
||||
height: var(--button-size) !important;
|
||||
width: var(--button-size) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
--button-size: 3.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.seekbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.85rem;
|
||||
width: 3rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
</style>
|
||||
@@ -41,8 +41,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type PropType } from 'vue'
|
||||
import { type Media } from '@/models/media'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import { type Track } from '@/models/media'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
@@ -56,7 +56,7 @@ export default defineComponent({
|
||||
name: 'MediaListItem',
|
||||
props: {
|
||||
media: {
|
||||
type: Object as PropType<Media>,
|
||||
type: Object as PropType<Track>,
|
||||
required: true,
|
||||
},
|
||||
mediaType: {
|
||||
@@ -91,8 +91,8 @@ export default defineComponent({
|
||||
const isActive = computed(() => props.media.id === currentMedia.value?.id)
|
||||
|
||||
const onPlay = () => emit('play', props.media)
|
||||
const onPlayNext = () => addAsNext(props.media)
|
||||
const onAddToQueue = () => addToQueue(props.media)
|
||||
const onPlayNext = () => addAsNext(trackToPlayable(props.media))
|
||||
const onAddToQueue = () => addToQueue(trackToPlayable(props.media))
|
||||
|
||||
return {
|
||||
isActive,
|
||||
|
||||
151
src/components/media/PodcastEpisodeCardItem.vue
Normal file
151
src/components/media/PodcastEpisodeCardItem.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="episode-card" :style="{ width }" @click="onClick">
|
||||
<PlayCircle :size="128" class="cover" />
|
||||
|
||||
<div class="metadata-container">
|
||||
<div class="metadata">
|
||||
<div class="title">{{ episode.title || 'Untitled Episode' }}</div>
|
||||
<div class="pubdate" v-if="episode.pub_date">
|
||||
{{ formatDate(episode.pub_date) }}
|
||||
</div>
|
||||
<div class="duration" v-if="episode.duration !== null">
|
||||
{{ formatDuration(episode.duration) }}
|
||||
</div>
|
||||
<div class="description" v-if="episode.description">{{ episode.description }}</div>
|
||||
</div>
|
||||
|
||||
<NcButton class="remove-button" @click.stop="remove(episode)">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import PlayCircle from '@icons/PlayCircle.vue'
|
||||
import Delete from '@icons/Delete.vue'
|
||||
import type { PodcastEpisode } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PodcastEpCardItem',
|
||||
props: {
|
||||
episode: {
|
||||
type: Object as PropType<PodcastEpisode>,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
},
|
||||
emits: ['click', 'remove'],
|
||||
components: {
|
||||
PlayCircle,
|
||||
Delete,
|
||||
NcButton,
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const onClick = () => emit('click')
|
||||
const remove = (ep: PodcastEpisode) => emit('remove', ep)
|
||||
|
||||
const formatDate = (iso: string): string => {
|
||||
const date = new Date(iso)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return {
|
||||
onClick,
|
||||
remove,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.episode-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius-element);
|
||||
display: flex;
|
||||
align-items: start;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
gap: 1rem;
|
||||
|
||||
&,
|
||||
& * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.cover {
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pubdate,
|
||||
.duration {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-top: 0.25rem;
|
||||
max-height: 3.6em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
120
src/components/media/PodcastEpisodeListItem.vue
Normal file
120
src/components/media/PodcastEpisodeListItem.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<NcListItem :active="isActive" :name="episode.title || 'Untitled Episode'" @click.prevent="onPlay" :bold="false">
|
||||
<template #icon>
|
||||
<img v-if="cover" :src="cover" alt="Podcast cover" class="cover" width="44" height="44" />
|
||||
<Podcast v-else :size="44" />
|
||||
</template>
|
||||
|
||||
<template #subname>
|
||||
{{ durationFormatted }} — {{ episode.pub_date ? new Date(episode.pub_date).toLocaleDateString() : 'Unknown date'
|
||||
}}
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<slot name="actions-start" />
|
||||
|
||||
<NcActionButton v-if="!disablePlay" @click.stop="onPlay">
|
||||
<template #icon>
|
||||
<Play :size="20" />
|
||||
</template>
|
||||
Play
|
||||
</NcActionButton>
|
||||
|
||||
<NcActionButton v-if="!disablePlayNext" @click.stop="onPlayNext">
|
||||
<template #icon>
|
||||
<SkipNext :size="20" />
|
||||
</template>
|
||||
Play Next
|
||||
</NcActionButton>
|
||||
|
||||
<NcActionButton v-if="!disableAddToQueue" @click.stop="onAddToQueue">
|
||||
<template #icon>
|
||||
<PlaylistPlus :size="20" />
|
||||
</template>
|
||||
Add to Queue
|
||||
</NcActionButton>
|
||||
|
||||
<slot name="actions-end" />
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type PropType } from 'vue'
|
||||
import { type PodcastEpisode } from '@/models/media'
|
||||
import playback, { podcastEpisodeToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
|
||||
import Podcast from '@icons/Podcast.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import PlaylistPlus from '@icons/PlaylistPlus.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PodcastEpisodeListItem',
|
||||
props: {
|
||||
episode: {
|
||||
type: Object as PropType<PodcastEpisode>,
|
||||
required: true,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
disablePlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disablePlayNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableAddToQueue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NcListItem,
|
||||
NcActionButton,
|
||||
Podcast,
|
||||
Play,
|
||||
SkipNext,
|
||||
PlaylistPlus,
|
||||
},
|
||||
emits: ['play'],
|
||||
setup(props, { emit }) {
|
||||
const { currentMedia, addToQueue, addAsNext } = playback
|
||||
|
||||
const isActive = computed(() => props.episode.id === currentMedia.value?.id)
|
||||
|
||||
const durationFormatted = computed(() => {
|
||||
if (!props.episode.duration) return 'No duration'
|
||||
const minutes = Math.floor(props.episode.duration / 60)
|
||||
const seconds = props.episode.duration % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const onPlay = () => emit('play', props.episode)
|
||||
const onPlayNext = () => addAsNext(podcastEpisodeToPlayable(props.episode))
|
||||
const onAddToQueue = () => addToQueue(podcastEpisodeToPlayable(props.episode))
|
||||
|
||||
return {
|
||||
isActive,
|
||||
durationFormatted,
|
||||
onPlay,
|
||||
onPlayNext,
|
||||
onAddToQueue,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cover {
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
135
src/components/media/PodcastSubscriptionCardItem.vue
Normal file
135
src/components/media/PodcastSubscriptionCardItem.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="podcast-card" :style="{ width }" @click="onClick">
|
||||
<img v-if="subscription.image" :src="subscription.image" alt="Cover" width="128" height="128" class="cover" />
|
||||
<Podcast v-else :size="128" />
|
||||
|
||||
<div class="metadata-container">
|
||||
<div class="metadata">
|
||||
<div class="title">{{ subscription.title || 'Untitled Podcast' }}</div>
|
||||
<div class="author" v-if="subscription.author">{{ subscription.author }}</div>
|
||||
<div class="description" v-if="subscription.description">{{ subscription.description }}</div>
|
||||
<!-- Optional: show feed URL -->
|
||||
<!-- <div class="url" v-if="subscription.url">{{ subscription.url }}</div> -->
|
||||
</div>
|
||||
|
||||
<NcButton class="remove-button" @click.stop="remove(subscription)">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import Podcast from '@icons/Podcast.vue'
|
||||
import Delete from '@icons/Delete.vue'
|
||||
import type { PodcastSubscription } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PodcastSubCardItem',
|
||||
props: {
|
||||
subscription: {
|
||||
type: Object as PropType<PodcastSubscription>,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '256px',
|
||||
},
|
||||
},
|
||||
emits: ['click', 'remove'],
|
||||
components: {
|
||||
Podcast,
|
||||
Delete,
|
||||
NcButton,
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const onClick = () => emit('click')
|
||||
const remove = (sub: PodcastSubscription) => emit('remove', sub)
|
||||
|
||||
return { onClick, remove }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.podcast-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius-element);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
|
||||
&,
|
||||
& * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.cover {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
margin-top: 0.5rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-top: 0.25rem;
|
||||
max-height: 3.6em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.url {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-word;
|
||||
color: var(--color-text-lighter);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,8 +35,8 @@ import NcPopover from '@nextcloud/vue/components/NcPopover'
|
||||
import MediaListItem from '@/components/media/MediaListItem.vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import Delete from '@icons/Delete.vue'
|
||||
import { type Media } from '@/models/media'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import { type Track } from '@/models/media'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QueuePopover',
|
||||
@@ -44,7 +44,7 @@ export default defineComponent({
|
||||
props: {
|
||||
shown: Boolean,
|
||||
queue: {
|
||||
type: Array as PropType<Media[]>,
|
||||
type: Array as PropType<Track[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
@@ -55,7 +55,7 @@ export default defineComponent({
|
||||
const onClose = () => {
|
||||
emit('update:shown', false)
|
||||
}
|
||||
const onPlay = (media: Media) => playback.playFromQueue(media)
|
||||
const onPlay = (media: Track) => playback.playFromQueue(trackToPlayable(media))
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popoverRef.value || !triggerRef.value) return
|
||||
@@ -72,8 +72,8 @@ export default defineComponent({
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
const onRemove = (media: Media) => {
|
||||
playback.removeFromQueue(media)
|
||||
const onRemove = (media: Track) => {
|
||||
playback.removeFromQueue(trackToPlayable(media))
|
||||
emit('update:shown', true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,121 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Media } from '@/models/media'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
import type { Track, PodcastEpisode, RadioStation } from '@/models/media'
|
||||
|
||||
type MediaType = 'track' | 'podcast' | 'radio'
|
||||
|
||||
// Base media type
|
||||
interface Playable {
|
||||
id: number | string
|
||||
type: MediaType
|
||||
duration?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function trackToPlayable(track: Track): Playable {
|
||||
return {
|
||||
type: 'track',
|
||||
...track,
|
||||
}
|
||||
}
|
||||
|
||||
export function podcastEpisodeToPlayable(episode: PodcastEpisode): Playable {
|
||||
return {
|
||||
type: 'podcast',
|
||||
...episode,
|
||||
}
|
||||
}
|
||||
|
||||
export function radioStationToPlayable(station: RadioStation): Playable {
|
||||
return {
|
||||
type: 'radio',
|
||||
...station,
|
||||
}
|
||||
}
|
||||
|
||||
const audio = new Audio()
|
||||
const isPlaying = ref(false)
|
||||
const queue = ref<Media[]>([])
|
||||
const currentIndex = ref<number>(-1)
|
||||
const queue = ref<Playable[]>([])
|
||||
const currentIndex = ref(-1)
|
||||
const loading = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const seek = computed(() => (duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0))
|
||||
const awaitingSeekResume = ref(false)
|
||||
const seek = computed(() =>
|
||||
duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
|
||||
)
|
||||
const resumePosition = ref(0)
|
||||
|
||||
const currentMedia = computed(() => {
|
||||
return currentIndex.value >= 0 ? queue.value[currentIndex.value] ?? null : null
|
||||
})
|
||||
const currentMedia = computed(() =>
|
||||
currentIndex.value >= 0 ? queue.value[currentIndex.value] ?? null : null
|
||||
)
|
||||
|
||||
function playMusic(media: Media) {
|
||||
const index = queue.value.findIndex(item => item.id === media.id)
|
||||
const streamPaths: Record<string, (_media: Playable) => string> = {
|
||||
track: (media) => `/music/tracks/${media.id}/stream`,
|
||||
podcast: (media) => `/podcasts/episodes/${media.id}/stream`,
|
||||
radio: (media) => `/radio/${media.uuid}/stream`,
|
||||
}
|
||||
|
||||
function getStreamUrl(media: Playable): string {
|
||||
const pathResolver = streamPaths[media.type]
|
||||
if (!pathResolver) {
|
||||
throw new Error(`Unsupported media type: ${media.type}`)
|
||||
}
|
||||
return axios.defaults.baseURL + pathResolver(media)
|
||||
}
|
||||
|
||||
function trackAction(
|
||||
media: Playable,
|
||||
action: 'play' | 'pause' | 'complete' | 'resume'
|
||||
) {
|
||||
|
||||
const endpoints: Record<MediaType, { path: string, data: (_media: Playable) => unknown } | undefined> = {
|
||||
podcast: {
|
||||
path: '/podcasts/track',
|
||||
data: (media: Playable) => ({
|
||||
id: media.id,
|
||||
guid: media.guid,
|
||||
action,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
position: Math.floor(currentTime.value),
|
||||
total: Math.floor(duration.value),
|
||||
device: 'web',
|
||||
}),
|
||||
},
|
||||
track: undefined,
|
||||
radio: undefined,
|
||||
}
|
||||
|
||||
if (!endpoints[media.type]) return 0
|
||||
|
||||
const endpoint = endpoints[media.type]!
|
||||
|
||||
axios.post(endpoint.path, endpoint.data(media)).catch(err => console.warn('Tracking failed:', err))
|
||||
}
|
||||
|
||||
async function getStartPosition(media: Playable): Promise<number> {
|
||||
const endpoints: Record<MediaType, ((_media: Playable) => string) | undefined> = {
|
||||
podcast: (media) => `/podcasts/episodes/${media.id}/position`,
|
||||
track: undefined,
|
||||
radio: undefined,
|
||||
}
|
||||
if (!endpoints[media.type]) return 0
|
||||
const endpoint = endpoints[media.type]!(media)
|
||||
try {
|
||||
const response = await axios.get(endpoint)
|
||||
const position = response.data.position || 0
|
||||
if (position > 0) {
|
||||
awaitingSeekResume.value = true
|
||||
}
|
||||
return position
|
||||
} catch (err) {
|
||||
console.warn('Failed to get start position:', err)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function playMedia(media: Playable) {
|
||||
const index = queue.value.findIndex(item => item.id === media.id && item.type === media.type)
|
||||
|
||||
if (index !== -1) {
|
||||
currentIndex.value = index
|
||||
@@ -24,56 +124,38 @@ function playMusic(media: Media) {
|
||||
currentIndex.value = queue.value.length - 1
|
||||
}
|
||||
|
||||
const newSrc = axios.defaults.baseURL + `/music/tracks/${media.id}/stream`
|
||||
const src = getStreamUrl(media)
|
||||
|
||||
if (audio.src !== newSrc) {
|
||||
audio.pause()
|
||||
audio.src = newSrc
|
||||
audio.load()
|
||||
}
|
||||
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Playback failed:', err)
|
||||
isPlaying.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function playRadioStation(uuid: string) {
|
||||
clearQueue()
|
||||
const src = axios.defaults.baseURL + `/radio/${uuid}/stream`
|
||||
if (audio.src !== src) {
|
||||
audio.pause()
|
||||
resumePosition.value = await getStartPosition(media)
|
||||
audio.src = src
|
||||
audio.load()
|
||||
currentTime.value = resumePosition.value
|
||||
} else {
|
||||
resumePosition.value = 0
|
||||
awaitingSeekResume.value = false
|
||||
}
|
||||
|
||||
duration.value = typeof media.duration === 'number' ? media.duration : 0
|
||||
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Playback failed:', err)
|
||||
isPlaying.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function playIndex(index: number) {
|
||||
if (queue.value[index]) {
|
||||
currentIndex.value = index
|
||||
playMusic(queue.value[index])
|
||||
playMedia(queue.value[index])
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (currentIndex.value + 1 < queue.value.length) {
|
||||
playIndex(currentIndex.value + 1)
|
||||
} else {
|
||||
console.warn('No next track in queue')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,36 +175,18 @@ function togglePlay() {
|
||||
if (audio.paused) {
|
||||
audio
|
||||
.play()
|
||||
.then(() => (isPlaying.value = true))
|
||||
.catch(err => {
|
||||
console.error('Toggle play failed:', err)
|
||||
})
|
||||
.catch(err => console.error('Toggle play failed:', err))
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
function addToQueue(media: Media | Media[]) {
|
||||
if (Array.isArray(media)) {
|
||||
queue.value.push(...media)
|
||||
} else {
|
||||
queue.value.push(media)
|
||||
}
|
||||
function addToQueue(media: Playable | Playable[]) {
|
||||
const items = Array.isArray(media) ? media : [media]
|
||||
queue.value.push(...items)
|
||||
}
|
||||
|
||||
function removeFromQueue(media: Media) {
|
||||
const index = queue.value.findIndex(item => item.id === media.id)
|
||||
if (index !== -1) {
|
||||
queue.value.splice(index, 1)
|
||||
if (currentIndex.value >= index) {
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, -1)
|
||||
}
|
||||
} else {
|
||||
console.warn('Media not found in queue:', media)
|
||||
}
|
||||
}
|
||||
|
||||
function addAsNext(media: Media) {
|
||||
function addAsNext(media: Playable) {
|
||||
if (currentIndex.value >= 0) {
|
||||
queue.value.splice(currentIndex.value + 1, 0, media)
|
||||
} else {
|
||||
@@ -130,28 +194,35 @@ function addAsNext(media: Media) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
queue.value = []
|
||||
currentIndex.value = -1
|
||||
}
|
||||
|
||||
function overwriteQueue(newQueue: Media[], startIndex = 0) {
|
||||
queue.value.splice(0, queue.value.length, ...newQueue)
|
||||
currentIndex.value = startIndex
|
||||
|
||||
if (queue.value[startIndex]) {
|
||||
playMusic(queue.value[startIndex])
|
||||
} else {
|
||||
console.warn('No valid track at startIndex', startIndex)
|
||||
function removeFromQueue(media: Playable) {
|
||||
const index = queue.value.findIndex(item => item.id === media.id && item.type === media.type)
|
||||
if (index !== -1) {
|
||||
queue.value.splice(index, 1)
|
||||
if (currentIndex.value >= index) {
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playFromQueue(media: Media) {
|
||||
const index = queue.value.findIndex(item => item.id === media.id)
|
||||
function clearQueue() {
|
||||
queue.value = []
|
||||
currentIndex.value = -1
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
}
|
||||
|
||||
function overwriteQueue(newQueue: Playable[], startIndex = 0) {
|
||||
queue.value.splice(0, queue.value.length, ...newQueue)
|
||||
currentIndex.value = startIndex
|
||||
if (queue.value[startIndex]) {
|
||||
playMedia(queue.value[startIndex])
|
||||
}
|
||||
}
|
||||
|
||||
function playFromQueue(media: Playable) {
|
||||
const index = queue.value.findIndex(item => item.id === media.id && item.type === media.type)
|
||||
if (index !== -1) {
|
||||
playIndex(index)
|
||||
} else {
|
||||
console.warn('Media not found in queue:', media)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,51 +232,109 @@ function setSeek(percent: number) {
|
||||
}
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
// console.log('[audio event] play', { resumePosition: resumePosition.value, currentTime: currentTime.value, awaitingSeekResume: awaitingSeekResume.value, currentMedia: currentMedia.value });
|
||||
isPlaying.value = true
|
||||
})
|
||||
if (awaitingSeekResume.value) return
|
||||
|
||||
trackAction(currentMedia.value!, currentTime.value > 0 ? 'resume' : 'play')
|
||||
})
|
||||
audio.addEventListener('pause', () => {
|
||||
// console.log('[audio event] pause', { currentMedia: currentMedia.value });
|
||||
isPlaying.value = false
|
||||
trackAction(currentMedia.value!, 'pause')
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
// console.log('[audio event] ended', { currentMedia: currentMedia.value });
|
||||
isPlaying.value = false
|
||||
trackAction(currentMedia.value!, 'complete')
|
||||
next()
|
||||
})
|
||||
|
||||
audio.addEventListener('waiting', () => {
|
||||
// console.log('[audio event] waiting');
|
||||
loading.value = true
|
||||
})
|
||||
audio.addEventListener('seeking', () => {
|
||||
// console.log('[audio event] seeking');
|
||||
loading.value = true
|
||||
})
|
||||
audio.addEventListener('seeked', () => {
|
||||
// console.log('[audio event] seeked');
|
||||
loading.value = false
|
||||
})
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
// console.log('[audio event] timeupdate', { currentTime: audio.currentTime, duration: audio.duration });
|
||||
currentTime.value = audio.currentTime
|
||||
duration.value = audio.duration || 0
|
||||
if (!duration.value && audio.duration) {
|
||||
duration.value = audio.duration || 0
|
||||
}
|
||||
})
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
// console.log('[audio event] loadedmetadata', { duration: audio.duration });
|
||||
if (!duration.value && audio.duration) {
|
||||
duration.value = audio.duration
|
||||
}
|
||||
|
||||
if (awaitingSeekResume.value && resumePosition.value > 0) {
|
||||
audio.currentTime = resumePosition.value
|
||||
currentTime.value = resumePosition.value
|
||||
}
|
||||
})
|
||||
audio.addEventListener('loadstart', () => {
|
||||
// console.log('[audio event] loadstart');
|
||||
loading.value = true
|
||||
})
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
duration.value = audio.duration
|
||||
audio.addEventListener('canplay', () => {
|
||||
// console.log('[audio event] canplay', { resumePosition: resumePosition.value, currentTime: currentTime.value, awaitingSeekResume: awaitingSeekResume.value, currentMedia: currentMedia.value });
|
||||
loading.value = false
|
||||
|
||||
audio.play().catch(err => {
|
||||
console.warn('Resume playback after seek failed:', err)
|
||||
})
|
||||
|
||||
if (awaitingSeekResume.value) {
|
||||
trackAction(currentMedia.value!, 'resume')
|
||||
awaitingSeekResume.value = false
|
||||
resumePosition.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
// console.log('[audio event] error');
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
watch(currentMedia, (_newMedia, oldMedia) => {
|
||||
if (oldMedia && oldMedia.type === 'podcast') {
|
||||
trackAction(oldMedia, 'pause')
|
||||
}
|
||||
})
|
||||
|
||||
function usePlayback() {
|
||||
return {
|
||||
playMusic,
|
||||
playRadioStation,
|
||||
pause,
|
||||
togglePlay,
|
||||
next,
|
||||
prev,
|
||||
isPlaying,
|
||||
currentMedia,
|
||||
queue,
|
||||
loading,
|
||||
currentIndex,
|
||||
addToQueue,
|
||||
removeFromQueue,
|
||||
playFromQueue,
|
||||
addAsNext,
|
||||
clearQueue,
|
||||
overwriteQueue,
|
||||
currentTime,
|
||||
duration,
|
||||
seek,
|
||||
playMedia,
|
||||
addToQueue,
|
||||
addAsNext,
|
||||
removeFromQueue,
|
||||
playFromQueue,
|
||||
overwriteQueue,
|
||||
clearQueue,
|
||||
playIndex,
|
||||
next,
|
||||
prev,
|
||||
togglePlay,
|
||||
pause,
|
||||
setSeek,
|
||||
}
|
||||
}
|
||||
|
||||
const playback = usePlayback()
|
||||
export const playback = usePlayback()
|
||||
export default playback
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface Media {
|
||||
export interface Track {
|
||||
id: number
|
||||
path: string
|
||||
title: string | null
|
||||
@@ -23,6 +23,32 @@ export interface Media {
|
||||
language: string | null
|
||||
}
|
||||
|
||||
export interface PodcastSubscription {
|
||||
id: number
|
||||
subscription_id: number | null
|
||||
title: string | null
|
||||
author: string | null
|
||||
description: string | null
|
||||
url: string | null
|
||||
user_id: string | null
|
||||
image: string | null
|
||||
subscribed: boolean
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface PodcastEpisode {
|
||||
id: number
|
||||
action_id: number | null
|
||||
subscription_data_id: number
|
||||
title: string | null
|
||||
guid: string | null
|
||||
pub_date: string | null
|
||||
duration: number | null
|
||||
media_url: string | null
|
||||
description: string | null
|
||||
user_id: string | null
|
||||
}
|
||||
|
||||
export interface RadioStation {
|
||||
id: number
|
||||
remoteUuid: string
|
||||
@@ -47,7 +73,7 @@ export interface Album {
|
||||
year: number | null
|
||||
cover: string | null
|
||||
genre: string | null
|
||||
tracks: Media[]
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
@@ -55,5 +81,5 @@ export interface Artist {
|
||||
cover: string | null
|
||||
genre: string | null
|
||||
albums: Album[]
|
||||
tracks: Media[]
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: '/albums/:artist/:album', component: () => import('@/views/AlbumView.vue') },
|
||||
{ path: '/artists', component: () => import('@/views/ArtistsView.vue') },
|
||||
{ path: '/artists/:id', component: () => import('@/views/ArtistView.vue') },
|
||||
// { path: '/podcasts', component: () => import('@/views/PodcastsView.vue') },
|
||||
{ path: '/podcasts', component: () => import('@/views/PodcastsView.vue') },
|
||||
{ path: '/podcasts/:id', component: () => import('@/views/PodcastView.vue') },
|
||||
// { path: '/audiobooks', component: () => import('@/views/AudiobooksView.vue') },
|
||||
// { path: '/videos', component: () => import('@/views/VideosView.vue') },
|
||||
// { path: '/genres', component: () => import('@/views/GenresView.vue') },
|
||||
|
||||
@@ -16,6 +16,10 @@ export function getRadioStationPath(uuid: string): string {
|
||||
return `/radio/${uuid}`;
|
||||
}
|
||||
|
||||
export function getPodcastPath(id: number): string {
|
||||
return `/podcasts/${id}`;
|
||||
}
|
||||
|
||||
export function useGoToRoute() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -38,3 +42,8 @@ export function useGoToRadioStation() {
|
||||
const goToRoute = useGoToRoute()
|
||||
return (uuid: string) => goToRoute(getRadioStationPath(uuid))
|
||||
}
|
||||
|
||||
export function useGoToPodcast() {
|
||||
const goToRoute = useGoToRoute()
|
||||
return (id: number) => goToRoute(getPodcastPath(id))
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { axios } from '@/axios'
|
||||
import type { Album, Media } from '@/models/media'
|
||||
import type { Album, Track } from '@/models/media'
|
||||
import { useGoToArtist } from '@/utils/routing'
|
||||
|
||||
import Page from '@/components/Page.vue'
|
||||
import MediaListItem from '@/components/media/MediaListItem.vue'
|
||||
import Music from '@icons/Music.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -61,11 +61,11 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (track: Media) => {
|
||||
const handlePlay = (track: Track) => {
|
||||
if (album.value) {
|
||||
const index = album.value.tracks.findIndex(t => t.id === track.id)
|
||||
if (index !== -1) {
|
||||
overwriteQueue([...album.value.tracks], index)
|
||||
overwriteQueue(album.value.tracks.map(trackToPlayable), index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
import type { Media, Album } from '@/models/media'
|
||||
import type { Track, Album } from '@/models/media'
|
||||
|
||||
import AlbumListItem from '@/components/media/AlbumListItem.vue'
|
||||
import Page from '@/components/Page.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
@@ -38,11 +38,11 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (track: Media) => {
|
||||
const handlePlay = (track: Track) => {
|
||||
for (const album of albums.value) {
|
||||
const index = album.tracks.findIndex(t => t.id === track.id)
|
||||
if (index !== -1) {
|
||||
overwriteQueue([...album.tracks], index)
|
||||
overwriteQueue(album.tracks.map(trackToPlayable), index)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { axios } from '@/axios'
|
||||
import type { Media, Artist } from '@/models/media'
|
||||
import type { Track, Artist } from '@/models/media'
|
||||
|
||||
import Page from '@/components/Page.vue'
|
||||
import MediaListItem from '@/components/media/MediaListItem.vue'
|
||||
import AlbumCardItem from '@/components/media/AlbumCardItem.vue'
|
||||
import Music from '@icons/Music.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ArtistView',
|
||||
@@ -60,11 +60,11 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (track: Media) => {
|
||||
const handlePlay = (track: Track) => {
|
||||
if (artist.value) {
|
||||
const index = artist.value.tracks.findIndex((t) => t.id === track.id)
|
||||
if (index !== -1) {
|
||||
overwriteQueue([...artist.value.tracks], index)
|
||||
overwriteQueue(artist.value.tracks.map(trackToPlayable), index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Media, Artist } from '@/models/media'
|
||||
import type { Track, Artist } from '@/models/media'
|
||||
|
||||
import ArtistListItem from '@/components/media/ArtistListItem.vue'
|
||||
import Page from '@/components/Page.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ArtistsView',
|
||||
@@ -38,11 +38,11 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (track: Media) => {
|
||||
const handlePlay = (track: Track) => {
|
||||
for (const artist of artists.value) {
|
||||
const index = artist.tracks.findIndex(t => t.id === track.id)
|
||||
if (index !== -1) {
|
||||
overwriteQueue([...artist.tracks], index)
|
||||
overwriteQueue(artist.tracks.map(trackToPlayable), index)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
115
src/views/PodcastView.vue
Normal file
115
src/views/PodcastView.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<Page :loading="isLoading">
|
||||
<template #title>
|
||||
{{ podcast?.title || 'Podcast' }}
|
||||
</template>
|
||||
|
||||
<div v-if="podcast" class="podcast-info">
|
||||
<img v-if="podcast.image" :src="podcast.image" alt="Podcast cover" class="cover" />
|
||||
<Podcast v-else :size="100" />
|
||||
|
||||
<div class="meta">
|
||||
<h2>{{ podcast.title }}</h2>
|
||||
<p v-if="podcast.author">By {{ podcast.author }}</p>
|
||||
<p v-if="podcast.description" class="description">{{ podcast.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="episodes.length">
|
||||
<PodcastEpisodeListItem v-for="ep in episodes" :key="ep.id" :episode="ep" :cover="podcast?.image ?? undefined"
|
||||
@play="handlePlay(ep)" />
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { axios } from '@/axios'
|
||||
import Page from '@/components/Page.vue'
|
||||
import PodcastEpisodeListItem from '@/components/media/PodcastEpisodeListItem.vue'
|
||||
import Podcast from '@icons/Podcast.vue'
|
||||
import playback, { podcastEpisodeToPlayable } from '@/composables/usePlayback'
|
||||
import type { PodcastSubscription, PodcastEpisode } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PodcastView',
|
||||
components: {
|
||||
Page,
|
||||
PodcastEpisodeListItem,
|
||||
Podcast,
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const podcast = ref<PodcastSubscription | null>(null)
|
||||
const episodes = ref<PodcastEpisode[]>([])
|
||||
const isLoading = ref(true)
|
||||
const { overwriteQueue } = playback
|
||||
|
||||
onMounted(async () => {
|
||||
const id = decodeURIComponent(route.params.id as string)
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/podcasts/subscriptions/${id}`)
|
||||
podcast.value = res.data.subscription
|
||||
|
||||
const epRes = await axios.get(`/podcasts/subscriptions/${id}/episodes`)
|
||||
episodes.value = (epRes.data.episodes as PodcastEpisode[]).sort((a, b) => {
|
||||
const aDate = new Date(a.pub_date || 0).getTime()
|
||||
const bDate = new Date(b.pub_date || 0).getTime()
|
||||
return bDate - aDate
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load podcast view:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (episode: PodcastEpisode) => {
|
||||
const index = episodes.value.findIndex((e) => e.id === episode.id)
|
||||
if (index !== -1) {
|
||||
overwriteQueue([podcastEpisodeToPlayable(episodes.value[index])], index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
podcast,
|
||||
episodes,
|
||||
isLoading,
|
||||
handlePlay,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.podcast-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
|
||||
.cover {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.meta {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
156
src/views/PodcastsView.vue
Normal file
156
src/views/PodcastsView.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Page :loading="isLoading">
|
||||
<template #title>
|
||||
Podcasts
|
||||
</template>
|
||||
|
||||
<AddPodcastModal v-if="isAddModalOpen" @add-subscription="addSubscription" @close="isAddModalOpen = false"
|
||||
:subscriptions="subscriptions" />
|
||||
|
||||
<div v-if="nextEpisodes.length">
|
||||
<div class="recent-episodes">
|
||||
<h4>Recently Played / Latest Episodes</h4>
|
||||
<PodcastEpisodeCardItem v-for="ep in nextEpisodes" :key="ep.id" :episode="ep" />
|
||||
<p class="empty-state">Coming soon…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subscriptions.length">
|
||||
<h4>
|
||||
My Podcasts
|
||||
|
||||
<NcButton @click="isAddModalOpen = true">
|
||||
<template #icon>
|
||||
<Plus />
|
||||
</template>
|
||||
Add
|
||||
</NcButton>
|
||||
</h4>
|
||||
<div class="podcast-sub-list">
|
||||
<PodcastSubscriptionCardItem v-for="sub in subscriptions" :key="sub.id" :subscription="sub"
|
||||
@click="openSubscription(sub.id)" @remove="removeSubscription(sub)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!subscriptions.length" class="empty-state">
|
||||
<p>No podcast subscriptions found. Add some to get started!</p>
|
||||
<NcButton @click="isAddModalOpen = true">
|
||||
<template #icon>
|
||||
<Plus />
|
||||
</template>
|
||||
Add
|
||||
</NcButton>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
import Page from '@/components/Page.vue'
|
||||
import AddPodcastModal from '@/components/media/AddPodcastModal.vue'
|
||||
import PodcastSubscriptionCardItem from '@/components/media/PodcastSubscriptionCardItem.vue'
|
||||
import PodcastEpisodeCardItem from '@/components/media/PodcastEpisodeCardItem.vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import Plus from '@icons/Plus.vue'
|
||||
import type { PodcastSubscription, PodcastEpisode } from '@/models/media'
|
||||
import { useGoToPodcast } from '@/utils/routing'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PodcastsView',
|
||||
components: {
|
||||
Page,
|
||||
NcButton,
|
||||
AddPodcastModal,
|
||||
PodcastSubscriptionCardItem,
|
||||
PodcastEpisodeCardItem,
|
||||
Plus,
|
||||
},
|
||||
setup() {
|
||||
const subscriptions = ref<PodcastSubscription[]>([])
|
||||
const nextEpisodes = ref<PodcastEpisode[]>([])
|
||||
const isAddModalOpen = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const goToPodcast = useGoToPodcast()
|
||||
|
||||
const fetchSubscriptions = async () => {
|
||||
try {
|
||||
const res = await axios.get('/podcasts/subscriptions')
|
||||
subscriptions.value = res.data.subscriptions
|
||||
} catch (err) {
|
||||
console.error('Failed to load subscriptions:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
const res = await axios.get('/podcasts/next')
|
||||
subscriptions.value = res.data.subscriptions
|
||||
} catch (err) {
|
||||
console.error('Failed to load subscriptions:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addSubscription = async (sub: PodcastSubscription) => {
|
||||
const index = subscriptions.value.findIndex(s => s.id === sub.id)
|
||||
if (index !== -1) {
|
||||
subscriptions.value[index] = sub
|
||||
return
|
||||
}
|
||||
subscriptions.value.unshift(sub)
|
||||
}
|
||||
|
||||
const removeSubscription = async (sub: PodcastSubscription) => {
|
||||
const index = subscriptions.value.findIndex(s => s.id === sub.id)
|
||||
if (index !== -1) {
|
||||
subscriptions.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const openSubscription = (id: number) => {
|
||||
goToPodcast(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSubscriptions()
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
subscriptions,
|
||||
nextEpisodes,
|
||||
isAddModalOpen,
|
||||
addSubscription,
|
||||
removeSubscription,
|
||||
openSubscription,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.podcast-sub-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.recent-episodes {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
</style>
|
||||
@@ -11,8 +11,7 @@
|
||||
<h4>Favorites</h4>
|
||||
<div class="radio-station-list">
|
||||
<RadioStationCardItem v-for="station in favorites" :key="station.remoteUuid" :station="station"
|
||||
@click="playStation(station.remoteUuid)" @unfavorite="setFavorite(station, false)"
|
||||
@remove="removeStation(station)" />
|
||||
@click="playStation(station)" @unfavorite="setFavorite(station, false)" @remove="removeStation(station)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,8 +28,8 @@
|
||||
</h4>
|
||||
<div class="radio-station-list">
|
||||
<RadioStationCardItem v-for="station in stations" :key="station.remoteUuid" :station="station"
|
||||
@click="playStation(station.remoteUuid)" @favorite="setFavorite(station, true)"
|
||||
@unfavorite="setFavorite(station, false)" @remove="removeStation(station)" />
|
||||
@click="playStation(station)" @favorite="setFavorite(station, true)" @unfavorite="setFavorite(station, false)"
|
||||
@remove="removeStation(station)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +55,7 @@ import SearchRadioStationModal from '@/components/media/SearchRadioStationModal.
|
||||
import Plus from '@icons/Plus.vue'
|
||||
import type { RadioStation } from '@/models/media'
|
||||
// import { useGoToRadioStation } from '@/utils/routing'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { radioStationToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RadioStationsView',
|
||||
@@ -73,10 +72,8 @@ export default defineComponent({
|
||||
const isSearchModalOpen = ref(false)
|
||||
const isLoading = ref(true)
|
||||
|
||||
// const playStation = useGoToRadioStation()
|
||||
|
||||
const playStation = (remoteUuid: string) => {
|
||||
playback.playRadioStation(remoteUuid)
|
||||
const playStation = (station: RadioStation) => {
|
||||
playback.playMedia(radioStationToPlayable(station))
|
||||
}
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
import { type Media } from '@/models/media'
|
||||
import { type Track } from '@/models/media'
|
||||
|
||||
import MediaListItem from '@/components/media/MediaListItem.vue'
|
||||
import Page from '@/components/Page.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TracksView',
|
||||
components: { MediaListItem, Page },
|
||||
setup() {
|
||||
const tracks = ref<Media[]>([])
|
||||
const tracks = ref<Track[]>([])
|
||||
const isLoading = ref(true)
|
||||
const { overwriteQueue } = playback
|
||||
|
||||
@@ -36,10 +36,10 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (track: Media) => {
|
||||
const handlePlay = (track: Track) => {
|
||||
const index = tracks.value.findIndex(t => t.id === track.id)
|
||||
if (index !== -1) {
|
||||
overwriteQueue([...tracks.value], index)
|
||||
overwriteQueue(tracks.value.map(trackToPlayable), index)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user