Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Private video-sharing PWA for friend groups. SvelteKit + SQLite + Twilio.
- **Backend:** SvelteKit adapter-node (monolith)
- **Database:** SQLite via Drizzle ORM
- **Styling:** Scoped `<style>` blocks (no CSS framework)
- **SMS:** Twilio for video ingestion + VCF delivery
- **SMS:** Twilio for phone verification (auth codes)
- **Push:** web-push with VAPID keys

## Code Conventions
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 312-dev
Copyright (c) 2026 312.dev LLC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Scrolly
Copyright 2026 312-dev
Copyright 2026 312.dev LLC

This software is licensed under the MIT License. See LICENSE for details.

Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default withMermaid(

footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2026 312-dev'
copyright: 'Copyright © 2026 312.dev LLC'
}
},

Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
| Video downloads | Pluggable providers (subprocess) | Host-installed at runtime; supports TikTok, Instagram, YouTube, Facebook, and more |
| Music resolution | Odesli | Cross-platform streaming link resolution (Spotify, Apple Music, YouTube Music) |
| Video storage | Local filesystem | `data/videos/` on VPS |
| SMS | Twilio | Inbound webhook for video/music ingestion, SMS verification codes |
| SMS | Twilio | SMS verification codes for phone-based auth |
| Push notifications | web-push (Node.js) | VAPID-based Web Push Protocol |
| Containerization | Docker | Single-container deployment with docker-compose |
| Language | TypeScript | End-to-end type safety |
Expand All @@ -33,7 +33,7 @@
│ (Drizzle) (videos) │
├─────────────────────────────────────┤
│ Download provider (subprocess) │
│ Twilio (SMS inbound/verification) │
│ Twilio (SMS verification)
│ web-push (notifications) │
│ Odesli (music link resolution) │
└─────────────────────────────────────┘
Expand Down Expand Up @@ -182,7 +182,7 @@ VPS (Ubuntu, e.g., DigitalOcean or Hetzner)
4. Configure environment variables (see `.env` template in repo)
5. Start app: `pm2 start build/index.js --name scrolly`
6. Generate VAPID keys: `npx web-push generate-vapid-keys`
7. Configure Twilio webhook URL: `https://your-domain.com/api/auth` (for SMS verification)
7. Configure Twilio for SMS verification codes (see deployment docs)
8. Set up a reverse proxy (Caddy, nginx, etc.) for HTTPS

## PWA Configuration
Expand Down
4 changes: 2 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar
|--------|------|-------|
| id | text | PK, UUID |
| username | text | Unique within group |
| phone | text | Required, E.164 format (+1234567890). Used to match inbound SMS. Unique across system. |
| phone | text | Required, E.164 format (+1234567890). Used for SMS verification and iOS Shortcut auth. Unique across system. |
| group_id | text | FK → groups.id |
| theme_preference | text | `'system'` / `'light'` / `'dark'`. Default `'system'`. |
| auto_scroll | integer | Boolean (0/1). Default 0. |
Expand All @@ -48,7 +48,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar
| original_url | text | Source URL. Preserved even after video deletion. Unique per group. |
| video_path | text | Nullable. Local filesystem path to downloaded video. |
| thumbnail_path | text | Nullable. Path to thumbnail image. |
| title | text | Caption from SMS, source metadata, or AI-generated. |
| title | text | User-provided caption, source metadata, or AI-generated. |
| duration_seconds | integer | Nullable |
| platform | text | `'tiktok'` / `'instagram'` / `'youtube'` / etc. |
| status | text | `'downloading'` / `'ready'` / `'failed'` / `'deleted'` |
Expand Down
29 changes: 10 additions & 19 deletions docs/deployment/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Scrolly is a lightweight monolith — a single Node.js process serving the front
| **Disk** | 1 GB + video storage | ~500 MB for the app/dependencies, rest for media |
| **OS** | Linux (x86_64 or arm64) | Ubuntu 22.04+, Debian 12+, or any Docker-capable host |
| **Runtime** | Node.js 24+ | Included in Docker image |
| **Network** | Public IP + HTTPS | Required for Twilio webhooks and push notifications |
| **Network** | Public IP + HTTPS | Required for push notifications |

### Recommended

Expand Down Expand Up @@ -90,32 +90,23 @@ Push notifications won't work without these. The app will still function, but us

