this repo has no description
0
fork

Configure Feed

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

centralize service definition

+202 -134
+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
apps/extension/src/utils/services/dom-video.ts
··· 18 18 let refreshFrame: number | null = null; 19 19 let stopped = false; 20 20 21 - 22 21 const readContext = (): ServiceContentContext | null => { 23 22 const mediaId = plugin.parseUrl(window.location.href)?.mediaId; 24 23 if (!activeVideo || !mediaId) return null;
+5 -26
apps/extension/src/utils/services/netflix.ts
··· 1 + import { getServiceDefinition } 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 - } 5 + const NETFLIX_DEFINITION = getServiceDefinition('netflix'); 14 6 15 - function extractNetflixMediaId(url: URL): string | undefined { 16 - return url.pathname.match(/^\/watch\/(\d+)/)?.[1]; 7 + if (!NETFLIX_DEFINITION) { 8 + throw new Error('Netflix service definition is missing.'); 17 9 } 18 10 19 11 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/*'], 12 + ...NETFLIX_DEFINITION, 30 13 playerNotReadyMessage: 'Netflix player is still loading.', 31 - parseUrl: (url) => { 32 - const parsed = parseNetflix(url); 33 - return parsed ? { mediaId: extractNetflixMediaId(parsed) } : null; 34 - }, 35 14 getVideo: () => document.querySelector<HTMLVideoElement>('video'), 36 15 getMediaTitle: () => document.title.replace(NETFLIX_TITLE_SUFFIX, '').trim() || 'Netflix', 37 16 };
+14 -14
apps/extension/src/utils/services/registry.ts
··· 1 - import type { ServiceId } from '@open-watch-party/shared'; 1 + import { 2 + getServiceDefinitionByUrl, 3 + type ServiceDescriptor, 4 + type ServiceId, 5 + } from '@open-watch-party/shared'; 2 6 3 7 import { NETFLIX_SERVICE } from './netflix'; 4 8 import { YOUTUBE_SERVICE } from './youtube'; 5 - import type { ServiceDescriptor, ServicePlugin } from './types'; 9 + import type { ServicePlugin } from './types'; 6 10 7 11 /** 8 12 * Every service the extension knows about. Order drives popup rendering. 9 13 * 10 14 * 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`. 15 + * 1. Add its shared definition to `packages/shared/src/services.ts`. 16 + * 2. Create the extension DOM integration at `apps/extension/src/utils/services/<id>.ts`. 13 17 * 3. Add a one-line entrypoint at `src/entrypoints/<id>.content.ts` via 14 18 * `runServiceContentScript(MY_SERVICE)`. 15 - * 4. Append the plugin below and add its origin(s) to `host_permissions` 16 - * in `wxt.config.ts`. 19 + * 4. Append the plugin below. 17 20 */ 18 21 export const SERVICE_PLUGINS: readonly ServicePlugin[] = [NETFLIX_SERVICE, YOUTUBE_SERVICE]; 19 22 ··· 36 39 export function findPluginByUrl( 37 40 url: string | null | undefined, 38 41 ): { plugin: ServicePlugin; isWatchPage: boolean } | null { 39 - 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 - } 45 - } 46 - return null; 42 + const serviceMatch = getServiceDefinitionByUrl(url); 43 + if (!serviceMatch) return null; 44 + 45 + const plugin = getPlugin(serviceMatch.service.id); 46 + return plugin ? { plugin, isWatchPage: serviceMatch.isWatchPage } : null; 47 47 }
+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 { SupportedServiceDefinition } 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 = SupportedServiceDefinition & { 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 + };
+5 -44
apps/extension/src/utils/services/youtube.ts
··· 1 + import { getServiceDefinition } 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 + const YOUTUBE_DEFINITION = getServiceDefinition('youtube'); 5 6 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; 7 + if (!YOUTUBE_DEFINITION) { 8 + throw new Error('YouTube service definition is missing.'); 35 9 } 36 10 37 11 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/*'], 12 + ...YOUTUBE_DEFINITION, 48 13 playerNotReadyMessage: 'YouTube player is still loading.', 49 - parseUrl: (url) => { 50 - const parsed = parseYoutube(url); 51 - return parsed ? { mediaId: extractYoutubeMediaId(parsed) } : null; 52 - }, 53 14 getVideo: () => 54 15 document.querySelector<HTMLVideoElement>('#movie_player video, video.html5-main-video, video'), 55 16 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(
+2 -2
packages/shared/src/protocol.ts
··· 1 1 import { z } from 'zod'; 2 + import { SUPPORTED_SERVICES, 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'
+49
packages/shared/src/room.test.ts
··· 5 5 applyPlaybackUpdate, 6 6 createRoomRequestSchema, 7 7 createRoomState, 8 + getServiceDefinitionByUrl, 8 9 joinRoomRequestSchema, 9 10 leaveRoomRequestSchema, 10 11 MAX_MEMBER_NAME_LENGTH, ··· 15 16 normalizeRoomCode, 16 17 resolvePlaybackState, 17 18 sanitizeMemberName, 19 + SUPPORTED_SERVICES, 20 + SUPPORTED_SERVICE_CONTENT_MATCHES, 18 21 toPartySnapshot, 19 22 upsertRoomMember, 20 23 } from './index'; ··· 203 206 expect(buildCanonicalWatchUrl('youtube', 'abc123_-')).toBe( 204 207 'https://www.youtube.com/watch?v=abc123_-', 205 208 ); 209 + }); 210 + 211 + it('classifies supported service watch urls', () => { 212 + expect(getServiceDefinitionByUrl('https://www.netflix.com/watch/123456')).toEqual({ 213 + service: expect.objectContaining({ id: 'netflix' }), 214 + isWatchPage: true, 215 + }); 216 + expect(getServiceDefinitionByUrl('https://www.youtube.com/watch?v=abc123')).toEqual({ 217 + service: expect.objectContaining({ id: 'youtube' }), 218 + isWatchPage: true, 219 + }); 220 + expect(getServiceDefinitionByUrl('https://youtu.be/abc123')).toEqual({ 221 + service: expect.objectContaining({ id: 'youtube' }), 222 + isWatchPage: true, 223 + }); 224 + expect(getServiceDefinitionByUrl('https://www.youtube.com/embed/abc123')).toEqual({ 225 + service: expect.objectContaining({ id: 'youtube' }), 226 + isWatchPage: true, 227 + }); 228 + expect(getServiceDefinitionByUrl('https://www.youtube.com/live/abc123')).toEqual({ 229 + service: expect.objectContaining({ id: 'youtube' }), 230 + isWatchPage: true, 231 + }); 232 + }); 233 + 234 + it('classifies supported service non-watch urls and unsupported urls', () => { 235 + expect(getServiceDefinitionByUrl('https://www.netflix.com/browse')).toEqual({ 236 + service: expect.objectContaining({ id: 'netflix' }), 237 + isWatchPage: false, 238 + }); 239 + expect(getServiceDefinitionByUrl('https://www.youtube.com/feed/subscriptions')).toEqual({ 240 + service: expect.objectContaining({ id: 'youtube' }), 241 + isWatchPage: false, 242 + }); 243 + expect(getServiceDefinitionByUrl('https://example.com/watch/123')).toBeNull(); 244 + expect(getServiceDefinitionByUrl('not a url')).toBeNull(); 245 + }); 246 + 247 + it('exposes supported service ids and content matches from one catalog', () => { 248 + expect(SUPPORTED_SERVICES).toEqual(['netflix', 'youtube']); 249 + expect(SUPPORTED_SERVICE_CONTENT_MATCHES).toEqual([ 250 + '*://*.netflix.com/*', 251 + '*://*.youtube.com/*', 252 + '*://youtu.be/*', 253 + '*://*.youtube-nocookie.com/*', 254 + ]); 206 255 }); 207 256 208 257 it('rejects invalid media ids when deriving canonical watch urls', () => {
+114 -11
packages/shared/src/services.ts
··· 1 - import type { ServiceId } from './protocol'; 2 - 3 1 const SAFE_MEDIA_ID_RE = /^[A-Za-z0-9_-]+$/; 2 + const NETFLIX_HOST_RE = /(^|\.)netflix\.com$/; 3 + const YOUTUBE_HOST_RE = /(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/; 4 4 5 5 function isSafeMediaId(mediaId: string): boolean { 6 6 return mediaId.length > 0 && SAFE_MEDIA_ID_RE.test(mediaId); 7 7 } 8 8 9 - export function buildCanonicalWatchUrl(serviceId: ServiceId, mediaId: string): string | null { 10 - if (!isSafeMediaId(mediaId)) { 9 + function parseUrl(rawUrl: string, hostPattern: RegExp): URL | null { 10 + try { 11 + const url = new URL(rawUrl); 12 + return hostPattern.test(url.hostname) ? url : null; 13 + } catch { 11 14 return null; 12 15 } 16 + } 17 + 18 + function extractNetflixMediaId(url: URL): string | undefined { 19 + return url.pathname.match(/^\/watch\/(\d+)/)?.[1]; 20 + } 13 21 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; 22 + function extractYoutubeMediaId(url: URL): string | undefined { 23 + const host = url.hostname; 24 + 25 + if (/(^|\.)youtube\.com$/.test(host)) { 26 + if (url.pathname === '/watch') { 27 + return url.searchParams.get('v') ?? undefined; 28 + } 29 + return url.pathname.match(/^\/(?:embed|live)\/([^/?#]+)/)?.[1] ?? undefined; 30 + } 31 + 32 + if (/(^|\.)youtube-nocookie\.com$/.test(host)) { 33 + return url.pathname.match(/^\/embed\/([^/?#]+)/)?.[1] ?? undefined; 34 + } 35 + 36 + if (host === 'youtu.be') { 37 + const id = url.pathname.replace(/^\//, '').split('/')[0]; 38 + return id || undefined; 39 + } 40 + 41 + return undefined; 42 + } 43 + 44 + export const SERVICE_DEFINITIONS = [ 45 + { 46 + id: 'netflix', 47 + descriptor: { 48 + id: 'netflix', 49 + label: 'Netflix', 50 + accent: '#e50914', 51 + accentContrast: '#ffffff', 52 + glyph: 'N', 53 + watchPathHint: 'netflix.com/watch/...', 54 + }, 55 + contentMatches: ['*://*.netflix.com/*'], 56 + parseUrl: (url: string) => { 57 + const parsed = parseUrl(url, NETFLIX_HOST_RE); 58 + return parsed ? { mediaId: extractNetflixMediaId(parsed) } : null; 59 + }, 60 + buildCanonicalWatchUrl: (mediaId: string) => 61 + isSafeMediaId(mediaId) && /^[0-9]+$/.test(mediaId) 62 + ? `https://www.netflix.com/watch/${mediaId}` 63 + : null, 64 + }, 65 + { 66 + id: 'youtube', 67 + descriptor: { 68 + id: 'youtube', 69 + label: 'YouTube', 70 + accent: '#ff0033', 71 + accentContrast: '#ffffff', 72 + glyph: 'Y', 73 + watchPathHint: 'youtube.com/watch?v=...', 74 + }, 75 + contentMatches: ['*://*.youtube.com/*', '*://youtu.be/*', '*://*.youtube-nocookie.com/*'], 76 + parseUrl: (url: string) => { 77 + const parsed = parseUrl(url, YOUTUBE_HOST_RE); 78 + return parsed ? { mediaId: extractYoutubeMediaId(parsed) } : null; 79 + }, 80 + buildCanonicalWatchUrl: (mediaId: string) => 81 + isSafeMediaId(mediaId) ? `https://www.youtube.com/watch?v=${mediaId}` : null, 82 + }, 83 + ] as const; 84 + 85 + export type SupportedServiceDefinition = (typeof SERVICE_DEFINITIONS)[number]; 86 + export type ServiceId = SupportedServiceDefinition['id']; 87 + export type ServiceDescriptor = SupportedServiceDefinition['descriptor']; 88 + 89 + export const SUPPORTED_SERVICES = SERVICE_DEFINITIONS.map((service) => service.id) as [ 90 + ServiceId, 91 + ...ServiceId[], 92 + ]; 93 + 94 + export const SUPPORTED_SERVICE_DESCRIPTORS = SERVICE_DEFINITIONS.map( 95 + (service) => service.descriptor, 96 + ); 97 + 98 + export const SUPPORTED_SERVICE_CONTENT_MATCHES = SERVICE_DEFINITIONS.flatMap( 99 + (service) => service.contentMatches, 100 + ); 101 + 102 + export function getServiceDefinition( 103 + serviceId: ServiceId | null | undefined, 104 + ): (typeof SERVICE_DEFINITIONS)[number] | null { 105 + return SERVICE_DEFINITIONS.find((service) => service.id === serviceId) ?? null; 106 + } 107 + 108 + export function getServiceDefinitionByUrl( 109 + url: string | null | undefined, 110 + ): { service: (typeof SERVICE_DEFINITIONS)[number]; isWatchPage: boolean } | null { 111 + if (!url) return null; 112 + 113 + for (const service of SERVICE_DEFINITIONS) { 114 + const parsedUrl = service.parseUrl(url); 115 + if (parsedUrl) { 116 + return { service, isWatchPage: Boolean(parsedUrl.mediaId) }; 117 + } 21 118 } 119 + 120 + return null; 121 + } 122 + 123 + export function buildCanonicalWatchUrl(serviceId: ServiceId, mediaId: string): string | null { 124 + return getServiceDefinition(serviceId)?.buildCanonicalWatchUrl(mediaId) ?? null; 22 125 }