Skip to content

Instantly share code, notes, and snippets.

@jumoog
Created April 22, 2026 18:29
Show Gist options
  • Select an option

  • Save jumoog/7e213632fba7be20186a630e4f2bdc7f to your computer and use it in GitHub Desktop.

Select an option

Save jumoog/7e213632fba7be20186a630e4f2bdc7f to your computer and use it in GitHub Desktop.

Plan: Add Jellyfin Media Segments + Skip Support

Context

Jellyfin 10.9+ includes a native Media Segments API (/MediaSegments/{itemId}) that marks skippable regions — Intro, Outro, Recap, Preview, Commercial — for any item (movies and episodes alike). WiiFin currently supports only the community Intro Skipper plugin (/Episode/{id}/IntroTimestamps), which only covers intros on episodes and requires a separate plugin.

Goal: call the native API first; fall back to the plugin endpoint for servers that don't have Jellyfin 10.9+ or don't have segments populated; show a per-segment skip prompt with type-aware wording; let the user press A to jump past any active segment.


Files to Modify

File Change
source/jellyfin/JellyfinClient.h Add MediaSegment struct + getMediaSegments() declaration
source/jellyfin/JellyfinClient.cpp Implement getMediaSegments()
source/player/PlayerOverlay.h Add segments to context; add activeSegmentIdx + updateSegmentPrompt() to class
source/player/PlayerOverlay.cpp Implement updateSegmentPrompt(); update A-press skip handler
source/core/App.cpp Call getMediaSegments() for all items; keep getIntroTimestamps() as episode-only fallback

Step-by-Step Implementation

1. JellyfinClient.h — Add struct + declaration

After the existing IntroInfo struct (line 89), add:

/* Native Jellyfin 10.9+ Media Segments API */
struct MediaSegment {
    float       startSecs = 0.0f;   /* start of skippable region (seconds) */
    float       endSecs   = 0.0f;   /* end   of skippable region (seconds) */
    std::string type;               /* "Intro", "Outro", "Recap", "Preview", "Commercial", … */
};

After getIntroTimestamps() declaration (line 284), add:

// Fetch skippable segments for any item (Jellyfin 10.9+).
// GET /MediaSegments/{itemId}
// Returns true even when empty (404 or no segments = not an error).
bool getMediaSegments(const std::string& serverUrl,
                      const JellyfinAuth& auth,
                      const std::string& itemId,
                      std::vector<MediaSegment>& out);

2. JellyfinClient.cpp — Implement getMediaSegments()

Add after getIntroTimestamps() (after line 2223):

// ---------------------------------------------------------------------------
// Native Media Segments API — Jellyfin 10.9+
// GET /MediaSegments/{itemId}
// Response: {"Items":[{"StartTicks":…,"EndTicks":…,"Type":"Intro"},…]}
// ---------------------------------------------------------------------------
bool JellyfinClient::getMediaSegments(const std::string& serverUrl,
                                       const JellyfinAuth& auth,
                                       const std::string& itemId,
                                       std::vector<MediaSegment>& out)
{
    out.clear();
    std::string url = serverUrl + "/MediaSegments/" + itemId;
    std::string resp;
    int status = httpRequest(url, "GET", "", "", auth.accessToken, resp);
    if (status != 200) return true;   /* not available / no segments: silent */

    /* Walk the "Items" array without a full JSON parser.
     * Find each {...} object in the array and extract the three fields. */
    size_t arrPos = resp.find("\"Items\"");
    if (arrPos == std::string::npos) return true;
    arrPos = resp.find('[', arrPos);
    if (arrPos == std::string::npos) return true;

    size_t pos = arrPos + 1;
    while (pos < resp.size()) {
        /* skip whitespace / commas between objects */
        while (pos < resp.size() &&
               (resp[pos] == ' ' || resp[pos] == '\n' ||
                resp[pos] == '\r' || resp[pos] == '\t' ||
                resp[pos] == ',')) ++pos;
        if (pos >= resp.size() || resp[pos] == ']') break;
        if (resp[pos] != '{') break;

        /* find matching closing brace */
        size_t objStart = pos;
        int depth = 0;
        while (pos < resp.size()) {
            if      (resp[pos] == '{') ++depth;
            else if (resp[pos] == '}') { if (--depth == 0) { ++pos; break; } }
            ++pos;
        }
        std::string obj = resp.substr(objStart, pos - objStart);

        long long startTicks = jsonGetLongLong(obj, "StartTicks");
        long long endTicks   = jsonGetLongLong(obj, "EndTicks");
        std::string type     = jsonGetString(obj, "Type");

        if (endTicks > startTicks && !type.empty()) {
            MediaSegment seg;
            seg.startSecs = (float)(startTicks / 10000000LL);
            seg.endSecs   = (float)(endTicks   / 10000000LL);
            seg.type      = type;
            out.push_back(seg);
        }
    }
    return true;
}

3. PlayerOverlay.h — Update context + class

In PlayerOverlayContext (after IntroInfo intro;):

