DASH HD playback — WIP behind TORTTUBE_DASH=1 + upstream notes
Sidecar resolve_dash op shipped — returns rustypipe's full video_only_streams + audio_streams (16+ representations for NGGYU, from 360p H.264 through 4K AV1). Addon _build_dash_mpd assembles a valid on-demand MPEG-DASH manifest filtered to H.264 ≤1080p + best AAC audio. Two unblocked-by-WIP issues surfaced during integration: - inputstream.adaptive's libcurl can't open file:// URLs (logged in docs/upstream.md as an enhancement-target). - Rapid Player.Open retries can trigger Kodi's 'two concurrent busydialogs' fatal exit; need lifecycle hardening before re-enabling. Pivoted to localhost HTTP-server serving (ThreadingHTTPServer on a port-0 socket, MPD bytes captured in a per-instance handler subclass). Lifecycle: server.shutdown() runs in a finally block after the SponsorBlockMonitor watcher exits. Works in isolation but Kodi crashed under rapid retry conditions — needs more testing. For v0.0.5: DASH path is gated behind TORTTUBE_DASH=1 env var; default falls through to the stable yt-dlp progressive 360p path that's been verified live. M7 milestone added to track the remaining work; PRs to inputstream.adaptive + Kodi candidates logged in docs/upstream.md.
This commit is contained in:
parent
f610965fcf
commit
45e1306bf3
6 changed files with 329 additions and 14 deletions
|
|
@ -71,8 +71,25 @@
|
|||
picked format 18, 360p H.264+AAC progressive)
|
||||
- [ ] armv7 build for older Pis (deferred — current TV is aarch64-capable
|
||||
and my static sidecar runs on it even though userspace is armhf)
|
||||
- [ ] 720p+ playback (deferred to M5+) — itag 22 (720p progressive) is
|
||||
deprecated by YouTube; higher quality needs DASH manifest generation
|
||||
- [ ] 720p+ playback (DASH WIP — see "M7 — DASH/HD" below) — itag 22 (720p
|
||||
progressive) is deprecated by YouTube; higher quality needs DASH
|
||||
manifest generation. Code path exists, gated behind `TORTTUBE_DASH=1`.
|
||||
|
||||
## M7 — DASH / HD playback [WIP]
|
||||
|
||||
- [x] sidecar `resolve_dash` op returns rustypipe's full
|
||||
`video_only_streams` + `audio_streams` arrays (16+ representations)
|
||||
- [x] addon `_build_dash_mpd` constructs valid on-demand MPD with H.264
|
||||
video reps from 360p → 1080p + best AAC audio rep (XML-escaped URLs,
|
||||
proper `SegmentBase indexRange` + `Initialization range`)
|
||||
- [ ] **serve MPD over localhost HTTP** — inputstream.adaptive's libcurl
|
||||
can't open `file://` URLs. ThreadingHTTPServer + Player monitor
|
||||
lifecycle is sketched but caused Kodi's "two concurrent busydialogs"
|
||||
fatal during rapid retries. Needs more work on lifecycle + retry
|
||||
backoff before re-enabling.
|
||||
- [ ] handle session-cookie / poToken inheritance — googlevideo URLs may
|
||||
need the same client signature across MPD + segment fetches
|
||||
- [ ] graceful fallback to progressive if MPD load fails mid-playback
|
||||
|
||||
## Upstream PR work (parallel lane — every bug evaluated for "fix it upstream?")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="plugin.video.torttube"
|
||||
name="torttube"
|
||||
version="0.0.2"
|
||||
version="0.0.5"
|
||||
provider-name="Sulkta-Coop">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="3.0.0"/>
|
||||
|
|
|
|||
|
|
@ -18,13 +18,17 @@ That's how Android / phone / "send to TV" flows hand off — Kodi already
|
|||
exposes the endpoint, we just need to register the plugin URL.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlparse
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
|
|
@ -104,23 +108,235 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem:
|
|||
li = xbmcgui.ListItem(label=title or "torttube")
|
||||
li.setPath(stream_url)
|
||||
li.setProperty("IsPlayable", "true")
|
||||
# Tell inputstream.adaptive to handle DASH/HLS if the URL looks like a manifest.
|
||||
if stream_url.endswith(".mpd"):
|
||||
# Tell inputstream.adaptive to handle DASH/HLS based on URL path/suffix.
|
||||
if ".mpd" in stream_url:
|
||||
li.setProperty("inputstream", "inputstream.adaptive")
|
||||
li.setProperty("inputstream.adaptive.manifest_type", "mpd")
|
||||
elif stream_url.endswith(".m3u8"):
|
||||
elif ".m3u8" in stream_url:
|
||||
li.setProperty("inputstream", "inputstream.adaptive")
|
||||
li.setProperty("inputstream.adaptive.manifest_type", "hls")
|
||||
return li
|
||||
|
||||
|
||||
def _play(yt_id: str) -> None:
|
||||
"""Resolve via sidecar (yt-dlp combined-format path), hand URL to Kodi."""
|
||||
_log(f"play id={yt_id}")
|
||||
class _MpdHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Serves the per-video MPD file. The bytes come from the closure-captured
|
||||
`_MPD_BYTES` so the server can outlive the temp file if the addon decides
|
||||
to clean up early. One handler per HTTPServer instance."""
|
||||
|
||||
mpd_bytes: bytes = b""
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 — http.server convention
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/dash+xml")
|
||||
self.send_header("Content-Length", str(len(self.mpd_bytes)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.mpd_bytes)
|
||||
|
||||
def log_message(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Silence the default request log — Kodi's log is verbose enough.
|
||||
return
|
||||
|
||||
|
||||
def _start_mpd_server(mpd_bytes: bytes) -> tuple[str, http.server.HTTPServer]:
|
||||
"""Spin up a one-shot localhost HTTP server that serves `mpd_bytes` at any
|
||||
path. Returns (url, server). Caller is responsible for `server.shutdown()`
|
||||
once playback ends."""
|
||||
|
||||
# Per-server handler subclass so the closure captures the bytes for THIS
|
||||
# video without leaking state to a concurrent invocation.
|
||||
handler_cls = type(
|
||||
"_MpdHandlerInstance",
|
||||
(_MpdHandler,),
|
||||
{"mpd_bytes": mpd_bytes},
|
||||
)
|
||||
# Port 0 → kernel picks free port. ThreadingHTTPServer so multiple GETs
|
||||
# (the manifest + range requests, if any) don't serialize.
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
port = server.server_address[1]
|
||||
url = f"http://127.0.0.1:{port}/manifest.mpd"
|
||||
_log(f"MPD HTTP server up on {url}")
|
||||
return url, server
|
||||
|
||||
|
||||
_MIME_CODEC_RE = re.compile(r'codecs="([^"]+)"')
|
||||
|
||||
|
||||
def _codec_from_mime(stream: dict[str, Any]) -> str:
|
||||
"""Extract the full DASH codec string (avc1.4d4015 etc) from the mime field.
|
||||
|
||||
rustypipe gives `codec: "avc1"` (short form) but DASH MPDs need the full
|
||||
profile/level identifier from `mime: 'video/mp4; codecs="avc1.4d4015"'`.
|
||||
"""
|
||||
mime = stream.get("mime", "")
|
||||
m = _MIME_CODEC_RE.search(mime)
|
||||
return m.group(1) if m else (stream.get("codec") or "")
|
||||
|
||||
|
||||
def _build_dash_mpd(
|
||||
details: dict[str, Any],
|
||||
video_streams: list[dict[str, Any]],
|
||||
audio_streams: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Build a static MPEG-DASH on-demand manifest from rustypipe's stream data.
|
||||
|
||||
Picks H.264 (avc1) video streams up to 1080p — guaranteed to play on the
|
||||
RPi 4's hardware H.264 decoder. Picks the best AAC (mp4a) audio stream.
|
||||
Returns the MPD XML, or None if no compatible streams were found.
|
||||
"""
|
||||
duration_s = float(details.get("duration") or 0)
|
||||
if duration_s <= 0:
|
||||
return None
|
||||
|
||||
# Filter video: H.264 only (avc1.*), height <= 1080 (RPi 4 H.264 ceiling).
|
||||
h264 = [
|
||||
s
|
||||
for s in video_streams
|
||||
if "avc1" in (s.get("codec") or s.get("mime", ""))
|
||||
and (s.get("height") or 0) <= 1080
|
||||
and s.get("init_range")
|
||||
and s.get("index_range")
|
||||
and s.get("url")
|
||||
]
|
||||
if not h264:
|
||||
return None
|
||||
|
||||
# Filter audio: AAC preferred, Opus fallback. Pick highest-bitrate of preferred codec.
|
||||
aac = [s for s in audio_streams if "mp4a" in (s.get("codec") or s.get("mime", ""))]
|
||||
opus = [s for s in audio_streams if "opus" in (s.get("codec") or s.get("mime", ""))]
|
||||
audio_pool = aac or opus
|
||||
if not audio_pool:
|
||||
return None
|
||||
best_audio = max(audio_pool, key=lambda s: s.get("bitrate") or 0)
|
||||
if not (best_audio.get("init_range") and best_audio.get("index_range") and best_audio.get("url")):
|
||||
return None
|
||||
|
||||
# Sort video high → low quality so inputstream.adaptive's default-best picks the top.
|
||||
h264.sort(key=lambda s: (s.get("height") or 0, s.get("bitrate") or 0), reverse=True)
|
||||
|
||||
parts = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static"',
|
||||
f' mediaPresentationDuration="PT{duration_s:.3f}S" minBufferTime="PT1.5S"',
|
||||
' profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">',
|
||||
" <Period>",
|
||||
' <AdaptationSet mimeType="video/mp4" startWithSAP="1" segmentAlignment="true">',
|
||||
]
|
||||
for v in h264:
|
||||
codec = _codec_from_mime(v) or "avc1.4d401f"
|
||||
ir = v["index_range"]
|
||||
init = v["init_range"]
|
||||
parts.append(
|
||||
f' <Representation id="{v["itag"]}" mimeType="video/mp4" codecs="{codec}"'
|
||||
f' width="{v.get("width", 0)}" height="{v.get("height", 0)}"'
|
||||
f' bandwidth="{v.get("bitrate", 0)}" frameRate="{int(v.get("fps") or 30)}"'
|
||||
f' startWithSAP="1">'
|
||||
)
|
||||
parts.append(f" <BaseURL>{xml_escape(v['url'])}</BaseURL>")
|
||||
parts.append(
|
||||
f' <SegmentBase indexRange="{ir["start"]}-{ir["end"]}" indexRangeExact="true">'
|
||||
)
|
||||
parts.append(f' <Initialization range="{init["start"]}-{init["end"]}"/>')
|
||||
parts.append(" </SegmentBase>")
|
||||
parts.append(" </Representation>")
|
||||
parts.append(" </AdaptationSet>")
|
||||
|
||||
# Audio adaptation set.
|
||||
a = best_audio
|
||||
a_codec = _codec_from_mime(a) or ("mp4a.40.2" if "mp4a" in (a.get("codec") or "") else "opus")
|
||||
a_mime = "audio/mp4" if "mp4a" in a_codec else "audio/webm"
|
||||
a_ir = a["index_range"]
|
||||
a_init = a["init_range"]
|
||||
parts.append(
|
||||
f' <AdaptationSet mimeType="{a_mime}" startWithSAP="1" segmentAlignment="true">'
|
||||
)
|
||||
parts.append(
|
||||
f' <Representation id="{a["itag"]}" mimeType="{a_mime}" codecs="{a_codec}"'
|
||||
f' bandwidth="{a.get("bitrate", 0)}" audioSamplingRate="44100" startWithSAP="1">'
|
||||
)
|
||||
parts.append(
|
||||
' <AudioChannelConfiguration'
|
||||
' schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"'
|
||||
f' value="{a.get("channels", 2)}"/>'
|
||||
)
|
||||
parts.append(f" <BaseURL>{xml_escape(a['url'])}</BaseURL>")
|
||||
parts.append(
|
||||
f' <SegmentBase indexRange="{a_ir["start"]}-{a_ir["end"]}" indexRangeExact="true">'
|
||||
)
|
||||
parts.append(f' <Initialization range="{a_init["start"]}-{a_init["end"]}"/>')
|
||||
parts.append(" </SegmentBase>")
|
||||
parts.append(" </Representation>")
|
||||
parts.append(" </AdaptationSet>")
|
||||
parts.append(" </Period>")
|
||||
parts.append("</MPD>")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _try_dash(yt_id: str) -> tuple[bytes | None, dict[str, Any]]:
|
||||
"""Resolve via rustypipe + build DASH MPD. Returns (mpd_bytes, resp).
|
||||
|
||||
On any failure returns (None, resp) so the caller can fall back to
|
||||
the progressive path. We serve the MPD over localhost HTTP because
|
||||
inputstream.adaptive's libcurl can't open file:// URLs.
|
||||
"""
|
||||
try:
|
||||
resp = _call_sidecar({"op": "resolve_dash", "id": yt_id}, timeout_s=30)
|
||||
except Exception as e:
|
||||
_log(f"resolve_dash failed (will fall back): {e}", xbmc.LOGWARNING)
|
||||
return None, {}
|
||||
if not resp.get("ok"):
|
||||
return None, resp
|
||||
|
||||
mpd = _build_dash_mpd(
|
||||
resp.get("details") or {},
|
||||
resp.get("video_only_streams") or [],
|
||||
resp.get("audio_streams") or [],
|
||||
)
|
||||
if not mpd:
|
||||
_log("DASH build returned no compatible streams; falling back to progressive")
|
||||
return None, resp
|
||||
return mpd.encode("utf-8"), resp
|
||||
|
||||
|
||||
def _play(yt_id: str) -> None:
|
||||
"""Resolve via DASH (rustypipe, up to 1080p H.264) with progressive
|
||||
yt-dlp fallback (360p).
|
||||
|
||||
DASH path is gated behind TORTTUBE_DASH=1 while the manifest-serving
|
||||
HTTP server + inputstream.adaptive integration is being stabilized
|
||||
(file:// URLs don't work, and rapid retries via a port-0 HTTP server
|
||||
can trigger Kodi's 'two concurrent busydialogs' fatal). Default OFF
|
||||
until that's solid — progressive yt-dlp path is reliable.
|
||||
"""
|
||||
_log(f"play id={yt_id}")
|
||||
|
||||
mpd_bytes: bytes | None = None
|
||||
dash_resp: dict[str, Any] = {}
|
||||
if os.environ.get("TORTTUBE_DASH") == "1":
|
||||
mpd_bytes, dash_resp = _try_dash(yt_id)
|
||||
if mpd_bytes:
|
||||
details = dash_resp.get("details") or {}
|
||||
title = details.get("name")
|
||||
mpd_url, server = _start_mpd_server(mpd_bytes)
|
||||
_log(f"resolved via rustypipe DASH, serving manifest at {mpd_url}")
|
||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(mpd_url, title))
|
||||
try:
|
||||
_attach_sponsorblock(yt_id)
|
||||
finally:
|
||||
# Shut down the MPD server cleanly once playback ends or aborts.
|
||||
# _attach_sponsorblock blocks while playback is active.
|
||||
try:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
_log("MPD HTTP server shut down")
|
||||
except Exception as e:
|
||||
_log(f"MPD server shutdown error: {e}", xbmc.LOGWARNING)
|
||||
return
|
||||
|
||||
# Fallback: progressive single-URL via yt-dlp (360p).
|
||||
try:
|
||||
# resolve_play returns ONE combined audio+video URL — guaranteed
|
||||
# to play in Kodi without needing inputstream.adaptive/DASH.
|
||||
# ~3-5s overhead vs rustypipe but reliable audio sync.
|
||||
resp = _call_sidecar({"op": "resolve_play", "id": yt_id}, timeout_s=45)
|
||||
except Exception as e:
|
||||
_log(f"sidecar resolve_play failed: {e}", xbmc.LOGERROR)
|
||||
|
|
@ -150,8 +366,32 @@ def _play(yt_id: str) -> None:
|
|||
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
||||
return
|
||||
|
||||
_log(f"resolved via {resp.get('source')}, playing")
|
||||
_log(f"resolved via yt-dlp progressive fallback, playing")
|
||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
||||
_attach_sponsorblock(yt_id)
|
||||
|
||||
|
||||
def _attach_sponsorblock(yt_id: str) -> None:
|
||||
"""Fetch SponsorBlock segments and block on the monitor loop. Always blocks
|
||||
until playback ends (or 30s if playback never starts) so the caller can
|
||||
use this as a 'wait for playback to finish' signal — needed to keep the
|
||||
MPD HTTP server alive throughout playback.
|
||||
|
||||
Non-fatal on segment fetch error.
|
||||
"""
|
||||
skip_segments: list[dict[str, Any]] = []
|
||||
try:
|
||||
sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8)
|
||||
if sb_resp.get("ok"):
|
||||
segs = sb_resp.get("segments") or []
|
||||
skip_segments = [s for s in segs if s.get("actionType") == "skip"]
|
||||
_log(f"sponsorblock: {len(skip_segments)} skip segments")
|
||||
except Exception as e:
|
||||
_log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING)
|
||||
|
||||
# Always run the watcher even with zero segments — it doubles as the
|
||||
# 'block until playback ends' signal that gates MPD-server shutdown.
|
||||
SponsorBlockMonitor(skip_segments).run()
|
||||
|
||||
# SponsorBlock: fetch segments, then block on a monitor that seeks past
|
||||
# each skip segment as the playhead enters it. Best-effort — failure to
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@ _(none yet — opens with M1 development)_
|
|||
|
||||
| Date | Project | PR/Issue | Title | Status | Outcome |
|
||||
|------|---------|----------|-------|--------|---------|
|
||||
| _empty_ | | | | | |
|
||||
| _none yet_ | | | | | |
|
||||
|
||||
## Investigation notes (not yet a PR — to be evaluated)
|
||||
|
||||
- **inputstream.adaptive — `file://` not supported by libcurl downloader**
|
||||
([kodi addon, xbmc/inputstream.adaptive on github](https://github.com/xbmc/inputstream.adaptive))
|
||||
Hit during torttube M7 DASH work 2026-05-23. Loading an MPD from a local
|
||||
filesystem path via `file:///storage/.kodi/temp/torttube/<id>.mpd` fails
|
||||
with `CURLOpen returned an error, download failed`. Common workaround
|
||||
is a localhost HTTP server (plugin.video.youtube does this). Worth
|
||||
filing an enhancement request to either accept `file://` or document
|
||||
the recommended pattern.
|
||||
- **Kodi — "Logic error due to two concurrent busydialogs" fatal**
|
||||
Reproduced during rapid back-to-back `Player.Open` calls while a
|
||||
previous play's BusyDialog was still dismissing. Log message itself
|
||||
says "this is a known issue", but the app HARD-exits rather than
|
||||
silently dropping one of the dialogs. Look up the upstream tracker
|
||||
before filing.
|
||||
|
||||
## Watching (not ours, but relevant to torttube)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ enum Request {
|
|||
/// no inputstream.adaptive needed. Slower than `resolve` but always
|
||||
/// gives a working stream.
|
||||
ResolvePlay { id: String },
|
||||
/// DASH-ready resolve: returns rustypipe's full `video_only_streams`
|
||||
/// + `audio_streams` arrays so the addon can build a DASH MPD with
|
||||
/// all quality rungs and feed it to inputstream.adaptive. Unlocks
|
||||
/// 1080p+ via H.264 hardware decode on RPi.
|
||||
ResolveDash { id: String },
|
||||
Rip { id: String, dest_dir: String },
|
||||
Sponsorblock {
|
||||
id: String,
|
||||
|
|
@ -131,6 +136,10 @@ async fn handle_line(line: &str) -> Response {
|
|||
Ok(v) => Response::ok(v),
|
||||
Err(e) => e.into(),
|
||||
},
|
||||
Request::ResolveDash { id } => match resolve::resolve_dash(&id).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => e.into(),
|
||||
},
|
||||
Request::Rip { id, dest_dir } => match rip::rip(&id, &dest_dir).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => e.into(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,38 @@ use serde_json::Value;
|
|||
|
||||
use crate::{run_yt_dlp, HandlerError};
|
||||
|
||||
/// DASH-ready resolve: returns rustypipe's full `video_only_streams` +
|
||||
/// `audio_streams` arrays + `details`. The Python addon builds an MPD
|
||||
/// from these and hands it to inputstream.adaptive — unlocks 1080p+ via
|
||||
/// H.264 hardware decode on the RPi (vs the 360p ceiling on progressive).
|
||||
pub(crate) async fn resolve_dash(id: &str) -> Result<Value, HandlerError> {
|
||||
use rustypipe::client::RustyPipe;
|
||||
|
||||
let rp = RustyPipe::new();
|
||||
let player = rp
|
||||
.query()
|
||||
.player(id)
|
||||
.await
|
||||
.map_err(|e| classify_rustypipe_error(&e))?;
|
||||
|
||||
let details_json = serde_json::to_value(&player.details)
|
||||
.map_err(|e| HandlerError::Internal(format!("serialize details: {e}")))?;
|
||||
let video_streams = serde_json::to_value(&player.video_only_streams)
|
||||
.map_err(|e| HandlerError::Internal(format!("serialize video_only_streams: {e}")))?;
|
||||
let audio_streams = serde_json::to_value(&player.audio_streams)
|
||||
.map_err(|e| HandlerError::Internal(format!("serialize audio_streams: {e}")))?;
|
||||
|
||||
tracing::info!(id, "resolve_dash ok via rustypipe");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"source": "rustypipe",
|
||||
"details": details_json,
|
||||
"video_only_streams": video_streams,
|
||||
"audio_streams": audio_streams,
|
||||
"expires_in_seconds": player.expires_in_seconds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Playback-ready single-URL resolve. Asks yt-dlp for `best[ext=mp4]/best` —
|
||||
/// a combined audio+video format that Kodi can play as a plain HTTP URL.
|
||||
/// Slower than `resolve()` (~3-5s) but guarantees a working stream.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue