Compare commits

...

34 Commits

Author SHA1 Message Date
Timo 869c64171b Fix Disney+ force sync, seek, and HCM regressions for v2.5.3
The v2.5.2 Disney+ page-API integration leaked blob-relative <video>
time into force sync, seeks, and heartbeats when the page-API bridge
had no fresh data, so force sync on Disney+ appeared broken.

- getSyncCurrentTime/getSyncDuration now refuse native values on Disney+
  (return null/0) so stale bridge data degrades to a clean no-op instead
  of broadcasting garbage to peers. The get_current_time handler and
  episode/lobby/hcmIsLive paths are routed through the same accessor.
- Validate FORCE_SYNC_PREPARE/SEEK payloads as finite before relaying;
  the internal coercion no longer treats '' as 0.
- Stop double-routing FORCE_SYNC_PREPARE from the popup path (the
  generic popup route now covers only play/pause/seek).
- popup force-sync: exclude null/empty peer times from the jump-to-others
  median (Number(null)===0 was dragging the target to 0), guard against
  NaN end-to-end, clear the dangling reset timer on failure, and retry
  without re-injecting when the content script responds but the Disney+
  bridge has not yet delivered a finite time.
- hcmIsLive skips the native-duration live signal on Disney+ only,
  preserving YouTube/Twitch Infinity-duration live detection.

Disney-specific logic remains strictly gated to disneyplus.com; no
Netflix/YouTube/Twitch/generic path is affected.
2026-07-02 16:57:48 +02:00
GitHub Action 6ccaf45f8e chore(release): update versions to v2.5.2 [skip ci] 2026-07-02 13:34:11 +00:00
Timo 1b7be23b29 Document v2.5.2 release 2026-07-02 15:33:47 +02:00
KoalaDev 9ec0e9dd4e Merge pull request #19 from Shik3i/codex/netflix-seek-fix
[codex] Fix Netflix and Disney+ seek handling
2026-07-02 15:29:55 +02:00
Timo 9ac8c28442 Remove Disney+ DOM scraping fallback 2026-07-02 15:08:54 +02:00
Timo 076157fef1 Sync Disney+ via the page media player API (precise time + seek)
Disney+'s <video> is blob-relative (unusable as an absolute clock) and its
scrubber aria-value freezes during playback, so DOM scraping lagged and the
+/-10s button seek could neither reach far targets nor land precisely. The
real player hangs off the <disney-web-player> custom element as
`.mediaPlayer`, exposing seek(ms) and timeline.info (playhead/duration ms).

Since the isolated content world can't read that page object, route it
through the existing MAIN-world page-API bridge (as Netflix already does):

- page-api-seek-overrides.js: register a 'disney' provider.
- background.js installPageApiSeekBridge: seek Disney via mediaPlayer.seek(),
  and post the exact playhead/duration (seconds) to the content world every
  250ms. Both are gated on provider === 'disney'; Netflix path unchanged.
- content.js: cache the pushed playhead, prefer it in getDisneyPlusTimeline
  (DOM scraping stays as fallback), and check the page-API seek first in
  seekVideo. Outcome is identical for Netflix and generic sites.

Verified live on Disney+: reported time matches the player exactly and
seek lands within ~1s of the target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:56:02 +02:00
Timo 236da46f5d Lift the 120s cap on Disney+ relative-seek so large seeks reach target
Disney+ has no absolute seek on its blob <video>, so absolute seeks are
translated into repeated clicks of the player's relative skip button. The
click count was capped at 12 (=120s at 10s/step), so any seek further than
two minutes away silently landed short — breaking absolute-position sync
(e.g. syncing to 5:00 from far away never arrived).

Derive the step size from the matched button's own label (10s default,
some UIs use 5/15/30) and issue enough clicks to cover the full delta,
bounded at a sane maximum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:45:12 +02:00
Timo 2fbcbc4f83 Harden Disney+ time fallback against <video> element recreation
When the control overlay (and its live time readout) is briefly absent,
the old fallback recomputed the timeline from the raw blob-relative
video.currentTime via a cached scale/start. Disney recreates the <video>
element on some play/pause transitions, which resets currentTime, so that
math produced a wildly wrong time ("loses the time" after a pause).

The native clock advances 1:1 with real playback, so instead anchor the
last live UI position to video.currentTime and extrapolate by the elapsed
native delta. If the delta is negative or implausibly large (element
recreated / seek), freeze at the last known position rather than emitting
a garbage time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:38:26 +02:00
Timo 8fe1c6dfbc Fix laggy Disney+ time by preferring the live "time remaining" indicator
Disney+ throttles the scrubber slider's aria-valuenow, so it can lag real
playback by several seconds (and its value snaps back when the control
overlay reappears on pause). The player's "time remaining" indicator,
however, updates live.

getDisneyPlusUiTimeline() now captures any element whose class/testid
mentions "remain", parses its clock text, and — when a reliable duration
is known — derives current = duration - remaining and prefers it over the
laggy slider value. Falls back to the previous behavior when no remaining
indicator is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:26:53 +02:00
Timo 51da169d69 Remove temporary Media Session interceptor and Video Event Log diagnostics
Clean up the exploratory Disney+ diagnostics now that the Shadow DOM
timeline/seek integration is in place:

- Remove Proposal 1 (Media Session interceptor): the background.js
  interceptor injection and the content.js message listener capturing
  __koalaMediaSessionCapture, plus the now-unused mediaSessionPosition
  reporting in content.js and popup.js.
- Remove Proposal 3 (Video Event Log): videoEventsLog, logVideoEvent(),
  the per-video event logging listeners, and the popup.html/popup.js UI.
- Keep Proposal 2 (DOM timestamp scraper) for ongoing diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:18:50 +02:00
Timo a9f5c0b9bf TEMPORARY DIAGNOSTICS: Integrate querySelectorAllShadow in Disney+ specific functions 2026-07-02 13:45:36 +02:00
Timo 705fbc146b TEMPORARY DIAGNOSTICS: Upgrade DOM scraper to support Shadow DOM recursion 2026-07-02 13:42:25 +02:00
Timo 09d35f3e48 TEMPORARY DIAGNOSTICS: Implement Proposal 2 and 3 logs in Dev tools tab 2026-07-02 13:35:08 +02:00
Timo 32994aaaa5 Support dynamic Disney+ timeline offset/scale caching for accurate seek sync 2026-07-02 13:30:20 +02:00
Timo bea0f6925c Implement cached duration fallback and simulate keyboard events fallback for Disney+ seeks when controls are hidden 2026-07-02 13:27:53 +02:00
Timo 71fa6e7315 Support absolute seeks on Disney+ by automatically translating them to relative jumps 2026-07-02 13:25:11 +02:00
Timo 54b806d270 Fix Media Session listener scoping by moving it to root IIFE level in content.js 2026-07-02 13:23:14 +02:00
Timo 066d2c9407 Implement temporary Media Session interceptor diagnostics for Disney+ and restore Netflix sessionId lookup guard 2026-07-02 13:19:47 +02:00
Timo ce522e2ab7 Safeguard Netflix player session ID lookup in background.js 2026-07-02 13:01:14 +02:00
Timo 9cbeb661d6 Save baseline state with Disney+ seek adjustments and build identifier 2026-07-02 13:00:41 +02:00
Timo beda924b65 Fix DEV_SIMULATE_REMOTE_SEEK targetTime routing in background.js and add build identifier 2026-07-02 12:57:58 +02:00
KoalaDev 01762b0c2f Normalize shared tab titles 2026-07-02 10:20:25 +02:00
KoalaDev 0af22998c8 Handle target tab navigation reinjection 2026-07-02 10:02:31 +02:00
KoalaDev 52265e84eb Generalize page API seek overrides 2026-07-02 09:57:57 +02:00
KoalaDev 57cd071d58 Add hidden remote seek test controls 2026-07-02 09:50:39 +02:00
KoalaDev 8a21fbe07f Fix Netflix seek handling 2026-07-02 09:43:11 +02:00
KoalaDev 7781d418ba Merge pull request #17 from Shik3i/feat/protocol-rate-limit-vitest
feat: Protocol spec, rate limiting, vitest, and documentation
2026-07-02 00:17:01 +02:00
Timo c022f6f490 fix: harden leave room rate limiting 2026-07-02 00:14:55 +02:00
Timo 9fe83f89b2 fix: make protocol docs match implementation 2026-07-02 00:04:49 +02:00
Timo e51e8ae436 fix: move relay changelog notes to v2.5.1 2026-07-01 23:55:42 +02:00
Timo e74abd00f7 fix: keep linux optional deps in lockfile 2026-07-01 23:51:52 +02:00
Timo dab7368537 fix: repair PR verification and docs cleanup 2026-07-01 23:46:23 +02:00
Skrockle 463f871958 feat: Add protocol spec, rate limiting, vitest framework, and documentation
## Added
- **WebSocket Protocol Specification** (docs/PROTOCOL.md) - Complete reference
  for all 20+ events with payload schemas, rate limits, and edge cases
- **LEAVE_ROOM Rate Limiting** - 10 requests/socket/minute to prevent abuse
- **Vitest Testing Framework** - Modern test setup with coverage reporting
- **Host Control Mode Documentation** - Consolidated EN documentation
  (docs/host-control-mode.md) with references to internal implementation

## Changed
- **Graceful Shutdown** - Socket.IO clients properly disconnected during
  server shutdown (server/index.js)
- **CI Workflow** - Added test step with continue-on-error for gradual rollout
- **README** - Added protocol documentation link
- **CHANGELOG** - Updated with all new features and improvements

## Moved
- Internal documentation moved to docs/internal/ for better organization
- Host Control Mode docs consolidated from 4 files to 1 comprehensive guide

## Technical Details
- Protocol spec: 495 lines, covers all events from shared/constants.js
- Rate limiter: Follows existing pattern (checkAuthRate, checkEventRate)
- Vitest: 6 tests for rate limiter, all passing
- Documentation: Host Control Mode doc includes edge cases, testing checklist,
  architecture decisions
2026-07-01 23:27:30 +02:00
GitHub Action 1f5196e1e6 chore(release): update versions to v2.5.1 [skip ci] 2026-07-01 17:48:39 +00:00
36 changed files with 4452 additions and 2250 deletions
+3 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.5.0-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.5.2-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
@@ -14,7 +14,7 @@
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.5.0 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.5.2 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
@@ -131,6 +131,7 @@ gh attestation verify dist/koalasync-chrome.zip \
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
- **[PROTOCOL.md](docs/PROTOCOL.md)**: WebSocket protocol specification and event reference.
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
+30
View File
@@ -6,8 +6,38 @@ All notable changes to the KoalaSync browser extension and relay server.
## Unreleased
---
## [v2.5.3] — 2026-07-02
### Fixed
- **Extension: Disney+ force sync and seek reliability** — The v2.5.2 Disney+ page-API integration leaked blob-relative `<video>` time into force sync, seeks, and heartbeats when the page-API bridge had no fresh data, which presented as "force sync does nothing on Disney+". Time/duration accessors now refuse to return native values on Disney+ (returning null/0) so stale bridge data degrades to a clean no-op instead of broadcasting garbage. `FORCE_SYNC_PREPARE` and `SEEK` payloads are now validated as finite before being relayed, and the popup's force-sync flow fails cleanly with a clear error rather than sending NaN through.
- **Extension: Force-sync no longer routed twice** — A popup-initiated `FORCE_SYNC_PREPARE` was being delivered to the content script twice (once by the generic popup route and once by the force-sync-specific route), causing a double seek. The generic route is now scoped to play/pause/seek only.
- **Extension: Disney+ Host Control Mode classification** — `hcmIsLive` previously read the native `<video>` duration to detect live streams, which on Disney+ is blob-relative garbage and falsely classified every stream as live (disabling snap-back and the desync dialog). The native-duration live signal is now skipped on Disney+ while YouTube/Twitch live detection via `Infinity` duration is preserved.
- **Extension: Disney+ episode auto-sync and lobby readiness** — Episode-transition detection and the episode-lobby "ready" poll now read the playhead through the gated time accessor, so they no longer rely on blob-relative `currentTime` on Disney+.
- **Extension: Force-sync median no longer skewed by peers without a known time** — A peer broadcasting `currentTime: null` (e.g. a Disney+ peer whose page-API bridge was not yet ready, or a freshly joined peer) was coerced to `0` by the jump-to-others median calculation, dragging the sync target toward the start of the video. Null and empty peer times are now excluded before the median.
- **Extension: Force-sync `jump-to-me` retry on Disney+** — When the content script responds but the page-API bridge has not yet delivered a finite time (typical during the first ~250 ms after a Disney+ player loads), the popup now retries once without redundantly re-injecting the content script.
- **Extension: Empty-string seek payload rejection** — The internal seek-time coercion no longer treats `''` as `0`; an empty-string `targetTime`/`currentTime` is rejected as invalid.
---
## [v2.5.2] — 2026-07-02
### Added
- **Extension: Privacy title controls** - Advanced users can now disable sending browser tab titles separately from media titles. Media titles can still be sent in full, reduced to detected episode identifiers such as `S01E04`, or hidden entirely. Defaults remain full titles for backwards compatibility.
- **Relay: Cleaner restart handling** — Connected clients are now disconnected explicitly during relay shutdown so reconnects recover more predictably.
- **Relay: Stronger abuse protection** — Rapid room-leave spam is now rate-limited.
- **Extension: Hidden remote seek diagnostics** — KoalaDev can use the hidden Dev tab to simulate remote seeks and inspect precise native/page-API timing while debugging playback integrations.
### Changed
- **Extension: Shared page-API seek bridge** — Netflix and Disney+ now use a common page-level seek bridge so private player APIs can be invoked from the page context while the default HTML5 path stays unchanged.
- **Build: Release build timestamp** — Extension builds now inject a build timestamp into the hidden Dev tab for easier local package verification.
### Fixed
- **Extension: Disney+ precise sync** — Disney+ now reads time and seeks through the real page media-player API, and the temporary DOM timeline/button scraping fallback has been removed.
- **Extension: Netflix seek reliability** — Netflix seeking keeps using the page player API with a safer session lookup path.
- **Extension: Tab-title counter cleanup** — Leading browser notification counters such as `(14)` or `[7]` are removed from shared tab titles and matching logic without changing the existing privacy controls.
- **Extension: Tab navigation reinjection** — Reinjecting the content script after selected-tab navigation now uses the same page-API-aware injection path.
---
+369
View File
@@ -0,0 +1,369 @@
# WebSocket Protocol Reference
This document describes the relay behavior implemented by `server/index.js` and
the event names defined in `shared/constants.js`.
## Transport
- The relay uses Socket.IO v4 events over WebSocket.
- Long-polling is disabled (`transports: ['websocket']`, `allowUpgrades: false`).
- Messages are Socket.IO event packets whose payload is an event name plus an
object payload.
- The relay caps incoming Socket.IO message size at 4 KB.
## Connection Handshake
The Socket.IO handshake must include:
- `token`: must match `OFFICIAL_SERVER_TOKEN`.
- `version`: optional app version. If present, it must be a valid semver-like
string and not older than `MIN_VERSION` (default `1.0.0`).
If the token is invalid, the relay emits `error` and disconnects the socket.
If `version` is invalid or too old, the relay emits `error` and disconnects the
socket.
After the socket is connected, `join_room` must include `protocolVersion`.
It must equal `PROTOCOL_VERSION` exactly. A mismatch emits `error` and rejects
the join attempt; it does not currently disconnect the socket.
## Room Join
### `join_room` (client -> server)
Payload:
```json
{
"roomId": "string, sanitized to [A-Za-z0-9-], max 64",
"peerId": "string, max 16",
"username": "string, max 30",
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"protocolVersion": "string, max 16"
}
```
Behavior:
- Creates the room if it does not exist and capacity allows it.
- The first peer becomes `hostPeerId`.
- Rooms may have an optional password hash.
- Joining with a duplicate `peerId` disconnects the previous socket for that peer.
- Joining the same room with the same socket and peer is ignored as a no-op.
- Switching rooms removes the socket from the old room first.
On success, the joining socket receives `room_data`.
Other room members receive `peer_status` with `status: "joined"`.
### `room_data` (server -> client)
Payload:
```json
{
"roomId": "string",
"peers": ["peer state objects"],
"activeLobby": "object or null",
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"controllers": ["peerId"],
"capabilities": ["host-control", "co-host"]
}
```
`room_data` is sent to the joining socket. It is not the general broadcast used
for every later room update.
## Room Leave
### `leave_room` (client -> server)
Payload: none.
Behavior:
- Rate-limited to 10 events per socket per minute.
- If the socket is mapped to a room, the relay removes it from that room.
- Remaining room members receive `peer_status` with `status: "left"` when the
peer is no longer represented by another socket.
- Empty rooms are deleted.
- If the host leaves and peers remain, the relay assigns the next peer as host,
falls back to `controlMode: "everyone"`, resets controllers to the new host,
and broadcasts `control_mode`.
Exceeding the `leave_room` limit is logged and the socket is disconnected.
## Relayed Room Events
The relay accepts and sanitizes these events, then emits the same event to other
peers in the room:
- `play`
- `pause`
- `seek`
- `peer_status`
- `force_sync_prepare`
- `force_sync_ack`
- `force_sync_execute`
- `episode_lobby`
- `episode_ready`
- `episode_lobby_cancel`
Relayed payload fields are sanitized and may include:
```json
{
"senderId": "peerId of sender",
"seq": "number",
"currentTime": "number 0..86400 or null",
"targetTime": "number 0..86400",
"playbackState": "playing | paused",
"username": "string, max 30",
"tabTitle": "string, max 100 or null",
"mediaTitle": "string, max 100 or null",
"volume": "number 0..1",
"muted": "boolean",
"desynced": "boolean",
"peerId": "sender peerId",
"status": "string, max 16",
"expectedTitle": "string, max 100",
"title": "string, max 100",
"actionTimestamp": "number"
}
```
Undefined fields are removed before relay. Raw client payloads are not forwarded.
## Media Control
### `play`, `pause`, `seek`
These are room-moving actions. In `host-only` mode, the relay drops them unless
the sender is a controller.
Common payload fields:
- `currentTime` for `play`/`pause`.
- `targetTime` for `seek`.
- `seq` and `actionTimestamp` when the extension needs stale-command or ACK
handling.
The content script applies additional client-side filtering for noisy native
player events before it sends these events.
## Peer Status
### `peer_status`
Used for heartbeats and peer state updates. The extension sends it every
`HEARTBEAT_INTERVAL` while syncing is active.
Typical fields:
- `peerId`
- `username`
- `tabTitle`
- `mediaTitle`
- `playbackState`
- `currentTime`
- `volume`
- `muted`
- `desynced`
- `status`
The relay stores sanitized peer state and relays the sanitized update to other
peers.
## Force Sync
Force sync coordination is implemented primarily in the extension. The relay
sanitizes and relays the events.
### `force_sync_prepare`
Payload includes `targetTime`. The initiator waits for ACKs or for
`FORCE_SYNC_TIMEOUT` before sending `force_sync_execute`.
In `host-only` mode, only controllers may initiate it.
### `force_sync_ack`
The extension sends ACKs with peer identity and sequence data. The relay relays
them with the same sanitized relay envelope as other room events, including
`senderId`.
### `force_sync_execute`
Payload includes `targetTime`. In `host-only` mode, only controllers may send it.
The relay also allows a matching initiator's execute event after that initiator
started the prepare step, even if their controller state changed before execute.
## Episode Lobby
Episode lobby coordination is implemented primarily in the extension. The relay
tracks enough state to include `activeLobby` in `room_data` for later joiners.
### `episode_lobby`
Payload uses `expectedTitle`. The relay creates `activeLobby` when this field is
present and no lobby is already active.
In `host-only` mode, only controllers may initiate it.
### `episode_ready`
Payload may include `title`. The relay adds the sender to the active lobby's
ready list when a lobby exists.
### `episode_lobby_cancel`
Clears the active lobby and is relayed to peers. In `host-only` mode, only
controllers may initiate it.
## Host Control Mode
### `set_control_mode` (client -> server)
Payload:
```json
{
"controlMode": "everyone | host-only"
}
```
Only the room host may change the mode. Non-host attempts are ignored and the
sender receives the current `control_mode` snapshot.
Mode changes are debounced per room with `CONTROL_MODE_MIN_INTERVAL_MS` (500 ms).
### `set_peer_role` (client -> server)
Payload:
```json
{
"peerId": "string, max 16",
"controller": "boolean"
}
```
Only the room host may promote or demote controllers. The host cannot demote
themself. Role changes use the same 500 ms per-room debounce as mode changes.
### `control_mode` (server -> client)
Payload:
```json
{
"controlMode": "everyone | host-only",
"hostPeerId": "string or null",
"controllers": ["peerId"]
}
```
Sent when mode or controller state changes, when host migration changes room
authority, and when unauthorized role/mode attempts need to resync the sender.
## Room List
### `get_rooms` (client -> server)
Payload: none.
No admin token is required for this Socket.IO event.
Limits:
- Counts against the per-socket event limit.
- Also has a 10 second per-socket cooldown.
### `room_list` (server -> client)
Payload:
```json
{
"rooms": [
{
"id": "room id",
"peerCount": 2,
"hasPassword": false
}
]
}
```
## Ping, Pong, and ACK
### `ping`
Payload:
```json
{
"t": 1234567890,
"target": "peerId, optional"
}
```
If `target` is omitted, the relay responds to the sender with `pong`.
If `target` is another peer in the same room, the relay sends `ping` to that peer
with `{ "t": ..., "sender": "senderPeerId" }`.
### `pong`
Payload:
```json
{
"t": 1234567890,
"target": "peerId, optional"
}
```
If `target` is a peer in the same room, the relay sends `pong` to that peer with
`{ "t": ... }`.
### `event_ack`
Client payload:
```json
{
"targetId": "peerId",
"actionTimestamp": 1234567890
}
```
If sender and target are still in the same room, the relay emits:
```json
{
"senderId": "sender peerId",
"actionTimestamp": 1234567890
}
```
## Rate Limits
- Connections: 10 per IP per minute; excess connections are disconnected.
- Relayed/events: 50 per socket per 10 seconds; excess disconnects the socket.
- `get_rooms`: 10 second cooldown per socket plus the event limit.
- `leave_room`: 10 per socket per minute; excess disconnects the socket.
- Invalid room passwords: tracked per IP and room. Five recent failures block
more password attempts for that room until the failure window ages out.
- HTTP health and admin-metrics endpoints have their own rate limits outside this
Socket.IO protocol.
## Capabilities
`room_data.capabilities` advertises server-backed features:
- `host-control`
- `co-host`
Clients should treat a missing or unknown capabilities list as unsupported.
+16 -1
View File
@@ -60,6 +60,22 @@
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
### Cross-frame video detection and control
- **Priority:** P3
- **Category:** Compatibility / Embedded Players
- **Background:** KoalaSync currently injects on demand into the selected tab's top frame. This works for normal top-frame players, including current Emby/Jellyfin usage, but does not cover cases where the real `<video>` lives inside a cross-origin iframe or an `about:blank`/`srcdoc` player frame.
- **Possible approach:** Add an opt-in frame bridge where child frames announce detected videos to the top frame, and the top frame routes remote play/pause/seek commands to the active child video.
- **Status:** Future compatibility work, not needed for current Emby behavior.
### Local extension E2E smoke tests
- **Priority:** P2
- **Category:** Testing / Release Confidence
- **Background:** The release verification covers unit tests, server integration, syntax, lint, audits, and builds, but it does not currently run a real browser extension flow. A small local E2E smoke suite would catch regressions in content-script injection, tab navigation reinjection, remote seek handling, and iframe player support.
- **Possible approach:** Add a separate local-only Playwright smoke command that loads the unpacked extension, opens two controlled video pages, and verifies play/pause/seek through the actual extension path. Keep it outside `npm run verify` until it is stable enough for CI.
- **Status:** Backlog, recommended before larger content-script or frame-bridge changes.
---
## ❌ Rejected
@@ -70,4 +86,3 @@
|---|---|
| *(none yet)* | |
-118
View File
@@ -1,118 +0,0 @@
# Co-Host (Multi-Controller) — Implementation Plan
Branch base: `feature/host-control-mode` (builds directly on it).
Goal: let the room owner grant **playback control to several peers** (co-hosts), not
just one — e.g. 4 of N people in a room may drive play/pause/seek, the rest are guests.
This is the second server-gated feature the `capabilities` hook was designed for
(`CAPABILITIES.CO_HOST = 'co-host'`, already stubbed in `shared/constants.js`).
---
## 1. Roles
| Role | Can drive (play/pause/seek/force-sync/episode-lobby) | Can promote/demote + toggle mode |
|------|------|------|
| **Owner** (room creator, = today's "host") | yes (always a controller) | **yes** |
| **Controller** (co-host) | yes (in `host-only` mode) | no |
| **Guest** | only in `everyone` mode | no |
The single-host feature is just the special case `controllers = { owner }`.
## 2. Data model
### Server (`room` object)
- `ownerPeerId` — the creator / manager. Keep `hostPeerId` as an **alias** (= ownerPeerId)
so older clients keep working.
- `controllers: Set<peerId>` — peers allowed to drive. **Always contains ownerPeerId.**
- `controlMode: 'everyone' | 'host-only'` — unchanged wire values (`'host-only'` now means
"restricted to controllers", not "single host").
- `MAX_CONTROLLERS` cap (e.g. 10) to bound the set + payload.
### Shared constants
- `CAPABILITIES.CO_HOST = 'co-host'` (un-stub it) → add to `SERVER_CAPABILITIES`.
- New events:
- `SET_PEER_ROLE` (client→server): `{ peerId, controller: boolean }` — owner promotes/demotes.
- Extend `CONTROL_MODE` (server→client) payload: `{ controlMode, ownerPeerId, hostPeerId, controllers: [peerId...] }`.
- `ROOM_DATA` gains `ownerPeerId` + `controllers`.
## 3. Gate generalization (the core change)
Today the gate compares against a single `hostPeerId`. Generalize to set membership:
- **Server relay gate** (`server/index.js`): `controlMode === 'host-only' && !room.controllers.has(mapping.peerId)` → drop. (Was `mapping.peerId !== room.hostPeerId`.)
- **Background gates** (sender + receiver): replace `amHost()` / `senderId !== hostPeerId`
with controller-set membership: `controllers.includes(myPeerId)` / `senderId ∈ controllers`.
- **Helpers:** split `amHost()` into `amOwner()` (manage rights) and `amController()`
(drive rights). The desync/snap-back path keys on `!amController()` instead of `!amHost()`.
`SET_PEER_ROLE` handler (server): validate sender is owner, target is a current peer in the
room, enforce `MAX_CONTROLLERS`, always keep owner in the set, then broadcast `CONTROL_MODE`
with the new `controllers`.
## 4. Client + UI
- **Owner** sees the peer list with a per-peer **"Controller" toggle** (promote/demote) plus
the existing mode toggle.
- **Controllers** see a "Controller" badge and are NOT locked out of the remote-control buttons.
- **Guests** see "Guest" + the host-only notice (unchanged).
- The promote UI + co-host badges render only when the relay advertises the `co-host`
capability (feature detection, same pattern as `hostControlSupported`).
- i18n: new keys (`ROLE_CONTROLLER`, `BTN_PROMOTE`, `BTN_DEMOTE`, …) across all locales.
## 5. Backwards compatibility
- **New client + old server** (host-control only, no `co-host` capability): no co-host UI;
behaves as today's single-host. ✓
- **Old client + new server**: ignores `controllers` / `SET_PEER_ROLE`. An old client that
the owner promotes still **gates itself** (its sender-gate only knows `!amHost`), so it
can't drive — it degrades to a guest. Co-host requires a client that understands
`controllers`. Document this; not a crash. ✓
- No `PROTOCOL_VERSION` bump needed — purely additive, same as host-control.
## 6. Edge cases
- **Controller leaves** → `removePeerFromRoom` also does `room.controllers.delete(peerId)`.
- **Owner leaves** → fallback: promote the earliest remaining **controller** to owner (prefer
a controller over a random peer); if none, earliest peer; keep the rest of the set. Reuse
the `peerJoinLocks` guard so a reconnect/second-tab doesn't demote (same fix as host).
- **Promote a peer not in the room** → server rejects (target must be a live peer).
- **Promote beyond `MAX_CONTROLLERS`** → server rejects, re-syncs the owner's UI.
- **`everyone` mode** → the `controllers` set is still maintained (so flipping to `host-only`
keeps the chosen co-hosts), it just isn't enforced while in `everyone`.
- **peerId spoofing** → unchanged accepted limitation (see `docs/KNOWN_LIMITATIONS.md`);
co-host doesn't widen it materially (still bounded to a temporary room).
## 7. Scale: the "4 of 510 people" part — read this
The role change above is moderate. **Putting 510 people in one room is a separate, larger
problem** and should be its own track:
- `MAX_PEERS_PER_ROOM` is **25** today. 510 needs a large raise + load testing.
- **The real bottleneck at scale is heartbeat fan-out, not control events.** Every peer
heartbeats and the relay broadcasts each to all peers → O(N²) per interval. At 510 that's
~510×509 / 15s ≈ **17k msg/s just for heartbeats** — the scaling wall.
- **Co-host actually *helps* the control-event side:** in `host-only` mode only the few
controllers emit play/pause/seek, so event *sources* drop from N to K (e.g. 4). Restricting
who can drive is synergistic with big rooms.
- Large rooms therefore need (independent of co-host):
- **Heartbeat fan-out reduction** — e.g. only relay controller/owner heartbeats to everyone,
relay guest heartbeats only to the owner/controllers (for the UI), or server-side
aggregation into periodic snapshots instead of per-peer relay.
- **`ROOM_DATA` payload trimming** — a 510-entry peer list is large; send counts + controller
details, lazy-load the full roster.
- Possibly the **socket.io Redis adapter** for horizontal scaling, and broadcast tuning.
## 8. Effort estimate
- **Co-host roles** (server gate generalization + `SET_PEER_ROLE` + owner-leave fallback +
client gates + promote UI + i18n), at the current ≤25-peer scale: **~34 dev days** (same
shape as host-control itself — mostly generalizing host→controller-set).
- **Large-room scaling (510)**: a **separate ~12 week** track (heartbeat redesign + payload
trimming + cap raise + load testing), independent of co-host. Recommend shipping co-host at
the current cap first, then scaling rooms as its own project.
## 9. Suggested sequencing
1. `CAPABILITIES.CO_HOST` + `controllers`/`ownerPeerId` in room state + `ROOM_DATA` (additive).
2. Server `SET_PEER_ROLE` + gate generalization + owner-leave fallback + WS tests.
3. Background: controller-set membership in both gates + `amOwner`/`amController`.
4. Popup: promote/demote toggles (owner) + Controller badge + i18n.
5. (Separate track) large-room scaling.
-283
View File
@@ -1,283 +0,0 @@
# Host Control Mode — Branch Overview, Goals & Edge Cases
> **This is the canonical entry-point doc for branch `feature/host-control-mode`.**
> If you're an agent or contributor picking this up: read this file first, then the
> implementation plan in [`host-control-mode-plan.md`](./host-control-mode-plan.md).
> Temporary working doc — delete or fold into permanent docs before merging to `main`.
>
> Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
---
## 0. Implementation status
All five layers are implemented & pushed (server + background + content + popup + i18n).
Automated checks green: ESLint, WS integration tests (incl. host-only gate, toggle
reject, host-leave fallback), content video-finder, locale consistency (15 langs),
full release verification.
**Still needs real-device testing** — the EC test matrix in §7 (YouTube/Netflix/
Twitch/Disney+/Jellyfin): involuntary-pause classification (EC-1/EC-5/EC-8), snap-back
reliability and fight-loops (EC-4), and the desync/resync flow across players. The
intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by
reading the code, not yet by watching them behave on each site.
Deferred by decision (see §8): host grace period on disconnect (EC-10).
### Capability detection (forward-compat hook)
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
enables host-control UI/behavior only when the flag is present, so the feature
degrades cleanly on an older relay (absent → off) and old clients ignore the
field. This is the extensible hook for the planned **co-host** feature (owner
promotes guests to additional controllers): it will add a `'co-host'` capability
+ events without a protocol bump or breaking older relays/clients. Add new flags
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
### Pre-test self-audit (fixed)
- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC
click was gated server-side but the button stuck on "Playing"/disabled with no
feedback. Now the remote controls are locked (disabled + tooltip) for guests, with
backstop guards in the handlers. (popup.js)
- **Desync dialog could break under strict CSP** — it used `innerHTML` with inline
`style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt
with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS
can't restyle/hide it. (content.js)
- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
heuristic in `hcmIsLive()`. (content.js)
### Pre-test self-audit
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
no longer a gated guest; single pending poll (no stacking).
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
since background only sends it to gated guests.
- ~~**Dialog/badge text is English-only**~~ FIXED — background resolves the strings
via GET_HCM_STRINGS (it has the i18n loader); content fetches them on init with
English fallback. 6 new keys (HCM_DIALOG_*/HCM_BADGE_*) across all 15 locales.
---
## 1. What this branch is for
Origin: a GitHub feature request. When watching with larger groups, anyone can pause
or seek and disrupt everyone else. The requester wants the room creator to optionally
restrict control to a single **host**, the way Teleparty works — guests who try to
pause get asked whether they want to pause *only their own* player (and desync), and
otherwise get snapped back to the room's position.
**Goal:** Add an optional per-room **Host Control Mode**. A room can be switched
between:
- **`everyone`** (default, current behavior): anyone can play/pause/seek for the room.
- **`host-only`**: only the host drives the room. A guest's deliberate play/pause/seek
is not broadcast; instead they're snapped back to the room position — unless they
explicitly choose to desync (go solo) with a "Resync" escape hatch.
## 2. Trust model (read this before over-engineering)
This is **client-side trust, by design**. It's a watch party, not a security boundary.
The point is preventing *accidental* and *casual* disruption, not stopping someone
determined to patch their own extension. We do **not** add auth, tokens, or
cryptographic host identity. `peerId` is unauthenticated and that's fine here.
(We still gate server-side as the robust chokepoint — see plan — but that's about
killing spam reliably, not about defeating a hostile client.)
## 3. Scope / non-goals
In scope:
- Host designation (first joiner = host), mode toggle, host-only gating of all
room-moving events, guest snap-back, deliberate-desync flow + resync, host UI.
Explicit non-goals (for this branch):
- Authenticated / spoof-proof host identity.
- Persistent host across server restarts (room state is in-memory).
- Syncing around personalized ad breaks.
- Host transfer UI (auto-fallback to `everyone` when host leaves; manual transfer
is a possible later add).
## 4. Architecture summary
Three-layer gate for room-moving events from a non-host in host-only mode
(`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`):
1. **Server** — doesn't relay them (robust chokepoint, kills spam regardless of client).
2. **Sender (guest)** — doesn't emit; shows confirm dialog / disables host-only buttons.
3. **Receiver** — drops any that slip through (covers old/buggy/modified clients).
Snap-back reuses the existing `_setSuppress` mechanism (content.js:442) so applying
the room state programmatically doesn't echo back as a new event. Target position is
extrapolated from the host's last known state (±1s). Full detail + code hooks in the
plan doc.
---
## 5. The central challenge
Everything hard about this feature reduces to **one question** (see EC-9):
> How do we reliably tell a **deliberate** guest pause/seek from an **involuntary**
> player/browser event (buffering, ads, tab throttling, source swaps, DRM hiccups)?
If we get this wrong, guests get spammed with desync dialogs and snap-back loops for
things they never did. The host/role plumbing is the easy part; this classifier is the
real work. **Design the intent-classifier before writing the gate.**
---
## 6. Edge cases
### EC-1 🔴 Buffering / loading fires a `pause` event
content.js listens to `play`/`pause`/`seeked`/`loadeddata` only (content.js:1000-1003),
not `waiting`/`stalled`. Pure HTML5 buffering fires `waiting` → harmless. But custom
players (Netflix/YouTube/Twitch/JW) often call `video.pause()` during buffering/ads →
real `pause` → guest gate would mis-classify as deliberate. Sub-cases: (a) initial load
sits paused, no event, fine; (b) mid-stream stall, player-dependent; (c) seek-induced
re-buffering may outlast the suppress window and leak. Mitigation: `isBuffering` flag
from `waiting`/`playing`, or grace window; in host-only the guest's own state is
irrelevant so just ignore involuntary pauses and let catch-up logic (content.js:489)
re-sync them.
### EC-2 🟢 Force-Sync / Episode-Lobby abuse by guests
Guest could seek + spam Force-Sync to drag everyone, or spam Episode-Lobby to pause
everyone. Decision: host-only blocks guest *initiation* of `FORCE_SYNC_*` and
`EPISODE_LOBBY`; guests may only respond (`FORCE_SYNC_ACK`, `EPISODE_READY`). Guests'
legitimate path is the personal "Resync" button.
### EC-3 🟢 Host leaves the room
Fall back to `controlMode = 'everyone'`, broadcast `CONTROL_MODE`. Never a stuck locked
room. (Auto-promote next peer deferred.)
### EC-4 🔴 Snap-back fight loop (pause/play/skip back/pause/play)
Mashing controls or a janky player → each snap-back may emit events → ping-pong.
Mitigation: cooldown (~600ms) after snap-back; ensure snap-back runs fully under
suppress. Also: if target is unreachable (seek past buffered range), retry K times then
give up — no infinite loop.
### EC-5 🔴 Ad breaks (YouTube/Twitch/…)
Mid-roll ads pause/swap the media element, differ per peer → desync is unavoidable and
must NOT spam the dialog. Probably covered by EC-1 buffering grace; flag for explicit
testing.
### EC-6 🟡 Snap-back target accuracy
No continuous room clock; extrapolate from host's `currentTime` + `lastHeartbeat`
(±1s, worse if stale). The follow-up host correction must also be suppressed so it
doesn't read as guest input.
### EC-7 🟢 Old / buggy / modified guest client
Covered by receiver-side + server-side gates.
### EC-8 🔴 Tab throttling / background tab
Backgrounding throttles timers and may pause media. There's existing
`visibilityGraceUntil` handling for seeks (content.js:892). Confirm a
background-induced pause isn't treated as deliberate in host-only; reuse the grace flag.
### EC-9 🔴 What counts as "deliberate" — the central unresolved question
Collapses EC-1/EC-5/EC-8. Candidate signals: `readyState`/`networkState`/`video.seeking`
at event time; recent `waiting`; recent user gesture (`navigator.userActivation`,
keydown/click); visibility/focus. Build one shared **intent-classifier** helper in
content.js that all host-only gating flows through.
### EC-10 🔴 Host brief disconnect / reconnect (network blip)
Host's wifi drops for 3s and reconnects. With "host leaves → fallback to everyone",
a blip would silently unlock the room and demote the host (peerId persists in
chrome.storage so they rejoin with the same id, but the server already cleared
`hostPeerId`). Mitigation idea: short **host grace period** (e.g. keep `hostPeerId`
reserved for ~30s after disconnect; if the same peerId rejoins, restore host + mode).
Needs the server reaper (server:644) and `removePeerFromRoom` (server:168) to cooperate.
### EC-11 🔴 New guest joins mid-session in host-only mode
On join they must (a) immediately sync to the host's current position without the host
doing anything, and (b) see they're a guest in the UI. ROOM_DATA already carries peers;
add `hostPeerId`/`controlMode` so a fresh client knows its role instantly. Verify the
existing "newcomer syncs without waiting" path (content.js:542) still fires.
### EC-12 🔴 Desync semantics — what does "solo" actually mean?
When a guest chooses "pause only me", do they (a) fully ignore all subsequent host
events until they Resync, or (b) keep receiving but not auto-applying? Define clearly.
Proposed: full solo — ignore host play/pause/seek while desynced; Resync re-attaches and
snaps to current host position. Also: what state does Resync land them in if the host is
currently paused vs playing?
### EC-13 🔴 Race: host flips to host-only exactly as a guest pauses
Event ordering between `SET_CONTROL_MODE`/`CONTROL_MODE` and an in-flight guest `PAUSE`.
The `seq` ordering helps, but define the tie-break. Likely: server is authoritative —
once it has `host-only`, it drops the guest event regardless of client-side timing.
### EC-14 🟡 Volume / mute / audio-options must NOT be gated
Those are per-peer, not room control. The gate must target only play/pause/seek +
forcesync/episode — not `PEER_STATUS` volume/mute fields. Easy to over-block; add a test.
### EC-15 🔴 Live streams (Twitch live, live DVR)
"Room timestamp" is fuzzy on live edge; seeking semantics differ. Decide whether
host-only even makes sense for live, or degrade gracefully. Low priority but log it.
### EC-16 🟡 Host's own involuntary events still drive the room
If the host buffers and the player auto-pauses, that pause is "allowed" and pauses
everyone. That's existing behavior, but in host-only it means the host's buffering
stalls the whole room. Acceptable? Probably yes (host is authoritative), but note it.
### EC-17 🟡 Server restart drops room state
`hostPeerId`/`controlMode` are in-memory. After a server restart, whoever rejoins first
becomes the new host and mode resets to `everyone`. Acceptable for now (non-goal), but
document so it's not a surprise.
### EC-18 🟡 Dialog dismissed without choosing
Guest clicks away / presses Esc on the desync prompt. Default = treat as "No" → snap
back. Make sure an un-answered dialog can't leave them in limbo (paused + not desynced +
no dialog).
### EC-19 🟡 Multiple video elements / element swap (SPA, ad → content)
Players that swap the `<video>` element mid-session: re-attach handlers (content.js
already re-binds on `loadeddata`) and make sure host-only gating follows the new element.
### EC-20 🟡 Peer list shape (object vs legacy string)
Throughout background.js peers may be objects or bare peerId strings
(`typeof p === 'object' ? p.peerId : p`). All new `hostPeerId` comparisons must handle
both forms, or we get a host that's never recognized.
### EC-21 🟡 Mode toggle spam / rate limiting
Host hammering the toggle → many `SET_CONTROL_MODE`. Covered by existing
`checkEventRate` (server), but debounce in the UI and ignore no-op transitions.
---
## 7. Test matrix (fill in during dev)
| Player | Buffer→`pause`? | Ad behavior | Snap-back works? | Element swap? | Notes |
|-------------------|-----------------|-------------|------------------|---------------|-------|
| Generic HTML5 | | | | | |
| YouTube | | | | | |
| Netflix | | | | | |
| Twitch (VOD) | | | | | |
| Twitch (live) | | | | | |
| Disney+ / DRM | | | | | |
| Jellyfin / Emby | | | | | |
## 8. Decisions (audited)
- [x] **Intent-classifier (EC-9):** A `pause`/`seek` is **involuntary** if ANY of:
`readyState < 3`, `video.seeking`, a `waiting` fired < ~1500ms ago (`isBuffering`
flag), inside `visibilityGraceUntil`, OR no own-tracked user gesture
(`Date.now() - lastUserGestureAt < 1000`, via capturing keydown/pointerdown — do
NOT use sticky `navigator.userActivation.hasBeenActive`). Bias: only *clearly*
involuntary is ignored; everything else = deliberate. **Note:** in host-only the
guest never broadcasts anyway, so this only decides dialog-vs-silent — a UX call,
not a room-integrity call. Start simple, tune later.
- [x] **Host grace on disconnect (EC-10): NOT in v1.** Immediate fallback to
`everyone` (EC-3). A grace window risks a multi-second hard-lock if the host never
returns. Revisit as polish once the core flow works.
- [x] **Desync semantics (EC-12): full solo.** Ignore host play/pause/seek while
desynced; Resync snaps to host position + adopts host play/pause state. Desync
auto-clears on new media/episode. Requires a persistent, obvious "You are desynced"
UI.
- [x] **Snap-back cooldown (EC-4): until-settled, not fixed.** Suppress re-trigger
until `readyState>=3 && playing && |Δt|<tol`, hard-cap ~1500ms. Retry target up to
3×, then give up (no infinite loop).
- [x] **Live streams (EC-15): degrade.** Disable the gate when
`video.duration === Infinity`. Caveat: live-DVR may report finite duration and slip
through — acceptable for v1.
-83
View File
@@ -1,83 +0,0 @@
# Host Control Mode — Beta Testing Guide
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
> The feature needs the **server** half too — the official relay
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
> server first.
## 1. Run the beta relay (Docker)
The branch publishes the server image to GHCR under non-production tags
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
never touched.
```bash
# Log in (the package is private → PAT with read:packages)
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
# you must remove and re-run (or use compose / Watchtower).
docker pull ghcr.io/shik3i/koalasync:beta
docker rm -f koala-beta 2>/dev/null || true
docker run -d --name koala-beta -p 3000:3000 \
-e SERVER_SALT='choose-your-own-salt' \
ghcr.io/shik3i/koalasync:beta
```
To always run the newest beta automatically, point **Watchtower** at the
container — it does the pull → recreate whenever `:beta` moves.
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
is needed. Set `SERVER_SALT` (used for room-password hashing).
## 2. Connect the extension to it
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
(see background.js "Upgraded to wss:// for remote host"). So a bare
`ws://your-server:3000` will fail without TLS. Two options:
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
```bash
ssh -L 3000:localhost:3000 your-beta-server
```
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
HTTPS) → `wss://beta.yourdomain`.
Then create/join a room.
## 3. ⚠️ Use two *new* clients
Test with **two browser profiles both running this branch build** (load unpacked
from `extension/`, or install the built zip). A stock release client as a guest
will be correctly gated by the server but has none of the content-side code, so
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
bug, but it looks like one during testing.
## 4. Verification checklist
| # | Step | Expect |
|---|------|--------|
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
| 9 | Host disables host-only | Card hides for guest; controls unlock |
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
| 12 | Switch the popup language | Dialog/badge text is localized |
## 5. Capability detection
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
only enables the feature when that flag is present, so:
- against this beta server → feature on;
- against the old official server → feature cleanly hidden (no errors).
This is the extensible hook for the planned **co-host** feature (owner promotes
guests to additional controllers) — it'll add a `'co-host'` capability + events
without breaking older relays/clients.
-122
View File
@@ -1,122 +0,0 @@
# Host Control Mode — Implementierungsplan
Branch: `feature/host-control-mode`
Issue: GitHub feature request (wasserrutschentester) — nur Host darf den Raum steuern; Gäste werden zurückgesnappt oder gehen bewusst in Desync.
## Ziel
Ein Raum kann zwischen zwei Modi umgeschaltet werden:
- **`everyone`** (Default, heutiges Verhalten): jeder kann play/pause/seek für alle auslösen.
- **`host-only`**: nur der Host steuert den Raum. Pause/Seek eines Gasts wird **nicht** gebroadcastet; stattdessen snappt die eigene Extension den Gast zurück auf den Raum-Zustand — es sei denn, der Gast entscheidet sich bewusst für Desync.
Trust-Modell: client-seitig durchgesetzt. Kein Token, keine Auth. Es geht um versehentliches Stören, nicht um Angriffsschutz.
---
## Datenmodell
### Server (`server/index.js`, Room-Objekt ~Z.331)
Room bekommt zwei neue Felder:
```js
room = {
...,
hostPeerId: peerId, // gesetzt beim Anlegen = erster Joiner
controlMode: 'everyone', // 'everyone' | 'host-only'
}
```
- In `ROOM_DATA` (~Z.418) mitschicken: `hostPeerId`, `controlMode`.
- Neues Event `SET_CONTROL_MODE` (siehe unten): nur akzeptieren, wenn `senderPeerId === room.hostPeerId`. Server setzt `room.controlMode`, broadcastet die Änderung an alle.
- **Host-Migration:** in `removePeerFromRoom` (~Z.168) — wenn der gehende Peer `hostPeerId` war: entweder neuen Host bestimmen (nächster Peer) **oder** `controlMode` auf `everyone` zurückfallen lassen. → Entscheidung: **Fallback auf `everyone`** (simpel, nie verwaister gesperrter Raum). Optional später: Host-Transfer-Button.
### Shared Constants (`shared/constants.js`)
Neue Events im `EVENTS`-Objekt:
```js
SET_CONTROL_MODE: "set_control_mode", // Client->Server: Host ändert Modus
CONTROL_MODE: "control_mode", // Server->Client: Modus geändert { controlMode, hostPeerId }
```
⚠️ Danach `node scripts/build-extension.cjs` laufen lassen (Single Source of Truth propagieren). Ggf. `PROTOCOL_VERSION` bumpen — **nein, nur wenn alte Clients brechen würden**. Da alles additiv ist und alte Clients die neuen Felder/Events einfach ignorieren, ist KEIN Protokoll-Bump nötig. (Alter Client in host-only-Raum kennt den Modus nicht und sendet weiter → Host-Extensions ignorieren fremde Events nicht... → siehe Edge Case 7. Evtl. doch Bump erwägen.)
### Extension State (`background.js`)
```js
let controlMode = 'everyone';
let hostPeerId = null;
// abgeleitet: const amHost = () => hostPeerId === peerId;
```
Aus `ROOM_DATA` / `CONTROL_MODE` befüllen, in `chrome.storage.session` persistieren (wie `currentRoom`).
---
## Implementierung nach Schichten
### 1. Server (`server/index.js`)
- [ ] Room-Objekt um `hostPeerId` + `controlMode` erweitern (~Z.331).
- [ ] `ROOM_DATA`-Payload erweitern (~Z.418).
- [ ] Handler `SET_CONTROL_MODE`: validieren (Host-Check + Wert in {everyone, host-only}), setzen, `CONTROL_MODE` an Raum broadcasten.
- [ ] Host-Migration in `removePeerFromRoom`: Fallback auf `everyone` + neues `CONTROL_MODE` broadcasten, wenn Host geht.
- [ ] `SET_CONTROL_MODE` in die `relayEvents`-Liste? **Nein** — eigener Handler, da Sonderlogik + Host-Check. (relayEvents broadcastet blind.)
### 2. Shared / Build
- [ ] `EVENTS.SET_CONTROL_MODE`, `EVENTS.CONTROL_MODE` ergänzen.
- [ ] `node scripts/build-extension.cjs`.
### 3. background.js (Gast-Logik = Kern)
- [ ] `controlMode` / `hostPeerId` aus `ROOM_DATA` (~Z.875) und neuem `CONTROL_MODE`-Case übernehmen + persistieren + an Popup/Content pushen.
- [ ] **Emit-Gate** im SEND-Pfad (~Z.1786): bei `host-only && !amHost()` und action ∈ {play, pause, seek}:
- NICHT `emit`en.
- Stattdessen Content-Script anweisen: "snap back" ODER Desync-Confirm anzeigen.
- [ ] **Snap-Back-Zielzeit berechnen:** aus Host-Peer-State (`playbackState`, `currentTime`, `lastHeartbeat`) extrapolieren:
`targetTime = host.currentTime + (host.playbackState==='playing' ? (now - host.lastHeartbeat)/1000 : 0)`.
Genauigkeit ~±1s, für Watchparty ok. (Force-Sync-Maschinerie als Referenz für Ziel-Zeit-Koordination.)
- [ ] Nachricht an content.js: `{ type: 'HOST_BLOCK', action, targetTime, hostPlaybackState }`.
### 4. content.js (Player-Reaktion + Dialog)
- [ ] Handler für `HOST_BLOCK`: Confirm-Dialog im Player-Overlay rendern:
"Pause only your own player and desync from the group? [Yes] [No]".
- **No** (Default): Player via bestehende `_setSuppress`-Mechanik ([content.js:442](../extension/content.js:442)) wieder in Raum-Zustand zwingen (play + seek auf targetTime). Suppress verhindert Re-Broadcast.
- **Yes:** lokal pausiert lassen, `isDesynced = true` setzen, dezenten "Desynced — Resync"-Button zeigen.
- [ ] "Resync"-Button → snappt zurück auf aktuelle Raum-Zeit, `isDesynced = false`.
- [ ] **Loop-Schutz:** nach einer Snap-Back-Aktion kurzes Cooldown-Fenster (z.B. 600ms), in dem weitere lokale pause/seek-Events nicht erneut den Dialog triggern (verhindert pause→play→pause-Pingpong).
### 5. popup (Host-UI)
- [ ] Host-Toggle "Only I can control" (nur sichtbar wenn `amHost()`), sendet `SET_CONTROL_MODE`.
- [ ] Rollen-Badge: "Host" / "Guest" + aktueller Modus.
- [ ] Gast-Hinweis wenn host-only aktiv: "The host controls playback".
- [ ] i18n-Keys in `extension/_locales` / `locales` für ~15 Sprachen.
---
## Edge Cases (Test-Checkliste)
1. **Pause nicht verhinderbar, nur revidierbar** → kurzer Flicker (~½s) beim Gast ist erwartet/akzeptabel.
2. **Snap-Back-Zielzeit** aus Heartbeat extrapoliert, ±1s. Bei stark veraltetem Host-State (kein Heartbeat) → letzten bekannten Wert nehmen.
3. **Kampf-Loop pause/play/skip back/pause/play** → Cooldown-Fenster nach Snap-Back. Testen mit aggressivem Mashing.
4. **Desync-Escape:** Gast kann bewusst pausieren (Klo/Telefon) → "Yes" → solo, dann Resync.
5. **Host verlässt Raum** → Fallback auf `everyone`, alle bekommen `CONTROL_MODE`-Update. Testen: Host schließt Tab / Disconnect / Netzabbruch.
6. **host-only + Episode-Auto-Sync / Force-Sync** (server/index.js:503-516): **Gast darf NICHT initiieren.** Force-Sync trägt eine `targetTime` und zwingt ALLE darauf (background.js:1261) — ein Gast könnte seeken → Force-Sync spammen und damit host-only komplett aushebeln. Episode-Lobby pausiert ebenfalls alle. → Im host-only-Modus dürfen `FORCE_SYNC_PREPARE`/`FORCE_SYNC_EXECUTE` und `EPISODE_LOBBY` nur vom Host **initiiert** werden. Gäste dürfen weiterhin nur **reagieren**: `FORCE_SYNC_ACK`, `EPISODE_READY`. Gäste brauchen Force-Sync nicht — ihr legitimer Fall ist der "Resync"-Button (snappt nur sie selbst, nicht alle).
7. **Alter Client (ohne Feature) in host-only-Raum** → kennt Modus nicht, sendet weiter pause/seek → andere Extensions wenden es an. Mitigation: Empfänger-seitiges Gate (host-only-Clients ignorieren play/pause/seek von Nicht-Host) ODER `MIN_VERSION`/Protokoll-Bump. → **Empfehlung: zusätzlich Empfänger-seitig filtern** (robuster als nur Sender-Gate).
8. **Seek getrennt von Pause** → host-only blockt auch Gast-Seeks, nicht nur Pausen.
9. **Mehrere Tabs / Multi-Peer mit gleicher peerId** (Dedup, server:381) → Host-Identität bleibt an peerId hängen, ok.
10. **DAU-Verwirrung** "warum kann ich nicht mehr pausieren?" → klare UI-Botschaft + der Desync-Dialog erklärt sich selbst.
## Architektur-Entscheidung zu Edge Case 7 (wichtig)
Wir setzen das Gate **doppelt** und über **alle raum-verschiebenden Events**, nicht nur play/pause/seek:
Geblockte Initiierungen für Nicht-Host im host-only-Modus:
`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`.
Weiterhin erlaubt für Gäste (reine Reaktion, verschiebt niemanden): `FORCE_SYNC_ACK`, `EPISODE_READY`, `PEER_STATUS`, `PING`/`PONG`.
- **Sender-seitig** (Gast sendet erst gar nicht) → saubere UX, Confirm-Dialog bei play/pause/seek; Force-Sync-/Episode-Lobby-Buttons im Gast-UI deaktiviert/ausgeblendet.
- **Empfänger-seitig** (in `handleServerEvent`, background.js:969 + Force-Sync-/Episode-Cases): wenn `host-only` und `data.senderId !== hostPeerId` → Event verwerfen (nicht an Content routen, keine State-Mutation).
So sind auch alte/buggy/manipulierte Clients abgedeckt, ohne harten Protokoll-Bump.
Optional zusätzlich **server-seitig** in den `relayEvents` (server/index.js:445): im host-only-Modus Initiierungs-Events von Nicht-Host gar nicht erst relayen. Spart Traffic + deckt alles zentral ab. Empfehlenswert, da der Server `hostPeerId`/`controlMode` ohnehin kennt.
---
## Reihenfolge der Umsetzung (kleine, testbare Schritte)
1. Constants + Build (Events da, nichts kaputt).
2. Server: hostPeerId/controlMode + ROOM_DATA + SET_CONTROL_MODE + Migration.
3. background.js: State übernehmen + Empfänger-seitiges Gate (Edge 7) — testbar ohne UI.
4. background.js: Sender-seitiges Gate + Snap-Back-Zielzeit.
5. content.js: Snap-Back-Apply + Confirm-Dialog + Loop-Cooldown.
6. popup: Host-Toggle + Badge + i18n.
7. Durchtesten der Edge-Case-Liste auf YT / Netflix / generischem HTML5-Player.
+151
View File
@@ -0,0 +1,151 @@
# Host Control Mode
This document describes the Host Control Mode implementation in the relay and
extension. It only covers behavior implemented in the current codebase.
## Modes
### `everyone`
- Default room mode.
- Any peer may send room-moving playback events.
### `host-only`
- Only controllers may send room-moving playback events.
- The host is always a controller.
- The host can promote additional peers to controllers.
- Guests can keep watching locally in solo/desynced mode, but their local actions
still do not drive the shared room.
Room-moving events are:
- `play`
- `pause`
- `seek`
- `force_sync_prepare`
- `force_sync_execute`
- `episode_lobby`
- `episode_lobby_cancel`
Heartbeats, force-sync ACKs, episode-ready events, ping/pong, and command ACKs
remain allowed for guests.
## Server State
Rooms store Host Control state in memory:
```js
{
hostPeerId,
controlMode,
controllers,
lastControlModeChangeAt,
lastRoleChangeAt,
forceSyncInitiator
}
```
`controllers` is a `Set` on the server and is serialized as an array in
`room_data` and `control_mode`.
State is not persisted across relay restarts.
## Authority Rules
### Changing mode
Only `hostPeerId` may send `set_control_mode`.
Valid values:
- `everyone`
- `host-only`
Invalid values are ignored. Non-host attempts are ignored and the sender receives
the current `control_mode` snapshot so optimistic UI can revert.
Mode changes are debounced per room for 500 ms.
### Promoting and demoting controllers
Only `hostPeerId` may send `set_peer_role`.
The host cannot demote themself. No-op role changes are ignored. Role changes are
debounced per room for 500 ms.
### Host leaving
When the host leaves and peers remain:
- the next peer becomes `hostPeerId`;
- `controlMode` falls back to `everyone`;
- `controllers` is reset to the new host;
- the relay broadcasts `control_mode`.
When a non-host controller leaves, the relay removes that peer from
`controllers` and broadcasts `control_mode`.
## Enforcement
The implementation has two enforcement points:
- The extension background script blocks local guest attempts in `host-only` and
sends `HOST_BLOCKED` to the content script for local UX.
- The relay drops room-moving events from non-controllers in `host-only`, so old
or modified clients cannot drive the room.
The relay is the authority for room-wide effects.
## Guest UX
When a guest action is blocked locally, the content script classifies it:
- deliberate user action: show the host-control dialog;
- likely involuntary player action (buffering, tab refocus, no recent gesture):
silently snap back when safe;
- live/DVR stream: degrade without forcing snap-back.
The dialog offers:
- stay in sync: resync to the host;
- watch on my own: enter solo/desynced mode.
In solo/desynced mode:
- the guest can control their local video;
- host room commands are ignored locally, except force-sync preparation is ACKed
so the host's flow can continue;
- the guest can resync to the host.
The extension reports `desynced` in peer status so the host UI can show that a
guest is watching solo.
## Force Sync Edge Case
The relay tracks `forceSyncInitiator` after a controller sends
`force_sync_prepare`.
This allows that same initiator's `force_sync_execute` through even if their
controller role changes before execute arrives. Without this, a demotion in the
middle of a force-sync flow could leave peers waiting after prepare.
The relay clears `forceSyncInitiator` after execute or when the initiator leaves.
## Capabilities
The relay advertises Host Control support in `room_data.capabilities`:
- `host-control`
- `co-host`
The extension hides or disables matching UI when capabilities are missing.
## Related Events
See [PROTOCOL.md](PROTOCOL.md) for payloads and relay behavior for:
- `set_control_mode`
- `control_mode`
- `set_peer_role`
- host-only gated relay events
+1 -1
View File
@@ -1,6 +1,6 @@
export default [
{
ignores: ["dist/**", "node_modules/**", "scratch/**"]
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**"]
},
{
languageOptions: {
+249 -10
View File
@@ -4,6 +4,7 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode } from './episode-utils.js';
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
import { initTabManager } from './modules/tab-manager.js';
import './page-api-seek-overrides.js';
// --- Uninstall URL Initialization ---
let uninstallURLInitPromise = null;
@@ -1592,6 +1593,188 @@ async function routeToContent(action, payload) {
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
}
function getTabVideoState(tabId) {
return new Promise((resolve) => {
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
if (chrome.runtime.lastError) {
resolve({ error: chrome.runtime.lastError.message });
return;
}
resolve(res);
});
});
}
async function getReadyTabVideoState(tabId) {
let state = await getTabVideoState(tabId);
if (!state || state.error) {
await injectContentScript(tabId);
await new Promise(resolve => setTimeout(resolve, 250));
state = await getTabVideoState(tabId);
}
return state;
}
async function simulateRemoteSeek(delta, explicitTargetTime = null) {
if (!currentTabId) return { status: 'no_tab' };
const tabId = parseInt(currentTabId);
if (isNaN(tabId)) return { status: 'no_tab' };
const state = await getReadyTabVideoState(tabId);
if (!state || state.error) return { status: 'error', message: state?.error || 'No video state' };
if (!state.found || !Number.isFinite(state.currentTime)) return { status: 'no_video' };
let targetTime = explicitTargetTime !== null ? explicitTargetTime : Math.max(0, state.currentTime + (delta || 0));
if (Number.isFinite(state.duration) && state.duration > 0) {
targetTime = Math.min(targetTime, Math.max(0, state.duration - 0.1));
}
const senderId = 'KoalaDev';
const timestamp = Date.now();
const payload = {
senderId,
actionTimestamp: timestamp,
currentTime: targetTime,
targetTime
};
addToHistory(EVENTS.SEEK, senderId);
showNotification(senderId, EVENTS.SEEK);
updateLastAction(EVENTS.SEEK, senderId, timestamp);
lastActionState.targetTime = targetTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
updateLocalPeerState(senderId, { currentTime: targetTime });
routeToContent(EVENTS.SEEK, payload);
return { status: 'ok', targetTime };
}
async function devRemoteToolsAllowed() {
const data = await chrome.storage.local.get(['username']);
return data.username === 'KoalaDev';
}
function shouldUsePageApiSeek(url) {
return typeof globalThis.koalaFindPageApiSeekProvider === 'function' &&
!!globalThis.koalaFindPageApiSeekProvider(url);
}
function installPageApiSeekBridge() {
if (window.__koalaPageApiSeekBridgeInstalled) return;
window.__koalaPageApiSeekBridgeInstalled = true;
function currentMatch() {
return typeof window.koalaFindPageApiSeekProvider === 'function'
? window.koalaFindPageApiSeekProvider(window.location.hostname)
: null;
}
// Disney+ ("hive"/BAM) player: the real media player hangs off the
// <disney-web-player> custom element as `.mediaPlayer`, exposing precise
// seek(ms) and timeline.info (playhead/duration in ms).
function disneyMediaPlayer() {
const el = document.querySelector('disney-web-player');
return el && el.mediaPlayer ? el.mediaPlayer : null;
}
function seekWithPageApi(time) {
const match = currentMatch();
if (!match) return;
try {
if (match.provider === 'netflix') {
const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer;
const ids = videoPlayer?.getAllPlayerSessionIds?.();
const sessionId = ids ? ids[0] : null;
const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null;
player?.seek(Math.round(time * 1000));
} else if (match.provider === 'disney') {
const mp = disneyMediaPlayer();
if (mp && typeof mp.seek === 'function') mp.seek(Math.round(time * 1000));
}
} catch (_e) {
// Player not ready or private API changed; the next sync tick can retry.
}
}
window.addEventListener('message', (event) => {
if (event.source !== window) return;
const data = event.data;
if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return;
seekWithPageApi(data.time);
});
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
// the isolated-world content script can't read an accurate position. Push
// the real playhead/duration (seconds) from the page's media player.
setInterval(() => {
try {
const match = currentMatch();
if (!match || match.provider !== 'disney') return;
const mp = disneyMediaPlayer();
const info = mp && mp.timeline && mp.timeline.info;
if (!info || typeof info.playheadPositionMs !== 'number' || typeof info.programDurationMs !== 'number') return;
if (info.programDurationMs <= 0) return;
window.postMessage({
__koalaPlayerTime: 1,
provider: 'disney',
position: info.playheadPositionMs / 1000,
duration: info.programDurationMs / 1000
}, '*');
} catch (_e) {
// Ignore transient errors (player teardown / element swap).
}
}, 250);
}
function setPageApiSeekEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
}
async function injectContentScript(tabId) {
let needsPageApiSeek = false;
let pageApiSeekReady = false;
try {
const tab = await chrome.tabs.get(tabId);
const url = tab?.url || '';
needsPageApiSeek = shouldUsePageApiSeek(url);
} catch (_e) {
// Fall through to the generic content script injection.
}
if (needsPageApiSeek) {
try {
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
func: installPageApiSeekBridge
});
pageApiSeekReady = true;
} catch (err) {
addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn');
}
}
await chrome.scripting.executeScript({
target: { tabId },
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
func: setPageApiSeekEnabled,
args: [pageApiSeekReady]
});
return chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
});
}
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
@@ -1606,10 +1789,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
return;
}
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
injectContentScript(tabId).then(() => {
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
}).catch(_err => {
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
@@ -1997,6 +2177,22 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse(res);
}
});
} else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') {
if (!(await devRemoteToolsAllowed())) {
sendResponse({ status: 'forbidden' });
return;
}
const delta = message.delta !== null && message.delta !== undefined ? Number(message.delta) : null;
const targetTime = message.targetTime !== null && message.targetTime !== undefined ? Number(message.targetTime) : null;
if (delta === null && targetTime === null) {
sendResponse({ status: 'invalid_params' });
return;
}
simulateRemoteSeek(delta, targetTime).then(sendResponse).catch(err => {
addLog(`Remote seek simulation failed: ${err.message}`, 'warn');
sendResponse({ status: 'error', message: err.message });
});
} else if (message.type === 'CONTENT_EVENT') {
const processEvent = async () => {
// Host Control Mode (sender-side): a non-controller in host-only mode must
@@ -2031,12 +2227,40 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
const payload = message.payload && typeof message.payload === 'object' ? message.payload : {};
const payloadNumber = (value) => value !== undefined && value !== null && value !== '' ? Number(value) : NaN;
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
const targetTime = payloadNumber(payload.targetTime);
if (!Number.isFinite(targetTime)) {
sendResponse({ status: 'invalid_params' });
return;
}
payload.targetTime = targetTime;
} else if (message.action === EVENTS.SEEK) {
const targetTime = payloadNumber(payload.targetTime !== undefined ? payload.targetTime : payload.currentTime);
if (!Number.isFinite(targetTime)) {
sendResponse({ status: 'invalid_params' });
return;
}
payload.currentTime = targetTime;
payload.targetTime = targetTime;
}
const timestamp = Date.now();
localSeq++;
chrome.storage.session.set({ localSeq });
updateLastAction(message.action, 'You', timestamp);
const payload = message.payload || {};
const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime);
if (!sender?.tab && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) {
const tabId = currentTabId ? parseInt(currentTabId) : NaN;
if (!isNaN(tabId)) {
const state = await getReadyTabVideoState(tabId);
if (state && !state.error && state.found && Number.isFinite(state.currentTime)) {
payload.currentTime = state.currentTime;
}
}
}
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
@@ -2050,6 +2274,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentTime: payload.currentTime !== undefined ? payload.currentTime : (payload.targetTime !== undefined ? payload.targetTime : undefined)
});
if (!sender?.tab && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK)) {
routeToContent(message.action, message.payload);
}
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
@@ -2103,7 +2331,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'error' });
});
} else {
routeToContent(message.action, message.payload);
processEvent().catch(err => {
addLog('Content event privacy error: ' + err.message, 'error');
sendResponse({ status: 'error' });
@@ -2196,6 +2423,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
addLog('Heartbeat settings error: ' + err.message, 'error');
sendResponse({ status: 'ok' });
});
} else if (message.type === 'INJECT_CONTENT_SCRIPT') {
const tabId = Number(message.tabId);
if (!Number.isInteger(tabId)) {
sendResponse({ status: 'invalid_tab' });
return true;
}
injectContentScript(tabId).then(() => {
sendResponse({ status: 'ok' });
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
sendResponse({ status: 'error', message: err.message });
});
return true;
} else if (message.type === 'SET_TARGET_TAB') {
const previousTabId = currentTabId;
currentTabId = message.tabId;
@@ -2213,10 +2454,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (currentTabId) {
const selectedTabId = currentTabId;
chrome.scripting.executeScript({
target: { tabId: selectedTabId },
files: ['content.js']
})
injectContentScript(selectedTabId)
.then(() => applyAudioSettingsToTab(selectedTabId))
.catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
@@ -2399,6 +2637,7 @@ initTabManager({
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
});
+1638 -1506
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.5.0",
"version": "2.5.2",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+2 -4
View File
@@ -12,6 +12,7 @@ export function initTabManager({
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
}) {
@@ -83,10 +84,7 @@ export function initTabManager({
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
})
injectContentScript(tabId)
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
+36
View File
@@ -0,0 +1,36 @@
(function(root) {
const PAGE_API_SEEK_FIXES = [
{
name: 'netflix-page-api-seek',
urls: ['netflix.com'],
provider: 'netflix'
},
{
name: 'disney-page-api-seek',
urls: ['disneyplus.com'],
provider: 'disney'
}
];
function normalizeHost(input) {
try {
return new URL(input).hostname.toLowerCase();
} catch (_e) {
return String(input || '').toLowerCase();
}
}
function matchesDomain(host, domain) {
const normalizedDomain = normalizeHost(domain);
return normalizedDomain && (host === normalizedDomain || host.endsWith(`.${normalizedDomain}`));
}
root.KOALA_PAGE_API_SEEK_FIXES = PAGE_API_SEEK_FIXES;
root.KOALA_PAGE_API_SEEK_PROVIDERS = PAGE_API_SEEK_FIXES;
root.koalaFindPageApiSeekProvider = (input) => {
const host = normalizeHost(input);
return PAGE_API_SEEK_FIXES.find(entry =>
Array.isArray(entry.urls) && entry.urls.some(url => matchesDomain(host, url))
) || null;
};
})(globalThis);
+13
View File
@@ -391,6 +391,7 @@
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
<button id="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button>
</div>
<!-- Room Tab -->
@@ -692,6 +693,18 @@
</div>
</div>
<div id="tab-devtools" class="tab-content">
<label>Remote Seek</label>
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
<button id="remoteSeekForward" class="secondary" style="flex:1; font-size:12px;">+30s</button>
</div>
<button id="remoteSeekFiveMin" class="secondary" style="width:100%; font-size:12px; margin-bottom:15px;">Seek 5:00</button>
<div style="font-size:10px; color:var(--text-muted); text-align:center; margin-top:10px; opacity:0.7;">
Build: <span id="buildIdentifier">__BUILD_TIMESTAMP__</span>
</div>
</div>
<script src="popup.js" type="module"></script>
<!-- Onboarding Overlay -->
+120 -28
View File
@@ -2,7 +2,7 @@ import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle } from './title-privacy.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
const elements = {
@@ -71,7 +71,11 @@ const elements = {
settingsVersion: document.getElementById('settingsVersion'),
devSupportLink: document.getElementById('devSupportLink'),
devReviewLink: document.getElementById('devReviewLink'),
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
devToolsTabBtn: document.getElementById('devToolsTabBtn'),
remoteSeekBack: document.getElementById('remoteSeekBack'),
remoteSeekForward: document.getElementById('remoteSeekForward'),
remoteSeekFiveMin: document.getElementById('remoteSeekFiveMin')
};
let localPeerId = null;
@@ -88,6 +92,19 @@ let errorToken = 0;
let forceSyncDone = false;
let connectionErrorTimer = null;
let pendingConnectionErrorMsg = null;
function devToolsEnabled() {
return elements.username && elements.username.value.trim() === 'KoalaDev';
}
function syncDevToolsVisibility() {
if (!elements.devToolsTabBtn) return;
const enabled = devToolsEnabled();
elements.devToolsTabBtn.style.display = enabled ? '' : 'none';
if (!enabled && document.getElementById('tab-devtools')?.classList.contains('active')) {
document.querySelector('.tab-btn[data-tab="tab-settings"]')?.click();
}
}
let roomListRefreshTimer = null;
let roomListRefreshInterval = null;
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
@@ -210,6 +227,7 @@ async function init() {
elements.roomId.value = localData.roomId || '';
elements.password.value = localData.password || '';
elements.username.value = username;
syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
@@ -277,13 +295,13 @@ async function init() {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
} else if (localData.activeTab) {
} else if (localData.activeTab && (localData.activeTab !== 'tab-devtools' || devToolsEnabled())) {
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
if (btn) btn.click();
}
} else {
await populateTabs();
if (localData.activeTab) {
if (localData.activeTab && (localData.activeTab !== 'tab-devtools' || devToolsEnabled())) {
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
if (btn) btn.click();
}
@@ -903,7 +921,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
const cleanTitle = (rawTitle) => {
if (!rawTitle) return '';
return rawTitle
return (normalizeTabTitle(rawTitle) || '')
.replace(/(?:\s*[-\|•]\s*(?:YouTube|Twitch|Jellyfin|Emby|Netflix|Vimeo|Dailymotion).*)$/i, '')
.replace(/^(?:Netflix|Twitch|YouTube|Emby|Jellyfin)\s*[-\|•]\s*/i, '')
.trim();
@@ -1275,8 +1293,10 @@ elements.serverUrl.addEventListener('input', () => {
chrome.storage.local.set({ serverUrl: elements.serverUrl.value });
});
elements.username.addEventListener('input', syncDevToolsVisibility);
elements.username.addEventListener('change', () => {
chrome.storage.local.set({ username: elements.username.value });
syncDevToolsVisibility();
});
if (elements.langSelector) {
@@ -1568,8 +1588,9 @@ elements.forceSyncBtn.addEventListener('click', async () => {
}
const peers = status.peers || [];
const otherTimes = peers
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && !isNaN(p.currentTime))
.map(p => p.currentTime);
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && p.currentTime !== '')
.map(p => Number(p.currentTime))
.filter(Number.isFinite);
if (otherTimes.length === 0) {
showError(getMessage('ERR_NO_PEERS_TIME'));
@@ -1598,38 +1619,57 @@ elements.forceSyncBtn.addEventListener('click', async () => {
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
const tabId = parseInt(status.targetTabId);
const failForceSyncTime = () => {
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
};
const sendForceSync = (time) => {
if (time === null || time === undefined) {
failForceSyncTime();
return;
}
const target = Number(time);
if (!Number.isFinite(target)) {
failForceSyncTime();
return;
}
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action: EVENTS.FORCE_SYNC_PREPARE,
payload: { targetTime: parseFloat(time) }
payload: { targetTime: target }
});
};
if (mode === 'jump-to-me') {
const retryQueryTime = () => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
return;
}
sendForceSync(retryResponse.currentTime);
});
};
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
setTimeout(() => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError) return;
if (retryResponse && retryResponse.currentTime !== undefined) {
sendForceSync(retryResponse.currentTime);
}
});
}, 500);
}).catch(() => {
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
if (Number.isFinite(response?.currentTime)) {
sendForceSync(response.currentTime);
return;
}
if (chrome.runtime.lastError || !response) {
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
failForceSyncTime();
return;
}
setTimeout(retryQueryTime, 500);
});
return;
}
sendForceSync(response.currentTime);
setTimeout(retryQueryTime, 500);
});
} else {
sendForceSync(targetTime);
@@ -1690,6 +1730,27 @@ elements.pauseBtn.addEventListener('click', () => {
}, 2500);
});
function simulateRemoteSeek({ delta = null, targetTime = null }) {
chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta, targetTime }, (res) => {
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
showToast(res?.message || 'Remote seek failed', 'error');
return;
}
showToast(`Remote seek -> ${formatTime(res.targetTime)}`, 'success', 1200);
refreshDebugInfo();
});
}
if (elements.remoteSeekBack) {
elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek({ delta: -30 }));
}
if (elements.remoteSeekForward) {
elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek({ delta: 30 }));
}
if (elements.remoteSeekFiveMin) {
elements.remoteSeekFiveMin.addEventListener('click', () => simulateRemoteSeek({ targetTime: 300 }));
}
elements.clearLogs.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'CLEAR_LOGS' }, () => {
elements.logList.innerHTML = '';
@@ -2047,6 +2108,26 @@ elements.copyLogs.addEventListener('click', () => {
lines.push(`- **ReadyState:** ${readyOk ? '\u2705' : '\u26A0\uFE0F'} ${safe(vs.readyState, '?')} (${readyLabel})`);
lines.push(`- **Network:** ${safe(vs.networkState, '?')} (${netLabel})`);
lines.push(`- **Buffered:** ${safe(vs.buffered, '?')}`);
if (vs.nativeCurrentTime != null || vs.nativeDuration != null) {
lines.push(`- **Native Time:** ${safe(vs.nativeCurrentTime, '?')}s / ${safe(vs.nativeDuration, '?')}s`);
}
if (vs.siteQuirk) {
const quirk = vs.siteQuirk;
const label = quirk.name || quirk.key || 'site';
if (quirk.timeline) {
lines.push(`- **${label} Timeline:** ${safe(quirk.timeline.current, '?')}s / ${safe(quirk.timeline.duration, '?')}s`);
}
const candidates = Array.isArray(quirk.timelineCandidates) ? quirk.timelineCandidates : [];
if (candidates.length > 0) {
lines.push(`- **${label} Timeline Candidates:**`);
candidates.forEach(c => lines.push(` - ${safe(c.source, '?')}: ${safe(c.current, '?')}s / ${safe(c.duration, '?')}s`));
}
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons : [];
if (buttons.length > 0) {
lines.push(`- **${label} Buttons:**`);
buttons.forEach(label => lines.push(` - ${label}`));
}
}
lines.push(`- **Dimensions:** ${safe(vs.videoWidth, '?')}x${safe(vs.videoHeight, '?')}${dimOk ? '' : ' \u26A0\uFE0F 0x0'}`);
lines.push(`- **Muted:** ${safe(vs.muted, '?')} | **Volume:** ${safe(vs.volume, '?')} | **Speed:** ${safe(vs.playbackRate, '?')}x`);
lines.push(`- **Seeking:** ${safe(vs.seeking, '?')} | **Ended:** ${safe(vs.ended, '?')} | **Loop:** ${safe(vs.loop, '?')}`);
@@ -2220,7 +2301,18 @@ function refreshDebugInfo() {
addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`,
state.networkState === 1 ? '#22c55e' : state.networkState === 3 ? '#ef4444' : 'var(--text-muted)');
addField('Buffered', state.buffered || '?');
if (state.nativeCurrentTime != null || state.nativeDuration != null) {
addField('Native Time', `${state.nativeCurrentTime ?? '?'}s / ${state.nativeDuration ?? '?'}s`);
}
if (state.siteQuirk) {
const quirk = state.siteQuirk;
const label = quirk.name || quirk.key || 'Site';
if (quirk.timeline) {
addField(`${label} Timeline`, `${quirk.timeline.current ?? '?'}s / ${quirk.timeline.duration ?? '?'}s`);
}
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : '';
if (buttons) addField(`${label} Buttons`, buttons);
}
addSection('Properties');
addField('Seeking', String(state.seeking));
addField('Ended', String(state.ended));
+7 -1
View File
@@ -17,9 +17,15 @@ export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_M
return normalizeTitlePrivacyMode(legacyMode) === TITLE_PRIVACY_MODES.FULL;
}
export function normalizeTabTitle(title) {
if (typeof title !== 'string') return null;
const normalized = title.replace(/^\s*(?:\(\d{1,2}\)|\[\d{1,2}\])\s+/, '').trim();
return normalized.length > 0 ? normalized : null;
}
export function sanitizeTabTitle(title, sendTabTitle) {
if (!sendTabTitle) return null;
return typeof title === 'string' && title.length > 0 ? title : null;
return normalizeTabTitle(title);
}
export function sanitizeSharedTitle(title, mode) {
+1440 -6
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.5.0",
"version": "2.5.2",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
@@ -8,15 +8,18 @@
"build:extension": "node scripts/build-extension.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node scripts/verify-release.mjs",
"test": "npm run verify",
"test:unit": "vitest run",
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.9",
"archiver": "^7.0.1",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1"
"svgo": "^4.0.1",
"vitest": "^4.1.9"
}
}
+6
View File
@@ -140,6 +140,12 @@ function copyExtensionFiles(targetDir, browserName) {
fs.writeFileSync(destPath, content);
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
} else if (item === 'popup.html') {
let content = fs.readFileSync(srcPath, 'utf8');
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
content = content.replace(/__BUILD_TIMESTAMP__/g, timestamp);
fs.writeFileSync(destPath, content);
console.log(`✓ Injected build timestamp into popup.html: ${timestamp}`);
} else {
fs.copyFileSync(srcPath, destPath);
}
+90 -1
View File
@@ -19,6 +19,14 @@ function extractFunction(name, text) {
throw new Error(`${name} body did not terminate`);
}
function makeSeekable(ranges = []) {
return {
length: ranges.length,
start(i) { return ranges[i][0]; },
end(i) { return ranges[i][1]; }
};
}
function makeVideo(name, width, height, options = {}) {
return {
name,
@@ -28,7 +36,9 @@ function makeVideo(name, width, height, options = {}) {
offsetWidth: width,
offsetHeight: height,
muted: options.muted ?? true,
duration: options.duration ?? 0
duration: options.duration ?? 0,
currentTime: options.currentTime ?? 0,
seekable: options.seekable ?? makeSeekable()
};
}
@@ -61,4 +71,83 @@ assert.strictEqual(
'findVideo should score Shadow DOM videos together with light DOM videos'
);
function makeDocument(nodes = []) {
return {
querySelectorAll() { return nodes; }
};
}
function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null) {
const disneyPageApiTime = pageApiTime
? `let disneyPageApiTime = { position: ${pageApiTime.position}, duration: ${pageApiTime.duration}, at: Date.now() - ${pageApiTime.ageMs || 0} };`
: 'let disneyPageApiTime = null;';
return Function('window', 'document', [
disneyPageApiTime,
extractFunction('hostMatchesUrl', source),
extractFunction('matchesPlayerUrls', source),
extractFunction('isDisneyPlusHost', source),
extractFunction('getDisneyPlusTimeline', source),
extractFunction('getSiteQuirkAdapters', source),
extractFunction('getActiveSiteQuirk', source),
extractFunction('getSiteQuirkTimeline', source),
extractFunction('getSiteQuirkDebug', source),
extractFunction('getSyncCurrentTime', source),
extractFunction('getSyncDuration', source),
extractFunction('toNativeSeekTime', source),
'return { getActiveSiteQuirk, getSyncCurrentTime, getSyncDuration, toNativeSeekTime };'
].join('\n'))({ location: { hostname } }, document);
}
function loadPlayerFixFns(hostname) {
return Function('window', [
extractFunction('hostMatchesUrl', source),
extractFunction('matchesPlayerUrls', source),
extractFunction('getPlayerActionFixes', source),
extractFunction('getActivePlayerActionFix', source),
'return { getPlayerActionFixes, getActivePlayerActionFix };'
].join('\n'))({ location: { hostname } });
}
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
position: 9,
duration: 10800
});
assert.equal(disneyFns.getActiveSiteQuirk().name, 'disneyplus-page-api');
assert.deepEqual(disneyFns.getActiveSiteQuirk().urls, ['disneyplus.com']);
const disneyVideo = makeVideo('disney-offset', 1920, 1080, {
currentTime: 29,
duration: 0,
seekable: makeSeekable([[0, 32400]])
});
assert.equal(disneyFns.getSyncCurrentTime(disneyVideo), 9);
assert.equal(disneyFns.getSyncDuration(disneyVideo), 10800);
assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 39);
assert.equal(disneyFns.getSyncCurrentTime(makeVideo('disney-native-broken', 1920, 1080, {
currentTime: Number.NaN,
duration: 0
})), 9);
const disneyNoPageApiFns = loadTimelineFns('www.disneyplus.com');
const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, {
currentTime: 29,
duration: 0,
seekable: makeSeekable([[20, 10820]])
});
assert.equal(disneyNoPageApiFns.getSyncCurrentTime(disneyOffsetVideo), null);
assert.equal(disneyNoPageApiFns.getSyncDuration(disneyOffsetVideo), 0);
assert.equal(disneyNoPageApiFns.toNativeSeekTime(disneyOffsetVideo, 39), 39);
const genericFns = loadTimelineFns('example.com');
assert.equal(genericFns.getActiveSiteQuirk(), null);
assert.equal(genericFns.getSyncCurrentTime(disneyVideo), 29);
assert.equal(genericFns.getSyncDuration(disneyVideo), 0);
assert.equal(genericFns.toNativeSeekTime(disneyVideo, 39), 39);
const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
const genericFixFns = loadPlayerFixFns('example.com');
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
console.log('content video finder tests passed');
+20 -3
View File
@@ -4,6 +4,7 @@ import {
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
checkAuthRate,
recordAuthFailure,
clearRateLimitMaps,
@@ -13,11 +14,13 @@ import {
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
leaveRoomCounts,
rateLimitDenied,
startRateLimitCleanup,
stopRateLimitCleanup,
CONNECTION_RATE_LIMIT,
EVENT_RATE_LIMIT
EVENT_RATE_LIMIT,
LEAVE_ROOM_RATE_LIMIT
} from '../server/rate-limiter.js';
// Helper: mock io for cleanup
@@ -26,7 +29,7 @@ const mockIo = { sockets: { sockets: new Map() } };
// Reset state before each test group
function reset() {
clearRateLimitMaps();
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
stopRateLimitCleanup();
}
@@ -52,6 +55,16 @@ assert.equal(rateLimitDenied.events, 1);
reset();
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
// --- checkLeaveRoomRate ---
reset();
assert.equal(checkLeaveRoomRate('sock-leave-1'), true, 'first leave-room event allowed');
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT - 1; i++) checkLeaveRoomRate('sock-leave-1');
assert.equal(checkLeaveRoomRate('sock-leave-1'), false, `leave-room beyond ${LEAVE_ROOM_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.leaveRoom, 1);
reset();
assert.equal(checkLeaveRoomRate('sock-leave-2'), true, 'separate leave-room socket independent');
// --- checkHealthRate ---
reset();
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
@@ -91,12 +104,14 @@ eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
roomListCooldowns.set('sock2', Date.now());
leaveRoomCounts.set('sock3', { count: 1, resetTime: Date.now() + 60000 });
clearRateLimitMaps();
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
assert.equal(leaveRoomCounts.size, 0, 'leaveRoomCounts cleared');
// --- startRateLimitCleanup / stopRateLimitCleanup ---
reset();
@@ -108,7 +123,9 @@ assert.ok(true, 'cleanup start/stop does not throw');
// --- rateLimitDenied reset ---
reset();
rateLimitDenied.connections = 5;
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
rateLimitDenied.leaveRoom = 5;
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
assert.equal(rateLimitDenied.leaveRoom, 0, 'leave-room denial counter resettable');
console.log('rate-limiter tests passed');
+5 -4
View File
@@ -52,7 +52,7 @@ const basicHealth = buildHealthPayload({
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 }
});
assert.deepEqual(
@@ -68,7 +68,8 @@ const adminHealth = buildHealthPayload({
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
rateLimitDenied: { leaveRoom: 8 }
});
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
@@ -79,8 +80,8 @@ assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'ad
assert.deepEqual(
adminHealth.rateLimits,
{
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 8 }
},
'admin metrics should expose rate-limit tracking and denial counts'
);
+6
View File
@@ -3,6 +3,7 @@ import {
TITLE_PRIVACY_MODES,
applyTitlePrivacyToPayload,
normalizeSendTabTitle,
normalizeTabTitle,
normalizeTitlePrivacyMode,
sanitizeSharedTitle,
sanitizeTabTitle
@@ -15,8 +16,13 @@ assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL), true);
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE), false);
assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(500) Days of Summer'), '(500) Days of Summer');
assert.equal(normalizeTabTitle(' '), null);
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
assert.equal(sanitizeTabTitle('(12) Private Tab', true), 'Private Tab');
assert.equal(sanitizeTabTitle('Private Tab', false), null);
assert.equal(sanitizeTabTitle('', true), null);
+1
View File
@@ -7,6 +7,7 @@ import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
['vitest unit tests', 'npm', ['run', 'test:unit']],
['server ops', 'node', ['scripts/test-server-ops.mjs']],
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
+15 -4
View File
@@ -22,6 +22,7 @@ import {
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
leaveRoomCounts,
rateLimitDenied,
checkAuthRate,
recordAuthFailure,
@@ -29,6 +30,7 @@ import {
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
startRateLimitCleanup,
stopRateLimitCleanup,
clearRateLimitMaps
@@ -114,7 +116,8 @@ app.get('/health', (req, res) => {
health: healthCounts.size,
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
roomList: roomListCooldowns.size,
leaveRoom: leaveRoomCounts.size
},
rateLimitDenied
})
@@ -661,6 +664,11 @@ io.on('connection', (socket) => {
});
socket.on(EVENTS.LEAVE_ROOM, () => {
if (!checkLeaveRoomRate(socket.id)) {
log('SECURITY', `LEAVE_ROOM rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
try {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
@@ -839,6 +847,7 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
leaveRoomCounts.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
try {
@@ -919,12 +928,14 @@ function gracefulShutdown(signal) {
log('SERVER', `${signal} received — starting graceful shutdown...`);
// 1. Notify all connected clients so they can display a meaningful message
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Reconnecting automatically...' });
// 2. Stop accepting new HTTP connections
// 2. Gracefully disconnect all Socket.IO clients
io.disconnectSockets(true);
// 3. Stop accepting new HTTP connections
httpServer.close(() => {
log('SERVER', 'HTTP server closed. Exiting.');
process.exit(0);
});
// 3. Safety net: force-exit after 5s if connections don't drain
// 4. Safety net: force-exit after 5s if connections don't drain
setTimeout(() => {
log('SERVER', 'Force-exit after timeout.');
process.exit(1);
@@ -943,7 +954,7 @@ export async function stopServerForTests() {
healthResponseCache.clear();
io.removeAllListeners();
io.disconnectSockets(true);
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
if (!httpServer.listening) return;
await new Promise((resolve, reject) => {
httpServer.close((err) => err ? reject(err) : resolve());
+4 -2
View File
@@ -97,14 +97,16 @@ export function buildHealthPayload({
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
roomList: rateLimitSizes.roomList || 0,
leaveRoom: rateLimitSizes.leaveRoom || 0
},
denied: {
connections: rateLimitDenied.connections || 0,
events: rateLimitDenied.events || 0,
health: rateLimitDenied.health || 0,
adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0,
roomList: rateLimitDenied.roomList || 0
roomList: rateLimitDenied.roomList || 0,
leaveRoom: rateLimitDenied.leaveRoom || 0
}
},
memory: {
+26 -1
View File
@@ -14,6 +14,8 @@ export const EVENT_RATE_LIMIT = 50; // max relayed events per soc
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
export const connectionCounts = new Map(); // ip -> { count, resetTime }
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
@@ -21,13 +23,15 @@ export const eventCounts = new Map(); // socketId -> { count, resetTime }
export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
export const rateLimitDenied = {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0
roomList: 0,
leaveRoom: 0
};
let authCleanupId = null;
@@ -122,6 +126,20 @@ export function checkAdminMetricsAuthRate(ip) {
return false;
}
export function checkLeaveRoomRate(socketId) {
const now = Date.now();
const entry = leaveRoomCounts.get(socketId) || { count: 0, resetTime: now + LEAVE_ROOM_RATE_WINDOW_MS };
if (now > entry.resetTime) {
entry.count = 0;
entry.resetTime = now + LEAVE_ROOM_RATE_WINDOW_MS;
}
entry.count++;
leaveRoomCounts.set(socketId, entry);
if (entry.count <= LEAVE_ROOM_RATE_LIMIT) return true;
rateLimitDenied.leaveRoom++;
return false;
}
export function startRateLimitCleanup(io) {
if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start
// Clean up old auth failure records (every 15 minutes)
@@ -145,6 +163,11 @@ export function startRateLimitCleanup(io) {
eventCounts.delete(socketId);
}
}
for (const [socketId, entry] of leaveRoomCounts.entries()) {
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
leaveRoomCounts.delete(socketId);
}
}
for (const [ip, entry] of healthCounts.entries()) {
if (now > entry.resetTime) healthCounts.delete(ip);
}
@@ -166,7 +189,9 @@ export function clearRateLimitMaps() {
connectionCounts.clear();
failedAuthAttempts.clear();
eventCounts.clear();
healthCounts.clear();
adminMetricsAuthCounts.clear();
roomListCooldowns.clear();
leaveRoomCounts.clear();
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
checkLeaveRoomRate,
LEAVE_ROOM_RATE_LIMIT,
LEAVE_ROOM_RATE_WINDOW_MS,
rateLimitDenied,
leaveRoomCounts,
clearRateLimitMaps
} from './rate-limiter.js';
describe('LEAVE_ROOM Rate Limiter', () => {
const testSocketId = 'test-socket-123';
beforeEach(() => {
clearRateLimitMaps();
rateLimitDenied.leaveRoom = 0;
});
afterEach(() => {
clearRateLimitMaps();
});
it('should allow LEAVE_ROOM within limit', () => {
// Test within the rate limit
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
const result = checkLeaveRoomRate(testSocketId);
expect(result).toBe(true);
}
expect(rateLimitDenied.leaveRoom).toBe(0);
});
it('should block LEAVE_ROOM when exceeding limit', () => {
// Fill up to the limit
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
checkLeaveRoomRate(testSocketId);
}
// Next request should be blocked
const result = checkLeaveRoomRate(testSocketId);
expect(result).toBe(false);
expect(rateLimitDenied.leaveRoom).toBe(1);
});
it('should reset count after window expires', () => {
// Fill up to the limit
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
checkLeaveRoomRate(testSocketId);
}
// Verify we're at the limit
let result = checkLeaveRoomRate(testSocketId);
expect(result).toBe(false);
// Fast-forward time beyond the rate limit window
const entry = leaveRoomCounts.get(testSocketId);
entry.resetTime = Date.now() - LEAVE_ROOM_RATE_WINDOW_MS - 1000;
leaveRoomCounts.set(testSocketId, entry);
// Next request should be allowed again
result = checkLeaveRoomRate(testSocketId);
expect(result).toBe(true);
});
it('should handle multiple sockets independently', () => {
const socketId2 = 'test-socket-456';
// Fill up first socket
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
checkLeaveRoomRate(testSocketId);
}
// Second socket should still be allowed
const result = checkLeaveRoomRate(socketId2);
expect(result).toBe(true);
// First socket should be blocked
const result2 = checkLeaveRoomRate(testSocketId);
expect(result2).toBe(false);
});
it('should increment rateLimitDenied counter on block', () => {
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
checkLeaveRoomRate(testSocketId);
}
checkLeaveRoomRate(testSocketId);
expect(rateLimitDenied.leaveRoom).toBe(1);
checkLeaveRoomRate(testSocketId);
expect(rateLimitDenied.leaveRoom).toBe(2);
});
it('should be cleared by the shared reset helper', () => {
checkLeaveRoomRate(testSocketId);
expect(leaveRoomCounts.size).toBe(1);
clearRateLimitMaps();
expect(leaveRoomCounts.size).toBe(0);
});
});
describe('Rate Limit Constants', () => {
it('should have correct rate limit values', () => {
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
expect(LEAVE_ROOM_RATE_WINDOW_MS).toBe(60000); // 1 minute
});
});
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.5.0";
export const APP_VERSION = "2.5.2";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: [
'server/**/*.test.js',
'server/**/*.test.mjs',
'shared/**/*.test.js',
'shared/**/*.test.mjs'
],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['server/**/*.js', 'shared/**/*.js'],
exclude: [
'**/node_modules/**',
'**/scripts/**',
'**/extension/**'
]
}
}
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+62 -62
View File
@@ -3,31 +3,31 @@
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://sync.koalastuff.net/imprint</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/privacy</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/impressum</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -49,7 +49,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/de/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -71,7 +71,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -93,7 +93,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/de/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -115,7 +115,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -137,7 +137,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/de/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -159,7 +159,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/fr/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -181,7 +181,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/fr/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -203,7 +203,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/fr/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -225,7 +225,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/es/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -247,7 +247,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/es/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -269,7 +269,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/es/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -291,7 +291,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/it/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -313,7 +313,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/it/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -335,7 +335,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/it/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -357,7 +357,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/nl/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -379,7 +379,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/nl/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -401,7 +401,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/nl/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -423,7 +423,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pl/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -445,7 +445,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pl/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -467,7 +467,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pl/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -489,7 +489,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -511,7 +511,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -533,7 +533,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -555,7 +555,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt-BR/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -577,7 +577,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt-BR/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -599,7 +599,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt-BR/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -621,7 +621,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ru/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -643,7 +643,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ru/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -665,7 +665,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ru/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -687,7 +687,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/tr/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -709,7 +709,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/tr/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -731,7 +731,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/tr/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -753,7 +753,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ja/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -775,7 +775,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ja/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -797,7 +797,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ja/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -819,7 +819,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ko/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -841,7 +841,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ko/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -863,7 +863,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ko/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -885,7 +885,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/zh/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -907,7 +907,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/zh/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -929,7 +929,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/zh/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -951,7 +951,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/uk/alternatives</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives"/>
@@ -973,7 +973,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/uk/alternatives/teleparty</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty"/>
@@ -995,7 +995,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/uk/alternatives/screen-sharing</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing"/>
@@ -1017,7 +1017,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1037,7 +1037,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/de/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1057,7 +1057,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/fr/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1077,7 +1077,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/es/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1097,7 +1097,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt-BR/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1117,7 +1117,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ru/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1137,7 +1137,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/it/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1157,7 +1157,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pl/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1177,7 +1177,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/tr/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1197,7 +1197,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/nl/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1217,7 +1217,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ja/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1237,7 +1237,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ko/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -1257,7 +1257,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt/</loc>
<lastmod>2026-06-29</lastmod>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
+1 -1
View File
@@ -106,7 +106,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "2.5.0",
"softwareVersion": "2.5.2",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.5.0",
"date": "2026-06-29T10:58:06Z"
"version": "2.5.2",
"date": "2026-07-02T13:34:11Z"
}