feat: add videos db, list view

This commit is contained in:
2025-10-06 02:12:37 +03:00
parent 6ae4222246
commit ec3f9a7f34
22 changed files with 1500 additions and 93 deletions

View File

@@ -43,10 +43,12 @@ It supports music files, podcasts (with gPodder sync), audiobooks, YouTube video
<background-jobs>
<job>OCA\Jukebox\Cron\FetchPodcastEpisodesTask</job>
<job>OCA\Jukebox\Cron\ParsePodcastSubscriptionTask</job>
<job>OCA\Jukebox\Cron\VideoScannerJob</job>
</background-jobs>
<commands>
<command>OCA\Jukebox\Command\ScanMusic</command>
<command>OCA\Jukebox\Command\ScanVideos</command>
<command>OCA\Jukebox\Command\ImportRadioStations</command>
<command>OCA\Jukebox\Command\PodcastFetchEpisodes</command>
<command>OCA\Jukebox\Command\ImportGpodderSync</command>

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Command;

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
* SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
* SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
* SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
* 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("<info>Scanning video files for user '$uid'...</info>");
$this->service->scanUserByUID($uid);
} else {
$output->writeln('<info>Scanning video files for the current session user...</info>');
$this->service->scanVideoFiles();
}
$output->writeln('<info>Scan completed successfully.</info>');
return Command::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>Scan failed: ' . $e->getMessage() . '</error>');
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Controller;
use OCA\Jukebox\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<Http::STATUS_OK, array{videos: list<array<string, mixed>>}, 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<Http::STATUS_OK, array<string, mixed>, array{}>
* @return JSONResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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<Http::STATUS_OK, array{}>
* | JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
* | JSONResponse<Http::STATUS_FORBIDDEN, array{message: string}, array{}>
* | JSONResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace OCA\Jukebox\Cron;
use OCA\Jukebox\Service\VideoScannerService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJob;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
class VideoScannerJob extends TimedJob {
public function __construct(
private ITimeFactory $time,
private VideoScannerService $service,
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->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();
}
}

126
lib/Db/Video.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $id)
* @method string 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,
];
}
}