/* Native Media Segments (Jellyfin 10.9+) — preferred over IntroInfo */
std::vector<MediaSegment> segments;

In the PlayerOverlay private section, replace:

bool introPromptShown = false;

with:

bool introPromptShown  = false;   /* fallback: intro-skipper plugin path   */
int  activeSegmentIdx  = -1;      /* index into ctx.segments, or -1        */

Add updateSegmentPrompt() alongside updateIntroPrompt():

void updateSegmentPrompt(float timePos);

4. PlayerOverlay.cpp — Segment prompt + skip action

Add helper before updateIntroPrompt():

static const char* segSkipPrompt(const std::string& t) {
    if (t == "Intro")      return "Press A to skip intro";
    if (t == "Outro")      return "Press A to skip credits";
    if (t == "Recap")      return "Press A to skip recap";
    if (t == "Preview")    return "Press A to skip preview";
    if (t == "Commercial") return "Press A to skip ad";
    return "Press A to skip";
}
static const char* segSkippedMsg(const std::string& t) {
    if (t == "Intro")      return "Skipped intro";
    if (t == "Outro")      return "Skipped credits";
    if (t == "Recap")      return "Skipped recap";
    if (t == "Preview")    return "Skipped preview";
    if (t == "Commercial") return "Skipped ad";
    return "Skipped";
}

Add updateSegmentPrompt() after updateIntroPrompt():

void PlayerOverlay::updateSegmentPrompt(float timePos)
{
    if (ctx.segments.empty()) return;

    int newIdx = -1;
    for (int i = 0; i < (int)ctx.segments.size(); ++i) {
        const MediaSegment& s = ctx.segments[i];
        if (timePos >= s.startSecs && timePos < s.endSecs) {
            newIdx = i;
            break;
        }
    }

    if (newIdx != activeSegmentIdx) {
        activeSegmentIdx = newIdx;
        if (newIdx >= 0)
            showOSD(segSkipPrompt(ctx.segments[newIdx].type), 6000);
    }
}

In tick(), call updateSegmentPrompt in addition to updateIntroPrompt:

updateIntroPrompt(timePos);
updateSegmentPrompt(timePos);

Update the A-press skip handler (currently around line 518): Replace the existing block:

if (introPromptShown && ctx.intro.hasIntro && !g_mplayer_paused) {
    wii_player_seek_abs(ctx.intro.introEnd + 0.5f);
    introPromptShown = false;
    showOSD("Skipped intro", 2000);
}

with:

if (!g_mplayer_paused) {
    if (activeSegmentIdx >= 0 && activeSegmentIdx < (int)ctx.segments.size()) {
        /* Native segment skip — preferred */
        const MediaSegment& seg = ctx.segments[activeSegmentIdx];
        wii_player_seek_abs(seg.endSecs + 0.5f);
        showOSD(segSkippedMsg(seg.type), 2000);
        activeSegmentIdx = -1;
    } else if (introPromptShown && ctx.intro.hasIntro) {
        /* Fallback: intro-skipper plugin */
        wii_player_seek_abs(ctx.intro.introEnd + 0.5f);
        introPromptShown = false;
        showOSD("Skipped intro", 2000);
    } else if (!videoMode) {
        wii_player_pause_toggle();
    }
}

5. App.cpp — Wire up both API calls (around line 226)

Replace:

/* Try to fetch intro timestamps (for TV episodes) */
if (!episodes.empty()) {
    SYS_Report("[runPlay] getIntroTimestamps start\n");
    client.getIntroTimestamps(serverUrl, auth, itemId, ctx.intro);
    SYS_Report("[runPlay] getIntroTimestamps done\n");
}

with:

/* Try native Media Segments API first (Jellyfin 10.9+, works for all items) */
SYS_Report("[runPlay] getMediaSegments start\n");
client.getMediaSegments(serverUrl, auth, itemId, ctx.segments);
SYS_Report("[runPlay] getMediaSegments done (%d segments)\n", (int)ctx.segments.size());

/* Fall back to Intro Skipper plugin for episodes on older servers */
if (ctx.segments.empty() && !episodes.empty()) {
    SYS_Report("[runPlay] getIntroTimestamps start\n");
    client.getIntroTimestamps(serverUrl, auth, itemId, ctx.intro);
    SYS_Report("[runPlay] getIntroTimestamps done\n");
}

Verification

  1. Build: make in the repo root — zero new warnings expected.
  2. Jellyfin 10.9+ server: Play an episode that has media segments set; verify "Press A to skip intro" (or "recap"/"credits") appears at the right time and that pressing A seeks past the segment.
  3. Multiple segments: Play content with both Intro and Outro segments; verify each prompts independently when their time range is active.
  4. Fallback path: On a server without Media Segments or segments populated, the ctx.segments vector will be empty and getIntroTimestamps() will run as before; existing intro-skip behaviour is preserved.
  5. Movie support: Media Segments also works on movies (not just episodes); verify a movie with a segment shows the prompt.
  6. No segments: Play content with no segments — no prompt should appear; normal A-button behaviour (pause/play) unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment