v0.1.8
Menu
Download
SponsorBlock API

SponsorBlock API

Community skip segments from SponsorBlock apply to local files, same idea as the browser extension, without streaming from YouTube.

When you press play, useSponsorBlockPlayback invokes ensure_sponsorblock_segments with the file path. Rust reads {stem}.sponsorblock.json beside the video if it exists. The YouTube video id comes from the yt-dlp .info.json sidecar (sourceId on the frontend). No id means no fetch; playback continues normally.

Stale or missing sidecars trigger an API call. sponsorblock_fetch_query sends every category RuForge cares about (sponsor, selfpromo, intro, outro, preview, filler, interaction, music_offtopic, poi_highlight, chapter, etc.), not the API default sponsor-only set. Failures are silent: cached segments keep working offline.

During playback the hook watches currentTime. Categories set to auto in Settings → Playback seek past segment ends. button mode shows SponsorBlockSkipButton instead. Adaptive learning adjusts per-category behavior over time based on manual skips and undo windows. Scrub overlays in SponsorBlockScrubOverlay.tsx and chapter ticks in ChapterScrubber.tsx paint the same color map as the official extension.

Sidecars mean repeat plays skip the network unless you force refresh. Mini player layouts with a scrub bar get the same overlays; compact micro layouts without a scrub bar do not.

In the repo

fn sponsorblock_fetch_query() -> Vec<(&'static str, &'static str)> {
    let mut q = vec![("service", "YouTube")];
    for cat in SB_FETCH_CATEGORIES {
        q.push(("category", *cat));
    }
    for action in SB_FETCH_ACTION_TYPES {
        q.push(("actionType", *action));
    }
    q
}

async fn fetch_segments_from_api(video_id: &str) -> Option<Vec<SponsorBlockSegmentDto>> {
    let prefix = hash_prefix_4(video_id);
    let url = format!("{SB_API_BASE}/api/skipSegments/{prefix}");
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(12))
        .build()
        .ok()?;
    let resp = client
        .get(&url)
        .query(&sponsorblock_fetch_query())
        .send()
        .await
        .ok()?;
    if !resp.status().is_success() {
        return None;
    }
    let blocks: Vec<ApiVideoBlock> = resp.json().await.ok()?;
    let block = blocks.into_iter().find(|b| b.video_id == video_id)?;
    let mut out = Vec::new();
    for s in block.segments {
        if let Some(n) = normalize_api_segment(s) {
            out.push(n);
        }
    }
    Some(out)
}
/// Load or fetch SponsorBlock segments for a library file. Failures are silent; returns cache when possible.
#[tauri::command]
pub async fn ensure_sponsorblock_segments(
    media_path: String,
    force: Option<bool>,
) -> EnsureSponsorBlockResult {
    let force = force.unwrap_or(false);
    let media = PathBuf::from(&media_path);
    let parent = match media.parent() {
        Some(p) => p,
        None => {
            return EnsureSponsorBlockResult {
                segments: vec![],
                from_cache: true,
            }
        }
    };
    let stem = match media.file_stem().and_then(|s| s.to_str()) {
        Some(s) => s,
        None => {
            return EnsureSponsorBlockResult {
                segments: vec![],
                from_cache: true,
            }
        }
    };

    let info_path = resolve_info_json_path(parent, stem);
    let video_id = match info_path.as_ref().and_then(|p| source_id_from_info(p)) {
        Some(id) => id,
        None => {
            return EnsureSponsorBlockResult {
                segments: vec![],
                from_cache: true,
            }
        }
    };

    let sidecar_path = parent.join(format!("{stem}.sponsorblock.json"));
    let cached = read_sidecar(&sidecar_path);
    let local_dur = local_duration_secs(info_path.as_deref(), 0.0);

    let need_fetch = force
        || cached.is_none()
        || cached
            .as_ref()
            .map(|c| c.api != SB_SIDECAR_API_TAG)
            .unwrap_or(false)
        || cached
            .as_ref()
            .map(|c| sidecar_is_stale(c, local_dur))
            .unwrap_or(false);

    if need_fetch {
        if let Some(segments) = fetch_segments_from_api(&video_id).await {
            let sidecar = SponsorBlockSidecarDto {
                video_id: video_id.clone(),
                fetched_at: iso_now(),
                api: SB_SIDECAR_API_TAG.to_string(),
                segments: segments.clone(),
            };
            let _ = write_sidecar(&sidecar_path, &sidecar);
            return EnsureSponsorBlockResult {
                segments,
                from_cache: false,
            };
        }
    }

    if let Some(c) = cached {
        return EnsureSponsorBlockResult {
            segments: c.segments,
            from_cache: true,
        };
    }

    EnsureSponsorBlockResult {
        segments: vec![],
        from_cache: true,
    }
}
  useEffect(() => {
    seenAppearanceRef.current.clear();
    autoSkippedRef.current.clear();
    lastAutoSkipRef.current = null;
    if (!enabled || !file.sourceId?.trim()) {
      setSegments([]);
      return;
    }
    let cancelled = false;
    void invoke<EnsurePayload>("ensure_sponsorblock_segments", {
      mediaPath: file.path,
      force: false,
    })
      .then((r) => {
        if (!cancelled) setSegments(mapSegments(r.segments));
      })
      .catch(() => {
        if (!cancelled) setSegments([]);
      });
    return () => {
      cancelled = true;
    };
  }, [file.path, file.sourceId, enabled]);

  const activeSkip = useMemo(
    () => (enabled ? activeSkipSegments(segments, currentTime) : []),
    [segments, currentTime, enabled],
  );

  const primarySkip = activeSkip[0] ?? null;

  useEffect(() => {
    if (!enabled || !primarySkip || !isSkipCategory(primarySkip.category)) return;
    const key = primarySkip.UUID;
    if (!key || seenAppearanceRef.current.has(key)) return;
    seenAppearanceRef.current.add(key);
    onAppearance(primarySkip.category);
  }, [enabled, primarySkip, onAppearance]);

  useEffect(() => {
    if (!enabled) return;
    const last = lastAutoSkipRef.current;
    if (!last) return;
    if (performance.now() - last.at > SB_DEMOTE_UNDO_WINDOW_SEC * 1000) return;
    if (currentTime < last.end - 1.5 && currentTime >= last.end - 8) {
      onDemoteUndo(last.category);
      lastAutoSkipRef.current = null;
    }
  }, [currentTime, enabled, onDemoteUndo]);

  useEffect(() => {
    if (!enabled) return;
    for (const s of activeSkip) {
      if (!isSkipCategory(s.category) || s.actionType !== "skip") continue;
      if (effectiveCategoryMode(settings, s.category) !== "auto") continue;
      const key = s.UUID;
      if (!key || autoSkippedRef.current.has(key)) continue;
      const end = s.segment[1];
      if (currentTime >= end - 0.25) continue;
      autoSkippedRef.current.add(key);
      lastAutoSkipRef.current = {
        end,
        at: performance.now(),
        category: s.category,
      };
      seekTo(end);
      return;
    }
  }, [currentTime, activeSkip, enabled, settings, seekTo]);

Where it shows up

  • src-tauri/src/commands/sponsorblock.rs fetch, normalize, sidecar read/write
  • useSponsorBlockPlayback.ts load on play, auto-skip, button mode
  • SponsorBlockSettingsTree.tsx under Settings → Playback
  • SponsorBlockScrubOverlay.tsx, SponsorBlockSkipButton.tsx, sponsorBlockColors.ts