forked from Ninjalama/streamyfin_mirror
wip
This commit is contained in:
44
utils/movpkg-to-vlc/parse/boot.ts
Normal file
44
utils/movpkg-to-vlc/parse/boot.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
export interface Boot {
|
||||
Version: string;
|
||||
HLSMoviePackageType: string;
|
||||
Streams: {
|
||||
Stream: Stream[];
|
||||
};
|
||||
MasterPlaylist: {
|
||||
NetworkURL: string;
|
||||
};
|
||||
DataItems: {
|
||||
Directory: string;
|
||||
DataItem: DataItem;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
ID: string;
|
||||
NetworkURL: string;
|
||||
Path: string;
|
||||
Complete: string; // "YES" or "NO"
|
||||
}
|
||||
|
||||
export interface DataItem {
|
||||
ID: string;
|
||||
Category: string;
|
||||
Name: string;
|
||||
DescriptorPath: string;
|
||||
DataPath: string;
|
||||
Role: string;
|
||||
}
|
||||
|
||||
export async function parseBootXML(xml: string): Promise<Boot> {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
parseAttributeValue: true,
|
||||
});
|
||||
const jsonObj = parser.parse(xml);
|
||||
const b = jsonObj.HLSMoviePackage as Boot;
|
||||
console.log(b.Streams);
|
||||
return jsonObj.HLSMoviePackage as Boot;
|
||||
}
|
||||
45
utils/movpkg-to-vlc/parse/streamInfoBoot.ts
Normal file
45
utils/movpkg-to-vlc/parse/streamInfoBoot.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
export interface StreamInfo {
|
||||
Version: string;
|
||||
Complete: string;
|
||||
PeakBandwidth: number;
|
||||
Compressable: string;
|
||||
MediaPlaylist: MediaPlaylist;
|
||||
Type: string;
|
||||
MediaSegments: {
|
||||
SEG: SEG[];
|
||||
};
|
||||
EvictionPolicy: string;
|
||||
MediaBytesStored: number;
|
||||
}
|
||||
|
||||
export interface MediaPlaylist {
|
||||
NetworkURL: string;
|
||||
PathToLocalCopy: string;
|
||||
}
|
||||
|
||||
export interface SEG {
|
||||
Dur: number;
|
||||
Len: number;
|
||||
Off: number;
|
||||
PATH: string;
|
||||
SeqNum: number;
|
||||
Tim: number;
|
||||
URL: string;
|
||||
}
|
||||
|
||||
export async function parseStreamInfoXml(xml: string): Promise<StreamInfo> {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
parseAttributeValue: true,
|
||||
isArray: (tagName, jPath) => {
|
||||
// Force SEG elements to always be an array
|
||||
if (jPath === "StreamInfo.MediaSegments.SEG") return true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const jsonObj = parser.parse(xml);
|
||||
return jsonObj.StreamInfo as StreamInfo;
|
||||
}
|
||||
116
utils/movpkg-to-vlc/tools.ts
Normal file
116
utils/movpkg-to-vlc/tools.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { parseBootXML } from "./parse/boot";
|
||||
import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot";
|
||||
|
||||
export async function rewriteM3U8Files(baseDir: string): Promise<void> {
|
||||
const bootData = await loadBootData(baseDir);
|
||||
if (!bootData) return;
|
||||
|
||||
const localPlaylistPaths = await processAllStreams(baseDir, bootData);
|
||||
await updateMasterPlaylist(
|
||||
`${baseDir}/Data/${bootData.DataItems.DataItem.DataPath}`,
|
||||
localPlaylistPaths
|
||||
);
|
||||
}
|
||||
|
||||
async function loadBootData(baseDir: string): Promise<any | null> {
|
||||
const bootPath = `${baseDir}/boot.xml`;
|
||||
try {
|
||||
const bootInfo = await FileSystem.getInfoAsync(bootPath);
|
||||
if (!bootInfo.exists) throw new Error("boot.xml not found");
|
||||
|
||||
const bootXML = await FileSystem.readAsStringAsync(bootPath);
|
||||
return parseBootXML(bootXML);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load boot.xml from ${baseDir}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processAllStreams(
|
||||
baseDir: string,
|
||||
bootData: any
|
||||
): Promise<string[]> {
|
||||
const localPaths: string[] = [];
|
||||
|
||||
for (const stream of bootData.Streams.Stream) {
|
||||
const streamDir = `${baseDir}/${stream.ID}`;
|
||||
try {
|
||||
const streamInfo = await processStream(streamDir);
|
||||
if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) {
|
||||
localPaths.push(
|
||||
`${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Skipping stream ${stream.ID} due to error:`, error);
|
||||
}
|
||||
}
|
||||
return localPaths;
|
||||
}
|
||||
|
||||
async function updateMasterPlaylist(
|
||||
masterPath: string,
|
||||
localPlaylistPaths: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const masterContent = await FileSystem.readAsStringAsync(masterPath);
|
||||
const updatedContent = updatePlaylistWithLocalSegments(
|
||||
masterContent,
|
||||
localPlaylistPaths
|
||||
);
|
||||
await FileSystem.writeAsStringAsync(masterPath, updatedContent);
|
||||
} catch (error) {
|
||||
console.error(`Error updating master playlist at ${masterPath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePlaylistWithLocalSegments(
|
||||
content: string,
|
||||
localPaths: string[]
|
||||
): string {
|
||||
const lines = content.split("\n");
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < lines.length && index < localPaths.length; i++) {
|
||||
if (lines[i].trim() && !lines[i].startsWith("#")) {
|
||||
lines[i] = localPaths[index++];
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function processStream(
|
||||
streamDir: string
|
||||
): Promise<StreamInfo | null> {
|
||||
const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`;
|
||||
|
||||
try {
|
||||
const streamXML = await FileSystem.readAsStringAsync(streamInfoPath);
|
||||
const streamInfo = await parseStreamInfoXml(streamXML);
|
||||
|
||||
const localM3u8RelPath = streamInfo.MediaPlaylist?.PathToLocalCopy;
|
||||
if (!localM3u8RelPath) {
|
||||
console.warn(`No local m3u8 specified in ${streamDir}; skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const m3u8Path = `${streamDir}/${localM3u8RelPath}`;
|
||||
const m3u8Content = await FileSystem.readAsStringAsync(m3u8Path);
|
||||
|
||||
const localSegmentPaths = streamInfo.MediaSegments.SEG.map(
|
||||
(seg) => `${streamDir}/${seg.PATH}`
|
||||
);
|
||||
const updatedContent = updatePlaylistWithLocalSegments(
|
||||
m3u8Content,
|
||||
localSegmentPaths
|
||||
);
|
||||
await FileSystem.writeAsStringAsync(m3u8Path, updatedContent);
|
||||
|
||||
return streamInfo;
|
||||
} catch (error) {
|
||||
console.error(`Error processing stream at ${streamDir}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import axios from "axios";
|
||||
import { writeToLog } from "./log";
|
||||
import { DownloadedItem } from "@/providers/DownloadProvider";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
interface IJobInput {
|
||||
deviceId?: string | null;
|
||||
authHeader?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface JobStatus {
|
||||
id: string;
|
||||
status:
|
||||
| "queued"
|
||||
| "optimizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "downloading";
|
||||
progress: number;
|
||||
outputPath: string;
|
||||
inputUrl: string;
|
||||
deviceId: string;
|
||||
itemId: string;
|
||||
item: BaseItemDto;
|
||||
speed?: number;
|
||||
timestamp: Date;
|
||||
base64Image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all jobs for a specific device.
|
||||
*
|
||||
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
|
||||
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
|
||||
* @param {string} params.authHeader - The authorization header for the API request.
|
||||
* @param {string} params.url - The base URL for the API endpoint.
|
||||
*
|
||||
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
|
||||
*
|
||||
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
|
||||
*/
|
||||
export async function getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
}: IJobInput): Promise<JobStatus[]> {
|
||||
const statusResponse = await axios.get(`${url}all-jobs`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
params: {
|
||||
deviceId,
|
||||
},
|
||||
});
|
||||
if (statusResponse.status !== 200) {
|
||||
console.error(
|
||||
statusResponse.status,
|
||||
statusResponse.data,
|
||||
statusResponse.statusText
|
||||
);
|
||||
throw new Error("Failed to fetch job status");
|
||||
}
|
||||
|
||||
return statusResponse.data;
|
||||
}
|
||||
|
||||
interface ICancelJob {
|
||||
authHeader: string;
|
||||
url: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function cancelJobById({
|
||||
authHeader,
|
||||
url,
|
||||
id,
|
||||
}: ICancelJob): Promise<boolean> {
|
||||
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (statusResponse.status !== 200) {
|
||||
throw new Error("Failed to cancel process");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
||||
if (!deviceId) return false;
|
||||
if (!authHeader) return false;
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
}).then((jobs) => {
|
||||
jobs.forEach((job) => {
|
||||
cancelJobById({
|
||||
authHeader,
|
||||
url,
|
||||
id: job.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to cancel all jobs", error);
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches statistics for a specific device.
|
||||
*
|
||||
* @param {IJobInput} params - The parameters for the API request.
|
||||
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
|
||||
* @param {string} params.authHeader - The authorization header for the API request.
|
||||
* @param {string} params.url - The base URL for the API endpoint.
|
||||
*
|
||||
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
|
||||
*
|
||||
* @throws {Error} Throws an error if any required parameter is missing.
|
||||
*/
|
||||
export async function getStatistics({
|
||||
authHeader,
|
||||
url,
|
||||
deviceId,
|
||||
}: IJobInput): Promise<any | null> {
|
||||
if (!deviceId || !authHeader || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResponse = await axios.get(`${url}statistics`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
params: {
|
||||
deviceId,
|
||||
},
|
||||
});
|
||||
|
||||
return statusResponse.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch statistics:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the download item info to disk - this data is used temporarily to fetch additional download information
|
||||
* in combination with the optimize server. This is used to not have to send all item info to the optimize server.
|
||||
*
|
||||
* @param {BaseItemDto} item - The item to save.
|
||||
* @param {MediaSourceInfo} mediaSource - The media source of the item.
|
||||
* @param {string} url - The URL of the item.
|
||||
* @return {boolean} A promise that resolves when the item info is saved.
|
||||
*/
|
||||
export function saveDownloadItemInfoToDiskTmp(
|
||||
item: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo,
|
||||
url: string
|
||||
): boolean {
|
||||
try {
|
||||
const storage = new MMKV();
|
||||
|
||||
const downloadInfo = JSON.stringify({
|
||||
item,
|
||||
mediaSource,
|
||||
url,
|
||||
});
|
||||
|
||||
storage.set(`tmp_download_info_${item.Id}`, downloadInfo);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save download item info to disk:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the download item info from disk.
|
||||
*
|
||||
* @param {string} itemId - The ID of the item to retrieve.
|
||||
* @return {{
|
||||
* item: BaseItemDto;
|
||||
* mediaSource: MediaSourceInfo;
|
||||
* url: string;
|
||||
* } | null} The retrieved download item info or null if not found.
|
||||
*/
|
||||
export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo;
|
||||
url: string;
|
||||
} | null {
|
||||
try {
|
||||
const storage = new MMKV();
|
||||
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
||||
|
||||
if (rawInfo) {
|
||||
return JSON.parse(rawInfo);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve download item info from disk:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download item info from disk.
|
||||
*
|
||||
* @param {string} itemId - The ID of the item to delete.
|
||||
* @return {boolean} True if the item info was successfully deleted, false otherwise.
|
||||
*/
|
||||
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
||||
try {
|
||||
const storage = new MMKV();
|
||||
storage.delete(`tmp_download_info_${itemId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete download item info from disk:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -22,28 +22,29 @@ export default {
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
// DirectPlayProfiles: [
|
||||
// {
|
||||
// Type: MediaTypes.Video,
|
||||
// Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
// VideoCodec:
|
||||
// "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
// AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||
// },
|
||||
// {
|
||||
// Type: MediaTypes.Audio,
|
||||
// Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
// AudioCodec:
|
||||
// "mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
// },
|
||||
// ],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
Container: "ts,mp4,mkv,avi,mov,flv,m2ts,webm,ogv,3gp",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
@@ -52,8 +53,9 @@ export default {
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user