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
+229 -191
Interdiff #0 #1
README.md

This file has not been changed.

+11 -9
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() { ··· 19 21 let stopped = false; 20 22 21 23 const readContext = (): ServiceContentContext | null => { 22 - const mediaId = plugin.parseUrl(window.location.href)?.mediaId; 23 - if (!activeVideo || !mediaId) return null; 24 + const mediaId = plugin.extractMediaId(new URL(window.location.href)); 25 + if (!activeVideo || mediaId === null) return null; 24 26 25 27 return { 26 - serviceId: plugin.id, 28 + serviceId, 27 29 href: window.location.href, 28 30 title: document.title, 29 31 mediaTitle: plugin.getMediaTitle(), ··· 32 34 }; 33 35 34 36 const readPlayback = (): PlaybackUpdateDraft | null => { 35 - const mediaId = plugin.parseUrl(window.location.href)?.mediaId; 36 - if (!activeVideo || !mediaId) return null; 37 + const mediaId = plugin.extractMediaId(new URL(window.location.href)); 38 + if (!activeVideo || mediaId === null) return null; 37 39 38 40 return { 39 - serviceId: plugin.id, 41 + serviceId, 40 42 mediaId, 41 43 title: plugin.getMediaTitle(), 42 44 positionSec: Number(activeVideo.currentTime.toFixed(3)),
+2 -6
apps/extension/src/utils/services/netflix.ts
··· 1 - import { getServiceDefinition } from '@open-watch-party/shared'; 1 + import { SERVICE_DEFINITION_BY_ID } from '@open-watch-party/shared'; 2 2 import type { ServicePlugin } from './types'; 3 3 4 4 const NETFLIX_TITLE_SUFFIX = /\s*-\s*Netflix$/i; 5 - const NETFLIX_DEFINITION = getServiceDefinition('netflix'); 6 - 7 - if (!NETFLIX_DEFINITION) { 8 - throw new Error('Netflix service definition is missing.'); 9 - } 5 + const NETFLIX_DEFINITION = SERVICE_DEFINITION_BY_ID.netflix; 10 6 11 7 export const NETFLIX_SERVICE: ServicePlugin = { 12 8 ...NETFLIX_DEFINITION,
+31 -16
apps/extension/src/utils/services/registry.ts
··· 1 - import { 2 - getServiceDefinitionByUrl, 3 - type ServiceDescriptor, 4 - type ServiceId, 5 - } from '@open-watch-party/shared'; 1 + import { findServiceDefinitionByUrl, type ServiceId } from '@open-watch-party/shared'; 6 2 7 3 import { NETFLIX_SERVICE } from './netflix'; 8 4 import { YOUTUBE_SERVICE } from './youtube'; ··· 15 11 * 1. Add its shared definition to `packages/shared/src/services.ts`. 16 12 * 2. Create the extension DOM integration at `apps/extension/src/utils/services/<id>.ts`. 17 13 * 3. Add a one-line entrypoint at `src/entrypoints/<id>.content.ts` via 18 - * `runServiceContentScript(MY_SERVICE)`. 14 + * `runServiceContentScript('my-service-id')`. 19 15 * 4. Append the plugin below. 20 16 */ 21 - 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>; 22 21 23 - export const SUPPORTED_SERVICE_DESCRIPTORS: readonly ServiceDescriptor[] = SERVICE_PLUGINS.map( 24 - (p) => p.descriptor, 25 - ); 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); 26 28 27 29 export function getPlugin(id: ServiceId | null | undefined): ServicePlugin | null { 28 - return SERVICE_PLUGINS.find((p) => p.id === id) ?? null; 30 + return id ? SERVICE_PLUGIN_BY_ID[id] : null; 29 31 } 30 32 31 - export function getServiceDescriptor(id: ServiceId | null | undefined): ServiceDescriptor | null { 33 + export function getServiceDescriptor( 34 + id: ServiceId | null | undefined, 35 + ): ServicePluginDescriptor | null { 32 36 return getPlugin(id)?.descriptor ?? null; 33 37 } 34 38 ··· 38 42 */ 39 43 export function findPluginByUrl( 40 44 url: string | null | undefined, 41 - ): { plugin: ServicePlugin; isWatchPage: boolean } | null { 42 - const serviceMatch = getServiceDefinitionByUrl(url); 45 + ): { serviceId: ServiceId; plugin: ServicePlugin; isWatchPage: boolean } | null { 46 + if (!url) return null; 47 + 48 + let parsedUrl: URL; 49 + try { 50 + parsedUrl = new URL(url); 51 + } catch { 52 + return null; 53 + } 54 + 55 + const serviceMatch = findServiceDefinitionByUrl(parsedUrl); 43 56 if (!serviceMatch) return null; 44 57 45 - const plugin = getPlugin(serviceMatch.service.id); 46 - return plugin ? { plugin, isWatchPage: serviceMatch.isWatchPage } : null; 58 + const plugin = getPlugin(serviceMatch.serviceId); 59 + return plugin 60 + ? { serviceId: serviceMatch.serviceId, plugin, isWatchPage: serviceMatch.isWatchPage } 61 + : null; 47 62 }
+2 -2
apps/extension/src/utils/services/types.ts
··· 1 - import type { SupportedServiceDefinition } from '@open-watch-party/shared'; 1 + import type { ServiceDefinition } from '@open-watch-party/shared'; 2 2 3 3 /** 4 4 * Service integration plus the extension-only DOM hooks needed by a content script. 5 5 */ 6 - export type ServicePlugin = SupportedServiceDefinition & { 6 + export type ServicePlugin = ServiceDefinition & { 7 7 /** Shown when a watch URL matched but the `<video>` element isn't ready. */ 8 8 readonly playerNotReadyMessage: string; 9 9 getVideo(): HTMLVideoElement | null;
+2 -6
apps/extension/src/utils/services/youtube.ts
··· 1 - import { getServiceDefinition } from '@open-watch-party/shared'; 1 + import { SERVICE_DEFINITION_BY_ID } from '@open-watch-party/shared'; 2 2 import type { ServicePlugin } from './types'; 3 3 4 4 const YOUTUBE_TITLE_SUFFIX = /\s*-\s*YouTube$/i; 5 - const YOUTUBE_DEFINITION = getServiceDefinition('youtube'); 6 - 7 - if (!YOUTUBE_DEFINITION) { 8 - throw new Error('YouTube service definition is missing.'); 9 - } 5 + const YOUTUBE_DEFINITION = SERVICE_DEFINITION_BY_ID.youtube; 10 6 11 7 export const YOUTUBE_SERVICE: ServicePlugin = { 12 8 ...YOUTUBE_DEFINITION,
apps/extension/wxt.config.ts

This file has not been changed.

apps/server/src/socket.ts

This file has not been changed.

+5 -2
packages/shared/src/protocol.ts
··· 1 1 import { z } from 'zod'; 2 - import { SUPPORTED_SERVICES, type ServiceId } from './services'; 2 + import { isServiceId, type ServiceId } from './services'; 3 3 export type { ServiceId } from './services'; 4 4 5 5 export const MAX_MEMBER_NAME_LENGTH = 64; ··· 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
+36 -23
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, 8 - getServiceDefinitionByUrl, 7 + findServiceDefinitionByUrl, 9 8 joinRoomRequestSchema, 10 9 leaveRoomRequestSchema, 11 10 MAX_MEMBER_NAME_LENGTH, ··· 16 15 normalizeRoomCode, 17 16 resolvePlaybackState, 18 17 sanitizeMemberName, 18 + SERVICE_DEFINITION_BY_ID, 19 19 SUPPORTED_SERVICES, 20 20 SUPPORTED_SERVICE_CONTENT_MATCHES, 21 21 toPartySnapshot, ··· 200 200 }); 201 201 202 202 it('builds canonical watch urls per service', () => { 203 - expect(buildCanonicalWatchUrl('netflix', '123456')).toBe( 203 + expect(SERVICE_DEFINITION_BY_ID.netflix.buildCanonicalWatchUrl('123456')).toBe( 204 204 'https://www.netflix.com/watch/123456', 205 205 ); 206 - expect(buildCanonicalWatchUrl('youtube', 'abc123_-')).toBe( 206 + expect(SERVICE_DEFINITION_BY_ID.youtube.buildCanonicalWatchUrl('abc123_-')).toBe( 207 207 'https://www.youtube.com/watch?v=abc123_-', 208 208 ); 209 209 }); 210 210 211 211 it('classifies supported service watch urls', () => { 212 - expect(getServiceDefinitionByUrl('https://www.netflix.com/watch/123456')).toEqual({ 213 - service: expect.objectContaining({ id: 'netflix' }), 212 + expect(findServiceDefinitionByUrl(new URL('https://www.netflix.com/watch/123456'))).toEqual({ 213 + serviceId: 'netflix', 214 + service: expect.any(Object), 214 215 isWatchPage: true, 215 216 }); 216 - expect(getServiceDefinitionByUrl('https://www.youtube.com/watch?v=abc123')).toEqual({ 217 - service: expect.objectContaining({ id: 'youtube' }), 217 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/watch?v=abc123'))).toEqual({ 218 + serviceId: 'youtube', 219 + service: expect.any(Object), 218 220 isWatchPage: true, 219 221 }); 220 - expect(getServiceDefinitionByUrl('https://youtu.be/abc123')).toEqual({ 221 - service: expect.objectContaining({ id: 'youtube' }), 222 + expect(findServiceDefinitionByUrl(new URL('https://youtu.be/abc123'))).toEqual({ 223 + serviceId: 'youtube', 224 + service: expect.any(Object), 222 225 isWatchPage: true, 223 226 }); 224 - expect(getServiceDefinitionByUrl('https://www.youtube.com/embed/abc123')).toEqual({ 225 - service: expect.objectContaining({ id: 'youtube' }), 227 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/embed/abc123'))).toEqual({ 228 + serviceId: 'youtube', 229 + service: expect.any(Object), 226 230 isWatchPage: true, 227 231 }); 228 - expect(getServiceDefinitionByUrl('https://www.youtube.com/live/abc123')).toEqual({ 229 - service: expect.objectContaining({ id: 'youtube' }), 232 + expect(findServiceDefinitionByUrl(new URL('https://www.youtube.com/live/abc123'))).toEqual({ 233 + serviceId: 'youtube', 234 + service: expect.any(Object), 230 235 isWatchPage: true, 231 236 }); 232 237 }); 233 238 234 239 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' }), 240 + expect(findServiceDefinitionByUrl(new URL('https://www.netflix.com/browse'))).toEqual({ 241 + serviceId: 'netflix', 242 + service: expect.any(Object), 237 243 isWatchPage: false, 238 244 }); 239 - expect(getServiceDefinitionByUrl('https://www.youtube.com/feed/subscriptions')).toEqual({ 240 - service: expect.objectContaining({ id: 'youtube' }), 245 + expect( 246 + findServiceDefinitionByUrl(new URL('https://www.youtube.com/feed/subscriptions')), 247 + ).toEqual({ 248 + serviceId: 'youtube', 249 + service: expect.any(Object), 241 250 isWatchPage: false, 242 251 }); 243 - expect(getServiceDefinitionByUrl('https://example.com/watch/123')).toBeNull(); 244 - expect(getServiceDefinitionByUrl('not a url')).toBeNull(); 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(); 245 258 }); 246 259 247 260 it('exposes supported service ids and content matches from one catalog', () => { ··· 255 268 }); 256 269 257 270 it('rejects invalid media ids when deriving canonical watch urls', () => { 258 - expect(buildCanonicalWatchUrl('netflix', 'abc123')).toBeNull(); 259 - 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); 260 273 expect(() => 261 274 createRoomState('ROOM04', { 262 275 memberId: 'member-a', ··· 270 283 playing: true, 271 284 }, 272 285 }), 273 - ).toThrow('Could not derive a canonical watch URL for this service.'); 286 + ).toThrow('Invalid media id for service.'); 274 287 }); 275 288 276 289 it('updates the canonical watch url when playback media changes', () => {
+47 -108
packages/shared/src/services.ts
··· 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)$/; 1 + import { NETFLIX_DEFINITION } from './services/netflix'; 2 + import { YOUTUBE_DEFINITION } from './services/youtube'; 4 3 5 - function isSafeMediaId(mediaId: string): boolean { 6 - return mediaId.length > 0 && SAFE_MEDIA_ID_RE.test(mediaId); 7 - } 8 - 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 { 14 - return null; 15 - } 16 - } 17 - 18 - function extractNetflixMediaId(url: URL): string | undefined { 19 - return url.pathname.match(/^\/watch\/(\d+)/)?.[1]; 20 - } 21 - 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 - } 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 + }; 11 + 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 + }; 31 34 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 - } 35 + export const SUPPORTED_SERVICES = Object.keys(SERVICE_DEFINITION_BY_ID); 40 36 41 - return undefined; 37 + export function isServiceId(value: string): value is ServiceId { 38 + return value in SERVICE_DEFINITION_BY_ID; 42 39 } 43 40 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 41 export const SUPPORTED_SERVICE_DESCRIPTORS = SERVICE_DEFINITIONS.map( 95 42 (service) => service.descriptor, 96 43 ); ··· 99 46 (service) => service.contentMatches, 100 47 ); 101 48 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) }; 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 + }; 117 60 } 118 61 } 119 62 120 - return null; 121 - } 122 - 123 - export function buildCanonicalWatchUrl(serviceId: ServiceId, mediaId: string): string | null { 124 - return getServiceDefinition(serviceId)?.buildCanonicalWatchUrl(mediaId) ?? null; 63 + return undefined; 125 64 }
+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 }
+10 -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 + try { 24 + return plugin.matchesUrl(new URL(rawUrl)); 25 + } catch { 26 + return false; 27 + } 28 + } 29 + 22 30 export class ControlledTabService { 23 31 constructor( 24 32 private readonly state: BackgroundState, ··· 35 43 if (tabId === controlledTab?.tabId && tab.url) { 36 44 const session = selectSession(this.state); 37 45 const sessionPlugin = session ? getPlugin(session.serviceId) : null; 38 - if (sessionPlugin && !sessionPlugin.parseUrl(tab.url)) { 46 + if (sessionPlugin && !isPluginUrl(sessionPlugin, tab.url)) { 39 47 this.state.lastWarning = `The controlled tab left ${sessionPlugin.descriptor.label}.`; 40 48 syncBackgroundState(this.state); 41 49 } ··· 164 172 throw new Error('This tab is not on a supported streaming service.'); 165 173 } 166 174 167 - const parsedContextUrl = plugin.parseUrl(context.href); 168 - if (!parsedContextUrl?.mediaId) { 175 + if (plugin.extractMediaId(new URL(context.href)) === null) { 169 176 throw new Error(`${plugin.descriptor.label} tab is not on a supported watch page.`); 170 177 } 171 178
+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 }
+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