Files
ytplayer/docs/p2p-transfer.md

154 lines
10 KiB
Markdown

# Direct device media transfers
## Existing paths (audit before implementation)
| Path | Existing media-byte route | Relevant code |
| --- | --- | --- |
| Paired remote | Controls only; the host plays its own source. No existing file-send action. | `frontend/app.js` Remote; `server/remote.js` |
| Presenter and OBS | Lyrics, timing and metadata only; neither currently plays host media. | Presenter and overlay modules; remote relay |
| Watch Party playback | Each guest loads independently through normal server/YouTube playback, unless already saved locally. | Party `applyState`, `Player.loadVideo`; `server/party.js` |
| Watch Party voice | Direct WebRTC audio; server forwards SDP/ICE only. | Party voice mesh, party `rtc` relay |
| On other devices / failed playback | Existing verified-file DataChannel download, 64 KiB frames, SHA-256 validation, worker writes OPFS. Only offered after server failure in playback. | `p2p-transfer.js`, `p2p-recv-worker.js`, `getFromPeers` |
| Save / preload | Server download first, then OPFS. | `preload`, `API.cacheDownload`, download endpoints |
| Export to Files / Photos | Local OPFS first, server URL next, peer fallback last. Native share is an OS export, not paired-device transfer. | `exportToDevice`, `export.js` |
| Profile / playlist share and send | Metadata only. Sending a playlist does not transfer its media files. | Profile/playlist sync APIs |
| Verify & share | Explicit device upload to server intake; hash and media validation admit it into the server cache. | `P2PClient.contribute`, `p2p-intake.js` |
| Automatic rehydration | Server requests a holder to upload its full file to intake. This is an implicit media-byte server path. | `createRehydrator`, client `upload-request` handler |
| Admin uploads / original YouTube downloads | Intentional server ingestion of original media; separate from direct device transfer. | Upload and media-cache endpoints |
| VLC / external player URLs | HTTP server media URLs; these players cannot consume this application's DataChannel. | External player actions |
The current file protocol has no resume: its receiver truncates partial files and
removes them on failure. Connection timeout is 20 seconds per holder. Device
signalling authenticates a device, but does not constrain its destination to a
verified profile or paired room. Profile names supplied at device registration
are not proof of profile membership. Existing server-verified holdings also
exclude files whose bytes the server has never admitted.
## Implementation design
Reuse ordered RTCDataChannel transfers with public STUN only. The WebSocket
server carries bounded, validated signalling and metadata, never file chunks.
Pair authorization must come from an authenticated remote/party room or a
verified profile session, not an arbitrary client-supplied profile name.
A receiver explicitly accepts an invitation before saving. A cryptographically
random, expiring, one-use invitation binds sender, receiver and file identity;
subsequent signalling remains bound to that pair. Reject replay, mismatched
identities, binary WebSocket messages and unrecognized protocol fields.
Send 64 KiB chunks with bufferedAmount backpressure. Persist a partial OPFS file
and resume at its exact committed byte offset. Rehash the retained prefix before
continuing and verify total size and SHA-256 before promoting the file into the
saved store; only then record metadata. A changed hash/size cannot reuse a partial.
Use a 10-second connection deadline and an explicit, clearly labelled server
fallback offer. Direct mode must not silently rehydrate server media from a
device. Register “Direct device transfer (P2P)” in Settings, default on.
Party playback should request a host's saved file when announced, then play the
receiver's OPFS copy. Remote controls and presenter lyrics retain their existing
behaviour; add explicit receive/play actions rather than automatically starting
media in a control-only screen. A manual send action targets authenticated peers.
Ordinary faststart MP4 cannot safely be appended as arbitrary MediaSource chunks.
Progressive playback requires supported fragmented media and codec detection;
otherwise use the completed, verified file (including iOS). Do not claim that
raw MP4 chunks provide progressive playback.
## Verification required before release
Pure tests cover protocol parsing, chunk bounds, offsets, invitation replay and
pair authorization. A two-page localhost browser test must transfer real bytes
into OPFS through WebRTC and assert that server HTTP/WS logs contain only
signalling and metadata. Exercise interruption/resume and explicit fallback.
Review real iPhone foreground/background behaviour and restrictive NAT failure.
## Implemented behaviour
- `direct-protocol.js` strips unexpected fields and validates file claims and
bounded signalling; `direct-relay.js` binds invitations to authenticated room
members. Accepting an invitation is one-use, and only the bound pair can signal.
- Remote, presenter and party relays carry the new protocol. Presenter and OBS
still display lyrics/control metadata; they do not automatically play media.
A presenter can explicitly accept a file sent by its paired host.
- Settings → Direct device transfer provides the default-on toggle and send
buttons for currently connected paired/party/profile peers. Send the currently
playing **saved** song; the receiving device confirms every copy.
- The paired remote has “Play here from paired screen” when its host has saved
media. Party guests and ordinary playback resolve an advertised direct source
before loading media. Saves and exports do the same; the existing OS export
sheet is preserved.
- `/ws/p2p` now has a profile-scoped, ephemeral file-id directory. Protected
profiles require password/key proof, checked against the server profile hash.
Unprotected profiles retain their existing name-based access policy. Claims
are **not** admitted as server-verified media. Each device advertises at most
1,000 local ids per directory update; use paired-room sending for larger libraries.
- The legacy unconfirmed global signalling path is refused. Older clients need
to reload the updated application to transfer files. This closes a bypass of
receiver confirmation and room/profile membership.
- `direct-media.js` uses public STUN, ordered 64 KiB DataChannel chunks and
bufferedAmount backpressure. `direct-recv-worker.js` writes partials directly
to OPFS, rehashes their retained prefix and verifies exact size plus SHA-256
before promotion. File extensions are preserved. A disconnected partial can
be resumed by sending/requesting the same file again, including non-aligned offsets.
- The 10-second deadline covers connection establishment. Prefix rehashing and
final verification do not consume it. Idle transfer timeout is 30 seconds.
Failures retain partial data and offer an explicit **Use server copy** action.
Automatic holder-to-server rehydration is blocked while direct mode is on;
**Verify & share** remains an intentional, explicit server upload.
- `direct-stream.js` supports progressive preview for compatible fragmented
H.264 MP4 through MediaSource. Ordinary faststart MP4, unsupported codecs,
unavailable MediaSource and preview buffer exhaustion use the complete file.
iPhone playback should be reviewed using that completed-file path. A preview
is interactive and independent of the main player's host clock; main party
playback follows the host after the verified copy is ready.
## Paths that still use server media
Original YouTube acquisition, server uploads, intentional Verify & share intake,
external VLC/M3U HTTP URLs and explicitly accepted server fallbacks still use the
server. If no connected device advertises a local copy, normal original-source
playback/download is preserved; this is not a transfer of another device's file.
Profile/playlist shares, chapters, lyrics, presenter state and OBS state remain
metadata/control only. Direct discovery requires an online paired room or an
existing profile, and the browser must remain available to serve its OPFS file.
No TURN service, deployment change or secret configuration is required.
## Checks and reviewer follow-up
Run:
```sh
node --test frontend/*.test.js
node --check frontend/app.js
bun build --no-bundle server/server.js
bun test server/direct-relay.test.js server/p2p-hub.test.js server/remote.test.js server/party.test.js
npx playwright test -c playwright.direct.config.js
npx playwright test -c playwright.glass-navigation.config.js
```
The two-page localhost test uses the production relay and transport, separate
browser stores, a real RTCDataChannel and actual OPFS writes. It resumes a 2 MB
file at byte 12,345, verifies the resulting SHA-256 and asserts that the server
log contains only invitation/acceptance/signalling/completion. A second case
adds 12 seconds of resume preparation to prove it is outside the 10-second
connection deadline. This is a local transport proof, not proof that arbitrary
NATs will connect.
Check real iPhone foreground/background suspension, large-file quota and final
promotion, iOS completed-file playback, restrictive/symmetric NAT fallback,
paired screen playback, party host changes during a copy, protected-profile
credential changes/reconnect, OS export gestures, and an actual fragmented MP4
MediaSource preview on desktop. Public STUN cannot overcome every NAT; declined
invitations do not start media acquisition. Peer directories update when the
presence socket connects and when local saves complete, rather than promising
availability of offline/backgrounded devices.
Final verification on 2026-10-03: 123 frontend unit tests, 22 touched server
tests (including remote and party), app syntax and server build, 8 Glass/Classic
navigation browser tests at 390/1440 px, and both direct-transfer browser cases
passed. The first navigation run lost its reused localhost server; a fresh-server
rerun passed all eight cases without a code change. Classic screenshot comparisons
remain within the test's rasterization tolerance. No real iPhone or restrictive
NAT test was performed here, and no code was pushed or deployed.