v1.0.3 — bundle pv.youtube + service-level SponsorBlock
Two big changes: - service.py is a new background-service component (xbmc.service extension). On first run it extracts the bundled pv.youtube 7.4.3 zip into Kodi's addons dir, forces a rescan, enables it. No more two-step install. The bundle stays as a separate Kodi addon at runtime; we just ship + auto-install it. - The service also subclasses xbmc.Player and runs SponsorBlock for ANY YouTube playback, not just plays initiated by torttube. The TorttubePlayerMonitor watches onAVStarted globally, pulls the YouTube ID from the playing-file URL (matches ?v=, ?file=XXX.mpd, ?video_id= — covers pv.youtube's resolved URLs in every shape), then runs a poll- skip loop on a background thread. Critical: this means SponsorBlock now works when a video is cast to Kodi from the phone YouTube app — the cast hits pv.youtube directly and our plugin code is never invoked, but the service is always alive and catches it. - Dropped the three _attach_sponsorblock call sites in main.py._play() and the SponsorBlockMonitor class. The DASH path still needs to block until playback ends so it can shut down its localhost MPD server; that's now a tiny _wait_for_playback_end() that doesn't do SB. - Removed the <import addon="plugin.video.youtube"> from addon.xml since we bundle it; kept inputstream.adaptive as an optional import.
This commit is contained in:
parent
954b528e3c
commit
45215d4503
4 changed files with 275 additions and 110 deletions
|
|
@ -1,16 +1,16 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.torttube"
|
<addon id="plugin.video.torttube"
|
||||||
name="torttube"
|
name="torttube"
|
||||||
version="1.0.2"
|
version="1.0.3"
|
||||||
provider-name="Sulkta">
|
provider-name="Sulkta">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
<import addon="plugin.video.youtube" version="7.0.0" optional="true"/>
|
|
||||||
<import addon="inputstream.adaptive" version="2.0.0" optional="true"/>
|
<import addon="inputstream.adaptive" version="2.0.0" optional="true"/>
|
||||||
</requires>
|
</requires>
|
||||||
<extension point="xbmc.python.pluginsource" library="main.py">
|
<extension point="xbmc.python.pluginsource" library="main.py">
|
||||||
<provides>video</provides>
|
<provides>video</provides>
|
||||||
</extension>
|
</extension>
|
||||||
|
<extension point="xbmc.service" library="service.py"/>
|
||||||
<extension point="xbmc.addon.metadata">
|
<extension point="xbmc.addon.metadata">
|
||||||
<summary lang="en_gb">YouTube via RustyPipe + SponsorBlock</summary>
|
<summary lang="en_gb">YouTube via RustyPipe + SponsorBlock</summary>
|
||||||
<description lang="en_gb">Browse, search, and play YouTube videos without an account. Backed by a native RustyPipe sidecar binary. SponsorBlock segments are skipped automatically.</description>
|
<description lang="en_gb">Browse, search, and play YouTube videos without an account. Backed by a native RustyPipe sidecar binary. SponsorBlock segments are skipped automatically.</description>
|
||||||
|
|
|
||||||
|
|
@ -515,12 +515,8 @@ def _play(yt_id: str) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if use_pv_youtube and _delegate_to_pv_youtube(yt_id):
|
if use_pv_youtube and _delegate_to_pv_youtube(yt_id):
|
||||||
try:
|
# SponsorBlock is owned by service.py and fires for every YouTube
|
||||||
_attach_sponsorblock(yt_id)
|
# play (including phone-cast plays that bypass our plugin entirely).
|
||||||
except Exception as e:
|
|
||||||
# Don't let a SB monitor bug pop a 'Plugin error' dialog on the TV
|
|
||||||
# after a successful delegate-to-pv.youtube hand-off.
|
|
||||||
_log(f"sponsorblock attach error (non-fatal): {e}", xbmc.LOGWARNING)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
mpd_bytes: bytes | None = None
|
mpd_bytes: bytes | None = None
|
||||||
|
|
@ -552,10 +548,8 @@ def _play(yt_id: str) -> None:
|
||||||
_log(f"resolved via rustypipe DASH, serving manifest at {mpd_url}")
|
_log(f"resolved via rustypipe DASH, serving manifest at {mpd_url}")
|
||||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(mpd_url, title))
|
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(mpd_url, title))
|
||||||
try:
|
try:
|
||||||
_attach_sponsorblock(yt_id)
|
_wait_for_playback_end()
|
||||||
finally:
|
finally:
|
||||||
# Shut down the MPD server cleanly once playback ends or aborts.
|
|
||||||
# _attach_sponsorblock blocks while playback is active.
|
|
||||||
try:
|
try:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
@ -597,107 +591,30 @@ def _play(yt_id: str) -> None:
|
||||||
|
|
||||||
_log(f"resolved via yt-dlp progressive fallback, playing")
|
_log(f"resolved via yt-dlp progressive fallback, playing")
|
||||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
||||||
try:
|
|
||||||
_attach_sponsorblock(yt_id)
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"sponsorblock attach error (non-fatal): {e}", xbmc.LOGWARNING)
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_sponsorblock(yt_id: str) -> None:
|
def _wait_for_playback_end() -> None:
|
||||||
"""Fetch SponsorBlock segments and block on the monitor loop. Always blocks
|
# Blocks until playback ends. Used by the DASH path to keep its
|
||||||
until playback ends (or 30s if playback never starts) so the caller can
|
# localhost MPD HTTP server alive for the duration of playback. The
|
||||||
use this as a 'wait for playback to finish' signal — needed to keep the
|
# service addon (service.py) owns SponsorBlock for all playback paths;
|
||||||
MPD HTTP server alive throughout playback.
|
# this function only handles HTTP-server lifecycle.
|
||||||
|
monitor = xbmc.Monitor()
|
||||||
Non-fatal on segment fetch error.
|
player = xbmc.Player()
|
||||||
"""
|
waited = 0.0
|
||||||
skip_segments: list[dict[str, Any]] = []
|
while waited < 30.0:
|
||||||
try:
|
if monitor.abortRequested():
|
||||||
sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8)
|
return
|
||||||
if sb_resp.get("ok"):
|
if player.isPlaying():
|
||||||
segs = sb_resp.get("segments") or []
|
break
|
||||||
skip_segments = [s for s in segs if s.get("actionType") == "skip"]
|
if monitor.waitForAbort(0.25):
|
||||||
_log(f"sponsorblock: {len(skip_segments)} skip segments")
|
return
|
||||||
except Exception as e:
|
waited += 0.25
|
||||||
_log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING)
|
else:
|
||||||
|
_log("dash: timed out waiting for playback to start")
|
||||||
# Always run the watcher even with zero segments — it doubles as the
|
return
|
||||||
# 'block until playback ends' signal that gates MPD-server shutdown.
|
while not monitor.abortRequested() and player.isPlaying():
|
||||||
SponsorBlockMonitor(skip_segments).run()
|
if monitor.waitForAbort(0.5):
|
||||||
|
|
||||||
|
|
||||||
class SponsorBlockMonitor(xbmc.Monitor):
|
|
||||||
"""Polls Kodi's player position and seeks past each SponsorBlock skip
|
|
||||||
segment exactly once. Exits on playback stop or Kodi shutdown.
|
|
||||||
|
|
||||||
Each segment has shape {"segment": [start_s, end_s], "category": str,
|
|
||||||
"UUID": str, "actionType": "skip"}. We sort by start time so we can
|
|
||||||
short-circuit the scan; we also dedupe via the UUID set so the same
|
|
||||||
segment doesn't trigger twice if the user manually rewinds into it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
POLL_S = 0.5
|
|
||||||
MAX_WAIT_FOR_PLAYBACK_S = 30.0
|
|
||||||
|
|
||||||
def __init__(self, segments: list[dict[str, Any]]):
|
|
||||||
super().__init__()
|
|
||||||
self.segments = sorted(
|
|
||||||
segments, key=lambda s: float(s.get("segment", [0, 0])[0])
|
|
||||||
)
|
|
||||||
self.skipped: set[str] = set()
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
player = xbmc.Player()
|
|
||||||
# Wait for playback to actually begin — setResolvedUrl is async,
|
|
||||||
# Kodi takes a beat to demux + start the streams.
|
|
||||||
waited = 0.0
|
|
||||||
while waited < self.MAX_WAIT_FOR_PLAYBACK_S:
|
|
||||||
if self.abortRequested():
|
|
||||||
return
|
|
||||||
if player.isPlaying():
|
|
||||||
break
|
|
||||||
if self.waitForAbort(0.25):
|
|
||||||
return
|
|
||||||
waited += 0.25
|
|
||||||
else:
|
|
||||||
_log("sponsorblock: timed out waiting for playback to start")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
while not self.abortRequested() and player.isPlaying():
|
|
||||||
try:
|
|
||||||
pos = float(player.getTime())
|
|
||||||
except Exception:
|
|
||||||
# getTime raises various types when the player goes away
|
|
||||||
# mid-poll (Kodi shutdown, plugin reload, etc). Wider catch so
|
|
||||||
# an exception path doesn't escape into _play's finally and
|
|
||||||
# leak the MPD HTTP server.
|
|
||||||
return
|
|
||||||
for seg in self.segments:
|
|
||||||
uuid = seg.get("UUID", "")
|
|
||||||
if uuid in self.skipped:
|
|
||||||
continue
|
|
||||||
start, end = float(seg["segment"][0]), float(seg["segment"][1])
|
|
||||||
if pos < start:
|
|
||||||
# Segments are sorted; nothing past here can match either.
|
|
||||||
break
|
|
||||||
if pos < end:
|
|
||||||
duration = end - start
|
|
||||||
category = seg.get("category", "?")
|
|
||||||
_log(
|
|
||||||
f"sponsorblock skip: {category} {start:.1f}-{end:.1f} "
|
|
||||||
f"({duration:.0f}s)"
|
|
||||||
)
|
|
||||||
player.seekTime(end)
|
|
||||||
self.skipped.add(uuid)
|
|
||||||
xbmcgui.Dialog().notification(
|
|
||||||
"SponsorBlock",
|
|
||||||
f"Skipped {category} ({duration:.0f}s)",
|
|
||||||
xbmcgui.NOTIFICATION_INFO,
|
|
||||||
2500,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
if self.waitForAbort(self.POLL_S):
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_url(**kwargs: Any) -> str:
|
def _plugin_url(**kwargs: Any) -> str:
|
||||||
|
|
|
||||||
248
addon/plugin.video.torttube/service.py
Normal file
248
addon/plugin.video.torttube/service.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# service.py - Kodi background service for torttube.
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# Two jobs:
|
||||||
|
# 1. First-run install of the bundled plugin.video.youtube zip, so users
|
||||||
|
# get one-addon UX. The bundled zip is the upstream release; we just
|
||||||
|
# extract it into Kodi's addons dir and force a rescan.
|
||||||
|
# 2. SponsorBlock for ANY YouTube playback, not just plays initiated by
|
||||||
|
# torttube. That includes phone-cast plays where Kodi receives a
|
||||||
|
# plugin://plugin.video.youtube/play/... URL directly and our plugin
|
||||||
|
# code never runs. The xbmc.Player subclass below observes every play,
|
||||||
|
# extracts the YouTube ID from the resolved URL, and runs the skip
|
||||||
|
# loop in a thread.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import xbmc
|
||||||
|
import xbmcaddon
|
||||||
|
import xbmcgui
|
||||||
|
import xbmcvfs
|
||||||
|
|
||||||
|
ADDON = xbmcaddon.Addon()
|
||||||
|
ADDON_ID = ADDON.getAddonInfo("id")
|
||||||
|
ADDON_PATH = ADDON.getAddonInfo("path")
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str, level: int = xbmc.LOGINFO) -> None:
|
||||||
|
xbmc.log(f"[{ADDON_ID}.service] {msg}", level=level)
|
||||||
|
|
||||||
|
|
||||||
|
# YouTube IDs appear in several URL shapes once pv.youtube + IA finish
|
||||||
|
# resolving: ?v=XXX (uri2addon), ?video_id=XXX (play handler),
|
||||||
|
# ?file=XXX.mpd (the localhost MPD server pv.youtube spins up). Match all.
|
||||||
|
_YT_ID_RE = re.compile(r"(?:[?&](?:v|file|video_id)=)([A-Za-z0-9_-]{11})")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_yt_id(url: str) -> str | None:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
m = _YT_ID_RE.search(url)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _call_sidecar(payload: dict, timeout_s: float = 10.0) -> dict:
|
||||||
|
binary = os.path.join(ADDON_PATH, "bin", "torttube-sidecar")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[binary],
|
||||||
|
input=json.dumps(payload),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"sidecar rc={proc.returncode}: {proc.stderr[:200]}")
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_sb_segments(yt_id: str) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8)
|
||||||
|
if resp.get("ok"):
|
||||||
|
segs = resp.get("segments") or []
|
||||||
|
return [s for s in segs if s.get("actionType") == "skip"]
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"sponsorblock fetch failed for {yt_id}: {e}", xbmc.LOGWARNING)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class TorttubePlayerMonitor(xbmc.Player):
|
||||||
|
"""Watches every playback. On AV-started, pulls the YouTube ID from
|
||||||
|
the playing-file URL (works for torttube plays AND phone-cast plays
|
||||||
|
that bypass our plugin entirely), fetches SponsorBlock segments, and
|
||||||
|
runs a poll-skip loop on a background thread until playback ends."""
|
||||||
|
|
||||||
|
POLL_S = 0.5
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._current_id: str | None = None
|
||||||
|
self._skip_thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
def onAVStarted(self) -> None:
|
||||||
|
try:
|
||||||
|
playing_file = self.getPlayingFile()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
yt_id = _extract_yt_id(playing_file)
|
||||||
|
if not yt_id:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if yt_id == self._current_id:
|
||||||
|
# Same video — segments already armed, skip thread alive.
|
||||||
|
return
|
||||||
|
self._stop_event.set()
|
||||||
|
self._current_id = yt_id
|
||||||
|
|
||||||
|
segments = _fetch_sb_segments(yt_id)
|
||||||
|
_log(f"armed {len(segments)} segments for {yt_id} (file={playing_file[:80]})")
|
||||||
|
if not segments:
|
||||||
|
return
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._skip_loop,
|
||||||
|
args=(yt_id, segments, stop_event),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._stop_event = stop_event
|
||||||
|
self._skip_thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def onPlayBackStopped(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
with self._lock:
|
||||||
|
self._current_id = None
|
||||||
|
|
||||||
|
def onPlayBackEnded(self) -> None:
|
||||||
|
self.onPlayBackStopped()
|
||||||
|
|
||||||
|
def onPlayBackError(self) -> None:
|
||||||
|
self.onPlayBackStopped()
|
||||||
|
|
||||||
|
def _skip_loop(
|
||||||
|
self,
|
||||||
|
yt_id: str,
|
||||||
|
segments: list[dict[str, Any]],
|
||||||
|
stop_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
segs = sorted(segments, key=lambda s: float(s.get("segment", [0, 0])[0]))
|
||||||
|
skipped: set[str] = set()
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
if not self.isPlaying():
|
||||||
|
return
|
||||||
|
pos = float(self.getTime())
|
||||||
|
# Once we switch to a different video the new monitor will
|
||||||
|
# also start; bail this loop if the underlying id changed.
|
||||||
|
current_file = self.getPlayingFile()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if _extract_yt_id(current_file) != yt_id:
|
||||||
|
return
|
||||||
|
for seg in segs:
|
||||||
|
uuid = seg.get("UUID", "")
|
||||||
|
if uuid in skipped:
|
||||||
|
continue
|
||||||
|
start = float(seg["segment"][0])
|
||||||
|
end = float(seg["segment"][1])
|
||||||
|
if pos < start:
|
||||||
|
break
|
||||||
|
if pos < end:
|
||||||
|
category = seg.get("category", "?")
|
||||||
|
duration = end - start
|
||||||
|
_log(f"skip {yt_id} {category} {start:.1f}-{end:.1f} ({duration:.0f}s)")
|
||||||
|
try:
|
||||||
|
self.seekTime(end)
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"seekTime failed: {e}", xbmc.LOGWARNING)
|
||||||
|
skipped.add(uuid)
|
||||||
|
try:
|
||||||
|
xbmcgui.Dialog().notification(
|
||||||
|
"SponsorBlock",
|
||||||
|
f"Skipped {category} ({duration:.0f}s)",
|
||||||
|
xbmcgui.NOTIFICATION_INFO,
|
||||||
|
2500,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
if stop_event.wait(self.POLL_S):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_pv_youtube() -> None:
|
||||||
|
addons_dir = xbmcvfs.translatePath("special://home/addons/")
|
||||||
|
target = os.path.join(addons_dir, "plugin.video.youtube")
|
||||||
|
if os.path.isdir(target):
|
||||||
|
return
|
||||||
|
|
||||||
|
bundled = os.path.join(ADDON_PATH, "vendored", "plugin.video.youtube-7.4.3.zip")
|
||||||
|
if not os.path.isfile(bundled):
|
||||||
|
_log(f"bundled pv.youtube zip missing at {bundled}", xbmc.LOGERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
_log("first-run: installing bundled pv.youtube 7.4.3")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(bundled) as zf:
|
||||||
|
zf.extractall(addons_dir)
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"pv.youtube extract failed: {e}", xbmc.LOGERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
xbmc.executebuiltin("UpdateLocalAddons")
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
enable_cmd = json.dumps({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "Addons.SetAddonEnabled",
|
||||||
|
"params": {"addonid": "plugin.video.youtube", "enabled": True},
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
xbmc.executeJSONRPC(enable_cmd)
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"failed to enable pv.youtube: {e}", xbmc.LOGWARNING)
|
||||||
|
|
||||||
|
try:
|
||||||
|
xbmcgui.Dialog().notification(
|
||||||
|
"torttube",
|
||||||
|
"Installed YouTube player addon",
|
||||||
|
xbmcgui.NOTIFICATION_INFO,
|
||||||
|
5000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_log("pv.youtube install complete")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_log("service starting")
|
||||||
|
try:
|
||||||
|
_ensure_pv_youtube()
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"_ensure_pv_youtube failed (non-fatal): {e}", xbmc.LOGWARNING)
|
||||||
|
|
||||||
|
player_monitor = TorttubePlayerMonitor()
|
||||||
|
abort = xbmc.Monitor()
|
||||||
|
# The Player instance only stays alive while there's a reference to it;
|
||||||
|
# keep the reference here for the lifetime of the service.
|
||||||
|
_ = player_monitor
|
||||||
|
while not abort.abortRequested():
|
||||||
|
if abort.waitForAbort(2.0):
|
||||||
|
break
|
||||||
|
_log("service stopping")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -3,7 +3,7 @@ resolver = "2"
|
||||||
members = ["crates/torttube-sidecar"]
|
members = ["crates/torttube-sidecar"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
authors = ["Sulkta <oss@sulkta.com>"]
|
authors = ["Sulkta <oss@sulkta.com>"]
|
||||||
|
|
|
||||||
Reference in a new issue