diff --git a/Makefile b/Makefile index 9b21778..8f25e91 100644 --- a/Makefile +++ b/Makefile @@ -181,13 +181,13 @@ lint: .PHONY: php-cs-fixer php-cs-fixer: @echo "\x1b[33mFixing PHP files...\x1b[0m" - @FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$$'); \ + @FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$$' | grep -v '^gen/'); \ if [ -z "$$FILES" ]; then \ echo "No PHP files staged."; \ else \ echo "Running CS fixer on:" $$FILES; \ php -l $$FILES || exit 1; \ - php vendor-bin/cs-fixer/vendor/php-cs-fixer/shim/php-cs-fixer.phar --config=.php-cs-fixer.dist.php fix $$FILES || exit 1; \ + PHP_CS_FIXER_IGNORE_ENV=true php vendor-bin/cs-fixer/vendor/php-cs-fixer/shim/php-cs-fixer.phar --config=.php-cs-fixer.dist.php fix $$FILES || exit 1; \ fi .PHONY: format diff --git a/appinfo/info.xml b/appinfo/info.xml index 53bb6ec..275fb86 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,13 +40,14 @@ It supports music files, podcasts (with gPodder sync), audiobooks, YouTube video - + + OCA\Jukebox\Cron\FetchPodcastEpisodesTask + OCA\Jukebox\Command\ScanMusic OCA\Jukebox\Command\ImportRadioStations + OCA\Jukebox\Command\PodcastFetchEpisodes diff --git a/composer.json b/composer.json index 57eb77e..4cbc4f8 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/gen/api/{{pascalCase name}}Controller.php b/gen/api/{{pascalCase name}}Controller.php index bebc752..eeca672 100644 --- a/gen/api/{{pascalCase name}}Controller.php +++ b/gen/api/{{pascalCase name}}Controller.php @@ -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); } diff --git a/gen/model/{{pascalCase name}}.php b/gen/model/{{pascalCase name}}.php index 45cc234..2d0054c 100755 --- a/gen/model/{{pascalCase name}}.php +++ b/gen/model/{{pascalCase name}}.php @@ -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(), ]; } } diff --git a/lib/Command/ImportRadioStations.php b/lib/Command/ImportRadioStations.php index c484b08..e6f3ddc 100644 --- a/lib/Command/ImportRadioStations.php +++ b/lib/Command/ImportRadioStations.php @@ -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(); } diff --git a/lib/Command/PodcastFetchEpisodes.php b/lib/Command/PodcastFetchEpisodes.php new file mode 100644 index 0000000..058b482 --- /dev/null +++ b/lib/Command/PodcastFetchEpisodes.php @@ -0,0 +1,73 @@ + + * 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('Running FetchPodcastEpisodesTask'); + if ($userId && $subscriptionId) { + $output->writeln("Arguments received: userId={$userId}, subscriptionId={$subscriptionId}"); + $sub = $this->subMapper->find($userId, $subscriptionId); + $allSubs = $sub ? [$sub] : []; + } else { + $output->writeln('No specific arguments received. Fetching all subscribed feeds.'); + $allSubs = $this->subMapper->findAllSubscribed(); + } + + foreach ($allSubs as $sub) { + $userId = $sub->getUserId(); + $url = $sub->getUrl(); + + if (!$userId || !$url) { + $output->writeln("Skipping sub {$sub->getId()} due to missing userId or url"); + continue; + } + + $output->writeln("Fetching episodes for user {$userId} from {$url}"); + + try { + $episodes = $this->parser->parseEpisodes($url); + $this->writer->storeEpisodes($userId, $sub, $episodes); + $output->writeln('Fetched ' . count($episodes) . ' episodes'); + } catch (\Throwable $e) { + $output->writeln("Failed to fetch episodes for {$url}: {$e->getMessage()}"); + } + } + + return Command::SUCCESS; + } +} diff --git a/lib/Controller/MusicController.php b/lib/Controller/MusicController.php index e2b1c04..adf2669 100644 --- a/lib/Controller/MusicController.php +++ b/lib/Controller/MusicController.php @@ -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); diff --git a/lib/Controller/PodcastController.php b/lib/Controller/PodcastController.php new file mode 100644 index 0000000..9abeb10 --- /dev/null +++ b/lib/Controller/PodcastController.php @@ -0,0 +1,474 @@ + +// 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, 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 + * + * 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 + * + * 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 + * }, 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 + * }, 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> + * }, 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 + * @return StreamResponse + * @return JSONResponse + * @return JSONResponse + * @return JSONResponse + * @return JSONResponse + * + * 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 + * @return JSONResponse + * @return JSONResponse + * + * 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]); + } +} diff --git a/lib/Controller/RadioController.php b/lib/Controller/RadioController.php index b39539f..539ce17 100644 --- a/lib/Controller/RadioController.php +++ b/lib/Controller/RadioController.php @@ -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 { diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2944a18..a9a9c08 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -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(); diff --git a/lib/Cron/FetchPodcastEpisodesTask.php b/lib/Cron/FetchPodcastEpisodesTask.php new file mode 100644 index 0000000..84d6e62 --- /dev/null +++ b/lib/Cron/FetchPodcastEpisodesTask.php @@ -0,0 +1,77 @@ + +// 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(), + ]); + } + } + } +} diff --git a/lib/Db/GpodderPodcastEpisodeAction.php b/lib/Db/GpodderPodcastEpisodeAction.php new file mode 100644 index 0000000..8d274c9 --- /dev/null +++ b/lib/Db/GpodderPodcastEpisodeAction.php @@ -0,0 +1,61 @@ + +// 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(), + ]; + } +} diff --git a/lib/Db/GpodderPodcastEpisodeActionMapper.php b/lib/Db/GpodderPodcastEpisodeActionMapper.php new file mode 100644 index 0000000..5d23f3f --- /dev/null +++ b/lib/Db/GpodderPodcastEpisodeActionMapper.php @@ -0,0 +1,54 @@ + +// 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 + */ +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 + */ + public function findAll(): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName()); + return $this->findEntities($qb); + } +} diff --git a/lib/Db/GpodderPodcastSubscription.php b/lib/Db/GpodderPodcastSubscription.php new file mode 100644 index 0000000..cc45179 --- /dev/null +++ b/lib/Db/GpodderPodcastSubscription.php @@ -0,0 +1,44 @@ + +// 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(), + ]; + } +} diff --git a/lib/Db/GpodderPodcastSubscriptionMapper.php b/lib/Db/GpodderPodcastSubscriptionMapper.php new file mode 100644 index 0000000..0c13058 --- /dev/null +++ b/lib/Db/GpodderPodcastSubscriptionMapper.php @@ -0,0 +1,99 @@ + +// 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 + */ +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 + */ + 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 + */ + public function findAll(): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName()); + return $this->findEntities($qb); + } +} diff --git a/lib/Db/PodcastEpisode.php b/lib/Db/PodcastEpisode.php new file mode 100644 index 0000000..31c9fb8 --- /dev/null +++ b/lib/Db/PodcastEpisode.php @@ -0,0 +1,67 @@ +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(), + ]; + } +} diff --git a/lib/Db/PodcastEpisodeMapper.php b/lib/Db/PodcastEpisodeMapper.php new file mode 100644 index 0000000..68eb6c7 --- /dev/null +++ b/lib/Db/PodcastEpisodeMapper.php @@ -0,0 +1,84 @@ + + */ +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); + } +} diff --git a/lib/Db/PodcastEpisodePlay.php b/lib/Db/PodcastEpisodePlay.php new file mode 100644 index 0000000..7c1a9df --- /dev/null +++ b/lib/Db/PodcastEpisodePlay.php @@ -0,0 +1,68 @@ + +// 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(), + ]; + } +} diff --git a/lib/Db/PodcastEpisodePlayMapper.php b/lib/Db/PodcastEpisodePlayMapper.php new file mode 100644 index 0000000..abc012d --- /dev/null +++ b/lib/Db/PodcastEpisodePlayMapper.php @@ -0,0 +1,97 @@ + +// 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 + */ +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); + } +} diff --git a/lib/Db/PodcastSubscription.php b/lib/Db/PodcastSubscription.php new file mode 100644 index 0000000..1227e8b --- /dev/null +++ b/lib/Db/PodcastSubscription.php @@ -0,0 +1,67 @@ +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), + ]; + } +} diff --git a/lib/Db/PodcastSubscriptionMapper.php b/lib/Db/PodcastSubscriptionMapper.php new file mode 100644 index 0000000..bc72b05 --- /dev/null +++ b/lib/Db/PodcastSubscriptionMapper.php @@ -0,0 +1,96 @@ + + */ +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); + } +} diff --git a/lib/Db/JukeboxRadioStation.php b/lib/Db/RadioStation.php similarity index 97% rename from lib/Db/JukeboxRadioStation.php rename to lib/Db/RadioStation.php index 9b5ff13..af5548f 100644 --- a/lib/Db/JukeboxRadioStation.php +++ b/lib/Db/RadioStation.php @@ -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 = ''; diff --git a/lib/Db/JukeboxRadioStationMapper.php b/lib/Db/RadioStationMapper.php similarity index 88% rename from lib/Db/JukeboxRadioStationMapper.php rename to lib/Db/RadioStationMapper.php index a78dd28..d27cd9d 100644 --- a/lib/Db/JukeboxRadioStationMapper.php +++ b/lib/Db/RadioStationMapper.php @@ -13,20 +13,20 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** - * @template-extends QBMapper + * @template-extends QBMapper */ -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 + * @return array */ 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 + * @return array */ public function findByUserId(string $userId): array { $qb = $this->db->getQueryBuilder(); @@ -77,7 +77,7 @@ class JukeboxRadioStationMapper extends QBMapper { /** * @param string $userId - * @return array + * @return array */ 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 + * @return array */ public function findPaginatedByUserId(string $userId, int $offset, int $limit): array { $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/JukeboxMusic.php b/lib/Db/Track.php similarity index 98% rename from lib/Db/JukeboxMusic.php rename to lib/Db/Track.php index ad65a1a..2ad7ad7 100644 --- a/lib/Db/JukeboxMusic.php +++ b/lib/Db/Track.php @@ -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; diff --git a/lib/Db/JukeboxMusicMapper.php b/lib/Db/TrackMapper.php similarity index 92% rename from lib/Db/JukeboxMusicMapper.php rename to lib/Db/TrackMapper.php index a79915f..5fb721d 100644 --- a/lib/Db/JukeboxMusicMapper.php +++ b/lib/Db/TrackMapper.php @@ -14,21 +14,21 @@ use OCP\IDBConnection; use Psr\Log\LoggerInterface; /** - * @template-extends QBMapper + * @template-extends QBMapper */ -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 + * @return array */ public function findByUserId(string $userId): array { $qb = $this->db->getQueryBuilder(); @@ -59,7 +59,7 @@ class JukeboxMusicMapper extends QBMapper { } /** - * @return array + * @return array */ public function findAll(): array { $qb = $this->db->getQueryBuilder(); @@ -70,7 +70,7 @@ class JukeboxMusicMapper extends QBMapper { /** * @param string $userId * @param string $query - * @return array + * @return array */ 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 + * @return array */ 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 + * @return array */ 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()) diff --git a/lib/Migration/Version1Date20250607001010.php b/lib/Migration/Version1Date20250607001010.php index a00708b..f0981ef 100644 --- a/lib/Migration/Version1Date20250607001010.php +++ b/lib/Migration/Version1Date20250607001010.php @@ -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; } diff --git a/lib/Service/GpodderSyncService.php b/lib/Service/GpodderSyncService.php new file mode 100644 index 0000000..d0a1867 --- /dev/null +++ b/lib/Service/GpodderSyncService.php @@ -0,0 +1,119 @@ +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; + } +} diff --git a/lib/Service/MusicScannerService.php b/lib/Service/MusicScannerService.php index 661d981..86150a3 100644 --- a/lib/Service/MusicScannerService.php +++ b/lib/Service/MusicScannerService.php @@ -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); diff --git a/lib/Service/PodcastEpisodeWriterService.php b/lib/Service/PodcastEpisodeWriterService.php new file mode 100644 index 0000000..a626a84 --- /dev/null +++ b/lib/Service/PodcastEpisodeWriterService.php @@ -0,0 +1,70 @@ + $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); + } + } + } +} diff --git a/lib/Service/PodcastFeedParserService.php b/lib/Service/PodcastFeedParserService.php new file mode 100644 index 0000000..c87c624 --- /dev/null +++ b/lib/Service/PodcastFeedParserService.php @@ -0,0 +1,107 @@ +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 + * + * @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; + } +} diff --git a/lib/Service/RadioSourcesService.php b/lib/Service/RadioSourcesService.php index 73e6f88..4096cc3 100644 --- a/lib/Service/RadioSourcesService.php +++ b/lib/Service/RadioSourcesService.php @@ -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'] ?? ''); diff --git a/openapi.json b/openapi.json index 21ed873..968a222 100644 --- a/openapi.json +++ b/openapi.json @@ -51,7 +51,6 @@ "get": { "operationId": "music-list-tracks", "summary": "List all tracks for the current user", - "description": "This endpoint requires admin access", "tags": [ "music" ], @@ -107,7 +106,6 @@ "get": { "operationId": "music-stream-track", "summary": "Stream a track file for playback", - "description": "This endpoint requires admin access", "tags": [ "music" ], @@ -214,7 +212,6 @@ "get": { "operationId": "music-list-albums", "summary": "Fetch albums with grouped tracks for current user", - "description": "This endpoint requires admin access", "tags": [ "music" ], @@ -300,7 +297,6 @@ "get": { "operationId": "music-get-album-by-id", "summary": "Fetch a single album by its album & artist", - "description": "This endpoint requires admin access", "tags": [ "music" ], @@ -393,7 +389,6 @@ "get": { "operationId": "music-list-artists", "summary": "Fetch a list of unique artists for the current user", - "description": "This endpoint requires admin access", "tags": [ "music" ], @@ -464,7 +459,6 @@ "get": { "operationId": "music-get-artist-by-id", "summary": "Fetch a single artist by their ID", - "description": "This endpoint requires admin access", "tags": [ "music" ], @@ -539,11 +533,912 @@ } } }, + "/ocs/v2.php/apps/jukebox/api/podcasts/subscriptions": { + "get": { + "operationId": "podcast-get-subscriptions", + "summary": "Get all podcast subscriptions for the current user", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Subscriptions listed", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "url", + "subscribed", + "updated" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "updated": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "podcast-subscribe", + "summary": "Subscribe to a podcast by URL", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "description": "The podcast feed URL" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Subscription created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "subscription" + ], + "properties": { + "subscription": { + "type": "object", + "required": [ + "id", + "url", + "subscribed", + "updated", + "title", + "author", + "description", + "image" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "updated": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "image": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "subscription" + ], + "properties": { + "subscription": { + "type": "object", + "required": [ + "id", + "url", + "subscribed", + "updated", + "title", + "author", + "description", + "image" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "updated": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "image": { + "type": "string" + } + } + } + } + } + } + } + }, + "200": { + "description": "Subscription updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "subscription" + ], + "properties": { + "subscription": { + "type": "object", + "required": [ + "id", + "url", + "subscribed", + "updated", + "title", + "author", + "description", + "image" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + }, + "updated": { + "type": "string" + }, + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "image": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/jukebox/api/podcasts/track": { + "post": { + "operationId": "podcast-track-action", + "summary": "Track a podcast playback action", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "guid", + "action", + "timestamp" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "Episode ID" + }, + "guid": { + "type": "string", + "description": "Episode GUID" + }, + "action": { + "type": "string", + "description": "e.g. \"play\", \"pause\", \"complete\"" + }, + "timestamp": { + "type": "integer", + "format": "int64", + "description": "UNIX timestamp" + }, + "position": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "Position in seconds" + }, + "total": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "Duration in seconds" + }, + "device": { + "type": "string", + "nullable": true, + "default": null, + "description": "Device name or ID" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Action logged", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/jukebox/api/podcasts/next": { + "get": { + "operationId": "podcast-get-next-episodes", + "summary": "Get the next unfinished episode per podcast", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Next episodes listed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "episodes" + ], + "properties": { + "episodes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "title", + "guid", + "pub_date", + "duration", + "media_url", + "description", + "subscription_data_id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string", + "nullable": true + }, + "guid": { + "type": "string", + "nullable": true + }, + "pub_date": { + "type": "string", + "nullable": true + }, + "duration": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "media_url": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "subscription_data_id": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/jukebox/api/podcasts/subscriptions/{id}": { + "get": { + "operationId": "podcast-get-subscription", + "summary": "Get a single podcast subscription", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "the subscription ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Subscription found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "subscription" + ], + "properties": { + "subscription": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "404": { + "description": "Subscription not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "subscription" + ], + "properties": { + "subscription": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/jukebox/api/podcasts/subscriptions/{id}/episodes": { + "get": { + "operationId": "podcast-get-episodes-for-subscription", + "summary": "Get all episodes for a podcast subscription", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "the subscription ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Episodes listed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "episodes" + ], + "properties": { + "episodes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + } + }, + "404": { + "description": "Subscription not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "episodes" + ], + "properties": { + "episodes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/jukebox/api/podcasts/episodes/{id}/stream": { + "get": { + "operationId": "podcast-stream-episode", + "summary": "Stream a podcast episode", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Episode ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "range", + "in": "query", + "description": "Optional HTTP Range header for seeking support", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "range", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Full content stream returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "Partial content stream returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "401": { + "description": "User is not authenticated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Episode not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid or missing media URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Error occurred while streaming", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/jukebox/api/podcasts/episodes/{id}/position": { + "get": { + "operationId": "podcast-get-episode-position", + "summary": "Get the last known playback position for a podcast episode", + "tags": [ + "podcast" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Episode ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Playback position returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "position" + ], + "properties": { + "position": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "401": { + "description": "User not authenticated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Episode not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/jukebox/api/radio/stations": { "get": { "operationId": "radio-index", "summary": "List all saved radio stations for current user, paginated", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -617,7 +1512,6 @@ "post": { "operationId": "radio-add-by-uuid", "summary": "Add a radio station to the database", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -692,7 +1586,6 @@ "get": { "operationId": "radio-favorites", "summary": "List all favorited radio stations for current user, paginated", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -768,7 +1661,6 @@ "get": { "operationId": "radio-search", "summary": "Search radio stations from Radio Browser by name", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -833,7 +1725,6 @@ "get": { "operationId": "radio-get-by-uuid", "summary": "List all favorited radio stations for current user, paginated", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -895,7 +1786,6 @@ "put": { "operationId": "radio-update-by-uuid", "summary": "Update an existing radio station by UUID", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -977,7 +1867,6 @@ "delete": { "operationId": "radio-delete-by-uuid", "summary": "Remove a saved radio station by its UUID", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -1072,7 +1961,6 @@ "get": { "operationId": "radio-stream-by-uuid", "summary": "Stream a radio station by its UUID", - "description": "This endpoint requires admin access", "tags": [ "radio" ], @@ -1134,7 +2022,6 @@ "put": { "operationId": "settings-save-settings", "summary": "Save user-specific settings", - "description": "This endpoint requires admin access", "tags": [ "settings" ], @@ -1225,7 +2112,6 @@ "get": { "operationId": "settings-get-settings", "summary": "Fetch all user-specific settings", - "description": "This endpoint requires admin access", "tags": [ "settings" ], diff --git a/src/App.vue b/src/App.vue index 124805b..efb54ff 100644 --- a/src/App.vue +++ b/src/App.vue @@ -31,7 +31,10 @@ - + @@ -65,51 +68,7 @@ -
-
- - - - - - - - - - - - -
-
- {{ formattedCurrentTime }} - - {{ formattedDuration }} -
-
+ @@ -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, } diff --git a/src/components/media/AddPodcastModal.vue b/src/components/media/AddPodcastModal.vue new file mode 100644 index 0000000..84f743f --- /dev/null +++ b/src/components/media/AddPodcastModal.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/media/AlbumListItem.vue b/src/components/media/AlbumListItem.vue index b71c4ce..c47a130 100644 --- a/src/components/media/AlbumListItem.vue +++ b/src/components/media/AlbumListItem.vue @@ -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(() => { diff --git a/src/components/media/MediaControls.vue b/src/components/media/MediaControls.vue new file mode 100644 index 0000000..f5d6916 --- /dev/null +++ b/src/components/media/MediaControls.vue @@ -0,0 +1,166 @@ + + + + diff --git a/src/components/media/MediaListItem.vue b/src/components/media/MediaListItem.vue index 6bd5945..c9ca2a9 100644 --- a/src/components/media/MediaListItem.vue +++ b/src/components/media/MediaListItem.vue @@ -41,8 +41,8 @@ + + diff --git a/src/components/media/PodcastEpisodeListItem.vue b/src/components/media/PodcastEpisodeListItem.vue new file mode 100644 index 0000000..92cd585 --- /dev/null +++ b/src/components/media/PodcastEpisodeListItem.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/components/media/PodcastSubscriptionCardItem.vue b/src/components/media/PodcastSubscriptionCardItem.vue new file mode 100644 index 0000000..c1ed499 --- /dev/null +++ b/src/components/media/PodcastSubscriptionCardItem.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/src/components/media/QueuePopover.vue b/src/components/media/QueuePopover.vue index ef0f9f1..e886ffb 100644 --- a/src/components/media/QueuePopover.vue +++ b/src/components/media/QueuePopover.vue @@ -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, + type: Array as PropType, 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) } diff --git a/src/composables/usePlayback.ts b/src/composables/usePlayback.ts index e8a1f0d..a6fe67a 100644 --- a/src/composables/usePlayback.ts +++ b/src/composables/usePlayback.ts @@ -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([]) -const currentIndex = ref(-1) +const queue = ref([]) +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> = { + 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 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 { + const endpoints: Record 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 diff --git a/src/models/media.ts b/src/models/media.ts index 3a77707..4c1b5b5 100644 --- a/src/models/media.ts +++ b/src/models/media.ts @@ -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[] } diff --git a/src/router/index.ts b/src/router/index.ts index 82080a8..b5dd656 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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') }, diff --git a/src/utils/routing.ts b/src/utils/routing.ts index 633270b..ade476d 100644 --- a/src/utils/routing.ts +++ b/src/utils/routing.ts @@ -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)) +} diff --git a/src/views/AlbumView.vue b/src/views/AlbumView.vue index 39061d7..e609f39 100644 --- a/src/views/AlbumView.vue +++ b/src/views/AlbumView.vue @@ -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) } } } diff --git a/src/views/AlbumsView.vue b/src/views/AlbumsView.vue index 6a53c34..af2660b 100644 --- a/src/views/AlbumsView.vue +++ b/src/views/AlbumsView.vue @@ -12,11 +12,11 @@ + + diff --git a/src/views/PodcastsView.vue b/src/views/PodcastsView.vue new file mode 100644 index 0000000..a959438 --- /dev/null +++ b/src/views/PodcastsView.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/views/RadioStationsView.vue b/src/views/RadioStationsView.vue index 117681b..a332abb 100644 --- a/src/views/RadioStationsView.vue +++ b/src/views/RadioStationsView.vue @@ -11,8 +11,7 @@

Favorites

+ @click="playStation(station)" @unfavorite="setFavorite(station, false)" @remove="removeStation(station)" />
@@ -29,8 +28,8 @@
+ @click="playStation(station)" @favorite="setFavorite(station, true)" @unfavorite="setFavorite(station, false)" + @remove="removeStation(station)" />
@@ -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 () => { diff --git a/src/views/TracksView.vue b/src/views/TracksView.vue index 1e89384..b9af6ba 100644 --- a/src/views/TracksView.vue +++ b/src/views/TracksView.vue @@ -11,17 +11,17 @@