feat: enhance streaming functionality with format support and fallback mechanism
- Added support for an optional 'format' query parameter in the streaming endpoint to allow raw file retrieval. - Implemented a fallback mechanism in the VideoPlayer to automatically switch to raw file playback when HLS streaming fails. - Improved error handling and logging for HLS playback issues, ensuring better user feedback and recovery attempts. - Updated the Indeehub API service to accommodate the new format parameter in the streaming URL request.
This commit is contained in:
@@ -80,6 +80,7 @@ export class ContentsController {
|
|||||||
@UseGuards(OptionalHybridAuthGuard)
|
@UseGuards(OptionalHybridAuthGuard)
|
||||||
async stream(
|
async stream(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
@Query('format') format: string,
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
// Try content ID first; if not found, try looking up project to find its content
|
// Try content ID first; if not found, try looking up project to find its content
|
||||||
@@ -94,7 +95,7 @@ export class ContentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}`,
|
`[stream] content=${content.id}, file=${content.file}, status=${content.status}, project=${content.project?.id}, format=${format ?? 'auto'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!content.file) {
|
if (!content.file) {
|
||||||
@@ -116,6 +117,10 @@ export class ContentsController {
|
|||||||
|
|
||||||
const dto = new StreamContentDTO(content);
|
const dto = new StreamContentDTO(content);
|
||||||
|
|
||||||
|
// When ?format=raw is requested, skip HLS and go straight to presigned URL.
|
||||||
|
// This is used as a fallback when the HLS stream fails (e.g. key decryption issues).
|
||||||
|
const forceRaw = format === 'raw';
|
||||||
|
|
||||||
// Check if the HLS manifest actually exists in the public bucket.
|
// Check if the HLS manifest actually exists in the public bucket.
|
||||||
// In dev (no transcoding pipeline) the raw upload lives in the
|
// In dev (no transcoding pipeline) the raw upload lives in the
|
||||||
// private bucket only — fall back to a presigned URL for it.
|
// private bucket only — fall back to a presigned URL for it.
|
||||||
@@ -125,17 +130,21 @@ export class ContentsController {
|
|||||||
? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8`
|
? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8`
|
||||||
: `${getFileRoute(content.file)}transcoded/file.m3u8`;
|
: `${getFileRoute(content.file)}transcoded/file.m3u8`;
|
||||||
|
|
||||||
this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`);
|
if (!forceRaw) {
|
||||||
|
this.logger.log(`[stream] Checking HLS at bucket=${publicBucket}, key=${outputKey}`);
|
||||||
|
|
||||||
const hlsExists = await this.uploadService.objectExists(
|
const hlsExists = await this.uploadService.objectExists(
|
||||||
outputKey,
|
outputKey,
|
||||||
publicBucket,
|
publicBucket,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hlsExists) {
|
if (hlsExists) {
|
||||||
const publicUrl = getPublicS3Url(outputKey);
|
const publicUrl = getPublicS3Url(outputKey);
|
||||||
this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`);
|
this.logger.log(`[stream] HLS found, returning publicUrl=${publicUrl}`);
|
||||||
return { ...dto, file: publicUrl };
|
return { ...dto, file: publicUrl };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[stream] Raw format requested, skipping HLS check`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLS not available — check that the raw file actually exists before
|
// HLS not available — check that the raw file actually exists before
|
||||||
|
|||||||
@@ -308,8 +308,14 @@ async function checkRentalAccess() {
|
|||||||
const result = await libraryService.checkRentExists(contentId)
|
const result = await libraryService.checkRentExists(contentId)
|
||||||
hasActiveRental.value = result.exists
|
hasActiveRental.value = result.exists
|
||||||
rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null
|
rentalExpiresAt.value = result.expiresAt ? new Date(result.expiresAt) : null
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.warn('Rental check failed:', err)
|
// 401/403 means the user's token is expired or missing — not a real
|
||||||
|
// error, just means they don't have rental access. Only log actual
|
||||||
|
// unexpected failures.
|
||||||
|
const status = err?.response?.status
|
||||||
|
if (status !== 401 && status !== 403) {
|
||||||
|
console.warn('Rental check failed:', err)
|
||||||
|
}
|
||||||
// Owner playback is handled by the isOwner computed property,
|
// Owner playback is handled by the isOwner computed property,
|
||||||
// so we don't fake a rental here. This keeps the "Rented" badge
|
// so we don't fake a rental here. This keeps the "Rented" badge
|
||||||
// accurate and still shows the rental price for non-owners.
|
// accurate and still shows the rental price for non-owners.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-white/70 text-sm max-w-sm">{{ streamError }}</p>
|
<p class="text-white/70 text-sm max-w-sm">{{ streamError }}</p>
|
||||||
<button @click="fetchStream" class="text-sm text-orange-400 hover:text-orange-300 transition-colors">
|
<button @click="() => fetchStream()" class="text-sm text-orange-400 hover:text-orange-300 transition-colors">
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +297,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
// ── Stream fetching ─────────────────────────────────────────────────
|
// ── Stream fetching ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchStream() {
|
/** Whether we already tried and failed the HLS path for this content */
|
||||||
|
let hlsFailed = false
|
||||||
|
|
||||||
|
async function fetchStream(format?: string) {
|
||||||
// Prefer the content entity ID; fall back to the project ID.
|
// Prefer the content entity ID; fall back to the project ID.
|
||||||
// The backend stream endpoint now accepts both — it will look up
|
// The backend stream endpoint now accepts both — it will look up
|
||||||
// content by ID first, then try project ID if not found.
|
// content by ID first, then try project ID if not found.
|
||||||
@@ -309,7 +312,8 @@ async function fetchStream() {
|
|||||||
|
|
||||||
console.log('[VideoPlayer] fetchStream — contentId:', contentId,
|
console.log('[VideoPlayer] fetchStream — contentId:', contentId,
|
||||||
'content.contentId:', props.content?.contentId,
|
'content.contentId:', props.content?.contentId,
|
||||||
'content.id:', props.content?.id)
|
'content.id:', props.content?.id,
|
||||||
|
format ? `format=${format}` : '')
|
||||||
|
|
||||||
isLoadingStream.value = true
|
isLoadingStream.value = true
|
||||||
streamError.value = null
|
streamError.value = null
|
||||||
@@ -318,7 +322,7 @@ async function fetchStream() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Try the backend stream endpoint (handles DRM, presigned URLs, etc.)
|
// Try the backend stream endpoint (handles DRM, presigned URLs, etc.)
|
||||||
const info = await indeehubApiService.getStreamingUrl(contentId)
|
const info = await indeehubApiService.getStreamingUrl(contentId, format)
|
||||||
console.log('[VideoPlayer] Stream API response:', JSON.stringify(info))
|
console.log('[VideoPlayer] Stream API response:', JSON.stringify(info))
|
||||||
|
|
||||||
// Check if backend returned an error message inside the response
|
// Check if backend returned an error message inside the response
|
||||||
@@ -363,6 +367,18 @@ async function fetchStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically fall back to the raw (non-HLS) file when HLS fails.
|
||||||
|
* Destroys the current HLS instance and re-fetches with ?format=raw.
|
||||||
|
*/
|
||||||
|
async function fallbackToRaw() {
|
||||||
|
console.warn('[VideoPlayer] HLS playback failed, falling back to raw file...')
|
||||||
|
hlsFailed = true
|
||||||
|
destroyHls()
|
||||||
|
streamError.value = null
|
||||||
|
await fetchStream('raw')
|
||||||
|
}
|
||||||
|
|
||||||
function initPlayer(url: string) {
|
function initPlayer(url: string) {
|
||||||
destroyHls()
|
destroyHls()
|
||||||
hlsRecoveryAttempts = 0
|
hlsRecoveryAttempts = 0
|
||||||
@@ -383,9 +399,13 @@ function initPlayer(url: string) {
|
|||||||
if (context.url?.includes('/contents/') && context.url?.includes('/key')) {
|
if (context.url?.includes('/contents/') && context.url?.includes('/key')) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(context.url)
|
const parsed = new URL(context.url)
|
||||||
const correctedUrl = `${window.location.origin}${parsed.pathname}`
|
// Only rewrite if the key URL is on a different origin
|
||||||
console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl)
|
// (e.g. CDN domain vs app domain)
|
||||||
context.url = correctedUrl
|
if (parsed.origin !== window.location.origin) {
|
||||||
|
const correctedUrl = `${window.location.origin}${parsed.pathname}`
|
||||||
|
console.log('[VideoPlayer] Rewriting key URL:', context.url, '→', correctedUrl)
|
||||||
|
context.url = correctedUrl
|
||||||
|
}
|
||||||
} catch { /* keep original URL */ }
|
} catch { /* keep original URL */ }
|
||||||
}
|
}
|
||||||
super.load(context, config, callbacks)
|
super.load(context, config, callbacks)
|
||||||
@@ -415,39 +435,39 @@ function initPlayer(url: string) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
// Log detailed error info including the fragment URL when available
|
// Only log fatal errors to avoid flooding the console
|
||||||
|
if (!data.fatal) return
|
||||||
|
|
||||||
const fragUrl = (data as any).frag?.url || (data as any).url || ''
|
const fragUrl = (data as any).frag?.url || (data as any).url || ''
|
||||||
console.error(
|
console.warn(
|
||||||
'[VideoPlayer] HLS error:', data.type, data.details,
|
`[VideoPlayer] HLS fatal error: ${data.type}/${data.details}`,
|
||||||
data.fatal ? '(FATAL)' : '',
|
|
||||||
fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '',
|
fragUrl ? `frag=${fragUrl.substring(0, 120)}` : '',
|
||||||
(data as any).response ? `status=${(data as any).response.code}` : '',
|
(data as any).response ? `status=${(data as any).response.code}` : '',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!data.fatal) return
|
|
||||||
|
|
||||||
// Track recovery attempts to avoid infinite loops
|
// Track recovery attempts to avoid infinite loops
|
||||||
hlsRecoveryAttempts++
|
hlsRecoveryAttempts++
|
||||||
|
|
||||||
if (hlsRecoveryAttempts > MAX_HLS_RECOVERY_ATTEMPTS) {
|
if (hlsRecoveryAttempts > MAX_HLS_RECOVERY_ATTEMPTS) {
|
||||||
console.error(`[VideoPlayer] Max recovery attempts (${MAX_HLS_RECOVERY_ATTEMPTS}) exceeded, giving up.`)
|
console.warn(`[VideoPlayer] Max recovery attempts (${MAX_HLS_RECOVERY_ATTEMPTS}) exceeded.`)
|
||||||
|
|
||||||
// fragParsingError usually means the segments are encrypted or
|
// fragParsingError usually means the AES-128 key doesn't match
|
||||||
// the file is corrupt / served incorrectly
|
// the encryption used for the segments. Automatically fall back
|
||||||
if (data.details === 'fragParsingError') {
|
// to the raw (non-encrypted) file so playback still works.
|
||||||
streamError.value = 'Unable to play this video. The stream segments could not be decoded — they may be encrypted or the file may still be processing.'
|
if (data.details === 'fragParsingError' && !hlsFailed) {
|
||||||
} else {
|
fallbackToRaw()
|
||||||
streamError.value = 'Playback failed after multiple attempts. Please try again later.'
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For other errors or if raw fallback already failed, show error
|
||||||
|
streamError.value = 'Playback failed after multiple attempts. Please try again later.'
|
||||||
destroyHls()
|
destroyHls()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
console.warn(`[VideoPlayer] HLS network error, retrying... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`)
|
|
||||||
hls?.startLoad()
|
hls?.startLoad()
|
||||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
console.warn(`[VideoPlayer] HLS media error, recovering... (attempt ${hlsRecoveryAttempts}/${MAX_HLS_RECOVERY_ATTEMPTS})`)
|
|
||||||
hls?.recoverMediaError()
|
hls?.recoverMediaError()
|
||||||
} else {
|
} else {
|
||||||
streamError.value = 'Playback error. Please try again.'
|
streamError.value = 'Playback error. Please try again.'
|
||||||
@@ -508,6 +528,7 @@ function resetState() {
|
|||||||
isLoadingStream.value = false
|
isLoadingStream.value = false
|
||||||
showSpeedMenu.value = false
|
showSpeedMenu.value = false
|
||||||
playbackRate.value = 1
|
playbackRate.value = 1
|
||||||
|
hlsFailed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Controls ────────────────────────────────────────────────────────
|
// ── Controls ────────────────────────────────────────────────────────
|
||||||
@@ -804,13 +825,15 @@ function formatTime(seconds: number): string {
|
|||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
.video-controls {
|
.video-controls {
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.6) 60%, transparent 100%);
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.85) 40%, rgba(0, 0, 0, 0.5) 75%, transparent 100%);
|
||||||
padding: 40px 24px 16px;
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
padding: 48px 24px 20px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 10;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
@@ -819,16 +842,16 @@ function formatTime(seconds: number): string {
|
|||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
transition: height 0.15s ease;
|
transition: height 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover {
|
.progress-bar:hover {
|
||||||
height: 8px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-buffered {
|
.progress-buffered {
|
||||||
@@ -836,8 +859,8 @@ function formatTime(seconds: number): string {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-filled {
|
.progress-filled {
|
||||||
@@ -846,18 +869,18 @@ function formatTime(seconds: number): string {
|
|||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #F7931A;
|
background: #F7931A;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-handle {
|
.progress-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%) scale(0);
|
transform: translate(-50%, -50%) scale(0);
|
||||||
width: 14px;
|
width: 16px;
|
||||||
height: 14px;
|
height: 16px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
transition: transform 0.15s ease;
|
transition: transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,7 +892,7 @@ function formatTime(seconds: number): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: white;
|
color: white;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -894,7 +917,7 @@ function formatTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-btn:hover {
|
.control-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +964,7 @@ function formatTime(seconds: number): string {
|
|||||||
/* Content info panel */
|
/* Content info panel */
|
||||||
.content-info-panel {
|
.content-info-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 120px;
|
bottom: 160px;
|
||||||
left: 24px;
|
left: 24px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
@@ -950,7 +973,7 @@ function formatTime(seconds: number): string {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
z-index: 10;
|
z-index: 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
@@ -989,6 +1012,28 @@ function formatTime(seconds: number): string {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.video-controls {
|
||||||
|
padding: 56px 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover {
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content-info-panel {
|
.content-info-panel {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -1003,5 +1048,13 @@ function formatTime(seconds: number): string {
|
|||||||
.video-controls {
|
.video-controls {
|
||||||
padding: 30px 16px 12px;
|
padding: 30px 16px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -130,16 +130,22 @@ class IndeehubApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get streaming URL for a content item
|
* Get streaming URL for a content item.
|
||||||
* Returns different data based on deliveryMode (native vs partner)
|
* Returns different data based on deliveryMode (native vs partner).
|
||||||
|
*
|
||||||
|
* @param contentId - The content or project ID
|
||||||
|
* @param format - Optional format override. Pass 'raw' to skip HLS and
|
||||||
|
* get a presigned URL to the original file (useful as a
|
||||||
|
* fallback when HLS decryption fails).
|
||||||
*/
|
*/
|
||||||
async getStreamingUrl(contentId: string): Promise<{
|
async getStreamingUrl(contentId: string, format?: string): Promise<{
|
||||||
url: string
|
url: string
|
||||||
deliveryMode: 'native' | 'partner'
|
deliveryMode: 'native' | 'partner'
|
||||||
keyUrl?: string
|
keyUrl?: string
|
||||||
drmToken?: string
|
drmToken?: string
|
||||||
}> {
|
}> {
|
||||||
const response = await this.client.get(`/contents/${contentId}/stream`)
|
const params = format ? { format } : undefined
|
||||||
|
const response = await this.client.get(`/contents/${contentId}/stream`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user