```mermaid
sequenceDiagram
participant U as User's Phone
participant T as Twilio
participant C as Client
participant S as Scrolly Server
participant T as Twilio

Note over U,S: SMS Video Ingestion
U->>T: Text a video link
T->>S: POST /api/auth (webhook)
S->>S: Download video
S-->>U: Clip appears in feed

Note over U,S: Phone Verification
Note over C,T: Phone Verification
C->>S: Request verification code
S->>T: Send SMS code
T->>U: Deliver code
U->>S: Submit code
S-->>U: Verified
T->>C: SMS code delivered
C->>S: Submit code
S-->>C: Verified
```

1. Create a [Twilio account](https://www.twilio.com)
2. Create a Verify service in the Twilio console
3. Get a phone number with SMS capability (for inbound video ingestion)
4. Set the webhook URL for inbound messages to `https://your-domain.com/api/auth`
5. Add the account SID, auth token, and Verify service SID to your `.env`
3. Add the account SID, auth token, and Verify service SID to your `.env`

Twilio is used for:
- **Phone verification** — SMS codes during onboarding and login
- **Video ingestion** — Users text links to the Scrolly number to add clips
Twilio is used for **phone verification only** — SMS codes during onboarding and login. Clip sharing is handled in-app, via Android share target, or iOS Shortcut.

## Data Storage

Expand Down
12 changes: 3 additions & 9 deletions docs/deployment/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,9 @@ scrolly.example.com {
}
```

## Twilio Webhook
## Twilio

Configure your Twilio phone number's webhook URL to point to your server:

```
https://your-domain.com/api/auth
```

This enables SMS-based video ingestion and phone verification.
Twilio is used for phone verification (SMS codes during onboarding and login). Configure your Twilio Verify service SID and credentials in your `.env` file. See the [Configuration](/deployment/configuration) page for details.

## Updating

Expand All @@ -85,7 +79,7 @@ By operating a self-hosted instance, you are responsible for:

- All content downloaded, stored, and shared on your instance
- Compliance with data protection laws (GDPR, CCPA, etc.)
- Compliance with telecommunications regulations if using SMS
- Compliance with telecommunications regulations for SMS verification
- Establishing your own terms of service and privacy policy
- Securing your deployment and protecting user data
- **Download providers:** Installing a provider is an explicit opt-in action. By doing so, you accept responsibility for compliance with applicable laws and the provider's own license terms. No download tools are bundled with or automatically installed by Scrolly.
Expand Down
6 changes: 3 additions & 3 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

## 1. Video Ingestion
- **Goal:** Allow users to share TikTok/Instagram/YouTube/Facebook links to the app. Videos are downloaded and re-hosted via a pluggable download provider.
- **Primary method:** SMS. Users text links to the Scrolly phone number (Twilio). Any non-URL text in the message becomes the video's **caption**.
- **Web app:** Paste URL into the add-video modal.
- **Android share target:** PWA registers as a share target via the Web Share Target API for direct share sheet integration.
- **iOS limitation:** PWAs cannot be share targets. SMS or paste-in-app works.
- **iOS Shortcut:** Share via iOS Shortcut integration (PWAs cannot be share targets on iOS).
- **Users can optionally provide a caption** when submitting a link.
- **Notes:** Videos are downloaded server-side via the active download provider and stored on the VPS filesystem. The host installs a provider from the Settings UI. Failed downloads can be retried from the UI.

## 2. Music Sharing
Expand Down Expand Up @@ -60,7 +60,7 @@

| Feature | Status | Notes |
|---------|--------|-------|
| SMS video ingestion | Done | Text links + captions to Scrolly number |
| iOS Shortcut sharing | Done | Share links via iOS Shortcut integration |
| Web URL submission | Done | Paste URL via add-video modal |
| Android share target | Done | Web Share Target API |
| Music sharing | Done | Odesli cross-platform resolution |
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ Share links from TikTok, Instagram, YouTube, Facebook, and more. Videos are down
```mermaid
flowchart LR
A["Share a link"] --> B{"How?"}
B --> C["SMS to Scrolly number"]
B --> D["Paste URL in-app"]
B --> E["Android share sheet"]
C & D & E --> F["Server downloads video"]
F --> G["Appears in group feed"]
B --> F["iOS Shortcut"]
D & E & F --> G["Server downloads video"]
G --> H["Appears in group feed"]
```

**How to share:**
- **SMS** — Text links to your group's Scrolly phone number (Twilio). Any extra text becomes the caption.
- **In-app** — Paste a URL into the add-video modal.
- **Android share sheet** — Share directly from any app (PWA share target).
- **iOS Shortcut** — Use the iOS Shortcut integration to share from any app.

Failed downloads can be retried from the UI.

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Scrolly is a PWA (Progressive Web App). Feature support varies by platform.

## iOS Limitations

- **No share target** — PWAs cannot register as share targets on iOS. Use SMS or paste links in-app.
- **No share target** — PWAs cannot register as share targets on iOS. Use the iOS Shortcut integration or paste links in-app.
- **Push notifications** — Only work when the PWA is installed to the Home Screen (not from Safari). Text-only, no images. Requires iOS 16.4+.
- **No silent push** — Push notifications require a visible, user-facing notification.
- **No background sync** — The app cannot sync data in the background.
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ features:
- title: TikTok-Style Feed
details: Full-screen vertical reel with swipe navigation, inline playback, and reactions.
- title: Video & Music Sharing
details: Share links via SMS or in-app. Videos downloaded via pluggable providers, music resolved across platforms via Odesli.
details: Share links in-app, via Android share sheet, or iOS Shortcut. Videos downloaded via pluggable providers, music resolved across platforms via Odesli.
- title: Private Groups
details: Invite-only access with customizable accent colors, group names, and host controls.
- title: Push Notifications
Expand Down
16 changes: 9 additions & 7 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ Real-time push notifications via the Web Push Protocol (VAPID).

### Notification Types

| Event | Who gets notified | Preference key |
|-------|-------------------|----------------|
| New clip added (web or SMS) | All group members except poster (push only) | `newAdds` |
| Reaction on a clip | Clip owner only (push + in-app) | `reactions` |
| Comment on a clip | Clip owner only (push + in-app) | `comments` |
| Daily reminder | Per-user opt-in | `dailyReminder` (not yet scheduled) |
| Event | Who gets notified | When sent | Preference key |
|-------|-------------------|-----------|----------------|
| New clip added | All group members except poster (push only) | After download succeeds (`status: 'ready'`) | `newAdds` |
| Reaction on a clip | Clip owner only (push + in-app) | Immediately after reaction is persisted | `reactions` |
| Comment on a clip | Clip owner only (push + in-app) | Immediately after comment is persisted | `comments` |
| Daily reminder | Per-user opt-in | — | `dailyReminder` (not yet scheduled) |

**Timing rationale:** New clip notifications are deferred until the download pipeline finishes successfully. This avoids notifying users about clips that may fail to download. If a download fails, no notification is sent. Reactions and comments notify immediately since the action is already complete.

### Customization

Expand Down Expand Up @@ -76,7 +78,7 @@ VAPID_SUBJECT=mailto:you@example.com

| Layer | File | Role |
|-------|------|------|
| Server push utility | `src/lib/server/push.ts` | VAPID init, `sendNotification()`, `sendGroupNotification()` |
| Server push utility | `src/lib/server/push.ts` | VAPID init, `sendNotification()`, `sendGroupNotification()`, `notifyNewClip()` |
| Subscribe API | `src/routes/api/push/subscribe/+server.ts` | POST/DELETE push subscriptions |
| Notifications API | `src/routes/api/notifications/+server.ts` | GET notification feed |
| Mark-read API | `src/routes/api/notifications/mark-read/+server.ts` | POST mark as read |
Expand Down
6 changes: 6 additions & 0 deletions src/lib/server/music/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { deduplicatedDownload } from '../download-lock';
import { getActiveProvider } from '../providers/registry';
import type { AudioDownloadResult } from '../providers/types';
import { DATA_DIR, getMaxFileSize, cleanupClipFiles } from '$lib/server/download-utils';
import { notifyNewClip } from '$lib/server/push';
import { createLogger } from '$lib/server/logger';

const log = createLogger('music');
Expand Down Expand Up @@ -113,6 +114,11 @@ async function finalizeMusicClip(
fileSizeBytes: fileSizeBytes || null
})
.where(eq(clips.id, clipId));

// Notify group now that the clip is actually ready
await notifyNewClip(clipId).catch((err) =>
log.error({ err, clipId }, 'push notification failed')
);
}

async function downloadMusicInner(clipId: string, url: string): Promise<void> {
Expand Down
32 changes: 31 additions & 1 deletion src/lib/server/push.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import webpush from 'web-push';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db';
import { pushSubscriptions, notificationPreferences, users } from '$lib/server/db/schema';
import { clips, pushSubscriptions, notificationPreferences, users } from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { createLogger } from '$lib/server/logger';

Expand Down Expand Up @@ -62,6 +62,36 @@ export async function sendNotification(
);
}

/**
* Send push notification to the group after a clip download succeeds.
* Called from the download pipeline — NOT from the API endpoint.
*/
export async function notifyNewClip(clipId: string): Promise<void> {
const clip = await db.query.clips.findFirst({
where: eq(clips.id, clipId)
});
if (!clip) return;

const uploader = await db.query.users.findFirst({
where: eq(users.id, clip.addedBy)
});
if (!uploader) return;

const label = clip.contentType === 'music' ? 'song' : 'video';

await sendGroupNotification(
clip.groupId,
{
title: 'New clip added',
body: `${uploader.username} shared a new ${label}`,
url: '/',
tag: 'new-clip'
},
'newAdds',
uploader.id
);
}

export async function sendGroupNotification(
groupId: string,
payload: NotificationPayload,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/server/video/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
cleanupClipFiles,
totalFileSize
} from '$lib/server/download-utils';
import { notifyNewClip } from '$lib/server/push';
import { createLogger } from '$lib/server/logger';

const log = createLogger('video');
Expand Down Expand Up @@ -94,6 +95,11 @@ async function downloadVideoInner(clipId: string, url: string): Promise<void> {
fileSizeBytes: fileSizeBytes || null
})
.where(eq(clips.id, clipId));

// Notify group now that the clip is actually ready
await notifyNewClip(clipId).catch((err) =>
log.error({ err, clipId }, 'push notification failed')
);
} catch (err) {
await handleDownloadError(clipId, err, maxFileSizeBytes);
}
Expand Down
14 changes: 1 addition & 13 deletions src/routes/api/clips/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
} from '$lib/url-validation';
import { downloadVideo } from '$lib/server/video/download';
import { downloadMusic } from '$lib/server/music/download';
import { sendGroupNotification } from '$lib/server/push';
import { normalizeUrl } from '$lib/server/download-lock';
import { getActiveProvider } from '$lib/server/providers/registry';
import { v4 as uuid } from 'uuid';
Expand Down Expand Up @@ -265,18 +264,7 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group }
downloadVideo(clipId, videoUrl).catch(markFailedOnError);
}

// Notify group members
sendGroupNotification(
user.groupId,
{
title: 'New clip added',
body: `${user.username} shared a new ${contentType === 'music' ? 'song' : 'video'}`,
url: '/',
tag: 'new-clip'
},
'newAdds',
user.id
).catch((err) => log.error({ err }, 'push notification failed'));
// Push notification is sent after download succeeds (see video/download.ts, music/download.ts)

return json({ clip: { id: clipId, status: 'downloading', contentType } }, { status: 201 });
});
14 changes: 1 addition & 13 deletions src/routes/api/clips/share/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
} from '$lib/url-validation';
import { downloadVideo } from '$lib/server/video/download';
import { downloadMusic } from '$lib/server/music/download';
import { sendGroupNotification } from '$lib/server/push';
import { normalizeUrl } from '$lib/server/download-lock';
import { getActiveProvider } from '$lib/server/providers/registry';
import { parseBody, isResponse } from '$lib/server/api-utils';
Expand Down Expand Up @@ -145,18 +144,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
downloadVideo(clipId, videoUrl).catch(markFailedOnError);
}

// 12. Push notification
sendGroupNotification(
group.id,
{
title: 'New clip added',
body: `${matchedUser.username} shared a new ${contentType === 'music' ? 'song' : 'video'}`,
url: '/',
tag: 'new-clip'
},
'newAdds',
matchedUser.id
).catch((err) => log.error({ err }, 'push notification failed'));
// Push notification is sent after download succeeds (see video/download.ts, music/download.ts)

return json({ ok: true, clipId, status: 'downloading' }, { status: 201 });
};
Loading