fix: streaming/seek playback

This commit is contained in:
2025-06-16 16:17:15 +03:00
parent 6af532bb38
commit 959acbbdad
16 changed files with 496 additions and 148 deletions

View File

@@ -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
*

View File

@@ -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);
}

View 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";
}
}