feat: podcasts

This commit is contained in:
2025-06-15 01:59:53 +03:00
parent 99700c4c82
commit 6af532bb38
54 changed files with 4067 additions and 320 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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]);
}
}

View File

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

View File

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

View 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(),
]);
}
}
}
}

View 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(),
];
}
}

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

View 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(),
];
}
}

View 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
View 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(),
];
}
}

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

View 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(),
];
}
}

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

View 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),
];
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -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[]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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