diff --git a/app/(pages)/settings/_components/icons.tsx b/app/(pages)/settings/_components/icons.tsx index e778f30..84724e9 100644 --- a/app/(pages)/settings/_components/icons.tsx +++ b/app/(pages)/settings/_components/icons.tsx @@ -19,6 +19,25 @@ export function PlexIcon(props: SVGProps) { ); } +export function EmbyIcon(props: SVGProps) { + return ( + + ); +} + export function JellyfinIcon(props: SVGProps) { return ( c.provider === "plex") ?? null; const jellyfinConnection = connections.find((c) => c.provider === "jellyfin") ?? null; + const embyConnection = connections.find((c) => c.provider === "emby") ?? null; - async function handleSave(provider: "plex" | "jellyfin", username: string) { - const label = provider === "plex" ? "Plex" : "Jellyfin"; - const isNew = !connections.find((c) => c.provider === provider); + async function handleConnect(provider: "plex" | "jellyfin" | "emby") { + const label = + provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin"; try { - const result = await saveWebhookConnection(provider, username); - setConnections((prev) => { - const existing = prev.find((c) => c.provider === provider); - if (existing) { - return prev.map((c) => - c.provider === provider - ? { ...result, recentEvents: existing.recentEvents } - : c, - ); - } - return [...prev, { ...result, recentEvents: [] }]; - }); - toast.success(isNew ? `${label} connected` : `${label} updated`); + const result = await saveWebhookConnection(provider); + setConnections((prev) => [...prev, { ...result, recentEvents: [] }]); + toast.success(`${label} connected`); } catch { - toast.error(`Failed to save ${label} connection`); + toast.error(`Failed to connect ${label}`); } } - async function handleDelete(provider: "plex" | "jellyfin") { - const label = provider === "plex" ? "Plex" : "Jellyfin"; + async function handleDelete(provider: "plex" | "jellyfin" | "emby") { + const label = + provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin"; const previous = connections; setConnections((prev) => prev.filter((c) => c.provider !== provider)); try { @@ -57,8 +49,9 @@ export function IntegrationsSection({ } } - async function handleRegenerateToken(provider: "plex" | "jellyfin") { - const label = provider === "plex" ? "Plex" : "Jellyfin"; + async function handleRegenerateToken(provider: "plex" | "jellyfin" | "emby") { + const label = + provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin"; try { const result = await regenerateWebhookToken(provider); setConnections((prev) => @@ -72,19 +65,18 @@ export function IntegrationsSection({ } } - async function handleToggle(provider: "plex" | "jellyfin", enabled: boolean) { - const label = provider === "plex" ? "Plex" : "Jellyfin"; + async function handleToggle( + provider: "plex" | "jellyfin" | "emby", + enabled: boolean, + ) { + const label = + provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin"; const previous = connections; setConnections((prev) => prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)), ); try { - const conn = connections.find((c) => c.provider === provider); - await saveWebhookConnection( - provider, - conn?.mediaServerUsername ?? "", - enabled, - ); + await saveWebhookConnection(provider, enabled); toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`); } catch { setConnections(previous); @@ -104,7 +96,7 @@ export function IntegrationsSection({ + Promise; - onDelete: (provider: "plex" | "jellyfin") => Promise; - onRegenerateToken: (provider: "plex" | "jellyfin") => Promise; - onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise; + onConnect: (provider: "plex" | "jellyfin" | "emby") => Promise; + onDelete: (provider: "plex" | "jellyfin" | "emby") => Promise; + onRegenerateToken: (provider: "plex" | "jellyfin" | "emby") => Promise; + onToggle: ( + provider: "plex" | "jellyfin" | "emby", + enabled: boolean, + ) => Promise; }) { - const [username, setUsername] = useState( - connection?.mediaServerUsername ?? "", - ); - const [saving, setSaving] = useState(false); + const [connecting, setConnecting] = useState(false); const [copied, setCopied] = useState(false); const [setupOpen, setSetupOpen] = useState(false); const [cardOpen, setCardOpen] = useState(false); const isPlex = provider === "plex"; - const label = isPlex ? "Plex" : "Jellyfin"; - const Icon = isPlex ? PlexIcon : JellyfinIcon; + const isEmby = provider === "emby"; + const label = isPlex ? "Plex" : isEmby ? "Emby" : "Jellyfin"; + const Icon = isPlex ? PlexIcon : isEmby ? EmbyIcon : JellyfinIcon; const webhookUrl = connection ? `${window.location.origin}/api/webhooks/${connection.token}` : null; - async function handleSave() { - if (!username.trim()) return; - setSaving(true); + async function handleConnect() { + setConnecting(true); try { - await onSave(provider, username.trim()); + await onConnect(provider); } finally { - setSaving(false); + setConnecting(false); } } @@ -147,116 +146,109 @@ export function WebhookCard({ href="https://www.plex.tv/plex-pass/" target="_blank" rel="noopener noreferrer" - className="font-medium text-foreground inline-flex items-center gap-0.5 hover:underline underline-offset-2" + className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" > Plex Pass - + {" "} subscription.

)} -
- -
- setUsername(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSave()} - /> - {!connection ? ( - - ) : ( - username.trim() !== connection.mediaServerUsername && ( - - ) - )} + Emby Premiere + + {" "} + subscription. +

-
+ )} - - {webhookUrl && ( - -
- -
- - - - } - > - {copied ? ( - - ) : ( - - )} - - Copy URL - + {!connection ? ( + + ) : ( + + {webhookUrl && ( + +
+ +
+ + + + } + > + {copied ? ( + + ) : ( + + )} + + Copy URL + +
-
-
- - -
- - )} - +
+ + +
+ + )} + + )} @@ -286,9 +278,26 @@ export function WebhookCard({ Sofa will automatically log movies and episodes when you finish watching them + + ) : isEmby ? ( +
  1. - Make sure the username above matches your Plex account - name + Open Emby, go to{" "} + + Settings > Webhooks + +
  2. +
  3. Add a new webhook and paste the URL above
  4. +
  5. + Enable the{" "} + + Playback Stop + {" "} + event type +
  6. +
  7. + Sofa will automatically log movies and episodes when you + finish watching them
) : ( @@ -320,10 +329,6 @@ export function WebhookCard({ {" "} notification type -
  • - Make sure the username above matches your Jellyfin - username -
  • )}
    diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 09d59db..fb1c783 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -46,7 +46,6 @@ export default async function SettingsPage() { id: conn.id, provider: conn.provider, token: conn.token, - mediaServerUsername: conn.mediaServerUsername, enabled: conn.enabled, lastEventAt: conn.lastEventAt?.toISOString() ?? null, recentEvents: events.map((e) => ({ diff --git a/app/api/webhooks/[token]/route.ts b/app/api/webhooks/[token]/route.ts index f00a021..f4dca9f 100644 --- a/app/api/webhooks/[token]/route.ts +++ b/app/api/webhooks/[token]/route.ts @@ -6,6 +6,7 @@ import { webhookConnections } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; import type { WebhookEvent } from "@/lib/services/webhooks"; import { + parseEmbyPayload, parseJellyfinPayload, parsePlexPayload, processWebhook, @@ -36,6 +37,9 @@ export async function POST( if (connection.provider === "plex") { const formData = await req.formData(); event = parsePlexPayload(formData); + } else if (connection.provider === "emby") { + const body = await req.json(); + event = parseEmbyPayload(body); } else { const body = await req.json(); event = parseJellyfinPayload(body); diff --git a/drizzle/20260305054757_high_cobalt_man/migration.sql b/drizzle/20260305054757_high_cobalt_man/migration.sql new file mode 100644 index 0000000..5456634 --- /dev/null +++ b/drizzle/20260305054757_high_cobalt_man/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `webhookConnections` DROP COLUMN `mediaServerUsername`; \ No newline at end of file diff --git a/drizzle/20260305054757_high_cobalt_man/snapshot.json b/drizzle/20260305054757_high_cobalt_man/snapshot.json new file mode 100644 index 0000000..8f5af92 --- /dev/null +++ b/drizzle/20260305054757_high_cobalt_man/snapshot.json @@ -0,0 +1,2049 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "ced24fed-4572-4e4f-8534-d214de1e4a40", + "prevIds": [ + "801f55b9-8091-42d4-b4ab-20eccf2258d9" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "availabilityOffers", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleRecommendations", + "entityType": "tables" + }, + { + "name": "titles", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "userEpisodeWatches", + "entityType": "tables" + }, + { + "name": "userMovieWatches", + "entityType": "tables" + }, + { + "name": "userRatings", + "entityType": "tables" + }, + { + "name": "userTitleStatus", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "name": "webhookConnections", + "entityType": "tables" + }, + { + "name": "webhookEventLog", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accountId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'US'", + "generated": null, + "name": "region", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerName", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logoPath", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "offerType", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "jobName", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonId", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeNumber", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillPath", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonNumber", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ipAddress", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userAgent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonatedBy", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recommendedTitleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rank", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalTitle", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "firstAirDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteAverage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteCount", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "emailVerified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banReason", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banExpires", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratingStars", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratedAt", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "addedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "connectionId", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_userId_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_availabilityOffers_titleId_titles_id_fk", + "entityType": "fks", + "table": "availabilityOffers" + }, + { + "columns": [ + "seasonId" + ], + "tableTo": "seasons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_episodes_seasonId_seasons_id_fk", + "entityType": "fks", + "table": "episodes" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_seasons_titleId_titles_id_fk", + "entityType": "fks", + "table": "seasons" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_userId_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "recommendedTitleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "episodeId" + ], + "tableTo": "episodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_titleId_titles_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_userId_user_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_titleId_titles_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_userId_user_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_titleId_titles_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_webhookConnections_userId_user_id_fk", + "entityType": "fks", + "table": "webhookConnections" + }, + { + "columns": [ + "connectionId" + ], + "tableTo": "webhookConnections", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_webhookEventLog_connectionId_webhookConnections_id_fk", + "entityType": "fks", + "table": "webhookEventLog" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "appSettings_pk", + "table": "appSettings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cronRuns_pk", + "table": "cronRuns", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "episodes_pk", + "table": "episodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "seasons_pk", + "table": "seasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titles_pk", + "table": "titles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userEpisodeWatches_pk", + "table": "userEpisodeWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userMovieWatches_pk", + "table": "userMovieWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "webhookConnections_pk", + "table": "webhookConnections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "webhookEventLog_pk", + "table": "webhookEventLog", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "region", + "isExpression": false + }, + { + "value": "providerId", + "isExpression": false + }, + { + "value": "offerType", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "availabilityOffers_unique", + "entityType": "indexes", + "table": "availabilityOffers" + }, + { + "columns": [ + { + "value": "jobName", + "isExpression": false + }, + { + "value": "startedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "cronRuns_jobName_startedAt", + "entityType": "indexes", + "table": "cronRuns" + }, + { + "columns": [ + { + "value": "seasonId", + "isExpression": false + }, + { + "value": "episodeNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "episodes_seasonId_episodeNumber", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "seasonNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "seasons_titleId_seasonNumber", + "entityType": "indexes", + "table": "seasons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "recommendedTitleId", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleRecommendations_unique", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titles_tmdbId_unique", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "releaseDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_releaseDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "firstAirDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_firstAirDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userRatings_userId_titleId", + "entityType": "indexes", + "table": "userRatings" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_titleId", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_status", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "webhookConnections_userId_provider", + "entityType": "indexes", + "table": "webhookConnections" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "webhookConnections_token", + "entityType": "indexes", + "table": "webhookConnections" + }, + { + "columns": [ + { + "value": "connectionId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "webhookEventLog_connectionId_receivedAt", + "entityType": "indexes", + "table": "webhookEventLog" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/lib/actions/settings.ts b/lib/actions/settings.ts index 2609f03..2b6c137 100644 --- a/lib/actions/settings.ts +++ b/lib/actions/settings.ts @@ -29,18 +29,14 @@ async function getAdminSession() { // --- Webhook actions --- export async function saveWebhookConnection( - provider: "plex" | "jellyfin", - mediaServerUsername: string, + provider: "plex" | "jellyfin" | "emby", enabled?: boolean, ) { const session = await getSession(); - if (!["plex", "jellyfin"].includes(provider)) { + if (!["plex", "jellyfin", "emby"].includes(provider)) { throw new Error("Invalid provider"); } - if (!mediaServerUsername?.trim()) { - throw new Error("Media server username is required"); - } const existing = db .select() @@ -57,7 +53,6 @@ export async function saveWebhookConnection( const connection = db .update(webhookConnections) .set({ - mediaServerUsername: mediaServerUsername.trim(), enabled: typeof enabled === "boolean" ? enabled : existing.enabled, }) .where(eq(webhookConnections.id, existing.id)) @@ -81,7 +76,6 @@ export async function saveWebhookConnection( userId: session.user.id, provider, token, - mediaServerUsername: mediaServerUsername.trim(), enabled: true, createdAt: now, }) @@ -95,10 +89,12 @@ export async function saveWebhookConnection( }; } -export async function deleteWebhookConnection(provider: "plex" | "jellyfin") { +export async function deleteWebhookConnection( + provider: "plex" | "jellyfin" | "emby", +) { const session = await getSession(); - if (!["plex", "jellyfin"].includes(provider)) { + if (!["plex", "jellyfin", "emby"].includes(provider)) { throw new Error("Invalid provider"); } @@ -112,10 +108,12 @@ export async function deleteWebhookConnection(provider: "plex" | "jellyfin") { .run(); } -export async function regenerateWebhookToken(provider: "plex" | "jellyfin") { +export async function regenerateWebhookToken( + provider: "plex" | "jellyfin" | "emby", +) { const session = await getSession(); - if (!["plex", "jellyfin"].includes(provider)) { + if (!["plex", "jellyfin", "emby"].includes(provider)) { throw new Error("Invalid provider"); } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 07ec26f..0d57ce9 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -181,7 +181,7 @@ export const userMovieWatches = sqliteTable( .references(() => titles.id, { onDelete: "cascade" }), watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(), source: text("source", { - enum: ["manual", "import", "plex", "jellyfin"], + enum: ["manual", "import", "plex", "jellyfin", "emby"], }) .notNull() .default("manual"), @@ -207,7 +207,7 @@ export const userEpisodeWatches = sqliteTable( .references(() => episodes.id, { onDelete: "cascade" }), watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(), source: text("source", { - enum: ["manual", "import", "plex", "jellyfin"], + enum: ["manual", "import", "plex", "jellyfin", "emby"], }) .notNull() .default("manual"), @@ -297,9 +297,10 @@ export const webhookConnections = sqliteTable( userId: text("userId") .notNull() .references(() => user.id, { onDelete: "cascade" }), - provider: text("provider", { enum: ["plex", "jellyfin"] }).notNull(), + provider: text("provider", { + enum: ["plex", "jellyfin", "emby"], + }).notNull(), token: text("token").notNull().unique(), - mediaServerUsername: text("mediaServerUsername").notNull(), enabled: int("enabled", { mode: "boolean" }).notNull().default(true), createdAt: int("createdAt", { mode: "timestamp" }).notNull(), lastEventAt: int("lastEventAt", { mode: "timestamp" }), diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index a8c9287..d1838ef 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -15,7 +15,7 @@ export function setTitleStatus( titleId: string, status: "watchlist" | "in_progress" | "completed", // biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers - source: "manual" | "import" | "plex" | "jellyfin" = "manual", + source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual", ) { const now = new Date(); db.insert(userTitleStatus) @@ -41,7 +41,7 @@ export function removeTitleStatus(userId: string, titleId: string) { export function logMovieWatch( userId: string, titleId: string, - source: "manual" | "import" | "plex" | "jellyfin" = "manual", + source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual", ) { const now = new Date(); db.insert(userMovieWatches) @@ -70,7 +70,7 @@ export function logMovieWatch( export function logEpisodeWatch( userId: string, episodeId: string, - source: "manual" | "import" | "plex" | "jellyfin" = "manual", + source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual", ) { const now = new Date(); db.insert(userEpisodeWatches) @@ -111,7 +111,7 @@ export function logEpisodeWatch( export function markAllEpisodesWatched( userId: string, titleId: string, - source: "manual" | "import" | "plex" | "jellyfin" = "manual", + source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual", ) { const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); if (!title || title.type !== "tv") return; diff --git a/lib/services/webhooks.ts b/lib/services/webhooks.ts index 35d1a62..9d079c2 100644 --- a/lib/services/webhooks.ts +++ b/lib/services/webhooks.ts @@ -15,7 +15,7 @@ import { logEpisodeWatch, logMovieWatch } from "./tracking"; // ─── Types ────────────────────────────────────────────────────────── export interface WebhookEvent { - provider: "plex" | "jellyfin"; + provider: "plex" | "jellyfin" | "emby"; mediaType: "movie" | "episode"; title: string; tmdbId?: number; @@ -109,6 +109,39 @@ export function parseJellyfinPayload( }; } +export function parseEmbyPayload( + body: Record, +): WebhookEvent | null { + // Emby sends "playback.stop" or "PlaybackStop" depending on webhook plugin version + const event = body.Event as string | undefined; + if (event !== "playback.stop" && event !== "PlaybackStop") return null; + if (body.PlayedToCompletion !== true) return null; + + const item = body.Item as Record | undefined; + if (!item) return null; + + const itemType = item.Type as string | undefined; + const isMovie = itemType === "Movie"; + const isEpisode = itemType === "Episode"; + if (!isMovie && !isEpisode) return null; + + const providerIds = (item.ProviderIds ?? {}) as Record; + const tmdbRaw = providerIds.Tmdb; + const tmdbId = tmdbRaw ? Number.parseInt(tmdbRaw, 10) : undefined; + + return { + provider: "emby", + mediaType: isMovie ? "movie" : "episode", + title: (item.Name ?? "") as string, + tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined, + imdbId: providerIds.Imdb || undefined, + tvdbId: providerIds.Tvdb || undefined, + seasonNumber: item.ParentIndexNumber as number | undefined, + episodeNumber: item.IndexNumber as number | undefined, + showTitle: (item.SeriesName ?? item.ShowName) as string | undefined, + }; +} + // ─── Title Resolution ─────────────────────────────────────────────── async function resolveMovieTmdbId(event: WebhookEvent): Promise { @@ -240,7 +273,12 @@ function logEvent( db.insert(webhookEventLog) .values({ connectionId, - eventType: event?.provider === "plex" ? "media.scrobble" : "PlaybackStop", + eventType: + event?.provider === "plex" + ? "media.scrobble" + : event?.provider === "emby" + ? "playback.stop" + : "PlaybackStop", mediaType: event?.mediaType ?? null, mediaTitle: event?.title ?? null, status, @@ -260,7 +298,7 @@ function logEvent( export async function processWebhook( connectionId: string, userId: string, - provider: "plex" | "jellyfin", + provider: "plex" | "jellyfin" | "emby", event: WebhookEvent, ): Promise<{ status: "success" | "ignored" | "error"; message: string }> { try {