118
lib/Db/VideoMapper.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Db;
use OCA\Jukebox\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
/**
* @template-extends QBMapper<Video>
*/
class VideoMapper extends QBMapper {
public function __construct(
IDBConnection $db,
private LoggerInterface $logger,
) {
parent::__construct($db, Application::tableName('videos'), Video::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(string $userId, string $id): Video {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
)
->andWhere(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId))
);
return $this->findEntity($qb);
}
/**
* Find all video entries for a specific user
*
* @param string $userId
* @return array<Video>
*/
public function findByUserId(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);
}
/**
* @return array<Video>
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string $query
* @return array<Video>
*/
public function searchVideos(string $userId, string $query): array {
$qb = $this->db->getQueryBuilder();
$expr = $qb->expr();
$searchExpr = $expr->iLike('title', $qb->createNamedParameter('%' . $query . '%'));
$qb->select('*')
->from($this->getTableName())
->where(
$expr->andX(
$expr->eq('user_id', $qb->createNamedParameter($userId)),
$searchExpr
)
);
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param string $path
* @return Video|null
*/
public function findByUserIdAndPath(string $userId, string $path): ?Video {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->andX(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId)),
$qb->expr()->eq('path', $qb->createNamedParameter($path))
)
)
->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException) {
return null;
}
}
}

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
* SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -21,24 +21,24 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
// Drop existing tables if they exist (dev only)
$this->createMusicTable($schema);
$this->createRadioStationsTable($schema);
$this->createPodcastSubscriptionsTable($schema);
$this->createPodcastEpisodesTable($schema);
$this->createPodcastEpisodePlaysTable($schema);
$this->createVideosTable($schema);
return $schema;
}
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
private function createMusicTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('jukebox_music')) {
$schema->dropTable('jukebox_music');
}
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');
return;
}
// Music Table
$media = $schema->createTable('jukebox_music');
$media->addColumn('id', 'integer', [
@@ -111,8 +111,13 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
$media->setPrimaryKey(['id']);
$media->addIndex(['user_id'], 'media_user_idx');
$media->addIndex(['path'], 'media_path_idx');
}
private function createRadioStationsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('jukebox_radio_stations')) {
return;
}
// Radio Table
$radio = $schema->createTable('jukebox_radio_stations');
$radio->addColumn('id', 'integer', [
@@ -185,8 +190,13 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
$radio->setPrimaryKey(['id']);
$radio->addUniqueIndex(['remote_uuid', 'user_id'], 'radio_remote_uuid_user_id_idx');
$radio->addIndex(['user_id'], 'radio_user_idx');
}
private function createPodcastSubscriptionsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('jukebox_podcast_subs')) {
return;
}
// Podcast Subscription Metadata Table
$subs = $schema->createTable('jukebox_podcast_subs');
$subs->addColumn('id', 'integer', [
@@ -234,8 +244,13 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
$subs->setPrimaryKey(['id']);
$subs->addUniqueIndex(['subscription_id'], 'podcast_sub_id_idx');
}
private function createPodcastEpisodesTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('jukebox_podcast_eps')) {
return;
}
// Podcast Episode Metadata Table
$eps = $schema->createTable('jukebox_podcast_eps');
$eps->addColumn('id', 'integer', [
@@ -281,8 +296,13 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
$eps->setPrimaryKey(['id']);
$eps->addIndex(['subscription_id'], 'podcast_ep_data_sub_id_idx');
$eps->addUniqueIndex(['action_id'], 'podcast_ep_data_action_id_idx');
}
private function createPodcastEpisodePlaysTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('jukebox_podcast_ep_plays')) {
return;
}
// Podcast Episode Playbacks Table
$epPlays = $schema->createTable('jukebox_podcast_ep_plays');
$epPlays->addColumn('id', 'integer', [
@@ -335,10 +355,87 @@ class Version1Date20250607001010 extends SimpleMigrationStep {
$epPlays->setPrimaryKey(['id'], 'ep_plays_pk');
$epPlays->addIndex(['user_id', 'episode_guid'], 'play_user_guid_idx');
return $schema;
}
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
private function createVideosTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('jukebox_videos')) {
return;
}
$videos = $schema->createTable('jukebox_videos');
$videos->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$videos->addColumn('path', 'string', [
'notnull' => true,
'length' => 1024,
]);
$videos->addColumn('title', 'string', [
'notnull' => false,
'length' => 255,
]);
$videos->addColumn('duration', 'integer', [
'notnull' => false,
'comment' => 'Duration in seconds',
]);
$videos->addColumn('thumbnail', 'blob', [
'notnull' => false,
'comment' => 'Raw binary image data for video thumbnail',
]);
$videos->addColumn('genre', 'string', [
'notnull' => false,
'length' => 255,
]);
$videos->addColumn('year', 'smallint', [
'notnull' => false,
]);
$videos->addColumn('bitrate', 'integer', [
'notnull' => false,
'comment' => 'In kbps',
]);
$videos->addColumn('width', 'integer', [
'notnull' => false,
'comment' => 'Video width in pixels',
]);
$videos->addColumn('height', 'integer', [
'notnull' => false,
'comment' => 'Video height in pixels',
]);
$videos->addColumn('video_codec', 'string', [
'notnull' => false,
'length' => 100,
]);
$videos->addColumn('audio_codec', 'string', [
'notnull' => false,
'length' => 100,
]);
$videos->addColumn('framerate', 'decimal', [
'notnull' => false,
'precision' => 10,
'scale' => 2,
'comment' => 'Frames per second',
]);
$videos->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
]);
$videos->addColumn('mtime', 'bigint', [
'notnull' => true,
'comment' => 'File modified time',
]);
$videos->addColumn('raw_data', 'text', [
'notnull' => false,
'comment' => 'Raw metadata as JSON',
]);
$videos->addColumn('favorited', 'boolean', [
'notnull' => false,
'default' => false,
]);
$videos->setPrimaryKey(['id']);
$videos->addIndex(['user_id'], 'videos_user_idx');
$videos->addIndex(['path'], 'videos_path_idx');
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Service;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace OCA\Jukebox\Service;
// SPDX-FileCopyrightText: Chen Asraf <casraf@pm.me>
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
use DateTimeInterface;

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace OCA\Jukebox\Service;
use getID3;
use OCA\Jukebox\AppInfo\Application;
use OCA\Jukebox\Db\Video;
use OCA\Jukebox\Db\VideoMapper;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Class VideoScannerService
*
* Scans user folders for video files and extracts metadata such as title, duration, and video properties.
*/
class VideoScannerService {
private IRootFolder $rootFolder;
private IUserSession $userSession;
public function __construct(
IRootFolder $rootFolder,
IUserSession $userSession,
private LoggerInterface $logger,
private IAppConfig $appConfig,
private VideoMapper $videoMapper,
private IDBConnection $db,
) {
$this->rootFolder = $rootFolder;
$this->userSession = $userSession;
}
/**
* Starts scanning the user's configured video directory for video files.
*
* @return void
*/
public function scanVideoFiles(): void {
$user = $this->userSession->getUser();
if ($user === null) {
$this->logger->warning('Video scan aborted: no user session.');
return;
}
$this->scanUserByUID($user->getUID());
}
/**
* Scans the video directory for a specific user by UID.
*
* @param string $uid
* @return void
*/
public function scanUserByUID(string $uid): void {
try {
$this->db->beginTransaction();
$userFolder = $this->rootFolder->getUserFolder($uid);
$relativePath = $this->appConfig->getValueString(Application::APP_ID, 'videos_folder_path_' . $uid, 'Videos');
/** @var Folder $videoFolder */
$videoFolder = $userFolder->get($relativePath);
if (!($videoFolder instanceof Folder)) {
$this->logger->warning("Configured video path '$relativePath' for user $uid is not a folder.");
return;
}
$this->logger->info("Starting video scan for user '$uid' in folder '$relativePath'");
$this->traverseFolder($videoFolder, $uid);
$this->db->commit();
} catch (NotFoundException $e) {
$this->logger->error("Could not find video folder for user $uid: " . $e->getMessage());
} catch (\Throwable $e) {
$this->db->rollBack();
$this->logger->error('Scan failed: ' . $e->getMessage());
}
}
/**
* Recursively traverses a folder and processes video files.
*
* @param Folder $folder
* @param string $uid
* @return void
*/
private function traverseFolder(Folder $folder, string $uid): void {
$this->logger->info('Scanning folder: ' . $folder->getPath());
foreach ($folder->getDirectoryListing() as $node) {
if ($node instanceof File) {
$mimeType = $node->getMimeType();
if (str_starts_with($mimeType, 'video/')) {
$this->logger->info('Found video file: ' . $node->getPath() . " (MIME: $mimeType)");
$this->processVideoFile($node, $uid);
}
} elseif ($node instanceof Folder) {
$this->traverseFolder($node, $uid);
}
}
}
/**
* Downloads a video file, reads metadata, and processes it.
*
* @param File $file
* @param string $uid
* @return void
*/
private function processVideoFile(File $file, string $uid): void {
$this->logger->info('Processing video file: ' . $file->getPath());
$tempPath = tempnam(sys_get_temp_dir(), 'jukebox_video_');
if ($tempPath === false) {
$this->logger->error('Could not create temporary file for video processing.');
return;
}
$stream = $file->fopen('r');
$handle = fopen($tempPath, 'w');
if ($stream === false || $handle === false) {
$this->logger->error('Failed to open file stream or temp handle.');
return;
}
stream_copy_to_stream($stream, $handle);
fclose($stream);
fclose($handle);
$getID3 = new getID3();
$info = $getID3->analyze($tempPath);
unlink($tempPath);
$this->saveMetadataToDatabase($uid, $file, $info);
}
/**
* Save video metadata to database.
*
* @param string $userId
* @param File $file
* @param array $info
* @return void
*/
private function saveMetadataToDatabase(string $userId, File $file, array $info): void {
try {
$path = $file->getPath();
$mtime = $file->getMTime();
$title = $info['tags']['quicktime']['title'][0]
?? $info['filename']
?? $file->getName();
// Check for existing
$existing = $this->videoMapper->findByUserIdAndPath($userId, $path);
$video = $existing ?? new Video();
$video->setUserId($userId);
$video->setPath($path);
$video->setMtime($mtime);
$video->setTitle($title);
$video->setDuration((int)($info['playtime_seconds'] ?? 0));
$video->setGenre($info['tags']['quicktime']['genre'][0] ?? null);
$video->setYear((int)($info['tags']['quicktime']['year'][0] ?? 0));
$video->setBitrate((int)($info['bitrate'] ?? 0) / 1000);
// Video-specific metadata
$video->setWidth((int)($info['video']['resolution_x'] ?? 0));
$video->setHeight((int)($info['video']['resolution_y'] ?? 0));
$video->setVideoCodec($info['video']['dataformat'] ?? null);
$video->setAudioCodec($info['audio']['dataformat'] ?? null);
$video->setFramerate((float)($info['video']['frame_rate'] ?? 0));
// Extract thumbnail if available
if (!empty($info['comments']['picture'][0]['data'])) {
$video->setThumbnail($info['comments']['picture'][0]['data']);
}
$sanitizedInfo = $this->sanitizeForJson($info);
$rawData = json_encode($sanitizedInfo);
if ($rawData !== false) {
$video->setRawData($rawData);
} else {
$this->logger->warning("Failed to encode metadata for file '{$file->getPath()}'");
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->warning('JSON encode error: ' . json_last_error_msg());
}
}
if ($existing) {
$this->videoMapper->update($video);
} else {
$this->videoMapper->insert($video);
}
$this->logger->info("Saved metadata for '$path'");
} catch (\Throwable $e) {
$this->logger->error("Failed to save metadata for file '{$file->getPath()}': " . $e->getMessage());
}
}
private function sanitizeForJson(mixed $data, int $depth = 0): mixed {
if ($depth > 30) {
return '**depth limit exceeded**';
}
if (is_resource($data)) {
return '**resource**';
}
if (is_object($data)) {
if (method_exists($data, '__toString')) {
return (string)$data;
}
return '**object**';
}
if (is_array($data)) {
$sanitized = [];
foreach ($data as $key => $value) {
if (
$key === 'data'
&& is_string($value)
&& isset($data['picturetype']) // heuristic for image
) {
// $sanitized[$key] = base64_encode($value);
$sanitized[$key] = '**binary data**'; // avoid large base64 strings in JSON
} else {
$sanitized[$key] = $this->sanitizeForJson($value, $depth + 1);
}
}
return $sanitized;
}
if (is_string($data)) {
// Convert broken encodings to valid UTF-8 using iconv with translit
if (!mb_check_encoding($data, 'UTF-8')) {
$converted = @iconv('ISO-8859-1', 'UTF-8//IGNORE', $data);
return $converted !== false ? $converted : '**invalid string**';
}
}
return $data;
}
}

View File

@@ -2164,6 +2164,239 @@
}
}
}
},
"/ocs/v2.php/apps/jukebox/api/video": {
"get": {
"operationId": "video-index",
"summary": "List all videos for the current user",
"tags": [
"video"
],
"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": "List of videos for current user",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"videos"
],
"properties": {
"videos": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/jukebox/api/video/{id}": {
"get": {
"operationId": "video-show",
"summary": "Get a single video by ID",
"tags": [
"video"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "Video 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": "Video details",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
},
"404": {
"description": "Video not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/jukebox/api/video/{id}/stream": {
"get": {
"operationId": "video-stream-video",
"summary": "Stream a video file for playback",
"tags": [
"video"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "Video 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": "File response returned successfully",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "User not authenticated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Video does not belong to current user",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"404": {
"description": "Video file or record not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"tags": [

View File

@@ -20,6 +20,7 @@
],
"dependencies": {
"@nextcloud/axios": "^2.5.2",
"@nextcloud/dialogs": "^6.3.2",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vite-config": "2.3.5",

206
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@nextcloud/axios':
specifier: ^2.5.2
version: 2.5.2
'@nextcloud/dialogs':
specifier: ^6.3.2
version: 6.3.2(@nextcloud/vue@9.0.0(typescript@5.9.3))(vue@3.5.22(typescript@5.9.3))
'@nextcloud/l10n':
specifier: ^3.4.0
version: 3.4.0
@@ -485,6 +488,9 @@ packages:
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@mdi/js@7.4.47':
resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==}
'@microsoft/api-extractor-model@7.30.6':
resolution: {integrity: sha512-znmFn69wf/AIrwHya3fxX6uB5etSIn6vg4Q4RB/tb5VDDs1rqREc+AvMC/p19MUN13CZ7+V/8pkYPTj7q8tftg==}
@@ -522,6 +528,13 @@ packages:
resolution: {integrity: sha512-L1NQtOfHWzkfj0Ple1MEJt6HmOHWAi3y4qs+OnwSWexqJT0DtXTVPyRxi7ADyITwRxS5H9R/HMl6USAj4Nr1nQ==}
engines: {node: ^20.0.0, npm: ^10.0.0}
'@nextcloud/dialogs@6.3.2':
resolution: {integrity: sha512-ioZ483wmKdNX1HdSJ1EG7ewTSyQAqlmbBALkhT4guZdR9JG8VIdnijX15qwKgAWITG2y36PWoi9Rimb3dDf+7A==}
engines: {node: ^20.0.0 || ^22.0.0 || ^24.0.0, npm: ^10.5.1}
peerDependencies:
'@nextcloud/vue': ^8.24.0
vue: ^2.7.16
'@nextcloud/eslint-config@8.4.2':
resolution: {integrity: sha512-zsDcBxvp2Vr/BgasK/vNYJ84LOXjl4RseJPrcp93zcnaB2WnygV50Sd0nQ5JN0ngTyPjiIlGd92MMzrMTofjRA==}
engines: {node: ^20.0.0, npm: ^10.0.0}
@@ -909,6 +922,9 @@ packages:
'@types/sizzle@2.3.9':
resolution: {integrity: sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==}
'@types/toastify-js@1.12.4':
resolution: {integrity: sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -918,6 +934,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -1159,14 +1178,23 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/core@11.3.0':
resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==}
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@11.3.0':
resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==}
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/shared@11.3.0':
resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==}
'@vueuse/shared@13.9.0':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
@@ -3593,6 +3621,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
toastify-js@1.12.0:
resolution: {integrity: sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==}
tributejs@5.1.3:
resolution: {integrity: sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==}
@@ -3805,12 +3836,28 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '>=6.0.0'
vue-frag@1.4.3:
resolution: {integrity: sha512-pQZj03f/j9LRhzz9vKaXTCXUHVYHuAXicshFv76VFqwz4MG3bcb+sPZMAbd0wmw7THjkrTPuoM0EG9TbG8CgMQ==}
peerDependencies:
vue: ^2.6.0
vue-material-design-icons@5.3.1:
resolution: {integrity: sha512-6UNEyhlTzlCeT8ZeX5WbpUGFTTPSbOoTQeoASTv7X4Ylh0pe8vltj+36VMK56KM0gG8EQVoMK/Qw/6evalg8lA==}
@@ -3937,10 +3984,10 @@ snapshots:
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.27.3(@babel/core@7.26.0)
'@babel/helpers': 7.27.6
'@babel/parser': 7.27.5
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
'@babel/types': 7.28.4
convert-source-map: 2.0.0
debug: 4.4.1
gensync: 1.0.0-beta.2
@@ -3959,8 +4006,8 @@ snapshots:
'@babel/generator@7.27.5':
dependencies:
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
jsesc: 3.1.0
@@ -3976,7 +4023,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
'@babel/types': 7.28.4
transitivePeerDependencies:
- supports-color
@@ -3998,7 +4045,7 @@ snapshots:
'@babel/helpers@7.27.6':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.27.6
'@babel/types': 7.28.4
'@babel/parser@7.27.5':
dependencies:
@@ -4013,16 +4060,16 @@ snapshots:
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@babel/traverse@7.27.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.27.5
'@babel/parser': 7.27.5
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/types': 7.27.6
'@babel/types': 7.28.4
debug: 4.4.1
globals: 11.12.0
transitivePeerDependencies:
@@ -4043,7 +4090,6 @@ snapshots:
'@buttercup/fetch@0.2.1':
optionalDependencies:
node-fetch: 3.3.2
optional: true
'@ckpack/vue-color@1.6.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
@@ -4244,7 +4290,7 @@ snapshots:
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/resolve-uri@3.1.2': {}
@@ -4258,10 +4304,12 @@ snapshots:
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/color@0.3.4': {}
'@mdi/js@7.4.47': {}
'@microsoft/api-extractor-model@7.30.6(@types/node@20.17.10)':
dependencies:
'@microsoft/tsdoc': 0.15.1
@@ -4325,6 +4373,32 @@ snapshots:
dependencies:
'@nextcloud/initial-state': 2.2.0
'@nextcloud/dialogs@6.3.2(@nextcloud/vue@9.0.0(typescript@5.9.3))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@mdi/js': 7.4.47
'@nextcloud/auth': 2.5.2
'@nextcloud/axios': 2.5.2
'@nextcloud/browser-storage': 0.4.0
'@nextcloud/event-bus': 3.3.2
'@nextcloud/files': 3.12.0
'@nextcloud/initial-state': 2.2.0
'@nextcloud/l10n': 3.4.0
'@nextcloud/router': 3.0.1
'@nextcloud/sharing': 0.2.4
'@nextcloud/typings': 1.9.1
'@nextcloud/vue': 9.0.0(typescript@5.9.3)
'@types/toastify-js': 1.12.4
'@vueuse/core': 11.3.0(vue@3.5.22(typescript@5.9.3))
cancelable-promise: 4.3.1
p-queue: 8.1.1
toastify-js: 1.12.0
vue: 3.5.22(typescript@5.9.3)
vue-frag: 1.4.3(vue@3.5.22(typescript@5.9.3))
webdav: 5.8.0
transitivePeerDependencies:
- '@vue/composition-api'
- debug
'@nextcloud/eslint-config@8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@9.37.0))(@nextcloud/eslint-plugin@2.2.1(eslint@9.37.0))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@9.37.0))(eslint@9.37.0)(typescript@5.9.3))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@9.37.0))(eslint-plugin-promise@6.6.0(eslint@9.37.0))(eslint@9.37.0))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@9.37.0))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@9.37.0))(eslint-plugin-n@16.6.2(eslint@9.37.0))(eslint-plugin-promise@6.6.0(eslint@9.37.0))(eslint-plugin-vue@9.32.0(eslint@9.37.0))(eslint@9.37.0)(typescript@5.9.3)':
dependencies:
'@babel/core': 7.26.0
@@ -4367,7 +4441,6 @@ snapshots:
is-svg: 6.1.0
typescript-event-target: 1.1.1
webdav: 5.8.0
optional: true
'@nextcloud/initial-state@2.2.0': {}
@@ -4385,8 +4458,7 @@ snapshots:
dependencies:
'@nextcloud/auth': 2.5.2
'@nextcloud/paths@2.2.1':
optional: true
'@nextcloud/paths@2.2.1': {}
'@nextcloud/router@3.0.1':
dependencies:
@@ -4395,7 +4467,6 @@ snapshots:
'@nextcloud/sharing@0.2.4':
dependencies:
'@nextcloud/initial-state': 2.2.0
optional: true
'@nextcloud/sharing@0.3.0':
dependencies:
@@ -4732,6 +4803,8 @@ snapshots:
'@types/sizzle@2.3.9': {}
'@types/toastify-js@1.12.4': {}
'@types/trusted-types@2.0.7':
optional: true
@@ -4739,6 +4812,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.37.0)(typescript@5.9.3))(eslint@9.37.0)(typescript@5.9.3)':
@@ -4885,7 +4960,7 @@ snapshots:
'@typescript-eslint/utils@7.18.0(eslint@9.37.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.37.0)
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0)
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3)
@@ -5076,6 +5151,16 @@ snapshots:
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/core@11.3.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 11.3.0
'@vueuse/shared': 11.3.0(vue@3.5.22(typescript@5.9.3))
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
@@ -5083,8 +5168,17 @@ snapshots:
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/metadata@11.3.0': {}
'@vueuse/metadata@13.9.0': {}
'@vueuse/shared@11.3.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@13.9.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
vue: 3.5.22(typescript@5.9.3)
@@ -5249,8 +5343,7 @@ snapshots:
balanced-match@2.0.0: {}
base-64@1.0.0:
optional: true
base-64@1.0.0: {}
base64-js@1.5.1: {}
@@ -5355,8 +5448,7 @@ snapshots:
dependencies:
semver: 7.7.2
byte-length@1.0.2:
optional: true
byte-length@1.0.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
@@ -5377,8 +5469,7 @@ snapshots:
callsites@3.1.0: {}
cancelable-promise@4.3.1:
optional: true
cancelable-promise@4.3.1: {}
caniuse-lite@1.0.30001724: {}
@@ -5397,8 +5488,7 @@ snapshots:
character-reference-invalid@2.0.1: {}
charenc@0.0.2:
optional: true
charenc@0.0.2: {}
chart.js@4.5.0:
dependencies:
@@ -5518,8 +5608,7 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypt@0.0.2:
optional: true
crypt@0.0.2: {}
crypto-browserify@3.12.1:
dependencies:
@@ -5547,8 +5636,7 @@ snapshots:
csstype@3.1.3: {}
data-uri-to-buffer@4.0.1:
optional: true
data-uri-to-buffer@4.0.1: {}
data-view-buffer@1.0.2:
dependencies:
@@ -5693,8 +5781,7 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1:
optional: true
entities@6.0.1: {}
env-paths@2.2.1: {}
@@ -5880,7 +5967,7 @@ snapshots:
eslint-plugin-es-x@7.8.0(eslint@9.37.0):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.37.0)
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0)
'@eslint-community/regexpp': 4.12.1
eslint: 9.37.0
eslint-compat-utils: 0.5.1(eslint@9.37.0)
@@ -5931,7 +6018,7 @@ snapshots:
eslint-plugin-n@16.6.2(eslint@9.37.0):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.37.0)
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0)
builtins: 5.1.0
eslint: 9.37.0
eslint-plugin-es-x: 7.8.0(eslint@9.37.0)
@@ -5950,7 +6037,7 @@ snapshots:
eslint-plugin-vue@9.32.0(eslint@9.37.0):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.37.0)
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0)
eslint: 9.37.0
globals: 13.24.0
natural-compare: 1.4.0
@@ -6105,7 +6192,6 @@ snapshots:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
optional: true
file-entry-cache@8.0.0:
dependencies:
@@ -6163,7 +6249,6 @@ snapshots:
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
optional: true
fs-extra@11.3.0:
dependencies:
@@ -6348,8 +6433,7 @@ snapshots:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
hot-patcher@2.0.1:
optional: true
hot-patcher@2.0.1: {}
html-tags@3.3.1: {}
@@ -6434,8 +6518,7 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-buffer@1.1.6:
optional: true
is-buffer@1.1.6: {}
is-builtin-module@3.2.1:
dependencies:
@@ -6608,8 +6691,7 @@ snapshots:
kolorist@1.8.0: {}
layerr@3.0.0:
optional: true
layerr@3.0.0: {}
levn@0.4.1:
dependencies:
@@ -6704,7 +6786,6 @@ snapshots:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
optional: true
mdast-squeeze-paragraphs@6.0.0:
dependencies:
@@ -7006,21 +7087,18 @@ snapshots:
natural-compare@1.4.0: {}
nested-property@4.0.0:
optional: true
nested-property@4.0.0: {}
node-addon-api@7.1.1:
optional: true
node-domexception@1.0.0:
optional: true
node-domexception@1.0.0: {}
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
optional: true
node-releases@2.0.19: {}
@@ -7176,8 +7254,7 @@ snapshots:
path-parse@1.0.7: {}
path-posix@1.0.0:
optional: true
path-posix@1.0.0: {}
path-type@4.0.0: {}
@@ -7296,8 +7373,7 @@ snapshots:
querystring-es3@0.2.1: {}
querystringify@2.2.0:
optional: true
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
@@ -7406,8 +7482,7 @@ snapshots:
requireindex@1.2.0: {}
requires-port@1.0.0:
optional: true
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
@@ -8000,6 +8075,8 @@ snapshots:
dependencies:
is-number: 7.0.0
toastify-js@1.12.0: {}
tributejs@5.1.3: {}
trim-lines@3.0.1: {}
@@ -8075,8 +8152,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
typescript-event-target@1.1.1:
optional: true
typescript-event-target@1.1.1: {}
typescript@5.8.2: {}
@@ -8148,14 +8224,12 @@ snapshots:
dependencies:
punycode: 2.3.1
url-join@5.0.0:
optional: true
url-join@5.0.0: {}
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
optional: true
url@0.11.4:
dependencies:
@@ -8234,6 +8308,10 @@ snapshots:
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)):
dependencies:
vue: 3.5.22(typescript@5.9.3)
vue-eslint-parser@9.4.3(eslint@9.37.0):
dependencies:
debug: 4.4.1
@@ -8247,6 +8325,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-frag@1.4.3(vue@3.5.22(typescript@5.9.3)):
dependencies:
vue: 3.5.22(typescript@5.9.3)
vue-material-design-icons@5.3.1: {}
vue-resize@2.0.0-alpha.1(vue@3.5.22(typescript@5.9.3)):
@@ -8278,8 +8360,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
web-streams-polyfill@3.3.3:
optional: true
web-streams-polyfill@3.3.3: {}
webdav@5.8.0:
dependencies:
@@ -8297,7 +8378,6 @@ snapshots:
path-posix: 1.0.0
url-join: 5.0.0
url-parse: 1.5.10
optional: true
which-boxed-primitive@1.1.1:
dependencies:

