This commit is contained in:
Fredrik Burmester
2025-02-16 16:01:49 +01:00
parent 696543d1b2
commit 1a2e044da6
31 changed files with 639 additions and 3062 deletions

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

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

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

View File

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

View File

@@ -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",
},
],