mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-23 09:46:27 +00:00
wip
This commit is contained in:
@@ -1,18 +1,31 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ProgressCircle from "@/components/ProgressCircle";
|
||||
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const PROGRESSBAR_HEIGHT = 10;
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
const formatETA = (seconds: number): string => {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (hrs > 0) parts.push(`${hrs}h`);
|
||||
if (mins > 0) parts.push(`${mins}m`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const getETA = (download: DownloadInfo): string | null => {
|
||||
@@ -26,7 +39,7 @@ const getETA = (download: DownloadInfo): string | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() / 100 - download.startTime; // seconds
|
||||
const elapsed = Date.now() / 1000 - download.startTime; // seconds
|
||||
|
||||
console.log("Elapsed (s):", Number(download.startTime), Date.now(), elapsed);
|
||||
|
||||
@@ -42,6 +55,16 @@ const getETA = (download: DownloadInfo): string | null => {
|
||||
return formatETA(secondsLeft);
|
||||
};
|
||||
|
||||
const formatBytes = (i: number) => {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let l = 0;
|
||||
let n = parseInt(i.toString(), 10) || 0;
|
||||
while (n >= 1024 && ++l) {
|
||||
n = n / 1024;
|
||||
}
|
||||
return n.toFixed(n < 10 && l > 0 ? 1 : 0) + " " + units[l];
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const { downloadedFiles, activeDownloads } = useNativeDownloads();
|
||||
const router = useRouter();
|
||||
@@ -51,55 +74,153 @@ export default function Index() {
|
||||
router.push("/player/direct-player?offline=true&itemId=" + item.id);
|
||||
};
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles.filter((i) => i.metadata.item?.Type === "Movie"),
|
||||
[downloadedFiles]
|
||||
);
|
||||
const episodes = useMemo(
|
||||
() => downloadedFiles.filter((i) => i.metadata.item?.Type === "Episode"),
|
||||
[downloadedFiles]
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<View className="p-4 space-y-2">
|
||||
{activeDownloads.map((i) => {
|
||||
const progress =
|
||||
i.bytesTotal && i.bytesDownloaded
|
||||
? i.bytesDownloaded / i.bytesTotal
|
||||
: 0;
|
||||
const eta = getETA(i);
|
||||
return (
|
||||
<View key={i.id}>
|
||||
<Text>{i.metadata?.item?.Name}</Text>
|
||||
{i.state === "PENDING" ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : i.state === "DOWNLOADING" ? (
|
||||
<Text>
|
||||
{i.bytesDownloaded} / {i.bytesTotal}
|
||||
</Text>
|
||||
) : null}
|
||||
<View
|
||||
className="bg-neutral-800"
|
||||
style={{
|
||||
height: PROGRESSBAR_HEIGHT,
|
||||
borderRadius: 5,
|
||||
overflow: "hidden",
|
||||
marginVertical: 4,
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={
|
||||
queryClient.isFetching({ queryKey: ["downloadedFiles"] }) > 0
|
||||
}
|
||||
onRefresh={async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedFiles"],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
className="p-4 space-y-2"
|
||||
>
|
||||
{activeDownloads.length ? (
|
||||
<View>
|
||||
<Text className="text-neutral-500 ml-2 text-xs mb-1">
|
||||
ACTIVE DOWNLOADS
|
||||
</Text>
|
||||
{activeDownloads.map((i) => {
|
||||
const progress =
|
||||
i.bytesTotal && i.bytesDownloaded
|
||||
? i.bytesDownloaded / i.bytesTotal
|
||||
: 0;
|
||||
const eta = getETA(i);
|
||||
const item = i.metadata?.item;
|
||||
return (
|
||||
<View
|
||||
className="bg-purple-600"
|
||||
style={{
|
||||
width: `${progress * 100}%`,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
key={i.id}
|
||||
className="flex flex-row items-center justify-between p-4 rounded-xl bg-neutral-900"
|
||||
>
|
||||
<View className="space-y-0.5">
|
||||
{item.Type === "Episode" ? (
|
||||
<Text className="text-xs">{item?.SeriesName}</Text>
|
||||
) : null}
|
||||
<Text className="font-semibold">{item?.Name}</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<View>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{eta ? `${eta} remaining` : "Calculating time..."}
|
||||
</Text>
|
||||
{i.state === "DOWNLOADING" && i.bytesTotal ? (
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{formatBytes(i.bytesTotal * 100000)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{i.state === "PENDING" ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<ProgressCircle
|
||||
size={48}
|
||||
fill={progress * 100}
|
||||
width={8}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="space-y-2">
|
||||
{movies && movies.length ? (
|
||||
<View>
|
||||
<Text className="text-neutral-500 ml-2 text-xs mb-1">MOVIES</Text>
|
||||
<View className="space-y-2">
|
||||
{movies.map((i) => (
|
||||
<TouchableOpacity
|
||||
key={i.id}
|
||||
onPress={() => goToVideo(i)}
|
||||
className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
{i.metadata.item.Type === "Episode" ? (
|
||||
<Text className="text-xs">
|
||||
{i.metadata.item?.SeriesName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className="font-semibold">
|
||||
{i.metadata.item?.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
{eta ? <Text>ETA: {eta}</Text> : <Text>Calculating...</Text>}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{downloadedFiles.map((i) => (
|
||||
<TouchableOpacity
|
||||
key={i.id}
|
||||
onPress={() => goToVideo(i)}
|
||||
className="bg-neutral-800 p-4 rounded-lg"
|
||||
>
|
||||
<Text>{i.metadata.item?.Name}</Text>
|
||||
<Text>{i.metadata.item?.Type}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{episodes && episodes.length ? (
|
||||
<View>
|
||||
<Text className="text-neutral-500 ml-2 text-xs mb-1">EPISODES</Text>
|
||||
<View className="space-y-2">
|
||||
{episodes.map((i) => (
|
||||
<TouchableOpacity
|
||||
key={i.id}
|
||||
onPress={() => goToVideo(i)}
|
||||
className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
{i.metadata.item.Type === "Episode" ? (
|
||||
<Text className="text-xs">
|
||||
{i.metadata.item?.SeriesName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className="font-semibold">
|
||||
{i.metadata.item?.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ async function checkForExistingDownloads(): Promise<DownloadInfo[]> {
|
||||
return HlsDownloaderModule.checkForExistingDownloads();
|
||||
}
|
||||
|
||||
async function cancelDownload(id: string): Promise<void> {
|
||||
return HlsDownloaderModule.cancelDownload(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to download progress events.
|
||||
* @param listener A callback invoked with progress updates.
|
||||
@@ -186,4 +190,5 @@ export {
|
||||
addErrorListener,
|
||||
addProgressListener,
|
||||
HlsDownloaderModule,
|
||||
cancelDownload,
|
||||
};
|
||||
|
||||
@@ -33,52 +33,91 @@ public class HlsDownloaderModule: Module {
|
||||
return
|
||||
}
|
||||
|
||||
let asset = AVURLAsset(url: assetURL)
|
||||
let configuration = URLSessionConfiguration.background(
|
||||
withIdentifier: "com.example.hlsdownload")
|
||||
let delegate = HLSDownloadDelegate(module: self)
|
||||
delegate.providedId = providedId
|
||||
delegate.startTime = startTime
|
||||
let downloadSession = AVAssetDownloadURLSession(
|
||||
configuration: configuration,
|
||||
assetDownloadDelegate: delegate,
|
||||
delegateQueue: OperationQueue.main
|
||||
)
|
||||
|
||||
guard
|
||||
let task = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: providedId,
|
||||
assetArtworkData: nil,
|
||||
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: startTime]
|
||||
)
|
||||
else {
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": "Failed to create download task",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:],
|
||||
"startTime": startTime,
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
delegate.taskIdentifier = task.taskIdentifier
|
||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
|
||||
self.sendEvent(
|
||||
"onProgress",
|
||||
[
|
||||
"id": providedId,
|
||||
"progress": 0.0,
|
||||
"state": "PENDING",
|
||||
"metadata": metadata ?? [:],
|
||||
"startTime": startTime,
|
||||
// Add asset options to allow cellular downloads and specify allowed media types
|
||||
let asset = AVURLAsset(
|
||||
url: assetURL,
|
||||
options: [
|
||||
"AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL",
|
||||
"AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "YourAppNameHere/1.0"],
|
||||
"AVURLAssetAllowsCellularAccessKey": true,
|
||||
])
|
||||
|
||||
task.resume()
|
||||
print("Download task started with identifier: \(task.taskIdentifier)")
|
||||
// Validate the asset before proceeding
|
||||
asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) {
|
||||
var error: NSError?
|
||||
let status = asset.statusOfValue(forKey: "playable", error: &error)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if status == .failed || error != nil {
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error":
|
||||
"Asset validation failed: \(error?.localizedDescription ?? "Unknown error")",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:],
|
||||
"startTime": startTime,
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let configuration = URLSessionConfiguration.background(
|
||||
withIdentifier: "com.example.hlsdownload.\(providedId)")
|
||||
configuration.allowsCellularAccess = true
|
||||
configuration.sessionSendsLaunchEvents = true
|
||||
configuration.isDiscretionary = false
|
||||
|
||||
let delegate = HLSDownloadDelegate(module: self)
|
||||
delegate.providedId = providedId
|
||||
delegate.startTime = startTime
|
||||
|
||||
let downloadSession = AVAssetDownloadURLSession(
|
||||
configuration: configuration,
|
||||
assetDownloadDelegate: delegate,
|
||||
delegateQueue: OperationQueue.main
|
||||
)
|
||||
|
||||
guard
|
||||
let task = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: providedId,
|
||||
assetArtworkData: nil,
|
||||
options: [
|
||||
AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000,
|
||||
AVAssetDownloadTaskMinimumRequiredPresentationSizeKey: NSValue(
|
||||
cgSize: CGSize(width: 480, height: 360)),
|
||||
]
|
||||
)
|
||||
else {
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": "Failed to create download task",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:],
|
||||
"startTime": startTime,
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
delegate.taskIdentifier = task.taskIdentifier
|
||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
|
||||
self.sendEvent(
|
||||
"onProgress",
|
||||
[
|
||||
"id": providedId,
|
||||
"progress": 0.0,
|
||||
"state": "PENDING",
|
||||
"metadata": metadata ?? [:],
|
||||
"startTime": startTime,
|
||||
])
|
||||
|
||||
task.resume()
|
||||
print("Download task started with identifier: \(task.taskIdentifier)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Function("checkForExistingDownloads") {
|
||||
@@ -105,6 +144,29 @@ public class HlsDownloaderModule: Module {
|
||||
return downloads
|
||||
}
|
||||
|
||||
Function("cancelDownload") { (providedId: String) -> Void in
|
||||
guard
|
||||
let entry = self.activeDownloads.first(where: { $0.value.delegate.providedId == providedId }
|
||||
)
|
||||
else {
|
||||
print("No active download found with identifier: \(providedId)")
|
||||
return
|
||||
}
|
||||
let (task, delegate, metadata, startTime) = entry.value
|
||||
task.cancel()
|
||||
self.activeDownloads.removeValue(forKey: task.taskIdentifier)
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": "Download cancelled",
|
||||
"state": "CANCELLED",
|
||||
"metadata": metadata,
|
||||
"startTime": startTime,
|
||||
])
|
||||
print("Download cancelled for identifier: \(providedId)")
|
||||
}
|
||||
|
||||
OnStartObserving {}
|
||||
OnStopObserving {}
|
||||
}
|
||||
@@ -114,17 +176,23 @@ public class HlsDownloaderModule: Module {
|
||||
}
|
||||
|
||||
func persistDownloadedFolder(originalLocation: URL, folderName: String) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let destinationDir = documents.appendingPathComponent("downloads", isDirectory: true)
|
||||
if !fileManager.fileExists(atPath: destinationDir.path) {
|
||||
try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
||||
let fm = FileManager.default
|
||||
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
|
||||
if !fm.fileExists(atPath: downloadsDir.path) {
|
||||
try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
|
||||
}
|
||||
let newLocation = destinationDir.appendingPathComponent(folderName, isDirectory: true)
|
||||
if fileManager.fileExists(atPath: newLocation.path) {
|
||||
try fileManager.removeItem(at: newLocation)
|
||||
let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true)
|
||||
if fm.fileExists(atPath: newLocation.path) {
|
||||
try fm.removeItem(at: newLocation)
|
||||
}
|
||||
|
||||
// If the original file exists, move it. Otherwise, if newLocation already exists, assume it was moved.
|
||||
if fm.fileExists(atPath: originalLocation.path) {
|
||||
try fm.moveItem(at: originalLocation, to: newLocation)
|
||||
} else if !fm.fileExists(atPath: newLocation.path) {
|
||||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
|
||||
}
|
||||
try fileManager.moveItem(at: originalLocation, to: newLocation)
|
||||
return newLocation
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
@@ -92,8 +92,6 @@ const getDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
||||
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file);
|
||||
if (fileInfo.isDirectory) continue;
|
||||
|
||||
console.log(file);
|
||||
|
||||
const doneFile = await isFileMarkedAsDone(file.replace(".json", ""));
|
||||
if (!doneFile) continue;
|
||||
|
||||
@@ -107,7 +105,6 @@ const getDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
||||
metadata: JSON.parse(fileContent) as DownloadMetadata,
|
||||
});
|
||||
}
|
||||
console.log(downloaded);
|
||||
return downloaded;
|
||||
};
|
||||
|
||||
@@ -128,6 +125,7 @@ export const NativeDownloadProvider: React.FC<{
|
||||
}> = ({ children }) => {
|
||||
const [downloads, setDownloads] = useState<Record<string, DownloadInfo>>({});
|
||||
const { saveImage } = useImageStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -135,6 +133,9 @@ export const NativeDownloadProvider: React.FC<{
|
||||
const { data: downloadedFiles } = useQuery({
|
||||
queryKey: ["downloadedFiles"],
|
||||
queryFn: getDownloadedFiles,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -166,7 +167,13 @@ export const NativeDownloadProvider: React.FC<{
|
||||
const progressListener = addProgressListener((download) => {
|
||||
if (!download.metadata) throw new Error("No metadata found in download");
|
||||
|
||||
console.log("[HLS] Download progress:", download);
|
||||
console.log(
|
||||
"[HLS] Download progress:",
|
||||
download.bytesTotal,
|
||||
download.bytesDownloaded,
|
||||
download.progress,
|
||||
download.state
|
||||
);
|
||||
setDownloads((prev) => ({
|
||||
...prev,
|
||||
[download.id]: {
|
||||
@@ -185,19 +192,22 @@ export const NativeDownloadProvider: React.FC<{
|
||||
if (!payload.id) throw new Error("No id found in payload");
|
||||
|
||||
try {
|
||||
rewriteM3U8Files(payload.location);
|
||||
markFileAsDone(payload.id);
|
||||
await rewriteM3U8Files(payload.location);
|
||||
await markFileAsDone(payload.id);
|
||||
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload.id];
|
||||
return newDownloads;
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] });
|
||||
|
||||
toast.success("Download complete ✅");
|
||||
} catch (error) {
|
||||
console.error("Failed to persist file:", error);
|
||||
toast.error("Failed to download ❌");
|
||||
}
|
||||
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload.id];
|
||||
return newDownloads;
|
||||
});
|
||||
});
|
||||
|
||||
const errorListener = addErrorListener((error) => {
|
||||
@@ -208,6 +218,7 @@ export const NativeDownloadProvider: React.FC<{
|
||||
delete newDownloads[error.id];
|
||||
return newDownloads;
|
||||
});
|
||||
toast.error("Failed to download ❌");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ async function processAllStreams(
|
||||
const streamInfo = await processStream(streamDir);
|
||||
if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) {
|
||||
localPaths.push(
|
||||
`${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}`
|
||||
`${streamDir}${streamInfo.MediaPlaylist.PathToLocalCopy}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user