diff --git a/src/pages/discover/components/FeaturedCarousel.tsx b/src/pages/discover/components/FeaturedCarousel.tsx
index 90287d1f..c3ac8a7b 100644
--- a/src/pages/discover/components/FeaturedCarousel.tsx
+++ b/src/pages/discover/components/FeaturedCarousel.tsx
@@ -573,10 +573,8 @@ export function FeaturedCarousel({
if (hasDigitalRelease) {
const digitalReleaseDate = new Date(releaseInfo.digital_release_date!);
- const twoDaysAfter = new Date(digitalReleaseDate);
- twoDaysAfter.setDate(twoDaysAfter.getDate() + 2);
- if (new Date() >= twoDaysAfter) {
+ if (new Date() >= digitalReleaseDate) {
return
;
}
}
@@ -585,10 +583,8 @@ export function FeaturedCarousel({
const theatricalReleaseDate = new Date(
releaseInfo.theatrical_release_date!,
);
- const fortyFiveDaysAfter = new Date(theatricalReleaseDate);
- fortyFiveDaysAfter.setDate(fortyFiveDaysAfter.getDate() + 45);
- if (new Date() >= fortyFiveDaysAfter) {
+ if (new Date() >= theatricalReleaseDate) {
return (
HD
From ecb5684529f3aa64158434ec702e4c08dda72689 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Tue, 4 Nov 2025 18:09:00 -0700
Subject: [PATCH 6/8] fix typo
---
src/setup/config.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/setup/config.ts b/src/setup/config.ts
index bf41548a..42b00a58 100644
--- a/src/setup/config.ts
+++ b/src/setup/config.ts
@@ -28,8 +28,8 @@ interface Config {
ALLOW_FEBBOX_KEY: boolean;
ALLOW_REAL_DEBRID_KEY: boolean;
SHOW_AD: boolean;
- AD_CONTENT_URL: string; // like
- TRACK_SCRIPT: string;
+ AD_CONTENT_URL: string;
+ TRACK_SCRIPT: string; // like
BANNER_MESSAGE: string;
BANNER_ID: string;
}
From 7d5c88c0a17d39c340b841749f4bc57ce5a62c4a Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Wed, 5 Nov 2025 14:14:37 -0700
Subject: [PATCH 7/8] log skips to new api
---
.../player/atoms/SkipIntroButton.tsx | 41 +++-
.../player/hooks/useSkipTracking.ts | 119 ++++++-----
.../player/internals/Backend/SkipTracker.tsx | 189 ++++--------------
3 files changed, 152 insertions(+), 197 deletions(-)
diff --git a/src/components/player/atoms/SkipIntroButton.tsx b/src/components/player/atoms/SkipIntroButton.tsx
index 4f8f08d6..b743622a 100644
--- a/src/components/player/atoms/SkipIntroButton.tsx
+++ b/src/components/player/atoms/SkipIntroButton.tsx
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/utils/Transition";
+import { useAuthStore } from "@/stores/auth";
import { usePlayerStore } from "@/stores/player/store";
function shouldShowSkipButton(
@@ -47,6 +48,8 @@ export function SkipIntroButton(props: {
const time = usePlayerStore((s) => s.progress.time);
const status = usePlayerStore((s) => s.status);
const display = usePlayerStore((s) => s.display);
+ const meta = usePlayerStore((s) => s.meta);
+ const account = useAuthStore((s) => s.account);
const showingState = shouldShowSkipButton(time, props.skipTime);
const animation = showingState === "hover" ? "slide-up" : "fade";
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
@@ -55,11 +58,47 @@ export function SkipIntroButton(props: {
? bottom
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
}
+
+ const sendSkipAnalytics = useCallback(
+ async (startTime: number, endTime: number, skipDuration: number) => {
+ try {
+ await fetch("https://skips.pstream.mov/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ start_time: startTime,
+ end_time: endTime,
+ skip_duration: skipDuration,
+ content_id: meta?.tmdbId,
+ content_type: meta?.type,
+ season_id: meta?.season?.tmdbId,
+ episode_id: meta?.episode?.tmdbId,
+ user_id: account?.userId,
+ session_id: `session_${Date.now()}`,
+ turnstile_token: "",
+ }),
+ });
+ } catch (error) {
+ console.error("Failed to send skip analytics:", error);
+ }
+ },
+ [meta, account],
+ );
+
const handleSkip = useCallback(() => {
if (typeof props.skipTime === "number" && display) {
+ const startTime = time;
+ const endTime = props.skipTime;
+ const skipDuration = endTime - startTime;
+
display.setTime(props.skipTime);
+
+ // Send analytics for intro skip button usage
+ // eslint-disable-next-line no-console
+ console.log(`Skip intro button used: ${skipDuration}s total`);
+ sendSkipAnalytics(startTime, endTime, skipDuration);
}
- }, [props.skipTime, display]);
+ }, [props.skipTime, display, time, sendSkipAnalytics]);
if (!props.inControl) return null;
let show = false;
diff --git a/src/components/player/hooks/useSkipTracking.ts b/src/components/player/hooks/useSkipTracking.ts
index e4747da8..c22253a0 100644
--- a/src/components/player/hooks/useSkipTracking.ts
+++ b/src/components/player/hooks/useSkipTracking.ts
@@ -26,77 +26,102 @@ interface SkipTrackingResult {
}
/**
- * Hook that tracks when users skip or scrub more than 15 seconds
- * Useful for gathering information about show intros and user behavior
+ * Hook that tracks rapid skipping sessions where users accumulate 30+ seconds of forward
+ * movement within a 5-second window. Sessions continue until 8 seconds pass without
+ * any forward movement, then report the total skip distance. Ignores skips that start
+ * after 20% of video duration (unlikely to be intro skipping).
*
- * @param minSkipThreshold Minimum skip duration in seconds to track (default: 15)
+ * @param minSkipThreshold Minimum total forward movement in 5-second window to start session (default: 30)
* @param maxHistory Maximum number of skip events to keep in history (default: 50)
*/
export function useSkipTracking(
- minSkipThreshold: number = 15,
+ minSkipThreshold: number = 30,
maxHistory: number = 50,
): SkipTrackingResult {
const [skipHistory, setSkipHistory] = useState
([]);
const previousTimeRef = useRef(0);
- const lastUpdateTimeRef = useRef(0);
- const isSeekingRef = useRef(false);
+ const skipWindowRef = useRef>([]);
+ const isInSkipSessionRef = useRef(false);
+ const skipSessionStartRef = useRef(0);
+ const sessionTotalRef = useRef(0);
// Get current player state
const progress = usePlayerStore((s) => s.progress);
- const mediaPlaying = usePlayerStore((s) => s.mediaPlaying);
const meta = usePlayerStore((s) => s.meta);
- const isSeeking = usePlayerStore((s) => s.interface.isSeeking);
-
- // Track seeking state to avoid false positives during drag seeking
- useEffect(() => {
- isSeekingRef.current = isSeeking;
- }, [isSeeking]);
+ const duration = progress.duration;
const clearHistory = useCallback(() => {
setSkipHistory([]);
previousTimeRef.current = 0;
- lastUpdateTimeRef.current = 0;
+ skipWindowRef.current = [];
+ isInSkipSessionRef.current = false;
+ skipSessionStartRef.current = 0;
+ sessionTotalRef.current = 0;
}, []);
const detectSkip = useCallback(() => {
const now = Date.now();
const currentTime = progress.time;
- // Don't track if video hasn't started playing or if we're actively seeking
- if (!mediaPlaying.hasPlayedOnce || isSeekingRef.current) {
- previousTimeRef.current = currentTime;
- lastUpdateTimeRef.current = now;
- return;
- }
-
// Initialize on first run
if (previousTimeRef.current === 0) {
previousTimeRef.current = currentTime;
- lastUpdateTimeRef.current = now;
return;
}
const timeDelta = currentTime - previousTimeRef.current;
- const realTimeDelta = now - lastUpdateTimeRef.current;
- // Calculate expected time change based on playback rate and real time passed
- const expectedTimeDelta = mediaPlaying.isPlaying
- ? (realTimeDelta / 1000) * mediaPlaying.playbackRate
- : 0;
+ // Track forward movements >= 1 second in sliding 5-second window
+ if (timeDelta >= 1) {
+ // Add forward movement to window and remove entries older than 5 seconds
+ skipWindowRef.current.push({ time: now, delta: timeDelta });
+ skipWindowRef.current = skipWindowRef.current.filter(
+ (entry) => entry.time > now - 5000,
+ );
- // Detect if the time jump is significantly different from expected
- // This accounts for normal playback, pausing, and small buffering hiccups
- const unexpectedJump = Math.abs(timeDelta - expectedTimeDelta);
+ // Calculate total forward movement in current window
+ const totalForwardMovement = skipWindowRef.current.reduce(
+ (sum, entry) => sum + entry.delta,
+ 0,
+ );
- // Only consider it a skip if:
- // 1. The time jump is greater than our threshold
- // 2. The unexpected jump is significant (more than 3 seconds difference from expected)
- // 3. We're not in a seeking state
- if (Math.abs(timeDelta) >= minSkipThreshold && unexpectedJump >= 3) {
+ // Start session when threshold exceeded
+ if (
+ totalForwardMovement >= minSkipThreshold &&
+ !isInSkipSessionRef.current
+ ) {
+ isInSkipSessionRef.current = true;
+ skipSessionStartRef.current = previousTimeRef.current;
+ sessionTotalRef.current = totalForwardMovement;
+ }
+ // Update session total while active
+ else if (isInSkipSessionRef.current) {
+ sessionTotalRef.current = totalForwardMovement;
+ }
+ }
+
+ // End session if no forward movement in last 8 seconds
+ const recentEntries = skipWindowRef.current.filter(
+ (entry) => entry.time > now - 8000,
+ );
+
+ if (isInSkipSessionRef.current && recentEntries.length === 0) {
+ // Ignore skips that start after 20% of video duration (likely not intro skipping)
+ const twentyPercentMark = duration * 0.2;
+ if (skipSessionStartRef.current > twentyPercentMark) {
+ // Reset session state without creating event
+ isInSkipSessionRef.current = false;
+ skipSessionStartRef.current = 0;
+ sessionTotalRef.current = 0;
+ skipWindowRef.current = [];
+ return;
+ }
+
+ // Create skip event for completed session
const skipEvent: SkipEvent = {
- startTime: previousTimeRef.current,
+ startTime: skipSessionStartRef.current,
endTime: currentTime,
- skipDuration: timeDelta,
+ skipDuration: sessionTotalRef.current,
timestamp: now,
meta: meta
? {
@@ -118,22 +143,22 @@ export function useSkipTracking(
? newHistory.slice(newHistory.length - maxHistory)
: newHistory;
});
+
+ // Reset session state
+ isInSkipSessionRef.current = false;
+ skipSessionStartRef.current = 0;
+ sessionTotalRef.current = 0;
+ skipWindowRef.current = [];
}
previousTimeRef.current = currentTime;
- lastUpdateTimeRef.current = now;
- }, [progress.time, mediaPlaying, meta, minSkipThreshold, maxHistory]);
+ }, [progress.time, duration, meta, minSkipThreshold, maxHistory]);
useEffect(() => {
- // Run detection every second when video is playing
- const interval = setInterval(() => {
- if (mediaPlaying.hasPlayedOnce) {
- detectSkip();
- }
- }, 1000);
-
+ // Monitor time changes every 100ms to catch rapid skipping
+ const interval = setInterval(detectSkip, 100);
return () => clearInterval(interval);
- }, [detectSkip, mediaPlaying.hasPlayedOnce]);
+ }, [detectSkip]);
// Reset tracking when content changes
useEffect(() => {
diff --git a/src/components/player/internals/Backend/SkipTracker.tsx b/src/components/player/internals/Backend/SkipTracker.tsx
index 4b28ce08..b3000264 100644
--- a/src/components/player/internals/Backend/SkipTracker.tsx
+++ b/src/components/player/internals/Backend/SkipTracker.tsx
@@ -1,173 +1,64 @@
-import { useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef } from "react";
import { useSkipTracking } from "@/components/player/hooks/useSkipTracking";
+import { useAuthStore } from "@/stores/auth";
import { usePlayerStore } from "@/stores/player/store";
/**
- * Component that tracks when users skip or scrub more than 15 seconds
- * Useful for gathering information about show intros and user behavior patterns
- * Currently logs to console - can be extended to send to backend analytics endpoint
+ * Component that tracks and reports completed skip sessions to analytics backend.
+ * Sessions are detected when users accumulate 30+ seconds of forward movement
+ * within a 5-second window and end after 8 seconds of no activity.
+ * Ignores skips that start after 20% of video duration (unlikely to be intro skipping).
*/
export function SkipTracker() {
- const { skipHistory, latestSkip } = useSkipTracking(15); // Track skips > 15 seconds
+ const { latestSkip } = useSkipTracking(30);
const lastLoggedSkipRef = useRef(0);
// Player metadata for context
const meta = usePlayerStore((s) => s.meta);
+ const account = useAuthStore((s) => s.account);
+ const turnstileToken = "";
+
+ const sendSkipAnalytics = useCallback(async () => {
+ if (!latestSkip) return;
+
+ try {
+ await fetch("https://skips.pstream.mov/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ start_time: latestSkip.startTime,
+ end_time: latestSkip.endTime,
+ skip_duration: latestSkip.skipDuration,
+ content_id: meta?.tmdbId,
+ content_type: meta?.type,
+ season_id: meta?.season?.tmdbId,
+ episode_id: meta?.episode?.tmdbId,
+ user_id: account?.userId,
+ session_id: `session_${Date.now()}`,
+ turnstile_token: turnstileToken ?? "",
+ }),
+ });
+ } catch (error) {
+ console.error("Failed to send skip analytics:", error);
+ }
+ }, [latestSkip, meta, account]);
useEffect(() => {
if (!latestSkip || !meta) return;
- // Avoid logging the same skip multiple times
+ // Avoid processing the same skip multiple times
if (latestSkip.timestamp === lastLoggedSkipRef.current) return;
- // Format skip duration for readability
- const formatDuration = (seconds: number): string => {
- const absSeconds = Math.abs(seconds);
- const minutes = Math.floor(absSeconds / 60);
- const remainingSeconds = Math.floor(absSeconds % 60);
-
- if (minutes > 0) {
- return `${minutes}m ${remainingSeconds}s`;
- }
- return `${remainingSeconds}s`;
- };
-
- // Format time position for readability
- const formatTime = (seconds: number): string => {
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = Math.floor(seconds % 60);
- return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
- };
-
- const skipDirection = latestSkip.skipDuration > 0 ? "forward" : "backward";
- const skipType = Math.abs(latestSkip.skipDuration) >= 30 ? "scrub" : "skip";
-
- // Log the skip event with detailed information
+ // Log completed skip session
// eslint-disable-next-line no-console
- console.log(`User ${skipType.toUpperCase()} detected`, {
- // Basic skip info
- direction: skipDirection,
- duration: formatDuration(latestSkip.skipDuration),
- from: formatTime(latestSkip.startTime),
- to: formatTime(latestSkip.endTime),
+ console.log(`Skip session completed: ${latestSkip.skipDuration}s total`);
- // Content context
- content: {
- title: latestSkip.meta?.title || "Unknown",
- type: latestSkip.meta?.type || "Unknown",
- tmdbId: latestSkip.meta?.tmdbId,
- },
-
- // Episode context (for TV shows)
- ...(meta.type === "show" && {
- episode: {
- season: latestSkip.meta?.seasonNumber,
- episode: latestSkip.meta?.episodeNumber,
- },
- }),
-
- // Analytics data that could be sent to backend
- analytics: {
- timestamp: new Date(latestSkip.timestamp).toISOString(),
- startTime: latestSkip.startTime,
- endTime: latestSkip.endTime,
- skipDuration: latestSkip.skipDuration,
- contentId: latestSkip.meta?.tmdbId,
- contentType: latestSkip.meta?.type,
- seasonId: meta.season?.tmdbId,
- episodeId: meta.episode?.tmdbId,
- },
- });
-
- // Log special cases that might indicate intro skipping
- if (
- meta.type === "show" &&
- latestSkip.startTime <= 30 && // Skip happened in first 30 seconds
- latestSkip.skipDuration > 15 && // Forward skip of at least 15 seconds
- latestSkip.skipDuration <= 120 // But not more than 2 minutes (reasonable intro length)
- ) {
- // eslint-disable-next-line no-console
- console.log(`Potential intro skip dete`, {
- show: latestSkip.meta?.title,
- season: latestSkip.meta?.seasonNumber,
- episode: latestSkip.meta?.episodeNumber,
- introSkipDuration: formatDuration(latestSkip.skipDuration),
- message: "User likely skipped intro sequence",
- });
- }
-
- // Log potential outro/credits skipping
- const progress = usePlayerStore.getState().progress;
- const timeRemaining = progress.duration - latestSkip.endTime;
- if (
- latestSkip.skipDuration > 0 && // Forward skip
- timeRemaining <= 300 && // Within last 5 minutes
- latestSkip.skipDuration >= 15
- ) {
- // eslint-disable-next-line no-console
- console.log(`Potential outro skip detected`, {
- content: latestSkip.meta?.title,
- timeRemaining: formatDuration(timeRemaining),
- skipDuration: formatDuration(latestSkip.skipDuration),
- message: "User likely skipped credits/outro",
- });
- }
+ // Send analytics data to backend
+ sendSkipAnalytics();
lastLoggedSkipRef.current = latestSkip.timestamp;
- }, [latestSkip, meta]);
-
- // Log summary statistics occasionally... just for testing, we likely wont use it unless useful.
- useEffect(() => {
- if (skipHistory.length > 0 && skipHistory.length % 5 === 0) {
- const forwardSkips = skipHistory.filter((s) => s.skipDuration > 0);
- const backwardSkips = skipHistory.filter((s) => s.skipDuration < 0);
- const avgSkipDuration =
- skipHistory.reduce((sum, s) => sum + Math.abs(s.skipDuration), 0) /
- skipHistory.length;
-
- // eslint-disable-next-line no-console
- console.log(`skip analytics`, {
- totalSkips: skipHistory.length,
- forwardSkips: forwardSkips.length,
- backwardSkips: backwardSkips.length,
- averageSkipDuration: `${Math.round(avgSkipDuration)}s`,
- content: meta?.title || "Unknown",
- });
- }
- }, [skipHistory.length, skipHistory, meta?.title]);
-
- // TODO: When backend endpoint is ready, replace console.log with API calls
- // Example implementation:
- /*
- useEffect(() => {
- if (!latestSkip || !account?.userId) return;
-
- // Send skip data to analytics endpoint
- const sendSkipAnalytics = async () => {
- try {
- await fetch(`${backendUrl}/api/analytics/skips`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- userId: account.userId,
- skipData: latestSkip,
- contentContext: {
- tmdbId: meta?.tmdbId,
- type: meta?.type,
- seasonId: meta?.season?.tmdbId,
- episodeId: meta?.episode?.tmdbId,
- }
- })
- });
- } catch (error) {
- console.error('Failed to send skip analytics:', error);
- }
- };
-
- sendSkipAnalytics();
- }, [latestSkip, account?.userId, meta]);
- */
+ }, [latestSkip, meta, sendSkipAnalytics]);
return null;
}
From 9bb54ee17e824df9c807bc3d38ecd8fb52e8d5c9 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Fri, 7 Nov 2025 16:23:28 -0700
Subject: [PATCH 8/8] fix manual source selection resetting progress
---
.../player/hooks/useSourceSelection.ts | 25 +++++++++++++++----
1 file changed, 20 insertions(+), 5 deletions(-)
diff --git a/src/components/player/hooks/useSourceSelection.ts b/src/components/player/hooks/useSourceSelection.ts
index 428096f6..aa5cf1d0 100644
--- a/src/components/player/hooks/useSourceSelection.ts
+++ b/src/components/player/hooks/useSourceSelection.ts
@@ -22,6 +22,21 @@ import { convertRunoutputToSource } from "@/components/player/utils/convertRunou
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { metaToScrapeMedia } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
+import { usePreferencesStore } from "@/stores/preferences";
+import { useProgressStore } from "@/stores/progress";
+
+function getSavedProgress(items: Record, meta: any): number {
+ const item = items[meta?.tmdbId ?? ""];
+ if (!item || !meta) return 0;
+ if (meta.type === "movie") {
+ if (!item.progress) return 0;
+ return item.progress.watched;
+ }
+
+ const ep = item.episodes[meta.episode?.tmdbId ?? ""];
+ if (!ep) return 0;
+ return ep.progress.watched;
+}
export function useEmbedScraping(
routerId: string,
@@ -33,8 +48,8 @@ export function useEmbedScraping(
const setCaption = usePlayerStore((s) => s.setCaption);
const setSourceId = usePlayerStore((s) => s.setSourceId);
const setEmbedId = usePlayerStore((s) => (s as any).setEmbedId);
- const progress = usePlayerStore((s) => s.progress.time);
const meta = usePlayerStore((s) => s.meta);
+ const progressItems = useProgressStore((s) => s.items);
const router = useOverlayRouter(routerId);
const { report } = useReportProviders();
@@ -81,7 +96,7 @@ export function useEmbedScraping(
setSource(
convertRunoutputToSource({ stream: result.stream[0] }),
convertProviderCaption(result.stream[0].captions),
- progress,
+ getSavedProgress(progressItems, meta),
);
router.close();
}, [embedId, sourceId, meta, router, report, setCaption]);
@@ -99,7 +114,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
const setCaption = usePlayerStore((s) => s.setCaption);
const setSourceId = usePlayerStore((s) => s.setSourceId);
const setEmbedId = usePlayerStore((s) => (s as any).setEmbedId);
- const progress = usePlayerStore((s) => s.progress.time);
+ const progressItems = useProgressStore((s) => s.items);
const router = useOverlayRouter(routerId);
const { report } = useReportProviders();
@@ -144,7 +159,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
setSource(
convertRunoutputToSource({ stream: result.stream[0] }),
convertProviderCaption(result.stream[0].captions),
- progress,
+ getSavedProgress(progressItems, meta),
);
setSourceId(sourceId);
router.close();
@@ -201,7 +216,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
setSource(
convertRunoutputToSource({ stream: embedResult.stream[0] }),
convertProviderCaption(embedResult.stream[0].captions),
- progress,
+ getSavedProgress(progressItems, meta),
);
router.close();
}