mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-18 01:39:00 +00:00
fix: streaming/seek playback
This commit is contained in:
@@ -15,17 +15,23 @@ use OCA\Jukebox\Db\PodcastEpisodePlayMapper;
|
||||
use OCA\Jukebox\Db\PodcastSubscription;
|
||||
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
|
||||
use OCA\Jukebox\Service\PodcastFeedParserService;
|
||||
use OCA\Jukebox\Service\SettingsService;
|
||||
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\Http\Response;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\IMimeTypeDetector;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -33,7 +39,7 @@ class PodcastController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private SettingsService $settings,
|
||||
private IL10N $l,
|
||||
private LoggerInterface $logger,
|
||||
private IUserSession $userSession,
|
||||
@@ -42,6 +48,8 @@ class PodcastController extends OCSController {
|
||||
private PodcastEpisodePlayMapper $playMapper,
|
||||
private PodcastEpisodeMapper $epMapper,
|
||||
private IJobList $jobList,
|
||||
private IRootFolder $rootFolder,
|
||||
private IMimeTypeDetector $mimeTypeDetector,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
@@ -388,6 +396,17 @@ class PodcastController extends OCSController {
|
||||
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Episode not found'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($this->settings->getBool($user->getUID(), 'download_podcast_episodes', false)) {
|
||||
return $this->downloadAndStreamLocal($user, $episode);
|
||||
}
|
||||
|
||||
return $this->streamRemote($user, $episode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Http\JSONResponse<int,array|object|stdClass|JsonSerializable,array<string,mixed>>
|
||||
*/
|
||||
private function streamRemote(IUser $user, PodcastEpisode $episode): JSONResponse {
|
||||
$url = $episode->getMediaUrl();
|
||||
if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Invalid media URL'], Http::STATUS_BAD_REQUEST);
|
||||
@@ -414,7 +433,7 @@ class PodcastController extends OCSController {
|
||||
if ($response === false || $headerSize === false) {
|
||||
$this->logger->error('Failed to stream podcast episode via cURL', [
|
||||
'userId' => $user->getUID(),
|
||||
'episodeId' => $id,
|
||||
'episodeId' => $episode->getId(),
|
||||
]);
|
||||
return new JSONResponse(['message' => 'Stream failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
@@ -445,6 +464,100 @@ class PodcastController extends OCSController {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a locally downloaded podcast episode.
|
||||
* Downloads it if not available locally.
|
||||
*
|
||||
* @param IUser $user
|
||||
* @param PodcastEpisode $episode
|
||||
*
|
||||
* @return FileDisplayResponse|JSONResponse
|
||||
*/
|
||||
private function downloadAndStreamLocal(IUser $user, PodcastEpisode $episode): FileDisplayResponse|JSONResponse {
|
||||
try {
|
||||
$path = $this->settings->getPodcastDownloadPath($user->getUID(), $episode->getSubscriptionId(), $episode->getId());
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check if file exists already
|
||||
if (!$userFolder->nodeExists($path)) {
|
||||
$mediaUrl = $episode->getMediaUrl();
|
||||
if (!$mediaUrl || !filter_var($mediaUrl, FILTER_VALIDATE_URL)) {
|
||||
return new JSONResponse(['message' => 'Invalid media URL'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Download to temporary stream
|
||||
$tempStream = fopen('php://temp', 'r+');
|
||||
$download = @fopen($mediaUrl, 'r');
|
||||
if (!$download) {
|
||||
return new JSONResponse(['message' => 'Failed to download episode'], Http::STATUS_BAD_GATEWAY);
|
||||
}
|
||||
stream_copy_to_stream($download, $tempStream);
|
||||
fclose($download);
|
||||
rewind($tempStream);
|
||||
|
||||
// Ensure intermediate folders exist
|
||||
$segments = explode('/', $path);
|
||||
$fileName = array_pop($segments);
|
||||
$current = $userFolder;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if (!$current->nodeExists($segment)) {
|
||||
$current = $current->newFolder($segment);
|
||||
} else {
|
||||
$current = $current->get($segment);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and write the file via stream to preserve range support
|
||||
$file = $current->newFile($fileName);
|
||||
$streamWrapper = $file->fopen('w');
|
||||
stream_copy_to_stream($tempStream, $streamWrapper);
|
||||
fclose($streamWrapper);
|
||||
fclose($tempStream);
|
||||
|
||||
$mimeType = $this->mimeTypeDetector->detect($file->getName());
|
||||
$this->logger->info('Streaming local podcast episode', [
|
||||
'filePath' => $file->getPath(),
|
||||
'fileName' => $file->getName(),
|
||||
'mimeType' => $mimeType,
|
||||
]);
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_PARTIAL_CONTENT);
|
||||
$response->addHeader('Content-Type', $mimeType);
|
||||
return $response;
|
||||
}
|
||||
|
||||
// File already exists, stream it
|
||||
$file = $userFolder->get($path);
|
||||
if (!($file instanceof File)) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$mimeType = $this->mimeTypeDetector->detect($file->getName());
|
||||
$this->logger->info('Streaming local podcast episode', [
|
||||
'filePath' => $file->getPath(),
|
||||
'fileName' => $file->getName(),
|
||||
'mimeType' => $mimeType,
|
||||
]);
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_PARTIAL_CONTENT);
|
||||
$response->addHeader('Content-Type', $mimeType);
|
||||
return $response;
|
||||
} catch (NotFoundException $e) {
|
||||
$this->logger->error('Local podcast file not found', [
|
||||
'userId' => $user->getUID(),
|
||||
'episodeId' => $episode->getId(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
return new JSONResponse(['message' => 'Episode file not found'], Http::STATUS_NOT_FOUND);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to stream or download podcast episode', [
|
||||
'userId' => $user->getUID(),
|
||||
'episodeId' => $episode->getId(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
return new JSONResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last known playback position for a podcast episode
|
||||
*
|
||||
|
||||
@@ -4,14 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCA\Jukebox\Service\SettingsService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
@@ -19,18 +18,13 @@ use OCP\IUserSession;
|
||||
* Handles user-specific settings such as the music folder path.
|
||||
*/
|
||||
class SettingsController extends OCSController {
|
||||
private IAppConfig $config;
|
||||
private IUserSession $userSession;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
IAppConfig $config,
|
||||
IUserSession $userSession,
|
||||
private SettingsService $settings,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->config = $config;
|
||||
$this->userSession = $userSession;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,19 +37,28 @@ class SettingsController extends OCSController {
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/settings')]
|
||||
public function saveSettings(mixed $data): DataResponse {
|
||||
public function saveSettings(mixed $data): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new DataResponse(['status' => 'unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
return new JSONResponse(['status' => 'unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$uid = $user->getUID();
|
||||
|
||||
if (array_key_exists('music_folder_path', $data)) {
|
||||
$this->config->setValueString(Application::APP_ID, 'music_folder_path_' . $uid, $data['music_folder_path']);
|
||||
$this->settings->setString($uid, 'music_folder_path', $data['music_folder_path']);
|
||||
}
|
||||
if (array_key_exists('download_podcast_episodes', $data)) {
|
||||
$this->settings->setBool($uid, 'download_podcast_episodes', $data['download_podcast_episodes']);
|
||||
}
|
||||
if (array_key_exists('podcast_download_path', $data)) {
|
||||
$this->settings->setString($uid, 'podcast_download_path', $data['podcast_download_path']);
|
||||
}
|
||||
if (array_key_exists('audiobooks_folder_path', $data)) {
|
||||
$this->settings->setString($uid, 'audiobooks_folder_path', $data['audiobooks_folder_path']);
|
||||
}
|
||||
|
||||
return new DataResponse(['status' => 'OK']);
|
||||
return new JSONResponse(['status' => 'OK']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +70,7 @@ class SettingsController extends OCSController {
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/settings')]
|
||||
public function getSettings(): DataResponse {
|
||||
public function getSettings(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
@@ -76,10 +79,10 @@ class SettingsController extends OCSController {
|
||||
$uid = $user->getUID();
|
||||
$result = [];
|
||||
|
||||
$musicPath = $this->config->getValueString(Application::APP_ID, 'music_folder_path_' . $uid, 'Music');
|
||||
if ($musicPath !== null) {
|
||||
$result['music_folder_path'] = $musicPath;
|
||||
}
|
||||
$result['music_folder_path'] = $this->settings->getString($uid, 'music_folder_path', 'Music');
|
||||
$result['download_podcast_episodes'] = $this->settings->getBool($uid, 'download_podcast_episodes', false);
|
||||
$result['podcast_download_path'] = $this->settings->getString($uid, 'podcast_download_path', 'Podcasts');
|
||||
$result['audiobooks_folder_path'] = $this->settings->getString($uid, 'audiobooks_folder_path', 'Audiobooks');
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
|
||||
52
lib/Service/SettingsService.php
Normal file
52
lib/Service/SettingsService.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use OCA\Jukebox\AppInfo\Application;
|
||||
use OCA\Jukebox\Db\PodcastEpisodeMapper;
|
||||
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
|
||||
use OCP\IAppConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SettingsService {
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private IAppConfig $config,
|
||||
private PodcastSubscriptionMapper $subsMapper,
|
||||
private PodcastEpisodeMapper $epMapper,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
public function setString(string $userId, string $name, string $value): void {
|
||||
$key = "{$name}_{$userId}";
|
||||
$this->config->setValueString(Application::APP_ID, $key, $value);
|
||||
}
|
||||
|
||||
public function getString(string $userId, string $name, ?string $default): ?string {
|
||||
$key = "{$name}_{$userId}";
|
||||
return $this->config->getValueString(Application::APP_ID, $key, $default);
|
||||
}
|
||||
|
||||
public function setBool(string $userId, string $name, bool $value): void {
|
||||
$key = "{$name}_{$userId}";
|
||||
$this->config->setValueBool(Application::APP_ID, $key, $value);
|
||||
}
|
||||
|
||||
public function getBool(string $userId, string $name, ?bool $default): ?bool {
|
||||
$key = "{$name}_{$userId}";
|
||||
return $this->config->getValueBool(Application::APP_ID, $key, $default);
|
||||
}
|
||||
|
||||
public function getPodcastDownloadPath(string $userId, int $subscriptionId, int $episodeId): string {
|
||||
$path = $this->getString($userId, 'podcast_download_path', 'Podcasts');
|
||||
$sub = $this->subsMapper->find($userId, $subscriptionId);
|
||||
$ep = $this->epMapper->find($userId, $episodeId);
|
||||
return rtrim($path, '/') . "/{$sub->getTitle()}/{$ep->getTitle()}.mp3";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user