mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-17 17:38:02 +00:00
feat: add videos db, list view
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
51
lib/Command/ScanVideos.php
Normal file
51
lib/Command/ScanVideos.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
135
lib/Controller/VideoController.php
Normal file
135
lib/Controller/VideoController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
32
lib/Cron/VideoScannerJob.php
Normal file
32
lib/Cron/VideoScannerJob.php
Normal 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
126
lib/Db/Video.php
Normal 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
118
lib/Db/VideoMapper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
255
lib/Service/VideoScannerService.php
Normal file
255
lib/Service/VideoScannerService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
233
openapi.json
233
openapi.json
@@ -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": [
|
||||
|
||||
@@ -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
206
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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 })
|
||||
|
||||
153
src/components/media/VideoGalleryItem.vue
Normal file
153
src/components/media/VideoGalleryItem.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
73
src/views/VideosView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user