diff --git a/lib/Controller/VideoController.php b/lib/Controller/VideoController.php index b0dfd21..781dbd6 100644 --- a/lib/Controller/VideoController.php +++ b/lib/Controller/VideoController.php @@ -12,7 +12,6 @@ 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; @@ -88,24 +87,18 @@ class VideoController extends OCSController { } /** - * Stream a video file for playback + * Stream a video file for playback with range request support * * @param int $id Video ID * - * @return FileDisplayResponse - * | JSONResponse - * | JSONResponse - * | JSONResponse + * @return JSONResponse * - * 200: File response returned successfully * 401: User not authenticated - * 403: Video does not belong to current user - * 404: Video file or record not found */ #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/api/video/{id}/stream')] - public function streamVideo(int $id): FileDisplayResponse|JSONResponse { + public function streamVideo(int $id) { $this->logger->info('Received request to stream video with ID: ' . $id); $user = $this->userSession->getUser(); @@ -125,7 +118,76 @@ class VideoController extends OCSController { throw new NotFoundException(); } - return new FileDisplayResponse($file); + // Get file info + $fileSize = $file->getSize(); + $mimeType = $file->getMimeType(); + + // Handle range requests for video seeking + $rangeHeader = $this->request->getHeader('range'); + $start = 0; + $end = $fileSize - 1; + $statusCode = Http::STATUS_OK; + + if ($rangeHeader && preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { + $start = (int)$matches[1]; + $end = $matches[2] !== '' ? (int)$matches[2] : $end; + $statusCode = Http::STATUS_PARTIAL_CONTENT; + } + + $length = $end - $start + 1; + + // Clear any previous output + while (ob_get_level() > 0) { + ob_end_clean(); + } + + // Set headers for streaming + http_response_code($statusCode); + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . $length); + header('Accept-Ranges: bytes'); + header('Cache-Control: public, max-age=86400'); + header('Content-Disposition: inline; filename="' . basename($file->getName()) . '"'); + header('X-Content-Type-Options: nosniff'); + + // Add CORS headers if needed + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, OPTIONS'); + header('Access-Control-Allow-Headers: Range, Content-Type'); + header('Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges'); + + if ($statusCode === Http::STATUS_PARTIAL_CONTENT) { + header("Content-Range: bytes $start-$end/$fileSize"); + } + + // Stream the file in chunks + $handle = $file->fopen('r'); + if ($handle === false) { + $this->logger->error('Failed to open video file for streaming'); + http_response_code(500); + exit; + } + + if ($start > 0) { + fseek($handle, $start); + } + + $remaining = $length; + $chunkSize = 1024 * 1024; // 1MB chunks for video + + while ($remaining > 0 && !feof($handle)) { + $readSize = min($chunkSize, $remaining); + $data = fread($handle, $readSize); + if ($data === false) { + break; + } + echo $data; + flush(); + $remaining -= strlen($data); + } + + fclose($handle); + exit; } catch (NotFoundException $e) { $this->logger->error('Video file not found for ID: ' . $id, ['exception' => $e]); return new JSONResponse(['message' => 'Video not found'], Http::STATUS_NOT_FOUND); diff --git a/openapi.json b/openapi.json index 4bf0892..9e9fa85 100644 --- a/openapi.json +++ b/openapi.json @@ -3011,7 +3011,7 @@ "/ocs/v2.php/apps/jukebox/api/video/{id}/stream": { "get": { "operationId": "video-stream-video", - "summary": "Stream a video file for playback", + "summary": "Stream a video file for playback with range request support", "tags": [ "video" ], @@ -3034,6 +3034,13 @@ "format": "int64" } }, + { + "name": "range", + "in": "header", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -3046,17 +3053,6 @@ } ], "responses": { - "200": { - "description": "File response returned successfully", - "content": { - "*/*": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, "401": { "description": "User not authenticated", "content": { @@ -3099,42 +3095,6 @@ } } } - }, - "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" - } - } - } - } - } } } } diff --git a/src/components/media/VideoGalleryItem.vue b/src/components/media/VideoGalleryItem.vue index df4e659..dd4c1f8 100644 --- a/src/components/media/VideoGalleryItem.vue +++ b/src/components/media/VideoGalleryItem.vue @@ -21,6 +21,7 @@ + + diff --git a/src/views/VideosView.vue b/src/views/VideosView.vue index 4f7b369..46fa1f1 100644 --- a/src/views/VideosView.vue +++ b/src/views/VideosView.vue @@ -3,7 +3,7 @@ @@ -15,7 +15,6 @@ import VideoGalleryItem from '@/components/media/VideoGalleryItem.vue' import Page from '@/components/Page.vue' - import playback from '@/composables/usePlayback' export default defineComponent({ name: 'VideosView', @@ -23,7 +22,6 @@ setup() { const videos = ref([]) const isLoading = ref(true) - const { overwriteQueue } = playback onMounted(async () => { try { @@ -36,28 +34,9 @@ } }) - 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, } }, })