All files / src/hooks useSkipSegments.ts

97.72% Statements 43/44
97.77% Branches 44/45
100% Functions 8/8
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126                2x             2x     2x     2x                                                                                       2x                 1953x   1953x 1953x 1953x 1953x   1953x 1953x     1953x 1953x   1953x 1953x   1953x 387x 84x 387x 16x   12x     16x   76x 387x 387x   69x 63x     6x 6x 6x 6x 6x           1953x          
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useEffect, useRef, useState } from "react";
import { getSkipSegments } from "@/lib/tauri";
import type { Channel, SkipSegment } from "@/lib/types";
 
/** What `useSkipSegments` returns while it has nothing, kept the same between renders. */
export const NO_SEGMENTS: readonly SkipSegment[] = Object.freeze([]);
 
/**
 * How far the stream's length can move from the one the times were looked
 * up with, in seconds, before they're looked up again. The first length a
 * TS or HLS stream reports can be an estimate.
 */
export const LENGTH_DRIFT = 15;
 
/** The most lookups for one video, counting the ones tried again after a failure. */
export const MAX_LOOKUPS = 3;
 
/** How long to wait before trying a failed lookup again, in milliseconds. */
export const RETRY_MS = 30_000;
 
interface SkipSegmentsQuery {
	/** The IMDb ID of the movie or series, which comes from OMDb. */
	imdbId: string | null;
	contentType: Channel["contentType"] | undefined;
	season: number | undefined;
	episode: number | undefined;
	/** The stream playing. A new one, even of the same episode, may differ in length. */
	url: string | null;
	/** The stream's length in seconds, or 0 while it isn't known. */
	duration: number;
	/** False when skipping is off for every kind of segment, so nothing is looked up. */
	enabled: boolean;
}
 
export interface SkipSegmentsFound {
	/**
	 * Tells videos apart, so `useSegmentSkipping` knows when to start over.
	 * Null when there's nothing to look up.
	 */
	video: string | null;
	/** The parts that can be skipped, in order. */
	segments: readonly SkipSegment[];
}
 
/** One lookup for a video, kept to decide whether to look it up again. */
interface Lookup {
	video: string;
	/** The length it was looked up with. */
	duration: number;
	/** How many lookups the video has had, this one included. */
	count: number;
	/** When it may be tried again after failing, in epoch milliseconds, or null. */
	retryAt: number | null;
}
 
/**
 * The parts of the movie or episode playing that can be skipped. They're
 * looked up once the stream's length is known, and again when the length
 * moves by more than `LENGTH_DRIFT` or a lookup fails, up to `MAX_LOOKUPS`
 * times a video. Live channels, and episodes without a season and episode
 * number, have none.
 */
export const useSkipSegments = ({
	imdbId,
	contentType,
	season,
	episode,
	url,
	duration,
	enabled,
}: SkipSegmentsQuery): SkipSegmentsFound => {
	const [found, setFound] = useState<{ video: string; segments: SkipSegment[] } | null>(null);
	// Bumped when a failed lookup may be tried again.
	const [retry, setRetry] = useState(0);
	const lookupRef = useRef<Lookup | null>(null);
	const retryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
	useEffect(() => () => clearTimeout(retryTimerRef.current), []);
 
	const isEpisode = contentType === "series";
	const known = isEpisode
		? season !== undefined && episode !== undefined
		: contentType === "movie";
	const lookupSeason = isEpisode ? (season ?? null) : null;
	const lookupEpisode = isEpisode ? (episode ?? null) : null;
	const video =
		imdbId && url && known ? `${url}|${imdbId}|${lookupSeason}|${lookupEpisode}` : null;
	const ready = enabled && video !== null && duration > 0;
 
	useEffect(() => {
		if (!ready || video === null || imdbId === null) return;
		const last = lookupRef.current?.video === video ? lookupRef.current : null;
		if (last) {
			if (last.count >= MAX_LOOKUPS) return;
			const due =
				last.retryAt !== null
					? Date.now() >= last.retryAt
					: Math.abs(duration - last.duration) > LENGTH_DRIFT;
			if (!due) return;
		}
		const lookup: Lookup = { video, duration, count: (last?.count ?? 0) + 1, retryAt: null };
		lookupRef.current = lookup;
		getSkipSegments(imdbId, lookupSeason, lookupEpisode, duration)
			.then((segments) => {
				if (lookupRef.current !== lookup) return;
				setFound({ video, segments: [...segments].sort((a, b) => a.start - b.start) });
			})
			.catch((e) => {
				console.warn("[skip] looking up skip times failed:", e);
				Iif (lookupRef.current !== lookup) return;
				lookup.retryAt = Date.now() + RETRY_MS;
				clearTimeout(retryTimerRef.current);
				retryTimerRef.current = setTimeout(() => setRetry((n) => n + 1), RETRY_MS);
			});
		// The video covers every other input that changes the answer.
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [ready, video, duration, retry]);
 
	return {
		video,
		segments: found !== null && found.video === video ? found.segments : NO_SEGMENTS,
	};
};