• Not Exported

    Detects patterns of consecutive rapid skips (skip streaks)

    Analyzes the chronological sequence of skip events to identify periods when the user rapidly skips multiple tracks in succession. This reveals potentially important behavioral patterns related to browsing, mood, or content quality.

    The multi-step detection algorithm:

    1. Reconstructs the timeline of all skip events across tracks
    2. Identifies consecutive skips occurring within short time windows (30 seconds)
    3. Groups these events into "streak" objects with relevant metadata
    4. Analyzes streak frequency, length, and consistency
    5. Calculates confidence scores based on streak metrics

    Skip streak patterns can indicate:

    • Browsing behavior (searching for specific tracks)
    • Mood-based rejection (e.g., seeking more upbeat tracks)
    • Playlist or recommendation quality issues
    • Listening context disruptions

    Parameters

    • skippedTracks: SkippedTrack[]

      Array of tracks with skip event data

    Returns DetectedPattern[]

    Array of detected skip streak patterns meeting confidence thresholds

    // Detect skip streak patterns
    const tracks = await getSkippedTracks();
    const streakPatterns = detectSkipStreakPatterns(tracks);

    // Example result:
    // {
    // type: "skip_streak",
    // confidence: 0.75,
    // description: "Often skips 4 tracks in a row",
    // details: {
    // streakCount: 12,
    // avgStreakLength: 4.2,
    // recentStreaks: [...]
    // }
    // }
    function detectSkipStreakPatterns(
    skippedTracks: SkippedTrack[],
    ): DetectedPattern[] {
    const patterns: DetectedPattern[] = [];

    // We need to reconstruct the timeline of skips
    const allSkipEvents: {
    timestamp: Date;
    track: string;
    artist: string;
    context?: Record<string, unknown>;
    }[] = [];

    // Collect all skip events
    skippedTracks.forEach((track) => {
    if (!track.skipEvents || track.skipEvents.length === 0) return;

    track.skipEvents.forEach((event) => {
    allSkipEvents.push({
    timestamp: new Date(event.timestamp),
    track: track.name,
    artist: track.artist,
    context: event.context as Record<string, unknown>,
    });
    });
    });

    // Sort skip events by timestamp
    allSkipEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());

    // Find skip streaks (consecutive skips with little time between them)
    const skipStreaks: {
    startTime: Date;
    endTime: Date;
    tracks: string[];
    artists: string[];
    contexts: Record<string, unknown>[];
    duration: number;
    }[] = [];

    let currentStreak: {
    tracks: string[];
    artists: string[];
    contexts: Record<string, unknown>[];
    events: {
    timestamp: Date;
    track: string;
    artist: string;
    context?: Record<string, unknown>;
    }[];
    } | null = null;

    // Look for consecutive skips
    for (let i = 0; i < allSkipEvents.length; i++) {
    const event = allSkipEvents[i];

    // If no current streak or it's been more than 30 seconds since last skip
    if (
    !currentStreak ||
    (i > 0 &&
    event.timestamp.getTime() - allSkipEvents[i - 1].timestamp.getTime() >
    30000)
    ) {
    // If we had a streak, check if it's valid and save it
    if (
    currentStreak &&
    currentStreak.events.length >= PATTERN_THRESHOLDS.STREAK_THRESHOLD
    ) {
    skipStreaks.push({
    startTime: currentStreak.events[0].timestamp,
    endTime:
    currentStreak.events[currentStreak.events.length - 1].timestamp,
    tracks: currentStreak.tracks,
    artists: currentStreak.artists,
    contexts: currentStreak.contexts.filter(
    (c) => c !== undefined,
    ) as Record<string, unknown>[],
    duration:
    (currentStreak.events[
    currentStreak.events.length - 1
    ].timestamp.getTime() -
    currentStreak.events[0].timestamp.getTime()) /
    1000, // in seconds
    });
    }

    // Start new streak
    currentStreak = {
    tracks: [event.track],
    artists: [event.artist],
    contexts: event.context ? [event.context] : [],
    events: [event],
    };
    } else {
    // Continue current streak
    currentStreak.tracks.push(event.track);
    currentStreak.artists.push(event.artist);
    if (event.context) currentStreak.contexts.push(event.context);
    currentStreak.events.push(event);
    }
    }

    // Check final streak
    if (
    currentStreak &&
    currentStreak.events.length >= PATTERN_THRESHOLDS.STREAK_THRESHOLD
    ) {
    skipStreaks.push({
    startTime: currentStreak.events[0].timestamp,
    endTime: currentStreak.events[currentStreak.events.length - 1].timestamp,
    tracks: currentStreak.tracks,
    artists: currentStreak.artists,
    contexts: currentStreak.contexts.filter((c) => c !== undefined) as Record<
    string,
    unknown
    >[],
    duration:
    (currentStreak.events[
    currentStreak.events.length - 1
    ].timestamp.getTime() -
    currentStreak.events[0].timestamp.getTime()) /
    1000, // in seconds
    });
    }

    // If we have enough streaks, create a pattern
    if (skipStreaks.length >= 3) {
    // Calculate average streak length
    const avgStreakLength =
    skipStreaks.reduce((sum, streak) => sum + streak.tracks.length, 0) /
    skipStreaks.length;

    // Calculate confidence based on number of streaks and their length
    const confidence = Math.min(
    0.9,
    (skipStreaks.length / 10) *
    (avgStreakLength / PATTERN_THRESHOLDS.STREAK_THRESHOLD),
    );

    if (confidence >= PATTERN_THRESHOLDS.CONFIDENCE_THRESHOLD) {
    patterns.push({
    type: PatternType.SKIP_STREAK,
    confidence,
    description: `Often skips ${Math.round(avgStreakLength)} tracks in a row`,
    occurrences: skipStreaks.length,
    relatedItems: skipStreaks
    .slice(0, 3)
    .map(
    (s) =>
    `${s.tracks.length} tracks on ${s.startTime.toLocaleDateString()}`,
    ),
    details: {
    streakCount: skipStreaks.length,
    avgStreakLength,
    longestStreak: Math.max(...skipStreaks.map((s) => s.tracks.length)),
    recentStreaks: skipStreaks.slice(-5).map((s) => ({
    date: s.startTime.toISOString(),
    tracks: s.tracks.length,
    duration: s.duration,
    })),
    },
    firstDetected: skipStreaks[0].startTime.toISOString(),
    lastDetected: skipStreaks[skipStreaks.length - 1].endTime.toISOString(),
    });
    }
    }

    return patterns;
    }