Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ media files, and every other site [yt-dlp](https://github.com/yt-dlp/yt-dlp) sup
iPhone and Mac, or any JSON-RPC call from scripts and Home Assistant.
- 💬 **Subtitles, title and artwork** from the website appear in Kodi's player.
- 💾 **Optional download** before playback, for unstable connections or to keep a copy.
- ⏭️ **SponsorBlock** (optional): skips sponsor segments, self promotion and other parts of YouTube videos you
choose, using the SponsorBlock community database.
- 🔐 **Logins and options**: a yt-dlp config file for cookies, proxies and extractor arguments, or per-request
options over JSON-RPC.
- 🆓 **Free and open source** (MIT), no tracking, nothing in between you and the website.
Expand Down
2 changes: 2 additions & 0 deletions addon.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<!-- Keep the Python interpreter between calls, so yt-dlp is imported only once. -->
<reuselanguageinvoker>true</reuselanguageinvoker>
</extension>
<!-- Skips SponsorBlock segments of YouTube videos while they play. -->
<extension point="xbmc.service" library="sponsorblock_service.py"/>
<extension point="xbmc.addon.metadata">
<summary lang="en">SendToKodi</summary>
<description lang="en">SendToKodi</description>
Expand Down
19 changes: 18 additions & 1 deletion core/runtime/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import xbmcgui
import xbmcvfs

from core import dash_builder
from core import dash_builder, sponsorblock
from core.addon_params import build_flat_playlist_item_url, resolve_playlist_item_title
from core.playback_selection import (
append_headers_to_url,
Expand Down Expand Up @@ -292,9 +292,26 @@ def resolve_fresh_result():
url = append_headers_to_url(url, effective_headers)
list_item.setPath(url)

_publish_sponsorblock_video(result, list_item, log)
return list_item


def _publish_sponsorblock_video(result, list_item, log):
"""Tell sponsorblock_service.py which YouTube video the item plays."""
video_id = sponsorblock.resolve_youtube_video_id(result)
if video_id is None:
return
list_item.setProperty(sponsorblock.VIDEO_ID_PROPERTY, video_id)
# Not every Kodi version hands ListItem properties to the playing item, so the path is published too.
try:
xbmcgui.Window(10000).setProperty(
sponsorblock.PLAYBACK_PROPERTY,
sponsorblock.encode_playback(list_item.getPath(), video_id),
)
except Exception as exc:
log("Could not publish the video for SponsorBlock: {}".format(exc), xbmc.LOGWARNING)


def _create_list_item_from_flat_playlist_item(video, plugin_url, paramstring):
list_item_url = build_flat_playlist_item_url(plugin_url, video["url"], paramstring)
title = resolve_playlist_item_title(video)
Expand Down
174 changes: 174 additions & 0 deletions core/sponsorblock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""SponsorBlock support: fetch the skip segments of a YouTube video and decide when to skip.

Kodi plays the stream itself, so nothing can be cut out of it. Instead the plugin
remembers which YouTube video it resolved, and sponsorblock_service.py watches the
playback and seeks over the segments.
"""
import hashlib
import json
import urllib.error
import urllib.parse
import urllib.request


DEFAULT_API_URL = 'https://sponsor.ajay.app'
# Window property that hands the resolved video from the plugin to the service.
PLAYBACK_PROPERTY = 'plugin.video.sendtokodi.sponsorblock'
# ListItem property with the YouTube video id of the resolved item.
VIDEO_ID_PROPERTY = 'sendtokodi.youtube_id'
SPONSORBLOCK_EXTRACTOR_KEY = 'Youtube'
REQUEST_TIMEOUT_SECONDS = 5
# Segments closer than this are skipped in one seek.
MERGE_GAP_SECONDS = 1.0
# Do not seek for the last moment of a segment, it would only cause a stutter.
MIN_REMAINING_SECONDS = 0.5

# https://wiki.sponsor.ajay.app/w/Types, with whether each is skipped by default.
CATEGORIES = (
('sponsor', True),
('selfpromo', True),
('interaction', True),
('intro', False),
('outro', False),
('preview', False),
('hook', False),
('filler', False),
('music_offtopic', False),
)


def category_setting_id(category):
return 'sponsorblock_category_' + category


def resolve_sponsorblock_settings(get_setting):
"""Read the SponsorBlock settings; get_setting takes a setting id and returns its string value."""
categories = [
category
for category, default in CATEGORIES
if _is_enabled(get_setting(category_setting_id(category)), default)
]
api_url = (get_setting('sponsorblock_api_url') or '').strip().rstrip('/') or DEFAULT_API_URL
if '://' not in api_url:
api_url = 'https://' + api_url
return {
'enabled': get_setting('sponsorblock_enabled') == 'true' and bool(categories),
'categories': categories,
'notify': _is_enabled(get_setting('sponsorblock_notify'), True),
'api_url': api_url,
}


def _is_enabled(value, default):
if value in ('true', 'false'):
return value == 'true'
return default


def resolve_youtube_video_id(result):
"""Return the YouTube video id of a yt-dlp result, or None for other sites."""
if result.get('extractor_key') != SPONSORBLOCK_EXTRACTOR_KEY:
return None
video_id = result.get('id')
return str(video_id) if video_id else None


def normalize_playing_path(path):
# Kodi keeps the "|header=value" suffix of a url as part of the path.
return (path or '').split('|', 1)[0]


def encode_playback(path, video_id):
return json.dumps({'path': normalize_playing_path(path), 'video_id': video_id})


def resolve_playing_video_id(item_video_id, playing_path, published_playback):
"""Return the video id of the current playback, or None when SendToKodi did not resolve it.

item_video_id is the ListItem property of the playing item; published_playback the
window property the plugin wrote, which only counts when its path is the playing one.
"""
if item_video_id:
return item_video_id
try:
published = json.loads(published_playback or '')
except ValueError:
return None
if not isinstance(published, dict) or not published.get('video_id'):
return None
if published.get('path') != normalize_playing_path(playing_path):
return None
return published['video_id']


def build_segments_url(video_id, categories, api_url=DEFAULT_API_URL):
# Only the first characters of the hash are sent, so the server does not learn which video plays.
video_hash = hashlib.sha256(video_id.encode('utf-8')).hexdigest()
query = urllib.parse.urlencode({
'categories': json.dumps(list(categories)),
'actionTypes': json.dumps(['skip']),
'service': 'YouTube',
})
return '{}/api/skipSegments/{}?{}'.format(api_url, video_hash[:4], query)


def parse_segments(payload, video_id, categories):
"""Return the sorted, merged (start, end) skip segments of video_id from an API response."""
segments = []
for video in payload if isinstance(payload, list) else []:
if not isinstance(video, dict) or video.get('videoID') != video_id:
continue
for segment in video.get('segments') or []:
if segment.get('category') not in categories or segment.get('actionType', 'skip') != 'skip':
continue
try:
start, end = (float(value) for value in segment['segment'])
except (KeyError, TypeError, ValueError):
continue
if end > start:
segments.append((start, end))
return merge_segments(segments)


def merge_segments(segments):
merged = []
for start, end in sorted(segments):
if merged and start <= merged[-1][1] + MERGE_GAP_SECONDS:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged


def fetch_skip_segments(video_id, categories, api_url=DEFAULT_API_URL, urlopen=urllib.request.urlopen):
request = urllib.request.Request(
build_segments_url(video_id, categories, api_url),
headers={'User-Agent': 'plugin.video.sendtokodi'},
)
try:
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as exc:
# 404 means that no video with this hash prefix has segments.
if exc.code == 404:
return []
raise
return parse_segments(payload, video_id, categories)


class SegmentSkipper:
"""Decide where to seek; every segment is skipped once, so seeking back into one plays it."""

def __init__(self, segments):
self._segments = list(segments)
self._skipped = set()

def seek_target(self, current_time):
for index, (start, end) in enumerate(self._segments):
if index in self._skipped:
continue
if start <= current_time < end - MIN_REMAINING_SECONDS:
self._skipped.add(index)
return end
return None
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ it from playlists, other add-ons or any JSON-RPC client, and how it works inside
|---|---|
| [Installation](installation.md) | Requirements, the SendToKodi repository for automatic updates, manual installation, first start, updating, uninstalling |
| [Usage](usage.md) | Sending links from the browser extension, from Android (Kore), from iPhone and Mac (Shortcut), playlists, the queue, choosing a stream, subtitles, downloads |
| [Settings](settings.md) | Every setting explained: General, JavaScript runtime, yt-dlp, Adaptive |
| [Settings](settings.md) | Every setting explained: General, JavaScript runtime, yt-dlp, Adaptive, SponsorBlock |
| [Troubleshooting](troubleshooting.md) | What to do when a video doesn't play, where the log is, YouTube and bot checks, cookies, ARMv7 devices |
| [FAQ](faq.md) | Short answers to the most common questions |

Expand All @@ -33,7 +33,7 @@ it from playlists, other add-ons or any JSON-RPC client, and how it works inside

| | |
|---|---|
| [How it works](how-it-works.md) | Architecture, the resolve-and-play flow, managed yt-dlp and Deno runtimes, the DASH manifest builder, stream selection, files on disk |
| [How it works](how-it-works.md) | Architecture, the resolve-and-play flow, managed yt-dlp and Deno runtimes, the DASH manifest builder, stream selection, SponsorBlock, files on disk |
| [Development](development.md) | Local setup, unit tests and coverage, project structure, running the add-on from a checkout, releases, writing documentation |

## The companion apps
Expand Down
6 changes: 4 additions & 2 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ can go to sleep, playback continues, and Kodi's own player handles it, with its

## Does it play YouTube without ads?

Kodi plays the streams yt-dlp picks, which contain no ad breaks. SponsorBlock and similar are not integrated.
Kodi plays the streams yt-dlp picks, which contain no ad breaks. Sponsor segments the creators put into the video
itself can be skipped with [SponsorBlock](settings.md#sponsorblock), which is off by default.

## Is it free? Is there tracking?

SendToKodi is free and open source under the MIT license. The add-on talks to the website you send, to GitHub for
yt-dlp and Deno updates, and to nothing else. The browser extension has its own
yt-dlp and Deno updates, only if you turn it on to SponsorBlock (which gets the first four characters of a hash of the
YouTube video ID), and to nothing else. The browser extension has its own
[privacy policy](https://github.com/firsttris/chrome.sendtokodi/blob/master/PRIVACY.md): no tracking, no server in
between.

Expand Down
11 changes: 10 additions & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Architecture of the SendToKodi Kodi add-on. How a link becomes a stream with yt-dlp, the managed yt-dlp and Deno runtimes and their updates, the DASH manifest builder for InputStream Adaptive, stream selection, headers, subtitles and the files in addon_data.
description: Architecture of the SendToKodi Kodi add-on. How a link becomes a stream with yt-dlp, the managed yt-dlp and Deno runtimes and their updates, the DASH manifest builder for InputStream Adaptive, stream selection, headers, subtitles, SponsorBlock and the files in addon_data.
---

# How it works
Expand Down Expand Up @@ -158,6 +158,15 @@ Subtitles yt-dlp lists are downloaded to `addon_data/plugin.video.sendtokodi/sub
20-second timeout per file, and attached to the list item, so they show up in Kodi's subtitle dialog. Title, description
and thumbnail come from the yt-dlp result and fill Kodi's player info.

## SponsorBlock

A plugin call ends as soon as Kodi has the stream, so it cannot watch the playback. For a YouTube video the plugin
therefore stores the video ID as a ListItem property and, for Kodi versions that do not pass it on to the playing item,
together with the stream path in a property of Kodi's home window. `sponsorblock_service.py`, a service extension of the
add-on, picks it up when playback starts, asks the [SponsorBlock API](https://wiki.sponsor.ajay.app/w/API_Docs) for the
segments (by hash prefix, so the server does not learn the video), and seeks over each segment once while the video
plays. A failing API only costs the skipping, never the playback.

## Files on disk

Everything the add-on writes lives in Kodi's profile folder, so it survives add-on updates and goes away with
Expand Down
17 changes: 16 additions & 1 deletion docs/settings.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Reference for every SendToKodi setting in Kodi: auto-download, JavaScript runtime (Deno, QuickJS), yt-dlp release channel and updates, yt-dlp config file, adaptive streaming, DASH builder, stream selection and maximum resolution.
description: Reference for every SendToKodi setting in Kodi: auto-download, JavaScript runtime (Deno, QuickJS), yt-dlp release channel and updates, yt-dlp config file, adaptive streaming, DASH builder, stream selection, maximum resolution and SponsorBlock.
---

# Settings
Expand Down Expand Up @@ -88,3 +88,18 @@ Settings for adaptive streaming through [InputStream Adaptive](https://kodi.wiki
| **Ask which stream to play** | off | Shows a list of all formats yt-dlp found before every playback and plays the one you choose. See [Stream selection](usage.md#stream-selection). |
| **Audio-only HLS: disable Opus for native m3u streams** | on | Skips Opus audio in audio-only HLS streams that Kodi plays natively (without ISA), which Kodi's HLS demuxer cannot handle. Turn off only if an audio stream has no other codec. |
| **Maximum resolution** | 1920x1080 (FHD) | Caps the width of the video stream the add-on selects or puts into its DASH manifest: 8K, 4K, 2K, Full HD, HD or SD. *Adaptive (auto quality)* removes the cap and leaves the choice to ISA, which adapts to the bandwidth. |

## SponsorBlock

Skips sponsor segments and similar parts of YouTube videos, using the community database of
[SponsorBlock](https://sponsor.ajay.app). Kodi plays the stream unchanged; a background service of the add-on watches the
playback and seeks over each segment once, so seeking back into a skipped segment plays it. Other sites are not
affected, the database only knows YouTube.

| Setting | Default | What it does |
|---|---|---|
| **Skip SponsorBlock segments in YouTube videos** | off | Turns SponsorBlock on. The segments are fetched when playback starts, so starting a video takes no longer. If the server cannot be reached, the video plays without skipping. |
| **Skip sponsors**, **unpaid/self promotion**, **interaction reminders** | on | The [segment categories](https://wiki.sponsor.ajay.app/w/Types) to skip. |
| **Skip intermissions/intro animations**, **endcards/credits**, **previews/recaps**, **hooks/greetings**, **filler tangents**, **non-music sections** | off | Further categories to skip. |
| **Show a notification when a segment is skipped** | on | Shows a short notification on every skip. |
| **SponsorBlock server** (Expert) | `https://sponsor.ajay.app` | The SponsorBlock API to ask, for a mirror or a self-hosted instance. |
56 changes: 56 additions & 0 deletions resources/language/resource.language.en_gb/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,59 @@ msgstr ""
msgctxt "#32072"
msgid "System (already importable, not downloaded)"
msgstr ""

msgctxt "#34000"
msgid "SponsorBlock"
msgstr ""

msgctxt "#34001"
msgid "Skip SponsorBlock segments in YouTube videos"
msgstr ""

msgctxt "#34002"
msgid "Show a notification when a segment is skipped"
msgstr ""

msgctxt "#34003"
msgid "Skip sponsors"
msgstr ""

msgctxt "#34004"
msgid "Skip unpaid/self promotion"
msgstr ""

msgctxt "#34005"
msgid "Skip interaction reminders (subscribe)"
msgstr ""

msgctxt "#34006"
msgid "Skip intermissions/intro animations"
msgstr ""

msgctxt "#34007"
msgid "Skip endcards/credits"
msgstr ""

msgctxt "#34008"
msgid "Skip previews/recaps"
msgstr ""

msgctxt "#34009"
msgid "Skip hooks/greetings"
msgstr ""

msgctxt "#34010"
msgid "Skip filler tangents"
msgstr ""

msgctxt "#34011"
msgid "Skip non-music sections in music videos"
msgstr ""

msgctxt "#34012"
msgid "SponsorBlock server"
msgstr ""

msgctxt "#34020"
msgid "Skipped SponsorBlock segment"
msgstr ""
Loading
Loading