From ec3f9a7f34d46b132951963ce2ddcbf57d6cdcdc Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 6 Oct 2025 02:12:37 +0300 Subject: [PATCH] feat: add videos db, list view --- appinfo/info.xml | 2 + lib/Command/ImportGpodderSync.php | 2 +- lib/Command/ImportRadioStations.php | 2 +- lib/Command/PodcastFetchEpisodes.php | 2 +- lib/Command/ScanMusic.php | 2 +- lib/Command/ScanVideos.php | 51 ++++ lib/Controller/VideoController.php | 135 ++++++++++ lib/Cron/VideoScannerJob.php | 32 +++ lib/Db/Video.php | 126 +++++++++ lib/Db/VideoMapper.php | 118 +++++++++ lib/Migration/Version1Date20250607001010.php | 143 +++++++++-- lib/Service/GpodderSyncService.php | 2 +- lib/Service/PodcastEpisodeWriterService.php | 2 +- lib/Service/VideoScannerService.php | 255 +++++++++++++++++++ openapi.json | 233 +++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 206 ++++++++++----- src/Settings.vue | 31 +++ src/components/media/VideoGalleryItem.vue | 153 +++++++++++ src/models/media.ts | 20 ++ src/router/index.ts | 2 +- src/views/VideosView.vue | 73 ++++++ 22 files changed, 1500 insertions(+), 93 deletions(-) create mode 100644 lib/Command/ScanVideos.php create mode 100644 lib/Controller/VideoController.php create mode 100644 lib/Cron/VideoScannerJob.php create mode 100644 lib/Db/Video.php create mode 100644 lib/Db/VideoMapper.php create mode 100644 lib/Service/VideoScannerService.php create mode 100644 src/components/media/VideoGalleryItem.vue create mode 100644 src/views/VideosView.vue diff --git a/appinfo/info.xml b/appinfo/info.xml index 4a0b798..85a27f1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -43,10 +43,12 @@ It supports music files, podcasts (with gPodder sync), audiobooks, YouTube video OCA\Jukebox\Cron\FetchPodcastEpisodesTask OCA\Jukebox\Cron\ParsePodcastSubscriptionTask + OCA\Jukebox\Cron\VideoScannerJob OCA\Jukebox\Command\ScanMusic + OCA\Jukebox\Command\ScanVideos OCA\Jukebox\Command\ImportRadioStations OCA\Jukebox\Command\PodcastFetchEpisodes OCA\Jukebox\Command\ImportGpodderSync diff --git a/lib/Command/ImportGpodderSync.php b/lib/Command/ImportGpodderSync.php index f5c4747..2c156cb 100644 --- a/lib/Command/ImportGpodderSync.php +++ b/lib/Command/ImportGpodderSync.php @@ -2,7 +2,7 @@ declare(strict_types=1); -// SPDX-FileCopyrightText: Chen Asraf +// SPDX-FileCopyrightText: Chen Asraf // SPDX-License-Identifier: AGPL-3.0-or-later namespace OCA\Jukebox\Command; diff --git a/lib/Command/ImportRadioStations.php b/lib/Command/ImportRadioStations.php index e6f3ddc..294e134 100644 --- a/lib/Command/ImportRadioStations.php +++ b/lib/Command/ImportRadioStations.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: Chen Asraf + * SPDX-FileCopyrightText: Chen Asraf * SPDX-License-Identifier: AGPL-3.0-or-later */ diff --git a/lib/Command/PodcastFetchEpisodes.php b/lib/Command/PodcastFetchEpisodes.php index 058b482..5ecc692 100644 --- a/lib/Command/PodcastFetchEpisodes.php +++ b/lib/Command/PodcastFetchEpisodes.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: Chen Asraf + * SPDX-FileCopyrightText: Chen Asraf * SPDX-License-Identifier: AGPL-3.0-or-later */ diff --git a/lib/Command/ScanMusic.php b/lib/Command/ScanMusic.php index afaf195..2d536ed 100644 --- a/lib/Command/ScanMusic.php +++ b/lib/Command/ScanMusic.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: Chen Asraf + * SPDX-FileCopyrightText: Chen Asraf * SPDX-License-Identifier: AGPL-3.0-or-later */ diff --git a/lib/Command/ScanVideos.php b/lib/Command/ScanVideos.php new file mode 100644 index 0000000..7598c09 --- /dev/null +++ b/lib/Command/ScanVideos.php @@ -0,0 +1,51 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Jukebox\Command; + +use OCA\Jukebox\Service\VideoScannerService; +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 ScanVideos extends Command { + public function __construct( + private VideoScannerService $service, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('jukebox:scan-videos') + ->setDescription('Scan video files for a user') + ->addArgument('uid', InputArgument::OPTIONAL, 'User ID to scan. If not provided, uses the current session user.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = $input->getArgument('uid'); + + try { + if ($uid) { + $output->writeln("Scanning video files for user '$uid'..."); + $this->service->scanUserByUID($uid); + } else { + $output->writeln('Scanning video files for the current session user...'); + $this->service->scanVideoFiles(); + } + + $output->writeln('Scan completed successfully.'); + return Command::SUCCESS; + } catch (\Throwable $e) { + $output->writeln('Scan failed: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + } +} diff --git a/lib/Controller/VideoController.php b/lib/Controller/VideoController.php new file mode 100644 index 0000000..b0dfd21 --- /dev/null +++ b/lib/Controller/VideoController.php @@ -0,0 +1,135 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Jukebox\Controller; + +use OCA\Jukebox\Db\VideoMapper; +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; +use OCP\AppFramework\OCSController; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +class VideoController extends OCSController { + /** + * Video constructor. + */ + public function __construct( + string $appName, + IRequest $request, + private IAppConfig $config, + private IL10N $l, + private LoggerInterface $logger, + private VideoMapper $videoMapper, + private IUserSession $userSession, + private IRootFolder $rootFolder, + ) { + parent::__construct($appName, $request); + } + + /** + * List all videos for the current user + * + * @return JSONResponse>}, array{}> + * + * 200: List of videos for current user + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/video')] + public function index(): JSONResponse { + $user = $this->userSession->getUser(); + if (!$user) { + return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED); + } + + $videos = $this->videoMapper->findByUserId($user->getUID()); + return new JSONResponse(['videos' => array_map(fn ($v) => $v->jsonSerialize(), $videos)]); + } + + /** + * Get a single video by ID + * + * @param int $id Video ID + * + * @return JSONResponse, array{}> + * @return JSONResponse + * + * 200: Video details + * 404: Video not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/video/{id}')] + public function show(int $id): JSONResponse { + $user = $this->userSession->getUser(); + if (!$user) { + return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $video = $this->videoMapper->find($user->getUID(), (string)$id); + return new JSONResponse($video->jsonSerialize()); + } catch (NotFoundException $e) { + return new JSONResponse(['message' => 'Video not found'], Http::STATUS_NOT_FOUND); + } + } + + /** + * Stream a video file for playback + * + * @param int $id Video ID + * + * @return FileDisplayResponse + * | JSONResponse + * | JSONResponse + * | JSONResponse + * + * 200: File response returned successfully + * 401: User not authenticated + * 403: Video does not belong to current user + * 404: Video file or record not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/video/{id}/stream')] + public function streamVideo(int $id): FileDisplayResponse|JSONResponse { + $this->logger->info('Received request to stream video with ID: ' . $id); + + $user = $this->userSession->getUser(); + if (!$user) { + return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED); + } + + $this->logger->info('Streaming video with ID: ' . $id, ['user' => $user->getUID()]); + + try { + $video = $this->videoMapper->find($user->getUID(), (string)$id); + + $file = $this->rootFolder->get($video->getPath()); + + if (!($file instanceof File)) { + $this->logger->error('Video file not found: ' . $video->getPath()); + throw new NotFoundException(); + } + + return new FileDisplayResponse($file); + } catch (NotFoundException $e) { + $this->logger->error('Video file not found for ID: ' . $id, ['exception' => $e]); + return new JSONResponse(['message' => 'Video not found'], Http::STATUS_NOT_FOUND); + } + } + +} diff --git a/lib/Cron/VideoScannerJob.php b/lib/Cron/VideoScannerJob.php new file mode 100644 index 0000000..8fe2136 --- /dev/null +++ b/lib/Cron/VideoScannerJob.php @@ -0,0 +1,32 @@ +service = $service; + $this->logger = $logger; + + // Run once a day + $this->setInterval(3600); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->logger->info('VideoScannerJob initialized'); + } + + protected function run($argument): void { + $this->service->scanVideoFiles(); + } +} diff --git a/lib/Db/Video.php b/lib/Db/Video.php new file mode 100644 index 0000000..74fc2f6 --- /dev/null +++ b/lib/Db/Video.php @@ -0,0 +1,126 @@ + +// 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 getPath() + * @method void setPath(string $path) + * @method string|null getTitle() + * @method void setTitle(?string $title) + * @method int|null getDuration() + * @method void setDuration(?int $duration) + * @method string|null getThumbnail() + * @method void setThumbnail(?string $thumbnail) + * @method string|null getGenre() + * @method void setGenre(?string $genre) + * @method int|null getYear() + * @method void setYear(?int $year) + * @method int|null getBitrate() + * @method void setBitrate(?int $bitrate) + * @method int|null getWidth() + * @method void setWidth(?int $width) + * @method int|null getHeight() + * @method void setHeight(?int $height) + * @method string|null getVideoCodec() + * @method void setVideoCodec(?string $videoCodec) + * @method string|null getAudioCodec() + * @method void setAudioCodec(?string $audioCodec) + * @method float|null getFramerate() + * @method void setFramerate(?float $framerate) + * @method string getUserId() + * @method void setUserId(string $userId) + * @method int getMtime() + * @method void setMtime(int $mtime) + * @method string|null getRawData() + * @method void setRawData(?string $rawData) + * @method bool isFavorited() + * @method void setFavorited(bool $favorited) + */ +class Video extends Entity implements JsonSerializable { + protected string $path = ''; + protected ?string $title = null; + protected ?int $duration = null; + protected ?string $thumbnail = null; + protected ?string $genre = null; + protected ?int $year = null; + protected ?int $bitrate = null; + protected ?int $width = null; + protected ?int $height = null; + protected ?string $videoCodec = null; + protected ?string $audioCodec = null; + protected ?float $framerate = null; + protected string $userId = ''; + protected int $mtime = 0; + protected ?string $rawData = null; + protected bool $favorited = false; + + public function __construct() { + $this->addType('title', 'string'); + $this->addType('duration', 'int'); + $this->addType('thumbnail', 'string'); + $this->addType('genre', 'string'); + $this->addType('year', 'int'); + $this->addType('bitrate', 'int'); + $this->addType('width', 'int'); + $this->addType('height', 'int'); + $this->addType('videoCodec', 'string'); + $this->addType('audioCodec', 'string'); + $this->addType('framerate', 'float'); + $this->addType('rawData', 'string'); + $this->addType('favorited', 'boolean'); + } + + /** + * Returns the base64-encoded version of the thumbnail blob + * + * @return string|null data URI like 'data:image/jpeg;base64,...' or null if no thumbnail + */ + public function getThumbnailBase64(): ?string { + if ($this->thumbnail === null) { + return null; + } + + // Attempt to detect MIME type, fallback to jpeg + $mime = 'image/jpeg'; + if (str_starts_with($this->thumbnail, "\x89PNG")) { + $mime = 'image/png'; + } elseif (str_starts_with($this->thumbnail, 'GIF')) { + $mime = 'image/gif'; + } + + return 'data:' . $mime . ';base64,' . base64_encode($this->thumbnail); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'path' => $this->path, + 'title' => $this->title, + 'duration' => $this->duration, + 'thumbnail' => $this->getThumbnailBase64(), + 'genre' => $this->genre, + 'year' => $this->year, + 'bitrate' => $this->bitrate, + 'width' => $this->width, + 'height' => $this->height, + 'videoCodec' => $this->videoCodec, + 'audioCodec' => $this->audioCodec, + 'framerate' => $this->framerate, + 'userId' => $this->userId, + 'mtime' => $this->mtime, + 'rawData' => $this->rawData, + 'favorited' => $this->favorited, + ]; + } +} diff --git a/lib/Db/VideoMapper.php b/lib/Db/VideoMapper.php new file mode 100644 index 0000000..ee3bbd4 --- /dev/null +++ b/lib/Db/VideoMapper.php @@ -0,0 +1,118 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Jukebox\Db; + +use OCA\Jukebox\AppInfo\Application; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +/** + * @template-extends QBMapper