View File

@@ -79,6 +79,30 @@
</div>
</NcAppSettingsSection>
<NcAppSettingsSection
:name="strings.videosLibrarySettings"
id="jukebox-videos-library-settings">
<div class="sections">
<div class="folder-select-wrapper">
<div class="input-with-button">
<NcTextField
v-model="videosFolder"
:label="strings.videosFolderLabel"
:placeholder="strings.videosFolderPlaceholder" />
<NcButton
@click="openFolderPicker('videosFolder')"
icon="icon-folder"
:aria-label="strings.pickFolder"
:title="strings.pickFolder"
:disabled="loading"
class="folder-button">
{{ strings.pickFolder }}
</NcButton>
</div>
</div>
</div>
</NcAppSettingsSection>
<div class="submit-buttons">
<NcButton type="submit" :disabled="loading">{{ strings.save }}</NcButton>
</div>
@@ -112,6 +136,7 @@
podcastFolder: '',
downloadPodcasts: false,
audiobooksFolder: '',
videosFolder: '',
strings: {
header: t('jukebox', 'Jukebox'),
musicLibrarySettings: t('jukebox', 'Music Library'),
@@ -123,6 +148,9 @@
audiobooksLibrarySettings: t('jukebox', 'Audiobooks Library'),
audiobooksFolderLabel: t('jukebox', 'Audiobooks Folder Path'),
audiobooksFolderPlaceholder: t('jukebox', 'e.g. Audiobooks'),
videosLibrarySettings: t('jukebox', 'Videos Library'),
videosFolderLabel: t('jukebox', 'Videos Folder Path'),
videosFolderPlaceholder: t('jukebox', 'e.g. Videos'),
downloadPodcastsLabel: t('jukebox', 'Download podcasts for offline playback'),
pickFolder: t('jukebox', 'Pick a folder'),
save: t('jukebox', 'Save'),
@@ -142,6 +170,7 @@
this.downloadPodcasts = data.download_podcast_episodes || false
this.podcastFolder = data.podcast_download_path || 'Podcasts'
this.audiobooksFolder = data.audiobooks_folder_path || 'Audiobooks'
this.videosFolder = data.videos_folder_path || 'Videos'
} catch (e) {
console.error('Failed to fetch settings:', e)
} finally {
@@ -154,11 +183,13 @@
this.musicFolder = this.cleanPath(this.musicFolder)
this.podcastFolder = this.cleanPath(this.podcastFolder)
this.audiobooksFolder = this.cleanPath(this.audiobooksFolder)
this.videosFolder = this.cleanPath(this.videosFolder)
const data = {
music_folder_path: this.musicFolder,
download_podcast_episodes: this.downloadPodcasts,
podcast_download_path: this.podcastFolder,
audiobooks_folder_path: this.audiobooksFolder,
videos_folder_path: this.videosFolder,
}
console.log('Saving settings :', data)
await axios.put('/settings', { data })

View File

@@ -0,0 +1,153 @@
<template>
<div class="video-card" :style="{ width }" @click="handleClick">
<div class="thumbnail-container">
<img v-if="video.thumbnail" :src="video.thumbnail" alt="Thumbnail" class="thumbnail" />
<Video v-else :size="96" class="placeholder-icon" />
<div class="duration-overlay" v-if="video.duration">
{{ formatDuration(video.duration) }}
</div>
</div>
<div class="metadata">
<div class="title">{{ video.title || 'Untitled' }}</div>
<div class="info">
<span v-if="video.width && video.height" class="resolution">
{{ video.width }}×{{ video.height }}
</span>
<span v-if="video.year" class="year">{{ video.year }}</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import type { Video } from '@/models/media'
import Video from '@icons/Video.vue'
export default defineComponent({
name: 'VideoGalleryItem',
props: {
video: {
type: Object as PropType<Video>,
required: true,
},
width: {
type: String,
default: '200px',
},
},
components: {
Video,
},
emits: ['play'],
setup(props, { emit }) {
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs
.toString()
.padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
const handleClick = () => {
emit('play', props.video)
}
return {
formatDuration,
handleClick,
width: props.width,
}
},
})
</script>
<style scoped lang="scss">
.video-card {
padding: 0.75rem;
border-radius: var(--border-radius-element);
display: flex;
flex-direction: column;
align-items: start;
transition: background 0.15s;
&,
& * {
cursor: pointer;
}
&:hover {
background-color: var(--color-background-hover);
}
.thumbnail-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
background-color: var(--color-background-dark);
display: flex;
align-items: center;
justify-content: center;
.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-icon {
color: var(--color-text-maxcontrast);
}
.duration-overlay {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
}
.metadata {
margin-top: 0.5rem;
width: 100%;
.title {
font-weight: bold;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
margin-top: 0.25rem;
.resolution::after {
content: '•';
margin-left: 0.5rem;
}
.resolution:last-child::after {
content: '';
}
}
}
}
</style>

View File

@@ -84,3 +84,23 @@ export interface Artist {
albums: Album[]
tracks: Track[]
}
export interface Video {
id: number
path: string
title: string | null
duration: number | null
thumbnail: string | null
genre: string | null
year: number | null
bitrate: number | null
width: number | null
height: number | null
videoCodec: string | null
audioCodec: string | null
framerate: number | null
userId: string
mtime: number
rawData: string | null
favorited: boolean
}

View File

@@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [
{ 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: '/videos', component: () => import('@/views/VideosView.vue') },
// { path: '/genres', component: () => import('@/views/GenresView.vue') },
{ path: '/radio', component: () => import('@/views/RadioStationsView.vue') },
]

73
src/views/VideosView.vue Normal file
View File

@@ -0,0 +1,73 @@
<template>
<Page :loading="isLoading">
<template #title> Videos </template>
<div class="video-gallery">
<VideoGalleryItem v-for="video in videos" :key="video.id" :video="video" @play="handlePlay" />
</div>
</Page>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { axios } from '@/axios'
import { type Video } from '@/models/media'
import VideoGalleryItem from '@/components/media/VideoGalleryItem.vue'
import Page from '@/components/Page.vue'
import playback from '@/composables/usePlayback'
export default defineComponent({
name: 'VideosView',
components: { VideoGalleryItem, Page },
setup() {
const videos = ref<Video[]>([])
const isLoading = ref(true)
const { overwriteQueue } = playback
onMounted(async () => {
try {
const res = await axios.get('/video')
videos.value = res.data.videos
} catch (err) {
console.error('Failed to load videos:', err)
} finally {
isLoading.value = false
}
})
const handlePlay = (video: Video) => {
const index = videos.value.findIndex((v) => v.id === video.id)
if (index !== -1) {
// Convert videos to playable format
const playableVideos = videos.value.map((v) => ({
id: v.id,
title: v.title || 'Untitled',
artist: null,
album: null,
duration: v.duration || 0,
thumbnail: v.thumbnail,
streamUrl: `/video/${v.id}/stream`,
type: 'video' as const,
}))
overwriteQueue(playableVideos, index)
}
}
return {
videos,
isLoading,
handlePlay,
}
},
})
</script>
<style scoped lang="scss">
.video-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
</style>