this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

centralize service definition #2

open opened by ruszabarov.tngl.sh targeting main from push-sunoulozlzqw
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:smondre4ixdrerjt3q7wdij7/sh.tangled.repo.pull/3mkqp3rpa3422
+285 -183
Diff #2
+5 -4
README.md
··· 13 13 | Netflix | `netflix.com/watch/...` | 14 14 | YouTube | `youtube.com/watch?v=...`, `youtu.be/...`, `youtube.com/embed/...`, `youtube.com/live/...` | 15 15 16 - Adding a service is a self-contained change: drop a `ServicePlugin` under 16 + Adding a service starts in `packages/shared/src/services.ts`, which owns the 17 + service ID, display metadata, URL parsing, canonical watch URL builder, and 18 + extension match patterns. Then add the extension-only DOM integration under 17 19 `apps/extension/src/utils/services/<id>.ts`, add a one-line 18 - `runServiceContentScript(MY_SERVICE)` entrypoint, register the plugin in 19 - `SERVICE_PLUGINS`, and append its origin to `host_permissions` in 20 - `apps/extension/wxt.config.ts`. 20 + `runServiceContentScript(MY_SERVICE)` entrypoint, and register the plugin in 21 + `SERVICE_PLUGINS`. 21 22 22 23 ## Commands 23 24
+1 -1
apps/extension/src/components/popup/Lobby.svelte
··· 59 59 60 60 <section class="flex flex-col gap-3"> 61 61 <div class="flex items-start gap-3"> 62 - <ServiceBadge serviceId={activeDescriptor?.id ?? null} /> 62 + <ServiceBadge serviceId={activeTab.activeServiceId} /> 63 63 <div class="min-w-0 space-y-1"> 64 64 <p class="m-0 text-sm font-semibold leading-5"> 65 65 {title}
+1 -2
apps/extension/src/entrypoints/netflix.content.ts
··· 1 1 import { runServiceContentScript } from '../utils/services/dom-video'; 2 - import { NETFLIX_SERVICE } from '../utils/services/netflix'; 3 2 4 - export default runServiceContentScript(NETFLIX_SERVICE); 3 + export default runServiceContentScript('netflix');
+1 -2
apps/extension/src/entrypoints/youtube.content.ts
··· 1 1 import { runServiceContentScript } from '../utils/services/dom-video'; 2 - import { YOUTUBE_SERVICE } from '../utils/services/youtube'; 3 2 4 - export default runServiceContentScript(YOUTUBE_SERVICE); 3 + export default runServiceContentScript('youtube');
+1 -1
apps/extension/src/utils/active-tab.ts
··· 40 40 return { 41 41 tabId: tab.id, 42 42 title: tab.title ?? '', 43 - activeServiceId: classification?.plugin.id ?? null, 43 + activeServiceId: classification?.serviceId ?? null, 44 44 isWatchPage: classification?.isWatchPage ?? false, 45 45 }; 46 46 }
+6 -3
apps/extension/src/utils/background/controlled-tab-service.ts
··· 19 19 playback: PlaybackUpdateDraft; 20 20 } 21 21 22 + function isPluginUrl(plugin: { matchesUrl(url: URL): boolean }, rawUrl: string): boolean { 23 + return URL.canParse(rawUrl) && plugin.matchesUrl(new URL(rawUrl)); 24 + } 25 + 22 26 export class ControlledTabService { 23 27 constructor( 24 28 private readonly state: BackgroundState, ··· 35 39 if (tabId === controlledTab?.tabId && tab.url) { 36 40 const session = selectSession(this.state); 37 41 const sessionPlugin = session ? getPlugin(session.serviceId) : null; 38 - if (sessionPlugin && !sessionPlugin.parseUrl(tab.url)) { 42 + if (sessionPlugin && !isPluginUrl(sessionPlugin, tab.url)) { 39 43 this.state.lastWarning = `The controlled tab left ${sessionPlugin.descriptor.label}.`; 40 44 syncBackgroundState(this.state); 41 45 } ··· 164 168 throw new Error('This tab is not on a supported streaming service.'); 165 169 } 166 170 167 - const parsedContextUrl = plugin.parseUrl(context.href); 168 - if (!parsedContextUrl?.mediaId) { 171 + if (plugin.extractMediaId(new URL(context.href)) === null) { 169 172 throw new Error(`${plugin.descriptor.label} tab is not on a supported watch page.`); 170 173 } 171 174
+11 -10
apps/extension/src/utils/services/dom-video.ts
··· 1 - import type { PlaybackUpdateDraft } from '@open-watch-party/shared'; 1 + import type { PlaybackUpdateDraft, ServiceId } from '@open-watch-party/shared'; 2 2 import { defineContentScript } from 'wxt/utils/define-content-script'; 3 3 4 4 import type { ServiceContentContext } from '../protocol/extension'; 5 5 import { onMessage, sendMessage } from '../protocol/messaging'; 6 - import type { ServicePlugin } from './types'; 6 + import { SERVICE_PLUGIN_BY_ID } from './registry'; 7 7 8 8 const VIDEO_EVENTS = ['play', 'pause', 'seeked', 'loadedmetadata', 'ended'] as const; 9 9 const SEEK_THRESHOLD_SEC = 1.5; 10 10 11 - export function runServiceContentScript(plugin: ServicePlugin) { 11 + export function runServiceContentScript(serviceId: ServiceId) { 12 + const plugin = SERVICE_PLUGIN_BY_ID[serviceId]; 13 + 12 14 return defineContentScript({ 13 15 matches: [...plugin.contentMatches], 14 16 main() { ··· 18 20 let refreshFrame: number | null = null; 19 21 let stopped = false; 20 22 21 - 22 23 const readContext = (): ServiceContentContext | null => { 23 - const mediaId = plugin.parseUrl(window.location.href)?.mediaId; 24 - if (!activeVideo || !mediaId) return null; 24 + const mediaId = plugin.extractMediaId(new URL(window.location.href)); 25 + if (!activeVideo || mediaId === null) return null; 25 26 26 27 return { 27 - serviceId: plugin.id, 28 + serviceId, 28 29 href: window.location.href, 29 30 title: document.title, 30 31 mediaTitle: plugin.getMediaTitle(), ··· 33 34 }; 34 35 35 36 const readPlayback = (): PlaybackUpdateDraft | null => { 36 - const mediaId = plugin.parseUrl(window.location.href)?.mediaId; 37 - if (!activeVideo || !mediaId) return null; 37 + const mediaId = plugin.extractMediaId(new URL(window.location.href)); 38 + if (!activeVideo || mediaId === null) return null; 38 39 39 40 return { 40 - serviceId: plugin.id, 41 + serviceId, 41 42 mediaId, 42 43 title: plugin.getMediaTitle(), 43 44 positionSec: Number(activeVideo.currentTime.toFixed(3)),
+3 -28
apps/extension/src/utils/services/netflix.ts
··· 1 + import { SERVICE_DEFINITION_BY_ID } from '@open-watch-party/shared'; 1 2 import type { ServicePlugin } from './types'; 2 3 3 - const NETFLIX_HOST_RE = /(^|\.)netflix\.com$/; 4 4 const NETFLIX_TITLE_SUFFIX = /\s*-\s*Netflix$/i; 5 - 6 - function parseNetflix(rawUrl: string): URL | null { 7 - try { 8 - const url = new URL(rawUrl); 9 - return NETFLIX_HOST_RE.test(url.hostname) ? url : null; 10 - } catch { 11 - return null; 12 - } 13 - } 14 - 15 - function extractNetflixMediaId(url: URL): string | undefined { 16 - return url.pathname.match(/^\/watch\/(\d+)/)?.[1]; 17 - } 5 + const NETFLIX_DEFINITION = SERVICE_DEFINITION_BY_ID.netflix; 18 6 19 7 export const NETFLIX_SERVICE: ServicePlugin = { 20 - id: 'netflix', 21 - descriptor: { 22 - id: 'netflix', 23 - label: 'Netflix', 24 - accent: '#e50914', 25 - accentContrast: '#ffffff', 26 - glyph: 'N', 27 - watchPathHint: 'netflix.com/watch/…', 28 - }, 29 - contentMatches: ['*://*.netflix.com/*'], 8 + ...NETFLIX_DEFINITION, 30 9 playerNotReadyMessage: 'Netflix player is still loading.', 31 - parseUrl: (url) => { 32 - const parsed = parseNetflix(url); 33 - return parsed ? { mediaId: extractNetflixMediaId(parsed) } : null; 34 - }, 35 10 getVideo: () => document.querySelector<HTMLVideoElement>('video'), 36 11 getMediaTitle: () => document.title.replace(NETFLIX_TITLE_SUFFIX, '').trim() || 'Netflix', 37 12 };
+35 -20
apps/extension/src/utils/services/registry.ts
··· 1 - import type { ServiceId } from '@open-watch-party/shared'; 1 + import { findServiceDefinitionByUrl, type ServiceId } from '@open-watch-party/shared'; 2 2 3 3 import { NETFLIX_SERVICE } from './netflix'; 4 4 import { YOUTUBE_SERVICE } from './youtube'; 5 - import type { ServiceDescriptor, ServicePlugin } from './types'; 5 + import type { ServicePlugin } from './types'; 6 6 7 7 /** 8 8 * Every service the extension knows about. Order drives popup rendering. 9 9 * 10 10 * Adding a service: 11 - * 1. Add a `ServiceId` to `packages/shared/src/protocol.ts`. 12 - * 2. Create a `ServicePlugin` at `apps/extension/src/utils/services/<id>.ts`. 11 + * 1. Add its shared definition to `packages/shared/src/services.ts`. 12 + * 2. Create the extension DOM integration at `apps/extension/src/utils/services/<id>.ts`. 13 13 * 3. Add a one-line entrypoint at `src/entrypoints/<id>.content.ts` via 14 - * `runServiceContentScript(MY_SERVICE)`. 15 - * 4. Append the plugin below and add its origin(s) to `host_permissions` 16 - * in `wxt.config.ts`. 14 + * `runServiceContentScript('my-service-id')`. 15 + * 4. Append the plugin below. 17 16 */ 18 - export const SERVICE_PLUGINS: readonly ServicePlugin[] = [NETFLIX_SERVICE, YOUTUBE_SERVICE]; 17 + export const SERVICE_PLUGIN_BY_ID = { 18 + netflix: NETFLIX_SERVICE, 19 + youtube: YOUTUBE_SERVICE, 20 + } satisfies Record<ServiceId, ServicePlugin>; 19 21 20 - export const SUPPORTED_SERVICE_DESCRIPTORS: readonly ServiceDescriptor[] = SERVICE_PLUGINS.map( 21 - (p) => p.descriptor, 22 - ); 22 + export const SERVICE_PLUGINS = Object.values(SERVICE_PLUGIN_BY_ID); 23 + 24 + type ServicePluginDescriptor = ServicePlugin['descriptor']; 25 + 26 + export const SUPPORTED_SERVICE_DESCRIPTORS: readonly ServicePluginDescriptor[] = 27 + SERVICE_PLUGINS.map((p) => p.descriptor); 23 28 24 29 export function getPlugin(id: ServiceId | null | undefined): ServicePlugin | null { 25 - return SERVICE_PLUGINS.find((p) => p.id === id) ?? null; 30 + return id ? SERVICE_PLUGIN_BY_ID[id] : null; 26 31 } 27 32 28 - export function getServiceDescriptor(id: ServiceId | null | undefined): ServiceDescriptor | null { 33 + export function getServiceDescriptor( 34 + id: ServiceId | null | undefined, 35 + ): ServicePluginDescriptor | null { 29 36 return getPlugin(id)?.descriptor ?? null; 30 37 } 31 38 ··· 35 42 */ 36 43 export function findPluginByUrl( 37 44 url: string | null | undefined, 38 - ): { plugin: ServicePlugin; isWatchPage: boolean } | null { 45 + ): { serviceId: ServiceId; plugin: ServicePlugin; isWatchPage: boolean } | null { 39 46 if (!url) return null; 40 - for (const plugin of SERVICE_PLUGINS) { 41 - const parsedUrl = plugin.parseUrl(url); 42 - if (parsedUrl) { 43 - return { plugin, isWatchPage: Boolean(parsedUrl.mediaId) }; 44 - } 47 + 48 + let parsedUrl: URL; 49 + try { 50 + parsedUrl = new URL(url); 51 + } catch { 52 + return null; 45 53 } 46 - return null; 54 + 55 + const serviceMatch = findServiceDefinitionByUrl(parsedUrl); 56 + if (!serviceMatch) return null; 57 + 58 + const plugin = getPlugin(serviceMatch.serviceId); 59 + return plugin 60 + ? { serviceId: serviceMatch.serviceId, plugin, isWatchPage: serviceMatch.isWatchPage } 61 + : null; 47 62 }
+4 -25
apps/extension/src/utils/services/types.ts
··· 1 - import type { ServiceId } from '@open-watch-party/shared'; 2 - 3 - /** Presentation metadata rendered by the popup UI. */ 4 - export interface ServiceDescriptor { 5 - readonly id: ServiceId; 6 - readonly label: string; 7 - readonly accent: string; 8 - readonly accentContrast: string; 9 - readonly glyph: string; 10 - readonly watchPathHint: string; 11 - } 1 + import type { ServiceDefinition } from '@open-watch-party/shared'; 12 2 13 3 /** 14 - * Self-contained service integration. A plugin bundles the service metadata, 15 - * URL classifier, DOM selectors, and the one optional playback override needed 16 - * to drive a service from a content script. 4 + * Service integration plus the extension-only DOM hooks needed by a content script. 17 5 */ 18 - export interface ServicePlugin { 19 - readonly id: ServiceId; 20 - readonly descriptor: ServiceDescriptor; 21 - /** WXT-style match patterns; consumed at build time for the manifest. */ 22 - readonly contentMatches: readonly string[]; 6 + export type ServicePlugin = ServiceDefinition & { 23 7 /** Shown when a watch URL matched but the `<video>` element isn't ready. */ 24 8 readonly playerNotReadyMessage: string; 25 - /** 26 - * Returns null for URLs outside the service. A non-null result without 27 - * `mediaId` means the URL belongs to the service but is not a watch page. 28 - */ 29 - parseUrl(url: string): { mediaId?: string } | null; 30 9 getVideo(): HTMLVideoElement | null; 31 10 getMediaTitle(): string; 32 - } 11 + };
+3 -46
apps/extension/src/utils/services/youtube.ts
··· 1 + import { SERVICE_DEFINITION_BY_ID } from '@open-watch-party/shared'; 1 2 import type { ServicePlugin } from './types'; 2 3 3 - const YOUTUBE_HOST_RE = /(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/; 4 4 const YOUTUBE_TITLE_SUFFIX = /\s*-\s*YouTube$/i; 5 - 6 - function parseYoutube(rawUrl: string): URL | null { 7 - try { 8 - const url = new URL(rawUrl); 9 - return YOUTUBE_HOST_RE.test(url.hostname) ? url : null; 10 - } catch { 11 - return null; 12 - } 13 - } 14 - 15 - function extractYoutubeMediaId(url: URL): string | undefined { 16 - const host = url.hostname; 17 - 18 - if (/(^|\.)youtube\.com$/.test(host)) { 19 - if (url.pathname === '/watch') { 20 - return url.searchParams.get('v') ?? undefined; 21 - } 22 - return url.pathname.match(/^\/(?:embed|live)\/([^/?#]+)/)?.[1] ?? undefined; 23 - } 24 - 25 - if (/(^|\.)youtube-nocookie\.com$/.test(host)) { 26 - return url.pathname.match(/^\/embed\/([^/?#]+)/)?.[1] ?? undefined; 27 - } 28 - 29 - if (host === 'youtu.be') { 30 - const id = url.pathname.replace(/^\//, '').split('/')[0]; 31 - return id || undefined; 32 - } 33 - 34 - return undefined; 35 - } 5 + const YOUTUBE_DEFINITION = SERVICE_DEFINITION_BY_ID.youtube; 36 6 37 7 export const YOUTUBE_SERVICE: ServicePlugin = { 38 - id: 'youtube', 39 - descriptor: { 40 - id: 'youtube', 41 - label: 'YouTube', 42 - accent: '#ff0033', 43 - accentContrast: '#ffffff', 44 - glyph: 'Y', 45 - watchPathHint: 'youtube.com/watch?v=…', 46 - }, 47 - contentMatches: ['*://*.youtube.com/*', '*://youtu.be/*', '*://*.youtube-nocookie.com/*'], 8 + ...YOUTUBE_DEFINITION, 48 9 playerNotReadyMessage: 'YouTube player is still loading.', 49 - parseUrl: (url) => { 50 - const parsed = parseYoutube(url); 51 - return parsed ? { mediaId: extractYoutubeMediaId(parsed) } : null; 52 - }, 53 10 getVideo: () => 54 11 document.querySelector<HTMLVideoElement>('#movie_player video, video.html5-main-video, video'), 55 12 getMediaTitle: () => document.title.replace(YOUTUBE_TITLE_SUFFIX, '').trim(),
+2 -6
apps/extension/wxt.config.ts
··· 1 1 import { defineConfig } from 'wxt'; 2 2 import tailwindcss from '@tailwindcss/vite'; 3 + import { SUPPORTED_SERVICE_CONTENT_MATCHES } from '@open-watch-party/shared'; 3 4 4 5 const LOCAL_SERVER_URL = 'http://localhost:8787'; 5 6 ··· 19 20 'wss://*', 20 21 ]; 21 22 22 - const hostPermissions = [ 23 - '*://*.netflix.com/*', 24 - '*://*.youtube.com/*', 25 - '*://youtu.be/*', 26 - '*://*.youtube-nocookie.com/*', 27 - ]; 23 + const hostPermissions = [...SUPPORTED_SERVICE_CONTENT_MATCHES]; 28 24 29 25 export default defineConfig({ 30 26 srcDir: 'src',
+2 -1
apps/server/src/socket.ts
··· 507 507 const key = memberKey(roomCode, memberId); 508 508 const priorSocketId = state.activeSocketByMember.get(key); 509 509 const allowPlaybackUpdate = 510 - state.sessionsBySocket.get(socketId)?.allowPlaybackUpdate ?? createPlaybackUpdateTokenConsumer(); 510 + state.sessionsBySocket.get(socketId)?.allowPlaybackUpdate ?? 511 + createPlaybackUpdateTokenConsumer(); 511 512 512 513 if (priorSocketId && priorSocketId !== socketId) { 513 514 log.info(
+6 -3
packages/shared/src/protocol.ts
··· 1 1 import { z } from 'zod'; 2 + import { isServiceId, type ServiceId } from './services'; 3 + export type { ServiceId } from './services'; 2 4 3 - export const SUPPORTED_SERVICES = ['netflix', 'youtube'] as const; 4 5 export const MAX_MEMBER_NAME_LENGTH = 64; 5 6 export const MAX_TITLE_LENGTH = 256; 6 7 export const MAX_PLAYBACK_POSITION_SEC = 48 * 60 * 60; 7 8 8 9 const CONTROL_CHARACTERS_PATTERN = /\p{Cc}+/gu; 9 10 10 - export type ServiceId = (typeof SUPPORTED_SERVICES)[number]; 11 11 export type ConnectionStatus = 12 12 | 'disconnected' 13 13 | 'connecting' ··· 63 63 return sanitizeText(value, MAX_TITLE_LENGTH) || ''; 64 64 } 65 65 66 - const serviceIdSchema = z.enum(SUPPORTED_SERVICES); 67 66 const roomCodeSchema = z 68 67 .string() 69 68 .trim() ··· 71 70 .pipe(z.string().min(1)); 72 71 const memberIdSchema = z.string().trim().min(1); 73 72 const mediaIdSchema = z.string().trim().min(1); 73 + const serviceIdSchema = z.custom<ServiceId>( 74 + (value) => typeof value === 'string' && isServiceId(value), 75 + { message: 'Unsupported service id' }, 76 + ); 74 77 const positionSchema = z.number().min(0).max(MAX_PLAYBACK_POSITION_SEC); 75 78 const memberNameSchema = z.string().transform(sanitizeMemberName); 76 79 const titleSchema = z
+68 -6
packages/shared/src/room.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 2 3 3 import { 4 - buildCanonicalWatchUrl, 5 4 applyPlaybackUpdate, 6 5 createRoomRequestSchema, 7 6 createRoomState, 7 + findServiceDefinitionByUrl, 8 8 joinRoomRequestSchema, 9 9 leaveRoomRequestSchema, 10 10 MAX_MEMBER_NAME_LENGTH, ··· 15 15 normalizeRoomCode, 16 16 resolvePlaybackState, 17 17 sanitizeMemberName, 18 + SERVICE_DEFINITION_BY_ID, 19 + SUPPORTED_SERVICES, 20 + SUPPORTED_SERVICE_CONTENT_MATCHES, 18 21 toPartySnapshot, 19 22 upsertRoomMember, 20 23 } from './index'; ··· 197 200 }); 198 201 199 202 it('builds canonical watch urls per service', () => { 200 - expect(buildCanonicalWatchUrl('netflix', '123456')).toBe( 203 + expect(SERVICE_DEFINITION_BY_ID.netflix.buildCanonicalWatchUrl('123456')).toBe( 201 204 'https://www.netflix.com/watch/123456', 202 205 ); 203 - expect(buildCanonicalWatchUrl('youtube', 'abc123_-')).toBe( 206 + expect(SERVICE_DEFINITION_BY_ID.youtube.buildCanonicalWatchUrl('abc123_-')).toBe( 204 207 'https://www.youtube.com/watch?v=abc123_-', 205 208 ); 206 209 }); 207 210 211 + it('classifies supported service watch urls', () => { 212 + expect(findServiceDefinitionByUrl(new URL('https://www.netflix.com/watch/123456'))).toEqual({ 213 + serviceId: 'netflix', 214 + service: expect.any(Object), 215 + isWatchPage: true, 216 + }); 217 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/watch?v=abc123'))).toEqual({ 218 + serviceId: 'youtube', 219 + service: expect.any(Object), 220 + isWatchPage: true, 221 + }); 222 + expect(findServiceDefinitionByUrl(new URL('https://youtu.be/abc123'))).toEqual({ 223 + serviceId: 'youtube', 224 + service: expect.any(Object), 225 + isWatchPage: true, 226 + }); 227 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/embed/abc123'))).toEqual({ 228 + serviceId: 'youtube', 229 + service: expect.any(Object), 230 + isWatchPage: true, 231 + }); 232 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/live/abc123'))).toEqual({ 233 + serviceId: 'youtube', 234 + service: expect.any(Object), 235 + isWatchPage: true, 236 + }); 237 + }); 238 + 239 + it('classifies supported service non-watch urls and unsupported urls', () => { 240 + expect(findServiceDefinitionByUrl(new URL('https://www.netflix.com/browse'))).toEqual({ 241 + serviceId: 'netflix', 242 + service: expect.any(Object), 243 + isWatchPage: false, 244 + }); 245 + expect( 246 + findServiceDefinitionByUrl(new URL('https://www.youtube.com/feed/subscriptions')), 247 + ).toEqual({ 248 + serviceId: 'youtube', 249 + service: expect.any(Object), 250 + isWatchPage: false, 251 + }); 252 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/watch?v='))).toEqual({ 253 + serviceId: 'youtube', 254 + service: expect.any(Object), 255 + isWatchPage: false, 256 + }); 257 + expect(findServiceDefinitionByUrl(new URL('https://example.com/watch/123'))).toBeUndefined(); 258 + }); 259 + 260 + it('exposes supported service ids and content matches from one catalog', () => { 261 + expect(SUPPORTED_SERVICES).toEqual(['netflix', 'youtube']); 262 + expect(SUPPORTED_SERVICE_CONTENT_MATCHES).toEqual([ 263 + '*://*.netflix.com/*', 264 + '*://*.youtube.com/*', 265 + '*://youtu.be/*', 266 + '*://*.youtube-nocookie.com/*', 267 + ]); 268 + }); 269 + 208 270 it('rejects invalid media ids when deriving canonical watch urls', () => { 209 - expect(buildCanonicalWatchUrl('netflix', 'abc123')).toBeNull(); 210 - expect(buildCanonicalWatchUrl('youtube', 'abc/123')).toBeNull(); 271 + expect(SERVICE_DEFINITION_BY_ID.netflix.isMediaIdValid('abc123')).toBe(false); 272 + expect(SERVICE_DEFINITION_BY_ID.youtube.isMediaIdValid('abc/123')).toBe(false); 211 273 expect(() => 212 274 createRoomState('ROOM04', { 213 275 memberId: 'member-a', ··· 221 283 playing: true, 222 284 }, 223 285 }), 224 - ).toThrow('Could not derive a canonical watch URL for this service.'); 286 + ).toThrow('Invalid media id for service.'); 225 287 }); 226 288 227 289 it('updates the canonical watch url when playback media changes', () => {
+9 -10
packages/shared/src/room.ts
··· 11 11 sanitizeOptionalTitle, 12 12 MAX_PLAYBACK_POSITION_SEC as maxPlaybackPositionSec, 13 13 } from './protocol'; 14 - import { buildCanonicalWatchUrl } from './services'; 14 + import { SERVICE_DEFINITION_BY_ID } from './services'; 15 15 16 16 export interface RoomState { 17 17 roomCode: string; ··· 49 49 throw new Error('Initial playback service must match the room service.'); 50 50 } 51 51 52 - assertCanonicalWatchUrl(request.serviceId, request.initialPlayback.mediaId); 52 + assertValidMediaId(request.serviceId, request.initialPlayback.mediaId); 53 53 54 54 const sequence = 1; 55 55 const playback: PlaybackState = { ··· 103 103 memberId: string, 104 104 now = Date.now(), 105 105 ): PlaybackState { 106 - assertCanonicalWatchUrl(update.serviceId, update.mediaId); 106 + assertValidMediaId(update.serviceId, update.mediaId); 107 107 108 108 const lastClientSequence = room.lastPlaybackClientSequenceByMember.get(memberId); 109 109 if (lastClientSequence !== undefined && update.clientSequence <= lastClientSequence) { ··· 143 143 } 144 144 145 145 export function toPartySnapshot(room: RoomState, now = Date.now()): PartySnapshot { 146 - const watchUrl = buildCanonicalWatchUrl(room.serviceId, room.playback.mediaId); 147 - if (!watchUrl) { 148 - throw new Error('Could not derive a canonical watch URL for this service.'); 149 - } 146 + const watchUrl = SERVICE_DEFINITION_BY_ID[room.serviceId].buildCanonicalWatchUrl( 147 + room.playback.mediaId, 148 + ); 150 149 151 150 return { 152 151 roomCode: room.roomCode, ··· 169 168 return Math.min(maxPlaybackPositionSec, Math.max(0, Number(value.toFixed(3)))); 170 169 } 171 170 172 - function assertCanonicalWatchUrl(serviceId: ServiceId, mediaId: string): void { 173 - if (!buildCanonicalWatchUrl(serviceId, mediaId)) { 174 - throw new Error('Could not derive a canonical watch URL for this service.'); 171 + function assertValidMediaId(serviceId: ServiceId, mediaId: string): void { 172 + if (!SERVICE_DEFINITION_BY_ID[serviceId].isMediaIdValid(mediaId)) { 173 + throw new Error('Invalid media id for service.'); 175 174 } 176 175 }
+57 -15
packages/shared/src/services.ts
··· 1 - import type { ServiceId } from './protocol'; 1 + import { NETFLIX_DEFINITION } from './services/netflix'; 2 + import { YOUTUBE_DEFINITION } from './services/youtube'; 2 3 3 - const SAFE_MEDIA_ID_RE = /^[A-Za-z0-9_-]+$/; 4 + export type ServiceDescriptor = { 5 + readonly label: string; 6 + readonly accent: string; 7 + readonly accentContrast: string; 8 + readonly glyph: string; 9 + readonly watchPathHint: string; 10 + }; 4 11 5 - function isSafeMediaId(mediaId: string): boolean { 6 - return mediaId.length > 0 && SAFE_MEDIA_ID_RE.test(mediaId); 12 + export type ServiceDefinition = { 13 + readonly descriptor: ServiceDescriptor; 14 + readonly contentMatches: readonly string[]; 15 + matchesUrl(url: URL): boolean; 16 + extractMediaId(url: URL): string | null; 17 + isMediaIdValid(mediaId: string): boolean; 18 + buildCanonicalWatchUrl(mediaId: string): string; 19 + }; 20 + 21 + export const SERVICE_DEFINITION_BY_ID = { 22 + netflix: NETFLIX_DEFINITION, 23 + youtube: YOUTUBE_DEFINITION, 24 + } satisfies Record<string, ServiceDefinition>; 25 + 26 + export const SERVICE_DEFINITIONS = Object.values(SERVICE_DEFINITION_BY_ID); 27 + 28 + export type ServiceId = keyof typeof SERVICE_DEFINITION_BY_ID; 29 + export type ServiceUrlMatch = { 30 + serviceId: ServiceId; 31 + service: ServiceDefinition; 32 + isWatchPage: boolean; 33 + }; 34 + 35 + export const SUPPORTED_SERVICES = Object.keys(SERVICE_DEFINITION_BY_ID); 36 + 37 + export function isServiceId(value: string): value is ServiceId { 38 + return value in SERVICE_DEFINITION_BY_ID; 7 39 } 8 40 9 - export function buildCanonicalWatchUrl(serviceId: ServiceId, mediaId: string): string | null { 10 - if (!isSafeMediaId(mediaId)) { 11 - return null; 12 - } 41 + export const SUPPORTED_SERVICE_DESCRIPTORS = SERVICE_DEFINITIONS.map( 42 + (service) => service.descriptor, 43 + ); 44 + 45 + export const SUPPORTED_SERVICE_CONTENT_MATCHES = SERVICE_DEFINITIONS.flatMap( 46 + (service) => service.contentMatches, 47 + ); 13 48 14 - switch (serviceId) { 15 - case 'netflix': 16 - return /^[0-9]+$/.test(mediaId) ? `https://www.netflix.com/watch/${mediaId}` : null; 17 - case 'youtube': 18 - return `https://www.youtube.com/watch?v=${mediaId}`; 19 - default: 20 - return null; 49 + export function findServiceDefinitionByUrl(url: URL): ServiceUrlMatch | undefined { 50 + for (const serviceId of SUPPORTED_SERVICES) { 51 + if (!isServiceId(serviceId)) continue; 52 + 53 + const service = SERVICE_DEFINITION_BY_ID[serviceId]; 54 + if (service.matchesUrl(url)) { 55 + return { 56 + serviceId, 57 + service, 58 + isWatchPage: service.extractMediaId(url) !== null, 59 + }; 60 + } 21 61 } 62 + 63 + return undefined; 22 64 }
+23
packages/shared/src/services/netflix.ts
··· 1 + import type { ServiceDefinition } from '../services'; 2 + import { isSafeMediaId } from '../utils'; 3 + 4 + const NETFLIX_HOST_RE = /(^|\.)netflix\.com$/; 5 + 6 + function extractNetflixMediaId(url: URL): string | null { 7 + return url.pathname.match(/^\/watch\/(\d+)/)?.[1] ?? null; 8 + } 9 + 10 + export const NETFLIX_DEFINITION = { 11 + descriptor: { 12 + label: 'Netflix', 13 + accent: '#e50914', 14 + accentContrast: '#ffffff', 15 + glyph: 'N', 16 + watchPathHint: 'netflix.com/watch/...', 17 + }, 18 + contentMatches: ['*://*.netflix.com/*'], 19 + matchesUrl: (url: URL) => NETFLIX_HOST_RE.test(url.hostname), 20 + extractMediaId: extractNetflixMediaId, 21 + isMediaIdValid: (mediaId: string) => isSafeMediaId(mediaId) && /^[0-9]+$/.test(mediaId), 22 + buildCanonicalWatchUrl: (mediaId: string) => `https://www.netflix.com/watch/${mediaId}`, 23 + } satisfies ServiceDefinition;
+42
packages/shared/src/services/youtube.ts
··· 1 + import type { ServiceDefinition } from '../services'; 2 + import { isSafeMediaId } from '../utils'; 3 + 4 + const YOUTUBE_HOST_RE = /(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/; 5 + 6 + function extractYoutubeMediaId(url: URL): string | null { 7 + const host = url.hostname; 8 + 9 + if (/(^|\.)youtube\.com$/.test(host)) { 10 + if (url.pathname === '/watch') { 11 + const id = url.searchParams.get('v')?.trim(); 12 + return id || null; 13 + } 14 + return url.pathname.match(/^\/(?:embed|live)\/([^/?#]+)/)?.[1] ?? null; 15 + } 16 + 17 + if (/(^|\.)youtube-nocookie\.com$/.test(host)) { 18 + return url.pathname.match(/^\/embed\/([^/?#]+)/)?.[1] ?? null; 19 + } 20 + 21 + if (host === 'youtu.be') { 22 + const id = url.pathname.replace(/^\//, '').split('/')[0]; 23 + return id || null; 24 + } 25 + 26 + return null; 27 + } 28 + 29 + export const YOUTUBE_DEFINITION = { 30 + descriptor: { 31 + label: 'YouTube', 32 + accent: '#ff0033', 33 + accentContrast: '#ffffff', 34 + glyph: 'Y', 35 + watchPathHint: 'youtube.com/watch?v=...', 36 + }, 37 + contentMatches: ['*://*.youtube.com/*', '*://youtu.be/*', '*://*.youtube-nocookie.com/*'], 38 + matchesUrl: (url: URL) => YOUTUBE_HOST_RE.test(url.hostname), 39 + extractMediaId: extractYoutubeMediaId, 40 + isMediaIdValid: isSafeMediaId, 41 + buildCanonicalWatchUrl: (mediaId: string) => `https://www.youtube.com/watch?v=${mediaId}`, 42 + } satisfies ServiceDefinition;
+5
packages/shared/src/utils.ts
··· 1 + const SAFE_MEDIA_ID_RE = /^[A-Za-z0-9_-]+$/; 2 + 3 + export function isSafeMediaId(mediaId: string): boolean { 4 + return SAFE_MEDIA_ID_RE.test(mediaId); 5 + }

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
centralize service definition
merge conflicts detected
expand
  • README.md:13
  • apps/extension/src/components/popup/Lobby.svelte:59
  • apps/extension/src/entrypoints/netflix.content.ts:1
  • apps/extension/src/entrypoints/youtube.content.ts:1
  • apps/extension/src/utils/active-tab.ts:40
  • apps/extension/src/utils/background/controlled-tab-service.ts:19
  • apps/extension/src/utils/services/dom-video.ts:1
  • apps/extension/src/utils/services/netflix.ts:1
  • apps/extension/src/utils/services/registry.ts:1
  • apps/extension/src/utils/services/types.ts:1
  • apps/extension/src/utils/services/youtube.ts:1
  • apps/extension/wxt.config.ts:1
  • apps/server/src/socket.ts:507
  • packages/shared/src/protocol.ts:1
  • packages/shared/src/room.test.ts:1
  • packages/shared/src/room.ts:11
  • packages/shared/src/services.ts:1
expand 0 comments
1 commit
expand
centralize service definition
expand 0 comments
1 commit
expand
centralize service definition
expand 0 comments