@acms-subir/core 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/docs/live-video.md +91 -0
- package/integration.mjs +39 -0
- package/package.json +2 -1
- package/src/actions/index.ts +3 -2
- package/src/components/LinksManager.tsx +5 -1
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/ThemeSections.astro +2 -1
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/editor/extensions/cards.tsx +3 -38
- package/src/components/editor/extensions/mediaCards.tsx +4 -0
- package/src/layouts/Admin.astro +1 -1
- package/src/layouts/Site.astro +28 -0
- package/src/lib/api.ts +6 -2
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/embeds.ts +50 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/repo/index.ts +12 -8
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +58 -12
- package/src/lib/repo/types.ts +3 -0
- package/src/lib/scheduler.ts +2 -0
- package/src/lib/schema.ts +7 -1
- package/src/lib/themeTokens.ts +9 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +15 -7
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +10 -1
- package/src/pages/[...slug].astro +6 -0
- package/src/pages/admin/calendar/integrations.astro +2 -219
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/api/calendar/docs.ts +4 -2
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/index.astro +6 -0
- package/src/pages/links.astro +6 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/theme-sdk/contract.mjs +1 -0
- package/src/theme-sdk/tokens.mjs +3 -0
- package/src/themes/editorial/theme.css +5 -0
- package/src/themes/editorial/theme.json +2 -0
- package/src/themes/field/theme.json +1 -0
- package/src/themes/night/theme.json +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ApiError } from '../api';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { encrypt, decrypt, encryptionReady, hash, randomToken } from './crypto';
|
|
4
|
+
import { channels, detect, exchangeGoogleCode, ProviderError, revoke, twitchLogin } from './providers';
|
|
5
|
+
import { LiveConfig, type Connection, type Credentials, type Provider, type LiveAdminState, type LiveSnapshot, type PublicLiveSnapshot } from './types';
|
|
6
|
+
|
|
7
|
+
export function canonicalOrigin(siteUrl: string, requestUrl: URL) {
|
|
8
|
+
let url: URL;
|
|
9
|
+
try { url=new URL(siteUrl || requestUrl.origin); } catch { throw new ApiError(400,'Set the site URL before connecting YouTube.'); }
|
|
10
|
+
if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname)))) throw new ApiError(400,'Use an HTTPS site URL (or localhost for development).');
|
|
11
|
+
return url.origin;
|
|
12
|
+
}
|
|
13
|
+
export function watchSource(raw: string): Pick<LiveSnapshot,'provider'|'id'|'embedId'|'watchUrl'> {
|
|
14
|
+
const u=new URL(raw);
|
|
15
|
+
const host=u.hostname.toLowerCase();
|
|
16
|
+
if(u.protocol!=='https:'||u.username||u.password)throw new ApiError(400,'Use an HTTPS watch URL without credentials.');
|
|
17
|
+
if(['youtube.com','www.youtube.com','m.youtube.com','youtu.be'].includes(host)) {
|
|
18
|
+
const id=host==='youtu.be'?u.pathname.slice(1):u.pathname==='/watch'?u.searchParams.get('v'):u.pathname.match(/^\/live\/([^/]+)\/?$/)?.[1];
|
|
19
|
+
if(id&&/^[\w-]{11}$/.test(id)) return {provider:'youtube',id,embedId:id,watchUrl:`https://www.youtube.com/watch?v=${id}`};
|
|
20
|
+
return {provider:'youtube',id:'',embedId:'',watchUrl:u.href};
|
|
21
|
+
}
|
|
22
|
+
if(['twitch.tv','www.twitch.tv'].includes(host)) { try {const login=twitchLogin(raw);return {provider:'twitch',id:login,embedId:login,watchUrl:`https://www.twitch.tv/${login}`};}catch {return {provider:'twitch',id:'',embedId:'',watchUrl:u.href};} }
|
|
23
|
+
return {provider:['facebook.com','www.facebook.com','m.facebook.com','fb.watch'].includes(host)?'facebook':'other',id:'',embedId:'',watchUrl:u.href};
|
|
24
|
+
}
|
|
25
|
+
export function effectiveSnapshot(config:LiveConfig,connections:Connection[],now=Date.now()):LiveSnapshot|null {
|
|
26
|
+
if(config.mode==='off')return null;
|
|
27
|
+
if(config.mode==='manual'&&config.manual.until>now&&config.manual.watchUrl) {
|
|
28
|
+
try{return {...watchSource(config.manual.watchUrl),status:'live',source:'manual',title:config.manual.title||'We’re live',startedAt:0,checkedAt:now,expiresAt:config.manual.until};}catch{return null;}
|
|
29
|
+
}
|
|
30
|
+
const candidates=config.priority.map(p=>connections.find(c=>c.provider===p&&c.enabled&&c.testedAt>0)).filter((c):c is Connection=>!!c);
|
|
31
|
+
for(const status of ['live',...(config.showUpcoming?['upcoming']:[])]) {
|
|
32
|
+
const s=candidates.map(c=>c.snapshot).find(s=>s?.status===status&&s.expiresAt>now);
|
|
33
|
+
if(s)return s;
|
|
34
|
+
}
|
|
35
|
+
if(config.fallbackUrl) {try{return {...watchSource(config.fallbackUrl),embedId:'',status:'offline',source:'automatic',title:'Watch our channel',startedAt:0,checkedAt:now,expiresAt:0};}catch{return null;} }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// The platform image for a live/upcoming snapshot, derived only from fixed
|
|
39
|
+
// hosts and validated ids, so the thumbnail proxy can never be pointed at an
|
|
40
|
+
// arbitrary URL.
|
|
41
|
+
export function thumbSource(s:LiveSnapshot|null):string {
|
|
42
|
+
if(!s||(s.status!=='live'&&s.status!=='upcoming'))return '';
|
|
43
|
+
if(s.provider==='youtube'&&/^[\w-]{11}$/.test(s.id))return `https://i.ytimg.com/vi/${s.id}/${s.status==='live'?'hqdefault_live':'hqdefault'}.jpg`;
|
|
44
|
+
if(s.provider==='twitch'&&s.status==='live'&&/^[a-z0-9_]{1,25}$/.test(s.embedId))return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${s.embedId}-640x360.jpg`;
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
export function publicSnapshot(s:LiveSnapshot|null,config:LiveConfig):PublicLiveSnapshot|null {
|
|
48
|
+
if(!s)return null;
|
|
49
|
+
const {viewers,...rest}=s;
|
|
50
|
+
// The version changes with the broadcast and once a minute, so live
|
|
51
|
+
// previews refresh while browsers and CDNs can still cache each one.
|
|
52
|
+
const version=`${s.provider}-${s.id||s.embedId}-${Math.floor(s.checkedAt/60_000)}`.replace(/[^\w-]/g,'');
|
|
53
|
+
return {...rest,...(config.display.viewers&&typeof viewers==='number'?{viewers}:{}),thumbUrl:config.display.thumbnail&&thumbSource(s)?`/api/live/thumb?v=${version}`:''};
|
|
54
|
+
}
|
|
55
|
+
export async function liveState(repo:ContentRepository) {
|
|
56
|
+
const [settings,connections]=await Promise.all([repo.getSettings(),repo.live.list()]);
|
|
57
|
+
return {config:settings.live,snapshot:effectiveSnapshot(settings.live,connections)};
|
|
58
|
+
}
|
|
59
|
+
export async function publicState(repo:ContentRepository) {
|
|
60
|
+
const {config,snapshot}=await liveState(repo);
|
|
61
|
+
return publicSnapshot(snapshot,config);
|
|
62
|
+
}
|
|
63
|
+
export async function adminState(repo:ContentRepository,url:URL,locals?:unknown):Promise<LiveAdminState> {
|
|
64
|
+
const [settings,connections,lastAt]=await Promise.all([repo.getSettings(),repo.live.list(),repo.live.lastHeartbeat()]);
|
|
65
|
+
let origin=url.origin;try{origin=canonicalOrigin(settings.siteUrl,url);}catch{/* UI can explain the bad site URL when authorizing. */}
|
|
66
|
+
return {config:settings.live,connections:connections.map(c=>{
|
|
67
|
+
let authorized=false;try{const s=decrypt<Credentials>(c.encrypted,c.provider,locals);authorized=c.provider==='twitch'?!!s.clientSecret:!!s.refreshToken;}catch{/* needs reconnect */}
|
|
68
|
+
const {encrypted,lease,leaseUntil,snapshot,upcoming,...visible}=c;
|
|
69
|
+
return {...visible,hasSecret:!!encrypted,authorized,broadcastStatus:snapshot&&snapshot.expiresAt>Date.now()?snapshot.status:'unknown'};
|
|
70
|
+
}),callbackUrl:`${origin}/api/live/oauth/youtube/callback`,encryptionReady:encryptionReady(locals),scheduler:{lastAt,healthy:lastAt>Date.now()-180_000},snapshot:effectiveSnapshot(settings.live,connections)};
|
|
71
|
+
}
|
|
72
|
+
const providerError=(error:unknown)=>error instanceof ProviderError?error:error instanceof ApiError?new ProviderError('auth',error.message):new ProviderError('unavailable','The provider returned an unexpected response. Try the connection test again.');
|
|
73
|
+
export async function syncConnection(repo:ContentRepository,provider:Provider,locals?:unknown,force=false):Promise<boolean> {
|
|
74
|
+
const conn=await repo.live.claim(provider,Date.now(),force);
|
|
75
|
+
if(!conn) {if(force)throw new ApiError(409,'A check is already running, or was run in the last 10 seconds. Try again shortly.');return false;}
|
|
76
|
+
let creds:Credentials|undefined;
|
|
77
|
+
try {
|
|
78
|
+
creds=decrypt<Credentials>(conn.encrypted,provider,locals);
|
|
79
|
+
const signal=AbortSignal.timeout(55_000);
|
|
80
|
+
if(force||!conn.channelId) {
|
|
81
|
+
conn.channels=await channels(conn,creds,signal);
|
|
82
|
+
if(!conn.channels.length)throw new ProviderError('channel','No YouTube channel was found. Authorize the Google or Brand account that owns your channel.');
|
|
83
|
+
const selected=conn.channels.find(c=>c.id===conn.channelId)??(conn.channels.length===1?conn.channels[0]:undefined);
|
|
84
|
+
if(!selected) {
|
|
85
|
+
await repo.live.patch(provider,{encrypted:encrypt(creds,provider,locals),channels:conn.channels,testedAt:0,error:'Choose a channel, then test the connection.',nextAt:0},conn.revision,conn.lease);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
conn.channelId=selected.id;conn.channelName=selected.name;
|
|
89
|
+
}
|
|
90
|
+
let snapshot=await detect(conn,creds,signal);
|
|
91
|
+
const settings=await repo.getSettings();
|
|
92
|
+
if(provider==='youtube'&&settings.live.showUpcoming&&snapshot.status==='offline') {
|
|
93
|
+
if(Date.now()-conn.upcomingCheckedAt>900_000||force) {
|
|
94
|
+
conn.upcoming=await detect(conn,creds,signal,true);conn.upcomingCheckedAt=Date.now();
|
|
95
|
+
}
|
|
96
|
+
if(conn.upcoming?.status==='upcoming'&&conn.upcoming.startedAt>Date.now()) {
|
|
97
|
+
snapshot={...conn.upcoming,checkedAt:snapshot.checkedAt,expiresAt:Math.min(snapshot.expiresAt,conn.upcomingCheckedAt+900_000,conn.upcoming.startedAt)};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const ok=await repo.live.patch(provider,{encrypted:encrypt(creds,provider,locals),channels:conn.channels,channelId:conn.channelId,channelName:conn.channelName,snapshot,upcoming:conn.upcoming,upcomingCheckedAt:conn.upcomingCheckedAt,testedAt:Date.now(),checkedAt:snapshot.checkedAt,nextAt:conn.lastAttemptAt+55_000,error:'',failures:0},conn.revision,conn.lease);
|
|
101
|
+
if(!ok&&force)throw new ApiError(409,'This connection changed during the check. Test the saved connection again.');
|
|
102
|
+
return ok;
|
|
103
|
+
}catch(error){
|
|
104
|
+
if(error instanceof ApiError&&error.status===409)throw error;
|
|
105
|
+
const failure=providerError(error),attempt=conn.failures+1;
|
|
106
|
+
const delay=failure.code==='auth'?3600_000:failure.code==='quota'?Math.max(failure.retryAfter,3600_000):Math.min(3600_000,60_000*2**Math.min(attempt,6));
|
|
107
|
+
// A rotated token must be persisted even if the following lookup fails.
|
|
108
|
+
let encrypted=conn.encrypted;try{if(creds)encrypted=encrypt(creds,provider,locals);}catch{/* preserve existing ciphertext */}
|
|
109
|
+
await repo.live.patch(provider,{encrypted,error:failure.message,failures:attempt,nextAt:Date.now()+delay+Math.floor(Math.random()*5000),...(failure.code==='auth'?{snapshot:null,enabled:false,testedAt:0}:{})},conn.revision,conn.lease);
|
|
110
|
+
if(force)throw new ApiError(400,failure.message);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function syncDueLive(repo:ContentRepository,locals?:unknown):Promise<number> {
|
|
115
|
+
await repo.live.heartbeat(Date.now());
|
|
116
|
+
const connections=(await repo.live.list()).filter(c=>c.enabled&&c.nextAt<=Date.now());
|
|
117
|
+
const results=await Promise.all(connections.map(c=>syncConnection(repo,c.provider,locals)));
|
|
118
|
+
return results.filter(Boolean).length;
|
|
119
|
+
}
|
|
120
|
+
export async function saveConnection(repo:ContentRepository,input:{provider:Provider;clientId:string;clientSecret:string;channelInput:string;googleStatus:'testing'|'production'},locals?:unknown) {
|
|
121
|
+
const current=await repo.live.get(input.provider);
|
|
122
|
+
if(input.provider==='twitch')twitchLogin(input.channelInput);
|
|
123
|
+
const same=current?.clientId===input.clientId;
|
|
124
|
+
let creds:Credentials={clientSecret:input.clientSecret};
|
|
125
|
+
if(same&¤t&&!input.clientSecret)creds=decrypt<Credentials>(current.encrypted,input.provider,locals);
|
|
126
|
+
if(!creds.clientSecret)throw new ApiError(400,'Enter the client secret for this developer app.');
|
|
127
|
+
await repo.live.save(input.provider,{...input,encrypted:encrypt(creds,input.provider,locals)});
|
|
128
|
+
const saved=await repo.live.get(input.provider);
|
|
129
|
+
if(saved&&(!same||input.provider==='twitch'&¤t?.channelInput!==input.channelInput||input.clientSecret))await repo.live.patch(input.provider,{channels:[],channelId:'',channelName:''},saved.revision);
|
|
130
|
+
}
|
|
131
|
+
export async function beginOAuth(repo:ContentRepository,session:string,url:URL,locals?:unknown) {
|
|
132
|
+
const conn=await repo.live.get('youtube');if(!conn)throw new ApiError(400,'Save your YouTube client ID and secret first.');
|
|
133
|
+
const creds=decrypt<Credentials>(conn.encrypted,'youtube',locals);
|
|
134
|
+
if(!creds.clientSecret)throw new ApiError(400,'Save your YouTube client secret first.');
|
|
135
|
+
const settings=await repo.getSettings(),origin=canonicalOrigin(settings.siteUrl,url);
|
|
136
|
+
if(origin!==url.origin)throw new ApiError(400,`Open the admin at the configured site origin (${origin}) to authorize YouTube.`);
|
|
137
|
+
const callbackUrl=`${origin}/api/live/oauth/youtube/callback`,state=randomToken(),verifier=randomToken();
|
|
138
|
+
await repo.live.state(hash(state),hash(session),conn.revision,encrypt({verifier,callbackUrl},'oauth',locals),Date.now()+600_000);
|
|
139
|
+
const auth=new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
140
|
+
const params={client_id:conn.clientId,redirect_uri:callbackUrl,response_type:'code',scope:'https://www.googleapis.com/auth/youtube.readonly',access_type:'offline',prompt:'consent select_account',state,code_challenge:Buffer.from(hash(verifier),'hex').toString('base64url'),code_challenge_method:'S256'};
|
|
141
|
+
Object.entries(params).forEach(([k,v])=>auth.searchParams.set(k,v));return auth.href;
|
|
142
|
+
}
|
|
143
|
+
export async function finishOAuth(repo:ContentRepository,session:string,url:URL,locals?:unknown) {
|
|
144
|
+
const state=url.searchParams.get('state')??'';
|
|
145
|
+
if(!/^[\w-]{43}$/.test(state))throw new ApiError(400,'Invalid OAuth state. Start authorization again.');
|
|
146
|
+
const stored=await repo.live.consumeState(hash(state),hash(session),Date.now());
|
|
147
|
+
const conn=await repo.live.get('youtube');
|
|
148
|
+
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
149
|
+
if(url.searchParams.has('error'))throw new ApiError(400,'YouTube authorization was not completed. Try again and approve read-only access.');
|
|
150
|
+
const code=url.searchParams.get('code');if(!code||code.length>8192)throw new ApiError(400,'Missing authorization code.');
|
|
151
|
+
const {verifier,callbackUrl}=decrypt<{verifier:string;callbackUrl:string}>(stored.encrypted,'oauth',locals);
|
|
152
|
+
if(new URL(callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
153
|
+
const old=decrypt<Credentials>(conn.encrypted,'youtube',locals);
|
|
154
|
+
try{
|
|
155
|
+
const creds=await exchangeGoogleCode(conn.clientId,old.clientSecret,code,verifier,callbackUrl,AbortSignal.timeout(30_000));
|
|
156
|
+
const choices=await channels(conn,creds,AbortSignal.timeout(30_000));
|
|
157
|
+
if(!choices.length)throw new ProviderError('channel','This Google identity has no YouTube channel. Reconnect with the correct account.');
|
|
158
|
+
const selected=choices.find(c=>c.id===conn.channelId)??(choices.length===1?choices[0]:undefined);
|
|
159
|
+
if(!await repo.live.patch('youtube',{encrypted:encrypt(creds,'youtube',locals),channels:choices,channelId:selected?.id??'',channelName:selected?.name??'',enabled:false,testedAt:0,lastAttemptAt:0,error:'',snapshot:null},conn.revision))throw new ApiError(409,'The connection changed. Authorize again.');
|
|
160
|
+
}catch(e){if(e instanceof ApiError)throw e;throw new ApiError(400,providerError(e).message);}
|
|
161
|
+
}
|
|
162
|
+
export async function disconnect(repo:ContentRepository,provider:Provider,locals?:unknown) {
|
|
163
|
+
const conn=await repo.live.get(provider);await repo.live.remove(provider);
|
|
164
|
+
if(conn){try{await revoke(provider,conn.clientId,decrypt<Credentials>(conn.encrypted,provider,locals));}catch{/* local removal succeeds even when old key cannot be read */}}
|
|
165
|
+
}
|
|
166
|
+
export function validateConfig(raw:unknown) {
|
|
167
|
+
const config=LiveConfig.parse(raw);
|
|
168
|
+
if(config.mode==='manual'&&(!config.manual.watchUrl||config.manual.until<=Date.now()||config.manual.until>Date.now()+12*3600_000+60_000))throw new ApiError(400,'Manual live status needs a watch URL and an expiry within the next 12 hours.');
|
|
169
|
+
return config;
|
|
170
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { subscribeLive } from './client';
|
|
2
|
+
import type { PublicLiveSnapshot } from './types';
|
|
3
|
+
|
|
4
|
+
// Site-wide live signals (Site.astro renders them hidden when the owner
|
|
5
|
+
// enables them): the live bar, the header marker and the floating dock. All
|
|
6
|
+
// three appear only while a broadcast is actually live, and dismissals last
|
|
7
|
+
// for that broadcast in this browser session.
|
|
8
|
+
|
|
9
|
+
const dismissKey = (what: string, s: PublicLiveSnapshot) => `acms-live-${what}-${s.provider}-${s.id || s.watchUrl}`;
|
|
10
|
+
const dismissed = (what: string, s: PublicLiveSnapshot) => {
|
|
11
|
+
try { return sessionStorage.getItem(dismissKey(what, s)) === '1'; } catch { return false; }
|
|
12
|
+
};
|
|
13
|
+
const dismiss = (what: string, s: PublicLiveSnapshot) => {
|
|
14
|
+
try { sessionStorage.setItem(dismissKey(what, s), '1'); } catch { /* private mode: dismiss for this page only */ }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// a live card already on the page makes the dock redundant and gives the
|
|
18
|
+
// header marker a local target
|
|
19
|
+
function pageCard(): HTMLElement | null {
|
|
20
|
+
const card = Array.from(document.querySelectorAll<HTMLElement>('[data-live-card]')).find((c) => !c.hidden && c.dataset.status === 'live');
|
|
21
|
+
if (card && !card.id) card.id = 'live-video';
|
|
22
|
+
return card ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function openExternally(a: HTMLAnchorElement, url: string) {
|
|
26
|
+
a.href = url;
|
|
27
|
+
a.target = '_blank';
|
|
28
|
+
a.rel = 'noopener noreferrer';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mountLiveSignals() {
|
|
32
|
+
const bar = document.querySelector<HTMLElement>('[data-live-bar]:not([data-ready])');
|
|
33
|
+
const marker = document.querySelector<HTMLAnchorElement>('[data-live-header]:not([data-ready])');
|
|
34
|
+
const dock = document.querySelector<HTMLElement>('[data-live-dock]:not([data-ready])');
|
|
35
|
+
let current: PublicLiveSnapshot | null = null;
|
|
36
|
+
|
|
37
|
+
if (bar) {
|
|
38
|
+
bar.dataset.ready = '';
|
|
39
|
+
bar.querySelector('.pc-live-x')?.addEventListener('click', () => {
|
|
40
|
+
if (current) dismiss('bar', current);
|
|
41
|
+
bar.hidden = true;
|
|
42
|
+
delete document.documentElement.dataset.liveBar;
|
|
43
|
+
});
|
|
44
|
+
subscribeLive(bar, (s) => {
|
|
45
|
+
current = s;
|
|
46
|
+
const show = s?.status === 'live' && !dismissed('bar', s);
|
|
47
|
+
bar.hidden = !show;
|
|
48
|
+
if (show) document.documentElement.dataset.liveBar = '';
|
|
49
|
+
else delete document.documentElement.dataset.liveBar;
|
|
50
|
+
if (!s || !show) return;
|
|
51
|
+
bar.querySelector('[data-live-bar-title]')!.textContent = s.title;
|
|
52
|
+
const card = pageCard();
|
|
53
|
+
const a = bar.querySelector<HTMLAnchorElement>('[data-live-bar-watch]')!;
|
|
54
|
+
if (card) { a.href = `#${card.id}`; a.removeAttribute('target'); } else openExternally(a, s.watchUrl);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (marker) {
|
|
59
|
+
marker.dataset.ready = '';
|
|
60
|
+
subscribeLive(marker, (s) => {
|
|
61
|
+
marker.hidden = s?.status !== 'live';
|
|
62
|
+
if (!s || marker.hidden) return;
|
|
63
|
+
const card = pageCard();
|
|
64
|
+
marker.title = s.title;
|
|
65
|
+
if (card) { marker.href = `#${card.id}`; marker.removeAttribute('target'); } else openExternally(marker, s.watchUrl);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (dock) {
|
|
70
|
+
dock.dataset.ready = '';
|
|
71
|
+
const link = dock.querySelector<HTMLAnchorElement>('a')!;
|
|
72
|
+
const img = dock.querySelector<HTMLImageElement>('img')!;
|
|
73
|
+
img.addEventListener('load', () => { img.hidden = false; });
|
|
74
|
+
img.addEventListener('error', () => { img.hidden = true; });
|
|
75
|
+
dock.querySelector('.pc-live-x')?.addEventListener('click', () => {
|
|
76
|
+
if (current) dismiss('dock', current);
|
|
77
|
+
dock.hidden = true;
|
|
78
|
+
});
|
|
79
|
+
subscribeLive(dock, (s) => {
|
|
80
|
+
current = s;
|
|
81
|
+
dock.hidden = s?.status !== 'live' || dismissed('dock', s) || !!pageCard();
|
|
82
|
+
if (!s || dock.hidden) return;
|
|
83
|
+
openExternally(link, s.watchUrl);
|
|
84
|
+
dock.querySelector('[data-live-dock-title]')!.textContent = s.title;
|
|
85
|
+
if (!s.thumbUrl) { img.hidden = true; img.removeAttribute('src'); }
|
|
86
|
+
else if (img.getAttribute('src') !== s.thumbUrl) img.src = s.thumbUrl;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import type { SqlDriver } from '../repo/sql';
|
|
3
|
+
import type { Connection, Provider, LiveSnapshot, Channel } from './types';
|
|
4
|
+
const parse = <T>(raw: unknown, fallback: T): T => { try { return raw ? JSON.parse(String(raw)) : fallback; } catch { return fallback; } };
|
|
5
|
+
function connection(r: Record<string, unknown>): Connection {
|
|
6
|
+
return { provider: r.provider as Provider, clientId: String(r.client_id), channelInput: String(r.channel_input), channelId: String(r.channel_id), channelName: String(r.channel_name), encrypted: String(r.encrypted), enabled: !!r.enabled, revision: Number(r.revision), channels: parse<Channel[]>(r.channels, []), googleStatus: r.google_status as Connection['googleStatus'], testedAt: Number(r.tested_at), checkedAt: Number(r.checked_at), lastAttemptAt: Number(r.last_attempt_at), nextAt: Number(r.next_at), failures: Number(r.failures), error: String(r.error), lease: String(r.lease), leaseUntil: Number(r.lease_until), snapshot: parse<LiveSnapshot | null>(r.snapshot, null), upcoming: parse<LiveSnapshot | null>(r.upcoming, null), upcomingCheckedAt: Number(r.upcoming_checked_at) };
|
|
7
|
+
}
|
|
8
|
+
const columns: Record<string, string> = { clientId:'client_id', channelInput:'channel_input', channelId:'channel_id', channelName:'channel_name', encrypted:'encrypted', enabled:'enabled', channels:'channels', googleStatus:'google_status', testedAt:'tested_at', checkedAt:'checked_at', lastAttemptAt:'last_attempt_at', nextAt:'next_at', failures:'failures', error:'error', snapshot:'snapshot', upcoming:'upcoming', upcomingCheckedAt:'upcoming_checked_at' };
|
|
9
|
+
const serialized = new Set(['channels','snapshot','upcoming']);
|
|
10
|
+
export class LiveStore {
|
|
11
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
12
|
+
async list() { return (await (await this.driver()).all('SELECT * FROM live_connections ORDER BY provider')).map(connection); }
|
|
13
|
+
async get(provider: Provider) { return (await this.list()).find(c => c.provider === provider) ?? null; }
|
|
14
|
+
// Revisions invalidate in-flight provider requests and OAuth callbacks when a setting changes.
|
|
15
|
+
async save(provider: Provider, data: { clientId: string; channelInput: string; encrypted: string; googleStatus: string }) {
|
|
16
|
+
// A recreated connection must not reuse the revision of an in-flight OAuth callback.
|
|
17
|
+
const revision = randomInt(1, 2 ** 48 - 1);
|
|
18
|
+
await (await this.driver()).run(`INSERT INTO live_connections (provider,client_id,channel_input,encrypted,google_status,revision) VALUES (?,?,?,?,?,?)
|
|
19
|
+
ON CONFLICT(provider) DO UPDATE SET client_id=excluded.client_id,channel_input=excluded.channel_input,encrypted=excluded.encrypted,google_status=excluded.google_status,
|
|
20
|
+
enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,upcoming_checked_at=0,error='',failures=0,next_at=0`, [provider,data.clientId,data.channelInput,data.encrypted,data.googleStatus,revision]);
|
|
21
|
+
}
|
|
22
|
+
async patch(provider: Provider, data: Partial<Connection>, revision: number, lease?: string): Promise<boolean> {
|
|
23
|
+
const entries = Object.entries(data).filter(([k]) => columns[k]);
|
|
24
|
+
if (!entries.length) return false;
|
|
25
|
+
const args = entries.map(([k,v]) => serialized.has(k) ? v == null ? null : JSON.stringify(v) : typeof v === 'boolean' ? Number(v) : v);
|
|
26
|
+
const rows = await (await this.driver()).all(`UPDATE live_connections SET ${entries.map(([k]) => `${columns[k]}=?`).join(',')},
|
|
27
|
+
${lease ? "lease='',lease_until=0" : "revision=revision+1,lease='',lease_until=0"}
|
|
28
|
+
WHERE provider=? AND revision=?${lease ? ' AND lease=?' : ''} RETURNING provider`, [...args,provider,revision,...(lease ? [lease] : [])]);
|
|
29
|
+
return rows.length > 0;
|
|
30
|
+
}
|
|
31
|
+
async claim(provider: Provider, now: number, force = false) {
|
|
32
|
+
const lease = crypto.randomUUID();
|
|
33
|
+
const rows = await (await this.driver()).all(`UPDATE live_connections SET lease=?,lease_until=?,last_attempt_at=?
|
|
34
|
+
WHERE provider=? AND lease_until<=? AND ${force ? '(last_attempt_at=0 OR last_attempt_at<=?)' : 'enabled=1 AND next_at<=?'} RETURNING *`, [lease,now+90_000,now,provider,now,force ? now-10_000 : now]);
|
|
35
|
+
return rows[0] ? connection(rows[0]) : null;
|
|
36
|
+
}
|
|
37
|
+
async remove(provider: Provider) {
|
|
38
|
+
await (await this.driver()).batch([{sql:'DELETE FROM live_connections WHERE provider=?',args:[provider]}, ...(provider === 'youtube' ? [{sql:'DELETE FROM live_oauth_states'}] : [])]);
|
|
39
|
+
}
|
|
40
|
+
async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
|
|
41
|
+
await (await this.driver()).batch([{sql:'DELETE FROM live_oauth_states WHERE expires_at<? OR session_hash=?',args:[Date.now(),sessionHash]}, {sql:'INSERT INTO live_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)',args:[hash,sessionHash,revision,encrypted,expiresAt]}]);
|
|
42
|
+
}
|
|
43
|
+
async consumeState(hash: string, sessionHash: string, now: number) {
|
|
44
|
+
const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE hash=? AND session_hash=? AND expires_at>? RETURNING revision,encrypted', [hash,sessionHash,now]);
|
|
45
|
+
return rows[0] ? {revision:Number(rows[0].revision),encrypted:String(rows[0].encrypted)} : null;
|
|
46
|
+
}
|
|
47
|
+
async heartbeat(now: number) { await (await this.driver()).run('UPDATE live_health SET last_at=? WHERE id=1',[now]); }
|
|
48
|
+
async lastHeartbeat() { return Number((await (await this.driver()).all('SELECT last_at FROM live_health WHERE id=1'))[0]?.last_at ?? 0); }
|
|
49
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const Provider = z.enum(['youtube', 'twitch']);
|
|
3
|
+
export type Provider = z.infer<typeof Provider>;
|
|
4
|
+
const httpsUrl = z.string().trim().max(2048).refine(v => { if (!v) return true; try { const u = new URL(v); return u.protocol === 'https:' && !u.username && !u.password; } catch { return false; } }, 'Use an HTTPS URL without credentials.');
|
|
5
|
+
export const LiveConfig = z.object({
|
|
6
|
+
mode: z.enum(['off', 'automatic', 'manual']).default('off'),
|
|
7
|
+
priority: z.array(Provider).max(2).default(['youtube', 'twitch']).refine(v => new Set(v).size === v.length, 'Choose each provider only once.'),
|
|
8
|
+
fallbackUrl: httpsUrl.default(''),
|
|
9
|
+
showUpcoming: z.boolean().default(false),
|
|
10
|
+
manual: z.object({ watchUrl: httpsUrl.default(''), title: z.string().trim().max(200).default('We’re live'), until: z.number().int().nonnegative().default(0) }).strict().default({}),
|
|
11
|
+
// How the public site presents the snapshot. Site-wide signals start off so
|
|
12
|
+
// an upgrade never changes an existing site without the owner opting in.
|
|
13
|
+
display: z.object({
|
|
14
|
+
style: z.enum(['poster', 'split', 'row']).default('poster'),
|
|
15
|
+
thumbnail: z.boolean().default(true),
|
|
16
|
+
viewers: z.boolean().default(false),
|
|
17
|
+
bar: z.boolean().default(false),
|
|
18
|
+
header: z.boolean().default(false),
|
|
19
|
+
dock: z.boolean().default(false),
|
|
20
|
+
}).strict().default({}),
|
|
21
|
+
}).strict();
|
|
22
|
+
export type LiveConfig = z.infer<typeof LiveConfig>;
|
|
23
|
+
export interface LiveSnapshot {
|
|
24
|
+
status: 'live' | 'offline' | 'upcoming' | 'unknown';
|
|
25
|
+
source: 'automatic' | 'manual';
|
|
26
|
+
provider: Provider | 'facebook' | 'other';
|
|
27
|
+
id: string; title: string; watchUrl: string; embedId: string;
|
|
28
|
+
startedAt: number; checkedAt: number; expiresAt: number;
|
|
29
|
+
// optional: snapshots stored before these existed parse unchanged
|
|
30
|
+
channel?: string; viewers?: number;
|
|
31
|
+
}
|
|
32
|
+
// What visitors receive: viewers only when the owner opts in, and a same-site
|
|
33
|
+
// thumbnail URL (empty when disabled or the provider has no image).
|
|
34
|
+
export interface PublicLiveSnapshot extends Omit<LiveSnapshot, 'viewers'> { viewers?: number; thumbUrl: string }
|
|
35
|
+
export interface Channel { id: string; name: string }
|
|
36
|
+
export interface Credentials { clientSecret: string; accessToken?: string; refreshToken?: string; tokenExpiresAt?: number; tokenValidatedAt?: number; callbackUrl?: string }
|
|
37
|
+
export interface Connection {
|
|
38
|
+
provider: Provider; clientId: string; channelInput: string; channelId: string; channelName: string;
|
|
39
|
+
encrypted: string; enabled: boolean; revision: number; channels: Channel[];
|
|
40
|
+
googleStatus: 'testing' | 'production'; testedAt: number; checkedAt: number; lastAttemptAt: number;
|
|
41
|
+
nextAt: number; failures: number; error: string; lease: string; leaseUntil: number;
|
|
42
|
+
snapshot: LiveSnapshot | null; upcoming: LiveSnapshot | null; upcomingCheckedAt: number;
|
|
43
|
+
}
|
|
44
|
+
export interface PublicConnection extends Omit<Connection, 'encrypted' | 'lease' | 'leaseUntil' | 'snapshot' | 'upcoming'> {
|
|
45
|
+
hasSecret: boolean; authorized: boolean; broadcastStatus: LiveSnapshot['status'];
|
|
46
|
+
}
|
|
47
|
+
export interface LiveAdminState {
|
|
48
|
+
config: LiveConfig; connections: PublicConnection[]; callbackUrl: string;
|
|
49
|
+
encryptionReady: boolean; scheduler: { lastAt: number; healthy: boolean };
|
|
50
|
+
snapshot: LiveSnapshot | null;
|
|
51
|
+
}
|
|
52
|
+
export const emptySnapshot = (provider: Provider, now: number): LiveSnapshot => ({ status: 'offline', source: 'automatic', provider, id: '', title: '', watchUrl: '', embedId: '', startedAt: 0, checkedAt: now, expiresAt: now + 120_000 });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { PublicLiveSnapshot } from './types';
|
|
2
|
+
|
|
3
|
+
// Display text for a live snapshot. Pure and DOM-free: LiveCard renders it on
|
|
4
|
+
// the server and the browser re-applies it on every status change, so both
|
|
5
|
+
// always agree.
|
|
6
|
+
|
|
7
|
+
export const PROVIDER_NAMES: Record<string, string> = { youtube: 'YouTube', twitch: 'Twitch', facebook: 'Facebook', other: '' };
|
|
8
|
+
|
|
9
|
+
export interface LiveView {
|
|
10
|
+
status: 'live' | 'upcoming' | 'offline';
|
|
11
|
+
provider: string;
|
|
12
|
+
// poster tag: "LIVE" or "Sat 7:00 PM · in 2 h"
|
|
13
|
+
tag: string;
|
|
14
|
+
// marker line above the title: "LIVE ON TWITCH"
|
|
15
|
+
kicker: string;
|
|
16
|
+
title: string;
|
|
17
|
+
// "heymunmusic · started 42 min ago"
|
|
18
|
+
meta: string;
|
|
19
|
+
viewers: string;
|
|
20
|
+
watchLabel: string;
|
|
21
|
+
watchUrl: string;
|
|
22
|
+
thumbUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
|
|
26
|
+
|
|
27
|
+
export function compactCount(n: number): string {
|
|
28
|
+
if (n < 1000) return String(n);
|
|
29
|
+
const [v, unit] = n < 1e6 ? [n / 1e3, 'K'] : [n / 1e6, 'M'];
|
|
30
|
+
return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${unit}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ago(ms: number): string {
|
|
34
|
+
if (ms < MIN) return 'just now';
|
|
35
|
+
if (ms < HOUR) return `${Math.floor(ms / MIN)} min ago`;
|
|
36
|
+
const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
|
|
37
|
+
return m ? `${h} h ${m} min ago` : `${h} h ago`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function until(ms: number): string {
|
|
41
|
+
if (ms < MIN) return 'starting now';
|
|
42
|
+
if (ms < HOUR) return `in ${Math.ceil(ms / MIN)} min`;
|
|
43
|
+
if (ms < DAY) return `in ${Math.round(ms / HOUR)} h`;
|
|
44
|
+
const d = Math.round(ms / DAY);
|
|
45
|
+
return `in ${d} day${d === 1 ? '' : 's'}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function when(ts: number, now: number): string {
|
|
49
|
+
const d = new Date(ts);
|
|
50
|
+
const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
51
|
+
if (ts - now < 6 * DAY) return `${d.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`;
|
|
52
|
+
return `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function liveView(s: PublicLiveSnapshot | null, now: number): LiveView | null {
|
|
56
|
+
if (!s || (s.status !== 'live' && s.status !== 'upcoming' && s.status !== 'offline')) return null;
|
|
57
|
+
const name = PROVIDER_NAMES[s.provider] ?? '';
|
|
58
|
+
const channel = s.channel?.trim() ?? '';
|
|
59
|
+
const base = { status: s.status, provider: s.provider, title: s.title, watchUrl: s.watchUrl, thumbUrl: s.thumbUrl };
|
|
60
|
+
if (s.status === 'offline') {
|
|
61
|
+
return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: 'Watch our channel ↗' };
|
|
62
|
+
}
|
|
63
|
+
const watchLabel = name ? `Watch on ${name} ↗` : 'Watch now ↗';
|
|
64
|
+
if (s.status === 'upcoming') {
|
|
65
|
+
const start = s.startedAt > 0 ? `${when(s.startedAt, now)} · ${until(s.startedAt - now)}` : '';
|
|
66
|
+
return {
|
|
67
|
+
...base, tag: start || 'UPCOMING', kicker: name ? `UPCOMING ON ${name.toUpperCase()}` : 'UPCOMING',
|
|
68
|
+
meta: [channel, name && 'scheduled'].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? `Open on ${name} ↗` : 'Open ↗',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const started = s.startedAt > 0 && s.startedAt <= now ? `started ${ago(now - s.startedAt)}` : '';
|
|
72
|
+
return {
|
|
73
|
+
...base, tag: 'LIVE', kicker: name ? `LIVE ON ${name.toUpperCase()}` : 'LIVE NOW',
|
|
74
|
+
meta: [channel, started].filter(Boolean).join(' · '),
|
|
75
|
+
viewers: typeof s.viewers === 'number' ? `${compactCount(s.viewers)} watching` : '',
|
|
76
|
+
watchLabel,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/lib/repo/index.ts
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import type { ContentRepository } from './types';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// The promise (not the instance) is cached: concurrent first requests would
|
|
4
|
+
// otherwise each create a repository and race to run pending migrations.
|
|
5
|
+
let repo: Promise<ContentRepository> | null = null;
|
|
4
6
|
|
|
5
7
|
// Adapter priority: Cloudflare D1 (when the Worker binding exists —
|
|
6
8
|
// production on Workers and local wrangler/astro dev via platformProxy)
|
|
7
9
|
// → libSQL file (plain Node self-hosting).
|
|
8
10
|
// Pass Astro.locals (or the endpoint's `locals`) so the D1 binding is
|
|
9
11
|
// reachable; the chosen adapter is cached for the isolate's lifetime.
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
+
export function getRepo(locals?: unknown): Promise<ContentRepository> {
|
|
13
|
+
repo ??= create(locals).catch((e) => { repo = null; throw e; });
|
|
14
|
+
return repo;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function create(locals?: unknown): Promise<ContentRepository> {
|
|
12
18
|
const d1 = (locals as { runtime?: { env?: { DB?: unknown } } } | undefined)?.runtime?.env?.DB;
|
|
13
19
|
if (d1) {
|
|
14
20
|
const { D1Repository } = await import('./d1');
|
|
15
|
-
|
|
16
|
-
} else {
|
|
17
|
-
const { SqliteRepository } = await import('./sqlite');
|
|
18
|
-
repo = new SqliteRepository();
|
|
21
|
+
return new D1Repository(d1 as import('./d1').D1Database);
|
|
19
22
|
}
|
|
20
|
-
|
|
23
|
+
const { SqliteRepository } = await import('./sqlite');
|
|
24
|
+
return new SqliteRepository();
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export type { ContentRepository };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { LIVE_MIGRATION } from '../live/migration';
|
|
1
2
|
import { EMAIL_MIGRATION } from '../email/migration';
|
|
3
|
+
import { WEBHOOK_MIGRATION } from '../webhook-sql';
|
|
2
4
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
3
5
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
4
6
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -495,6 +497,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
495
497
|
actual_revision INTEGER NOT NULL CHECK (expected_revision = actual_revision)
|
|
496
498
|
)`,
|
|
497
499
|
] },
|
|
500
|
+
WEBHOOK_MIGRATION,
|
|
501
|
+
LIVE_MIGRATION,
|
|
498
502
|
];
|
|
499
503
|
|
|
500
504
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|