mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
Merge pull request #71 from afyef/feat/skip-source-button
feat: add skip source button during scraping
This commit is contained in:
commit
d1356405d2
11 changed files with 204 additions and 29 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,3 +28,4 @@ package-lock.json
|
|||
|
||||
# config
|
||||
.env
|
||||
local-libs/
|
||||
|
|
|
|||
|
|
@ -852,6 +852,7 @@
|
|||
"title": "Failed to play video!"
|
||||
},
|
||||
"scraping": {
|
||||
"skip": "Skip source",
|
||||
"items": {
|
||||
"failure": "Error occurred",
|
||||
"notFound": "Doesn't have the video (╥﹏╥)",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const segmentStatusMap: Record<
|
|||
failure: "failed",
|
||||
pending: null,
|
||||
waiting: null,
|
||||
skipped: "notfound",
|
||||
};
|
||||
|
||||
export function scrapeSourceOutputToProviderMetric(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
Fetcher,
|
||||
makeSimpleProxyFetcher,
|
||||
makeStandardFetcher,
|
||||
setM3U8ProxyUrl,
|
||||
} from "@p-stream/providers";
|
||||
|
||||
|
|
@ -82,8 +83,19 @@ export function setupM3U8Proxy() {
|
|||
|
||||
export function makeLoadBalancedSimpleProxyFetcher() {
|
||||
const fetcher: Fetcher = async (a, b) => {
|
||||
const proxyUrl = getLoadbalancedProxyUrl();
|
||||
|
||||
// If no proxy URL is available, fall back to direct fetch
|
||||
if (!proxyUrl) {
|
||||
console.warn(
|
||||
"[makeLoadBalancedSimpleProxyFetcher] No proxy URL available, using direct fetch",
|
||||
);
|
||||
const directFetcher = makeStandardFetcher(fetchButWithApiTokens);
|
||||
return directFetcher(a, b);
|
||||
}
|
||||
|
||||
const currentFetcher = makeSimpleProxyFetcher(
|
||||
getLoadbalancedProxyUrl(),
|
||||
proxyUrl,
|
||||
fetchButWithApiTokens,
|
||||
);
|
||||
return currentFetcher(a, b);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ import {
|
|||
import { Transition } from "@/components/utils/Transition";
|
||||
|
||||
export interface ScrapeItemProps {
|
||||
status: "failure" | "pending" | "notfound" | "success" | "waiting";
|
||||
status:
|
||||
| "failure"
|
||||
| "pending"
|
||||
| "notfound"
|
||||
| "success"
|
||||
| "waiting"
|
||||
| "skipped";
|
||||
name: string;
|
||||
id?: string;
|
||||
percentage?: number;
|
||||
|
|
@ -24,6 +30,7 @@ const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
|
|||
notfound: "player.scraping.items.notFound",
|
||||
failure: "player.scraping.items.failure",
|
||||
pending: "player.scraping.items.pending",
|
||||
skipped: "player.scraping.items.notFound",
|
||||
};
|
||||
|
||||
const statusMap: Record<ScrapeCardProps["status"], StatusCircleProps["type"]> =
|
||||
|
|
@ -33,6 +40,7 @@ const statusMap: Record<ScrapeCardProps["status"], StatusCircleProps["type"]> =
|
|||
pending: "loading",
|
||||
success: "success",
|
||||
waiting: "waiting",
|
||||
skipped: "noresult",
|
||||
};
|
||||
|
||||
export function ScrapeItem(props: ScrapeItemProps) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ export interface ScrapingSegment {
|
|||
name: string;
|
||||
id: string;
|
||||
embedId?: string;
|
||||
status: "failure" | "pending" | "notfound" | "success" | "waiting";
|
||||
status:
|
||||
| "failure"
|
||||
| "pending"
|
||||
| "notfound"
|
||||
| "success"
|
||||
| "waiting"
|
||||
| "skipped";
|
||||
reason?: string;
|
||||
error?: any;
|
||||
percentage: number;
|
||||
|
|
@ -36,6 +42,7 @@ function useBaseScrape() {
|
|||
const [sources, setSources] = useState<Record<string, ScrapingSegment>>({});
|
||||
const [sourceOrder, setSourceOrder] = useState<ScrapingItems[]>([]);
|
||||
const [currentSource, setCurrentSource] = useState<string>();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const lastId = useRef<string | null>(null);
|
||||
|
||||
const initEvent = useCallback((evt: ScraperEvent<"init">) => {
|
||||
|
|
@ -64,12 +71,16 @@ function useBaseScrape() {
|
|||
const lastIdTmp = lastId.current;
|
||||
setSources((s) => {
|
||||
if (s[id]) s[id].status = "pending";
|
||||
if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending")
|
||||
// Only mark as success if it's pending - don't overwrite skipped status
|
||||
if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending") {
|
||||
s[lastIdTmp].status = "success";
|
||||
}
|
||||
return { ...s };
|
||||
});
|
||||
setCurrentSource(id);
|
||||
lastId.current = id;
|
||||
// Create new AbortController for this source
|
||||
abortControllerRef.current = new AbortController();
|
||||
}, []);
|
||||
|
||||
const updateEvent = useCallback((evt: ScraperEvent<"update">) => {
|
||||
|
|
@ -128,6 +139,35 @@ function useBaseScrape() {
|
|||
return output;
|
||||
}, []);
|
||||
|
||||
const skipCurrentSource = useCallback(() => {
|
||||
if (currentSource) {
|
||||
// Get the parent source ID (remove embed suffix like "-0", "-1", etc.)
|
||||
const parentSourceId = currentSource.split("-")[0];
|
||||
|
||||
// Abort the current operation FIRST - abort all pending requests immediately
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Mark the parent source and all its embeds as skipped AFTER aborting
|
||||
// This ensures the abort happens immediately and can interrupt ongoing operations
|
||||
setSources((s) => {
|
||||
Object.keys(s).forEach((key) => {
|
||||
// Check if this is the parent source or one of its embeds
|
||||
if (key === parentSourceId || key.startsWith(`${parentSourceId}-`)) {
|
||||
if (s[key]) {
|
||||
// Mark as skipped regardless of current status (even if it succeeded)
|
||||
s[key].status = "skipped";
|
||||
s[key].reason = "Skipped by user";
|
||||
s[key].percentage = 100;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { ...s };
|
||||
});
|
||||
}
|
||||
}, [currentSource]);
|
||||
|
||||
return {
|
||||
initEvent,
|
||||
startEvent,
|
||||
|
|
@ -138,6 +178,8 @@ function useBaseScrape() {
|
|||
sources,
|
||||
sourceOrder,
|
||||
currentSource,
|
||||
skipCurrentSource,
|
||||
abortControllerRef,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +194,8 @@ export function useScrape() {
|
|||
getResult,
|
||||
startEvent,
|
||||
startScrape,
|
||||
skipCurrentSource,
|
||||
abortControllerRef,
|
||||
} = useBaseScrape();
|
||||
|
||||
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
|
||||
|
|
@ -171,6 +215,7 @@ export function useScrape() {
|
|||
async (media: ScrapeMedia, startFromSourceId?: string) => {
|
||||
const providerInstance = getProviders();
|
||||
const allSources = providerInstance.listSources();
|
||||
|
||||
const playerState = usePlayerStore.getState();
|
||||
const failedSources = playerState.failedSources;
|
||||
const failedEmbeds = playerState.failedEmbeds;
|
||||
|
|
@ -234,6 +279,7 @@ export function useScrape() {
|
|||
: undefined;
|
||||
|
||||
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
||||
|
||||
if (providerApiUrl && !isExtensionActiveCached()) {
|
||||
startScrape();
|
||||
const baseUrlMaker = makeProviderUrl(providerApiUrl);
|
||||
|
|
@ -258,10 +304,25 @@ export function useScrape() {
|
|||
|
||||
startScrape();
|
||||
const providers = getProviders();
|
||||
|
||||
// Create initial abort controller if it doesn't exist
|
||||
if (!abortControllerRef.current) {
|
||||
abortControllerRef.current = new AbortController();
|
||||
}
|
||||
|
||||
// Create a wrapper that always gets the current abort controller
|
||||
const getCurrentAbortController = () => abortControllerRef.current;
|
||||
|
||||
const output = await providers.runAll({
|
||||
media,
|
||||
sourceOrder: filteredSourceOrder,
|
||||
embedOrder: filteredEmbedOrder,
|
||||
abortController: {
|
||||
get signal() {
|
||||
const controller = getCurrentAbortController();
|
||||
return controller ? controller.signal : undefined;
|
||||
},
|
||||
} as AbortController,
|
||||
events: {
|
||||
init: initEvent,
|
||||
start: startEvent,
|
||||
|
|
@ -288,6 +349,7 @@ export function useScrape() {
|
|||
preferredEmbedOrder,
|
||||
enableEmbedOrder,
|
||||
disabledEmbeds,
|
||||
abortControllerRef,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -304,6 +366,7 @@ export function useScrape() {
|
|||
sourceOrder,
|
||||
sources,
|
||||
currentSource,
|
||||
skipCurrentSource,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function RealPlayerView() {
|
|||
episode?: string;
|
||||
season?: string;
|
||||
}>();
|
||||
const [skipSourceFn, setSkipSourceFn] = useState<(() => void) | null>(null);
|
||||
const [errorData, setErrorData] = useState<{
|
||||
sources: Record<string, ScrapingSegment>;
|
||||
sourceOrder: ScrapingItems[];
|
||||
|
|
@ -204,7 +205,11 @@ export function RealPlayerView() {
|
|||
);
|
||||
|
||||
return (
|
||||
<PlayerPart backUrl={backUrl} onMetaChange={metaChange}>
|
||||
<PlayerPart
|
||||
backUrl={backUrl}
|
||||
onMetaChange={metaChange}
|
||||
skipSourceFn={skipSourceFn}
|
||||
>
|
||||
{status === playerStatus.IDLE ? (
|
||||
<MetaPart onGetMeta={handleMetaReceived} />
|
||||
) : null}
|
||||
|
|
@ -223,6 +228,7 @@ export function RealPlayerView() {
|
|||
key={`scraping-${resumeFromSourceId || "default"}`}
|
||||
media={scrapeMedia}
|
||||
startFromSourceId={resumeFromSourceId || undefined}
|
||||
onSkipSourceReady={(fn) => setSkipSourceFn(() => fn)}
|
||||
onResult={(sources, sourceOrder) => {
|
||||
setErrorData({
|
||||
sourceOrder,
|
||||
|
|
@ -232,7 +238,29 @@ export function RealPlayerView() {
|
|||
// Clear resume state after scraping
|
||||
setResumeFromSourceId(null);
|
||||
}}
|
||||
onGetStream={playAfterScrape}
|
||||
onGetStream={(out, sources) => {
|
||||
// Check if the source was skipped by user
|
||||
if (out) {
|
||||
const outSourceId = out.sourceId;
|
||||
const parentSourceId = outSourceId.split("-")[0];
|
||||
|
||||
// Check both the parent and the specific embed
|
||||
const parentData = sources[parentSourceId];
|
||||
const embedData = sources[outSourceId];
|
||||
|
||||
// If the source or embed was skipped by user, don't play it
|
||||
// Just ignore the result and let scraping continue to next source
|
||||
if (
|
||||
parentData?.status === "skipped" ||
|
||||
embedData?.status === "skipped" ||
|
||||
parentData?.reason === "Skipped by user" ||
|
||||
embedData?.reason === "Skipped by user"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
playAfterScrape(out);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface PlayerPartProps {
|
|||
backUrl: string;
|
||||
onLoad?: () => void;
|
||||
onMetaChange?: (meta: PlayerMeta) => void;
|
||||
skipSourceFn?: (() => void) | null;
|
||||
}
|
||||
|
||||
export function PlayerPart(props: PlayerPartProps) {
|
||||
|
|
@ -139,11 +140,15 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
</div>
|
||||
</Player.TopControls>
|
||||
|
||||
<Player.BottomControls show={showTargets}>
|
||||
<Player.BottomControls
|
||||
show={showTargets || status === playerStatus.SCRAPING}
|
||||
>
|
||||
{status !== playerStatus.PLAYING && !manualSourceSelection && <Tips />}
|
||||
<div className="flex items-center justify-center space-x-3 h-full">
|
||||
{status === playerStatus.SCRAPING ? (
|
||||
<ScrapingPartInterruptButton />
|
||||
<ScrapingPartInterruptButton
|
||||
skipCurrentSource={props.skipSourceFn || undefined}
|
||||
/>
|
||||
) : null}
|
||||
{status === playerStatus.PLAYING ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -26,18 +26,28 @@ import { WarningPart } from "../util/WarningPart";
|
|||
|
||||
export interface ScrapingProps {
|
||||
media: ScrapeMedia;
|
||||
onGetStream?: (stream: AsyncReturnType<ProviderControls["runAll"]>) => void;
|
||||
onGetStream?: (
|
||||
stream: AsyncReturnType<ProviderControls["runAll"]>,
|
||||
sources: Record<string, ScrapingSegment>,
|
||||
) => void;
|
||||
onResult?: (
|
||||
sources: Record<string, ScrapingSegment>,
|
||||
sourceOrder: ScrapingItems[],
|
||||
) => void;
|
||||
startFromSourceId?: string;
|
||||
onSkipSourceReady?: (skipFn: () => void) => void;
|
||||
}
|
||||
|
||||
export function ScrapingPart(props: ScrapingProps) {
|
||||
const { report } = useReportProviders();
|
||||
const { startScraping, resumeScraping, sourceOrder, sources, currentSource } =
|
||||
useScrape();
|
||||
const {
|
||||
startScraping,
|
||||
resumeScraping,
|
||||
sourceOrder,
|
||||
sources,
|
||||
currentSource,
|
||||
skipCurrentSource,
|
||||
} = useScrape();
|
||||
const isMounted = useMountedState();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -63,30 +73,44 @@ export function ScrapingPart(props: ScrapingProps) {
|
|||
}, [sourceOrder, sources]);
|
||||
|
||||
const started = useRef<string | null>(null);
|
||||
|
||||
// Pass skip function to parent
|
||||
useEffect(() => {
|
||||
props.onSkipSourceReady?.(skipCurrentSource);
|
||||
}, [skipCurrentSource, props]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only start scraping if we haven't started with this startFromSourceId before
|
||||
const currentKey = props.startFromSourceId || "default";
|
||||
if (started.current === currentKey) return;
|
||||
if (started.current === currentKey) {
|
||||
return;
|
||||
}
|
||||
started.current = currentKey;
|
||||
|
||||
(async () => {
|
||||
const output = props.startFromSourceId
|
||||
? await resumeScraping(props.media, props.startFromSourceId)
|
||||
: await startScraping(props.media);
|
||||
if (!isMounted()) return;
|
||||
props.onResult?.(
|
||||
resultRef.current.sources,
|
||||
resultRef.current.sourceOrder,
|
||||
);
|
||||
report(
|
||||
scrapePartsToProviderMetric(
|
||||
props.media,
|
||||
resultRef.current.sourceOrder,
|
||||
try {
|
||||
const output = props.startFromSourceId
|
||||
? await resumeScraping(props.media, props.startFromSourceId)
|
||||
: await startScraping(props.media);
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
props.onResult?.(
|
||||
resultRef.current.sources,
|
||||
),
|
||||
);
|
||||
props.onGetStream?.(output);
|
||||
})().catch(() => setFailedStartScrape(true));
|
||||
resultRef.current.sourceOrder,
|
||||
);
|
||||
report(
|
||||
scrapePartsToProviderMetric(
|
||||
props.media,
|
||||
resultRef.current.sourceOrder,
|
||||
resultRef.current.sources,
|
||||
),
|
||||
);
|
||||
props.onGetStream?.(output, resultRef.current.sources);
|
||||
} catch (error) {
|
||||
setFailedStartScrape(true);
|
||||
}
|
||||
})();
|
||||
}, [startScraping, resumeScraping, props, report, isMounted]);
|
||||
|
||||
let currentProviderIndex = sourceOrder.findIndex(
|
||||
|
|
@ -162,7 +186,9 @@ export function ScrapingPart(props: ScrapingProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function ScrapingPartInterruptButton() {
|
||||
export function ScrapingPartInterruptButton(props: {
|
||||
skipCurrentSource?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
|
@ -183,6 +209,16 @@ export function ScrapingPartInterruptButton() {
|
|||
>
|
||||
{t("notFound.reloadButton")}
|
||||
</Button>
|
||||
{props.skipCurrentSource && (
|
||||
<Button
|
||||
onClick={props.skipCurrentSource}
|
||||
theme="purple"
|
||||
padding="md:px-17 p-3"
|
||||
className="mt-6"
|
||||
>
|
||||
{t("player.scraping.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable no-console */
|
||||
|
||||
import { ScrapeMedia } from "@p-stream/providers";
|
||||
|
||||
import { MakeSlice } from "@/stores/player/slices/types";
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ export default defineConfig(({ mode }) => {
|
|||
const env = loadEnv(mode, process.cwd());
|
||||
return {
|
||||
base: env.VITE_BASE_URL || "/",
|
||||
assetsInclude: ['**/*.wasm'],
|
||||
server: {
|
||||
fs: {
|
||||
allow: [
|
||||
// Default: allow serving files from project root
|
||||
path.resolve(__dirname),
|
||||
// Allow serving from the linked providers directory
|
||||
path.resolve(__dirname, '../providers/@p-stream/providers'),
|
||||
],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
million.vite({ auto: true, mute: true }),
|
||||
handlebars({
|
||||
|
|
@ -123,6 +134,14 @@ export default defineConfig(({ mode }) => {
|
|||
|
||||
build: {
|
||||
sourcemap: mode !== "production",
|
||||
assetsInlineLimit: (filePath: string) => {
|
||||
// Never inline WASM files
|
||||
if (filePath.endsWith('.wasm')) {
|
||||
return false;
|
||||
}
|
||||
// Use default 4KB limit for other assets
|
||||
return undefined;
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue