Add viewer accounts, device linking, and Android TV client foundation.

This commit is contained in:
Jos Vooges | STH 2026-08-31 01:11:43 +02:00
parent d233878e6d
commit 0b107515e4
33 changed files with 3173 additions and 0 deletions

View file

@ -0,0 +1,142 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
export default function LinkDevicePage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
useEffect(() => {
const q = new URLSearchParams(window.location.search);
const c = q.get("code");
if (c) setCode(c.toUpperCase());
}, []);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr(null);
setOk(null);
try {
const res = await fetch("/api/v1/link/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, code }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Koppelen mislukt");
return;
}
setOk(`Apparaat “${data.deviceName ?? "TV"}” is gekoppeld. Je kunt dit venster sluiten.`);
setPassword("");
} finally {
setBusy(false);
}
}
return (
<main className="link-page">
<div className="link-card">
<p className="page-kicker">Media Cluster</p>
<h1>Apparaat koppelen</h1>
<p className="muted">
Log in met je kijker-account en voer de code van je TV of telefoon in.
</p>
<form onSubmit={onSubmit} className="link-form">
<label>
E-mail
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Wachtwoord
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<label>
Code
<input
type="text"
inputMode="text"
autoCapitalize="characters"
autoComplete="one-time-code"
placeholder="ABCD-EFGH"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
required
/>
</label>
{err && <p className="form-error">{err}</p>}
{ok && <p className="form-ok">{ok}</p>}
<button type="submit" className="btn-primary" disabled={busy}>
{busy ? "Bezig…" : "Koppelen"}
</button>
</form>
</div>
<style jsx>{`
.link-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 120, 255, 0.25), transparent),
#04060a;
}
.link-card {
width: min(420px, 100%);
background: rgba(12, 16, 24, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 1.75rem;
}
h1 {
margin: 0.25rem 0 0.5rem;
font-size: 1.75rem;
}
.link-form {
display: grid;
gap: 0.9rem;
margin-top: 1.25rem;
}
label {
display: grid;
gap: 0.35rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
}
input {
padding: 0.7rem 0.8rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0a0e16;
color: #fff;
font-size: 1rem;
}
.form-error {
color: #ff8b8b;
margin: 0;
}
.form-ok {
color: #8dffb0;
margin: 0;
}
`}</style>
</main>
);
}

View file

@ -0,0 +1,225 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
type Device = {
id: string;
name: string;
platform: string;
lastSeenAt: string | null;
createdAt: string;
};
type Viewer = {
id: string;
email: string;
name: string | null;
enabled: boolean;
appAccess: boolean;
createdAt: string;
deviceCount: number;
devices: Device[];
};
export default function ViewersPage() {
useAuth();
const [viewers, setViewers] = useState<Viewer[]>([]);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" })
.then((r) => r.json())
.then((d) => setViewers(d.viewers ?? []))
.catch(() => setErr("Kon kijkers niet laden"));
}, []);
useEffect(() => {
load();
}, [load]);
async function createViewer(e: FormEvent) {
e.preventDefault();
setBusy(true);
setMsg(null);
setErr(null);
try {
const res = await fetch("/api/v1/admin/viewers", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined, appAccess: true }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Aanmaken mislukt");
return;
}
setMsg(`Kijker ${data.viewer.email} aangemaakt`);
setEmail("");
setPassword("");
setName("");
load();
} finally {
setBusy(false);
}
}
async function toggle(v: Viewer, field: "enabled" | "appAccess") {
await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: !v[field] }),
});
load();
}
async function revokeDevice(deviceId: string) {
if (!confirm("Apparaat intrekken?")) return;
await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
method: "POST",
credentials: "include",
});
load();
}
return (
<>
<Nav />
<main className="page">
<header className="page-header">
<div>
<p className="page-kicker">Mensen</p>
<h1>Kijkers</h1>
<p className="muted">
Accounts voor de Android TV / phone-app. Gebruikers koppelen zelf via{" "}
<a href="/link">/link</a> met een code.
</p>
</div>
</header>
<section className="card-block">
<h2>Nieuwe kijker</h2>
<form className="form-grid" onSubmit={createViewer}>
<label>
Naam
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Jos" />
</label>
<label>
E-mail
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
Wachtwoord
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit" className="btn-primary" disabled={busy}>
Aanmaken
</button>
</form>
{msg && <p className="form-ok">{msg}</p>}
{err && <p className="form-error">{err}</p>}
</section>
<section className="card-block">
<h2>Overzicht</h2>
{viewers.length === 0 ? (
<p className="muted">Nog geen kijkers.</p>
) : (
<div className="viewer-list">
{viewers.map((v) => (
<article key={v.id} className="viewer-row">
<div>
<strong>{v.name || v.email}</strong>
<div className="muted">{v.email}</div>
<div className="muted">
{v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "}
{v.deviceCount} apparaat(en)
</div>
</div>
<div className="viewer-actions">
<button type="button" onClick={() => toggle(v, "enabled")}>
{v.enabled ? "Uitschakelen" : "Activeren"}
</button>
<button type="button" onClick={() => toggle(v, "appAccess")}>
{v.appAccess ? "App uit" : "App aan"}
</button>
</div>
{v.devices.length > 0 && (
<ul className="device-list">
{v.devices.map((d) => (
<li key={d.id}>
<span>
{d.name} <span className="muted">({d.platform})</span>
</span>
<button type="button" onClick={() => revokeDevice(d.id)}>
Intrekken
</button>
</li>
))}
</ul>
)}
</article>
))}
</div>
)}
</section>
</main>
<style jsx>{`
.viewer-list {
display: grid;
gap: 1rem;
}
.viewer-row {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1rem;
display: grid;
gap: 0.75rem;
}
.viewer-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.device-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.device-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
font-size: 0.9rem;
}
.form-ok {
color: #8dffb0;
}
.form-error {
color: #ff8b8b;
}
`}</style>
</>
);
}

View file

@ -13,6 +13,7 @@ const desktopLinks = [
{ href: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" },
{ href: "/accounts", label: "Accounts" },
{ href: "/viewers", label: "Kijkers" },
{ href: "/settings", label: "Instellingen" },
];

12
apps/android-tv/.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
/.gradle/
/.idea/
/.kotlin/
**/build/
local.properties
*.iml
.DS_Store
captures/
*.apk
*.aab
hs_err_pid*
replay_pid*

30
apps/android-tv/README.md Normal file
View file

@ -0,0 +1,30 @@
# Media Cluster — Android TV / phone client
Native client for the private Media Cluster (alternative to Stremio).
## Features (v0.1)
- Device linking via short code + `/link` page
- Home with **Verder kijken** + configurable shelves
- Detail → ExoPlayer direct play (no transcoding)
- Progress sync to master
- Shelf visibility / order per viewer
## Setup
1. Open `apps/android-tv` in Android Studio.
2. Set `DEFAULT_API_BASE` in `app/build.gradle.kts` if needed (default `https://master.vonas.nl`).
3. On master, set `CLIENT_LINK_BASE_URL` to your admin UI origin (e.g. `https://admin.vonas.nl`) so the TV shows the right link URL.
4. Run migration `20260831010000_viewer_client`.
5. Admin → **Kijkers** → create a viewer account.
6. Install APK on TV → enter code at `/link`.
## API used
- `POST /api/v1/client/device/code`
- `GET /api/v1/client/device/poll`
- `POST /api/v1/link/claim`
- `GET /api/v1/client/home`
- `POST /api/v1/client/play`
- `POST /api/v1/client/progress`
- `GET|PUT /api/v1/client/shelves/prefs`

View file

@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "nl.vonas.mediacluster.tv"
compileSdk = 35
defaultConfig {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("io.coil-kt:coil-compose:2.7.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}

View file

@ -0,0 +1 @@
# Add project specific ProGuard rules here.

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name=".McApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/ic_launcher_fg"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MediaClusterTv"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:banner="@drawable/app_banner"
android:configChanges="keyboard|keyboardHidden|navigation"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,20 @@
package nl.vonas.mediacluster.tv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import nl.vonas.mediacluster.tv.ui.McNav
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MediaClusterTheme {
McNav()
}
}
}
}

View file

@ -0,0 +1,5 @@
package nl.vonas.mediacluster.tv
import android.app.Application
class McApp : Application()

View file

@ -0,0 +1,278 @@
package nl.vonas.mediacluster.tv.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class ApiClient(
private var baseUrl: String,
private var token: String? = null,
) {
private val http = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
fun configure(baseUrl: String, token: String?) {
this.baseUrl = baseUrl.trimEnd('/')
this.token = token
}
suspend fun createDeviceCode(deviceName: String): DeviceCodeResponse = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("deviceName", deviceName)
.put("platform", "android_tv")
.toString()
.toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/client/device/code")
.post(body)
.build()
parseObject(execute(req)) { o ->
DeviceCodeResponse(
code = o.getString("code"),
expiresAt = o.optString("expiresAt"),
pollIntervalSeconds = o.optInt("pollIntervalSeconds", 2),
linkUrl = o.optString("linkUrl"),
qrPayload = o.optString("qrPayload"),
)
}
}
suspend fun pollDeviceCode(code: String): PollResult = withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/client/device/poll?code=${java.net.URLEncoder.encode(code, "UTF-8")}")
.get()
.build()
parseObject(execute(req)) { o ->
when (o.optString("status")) {
"linked" -> PollResult.Linked(
refreshToken = o.getString("refreshToken"),
viewerName = o.optJSONObject("viewer")?.optString("name")
?: o.optJSONObject("viewer")?.optString("email")
?: "Kijker",
)
"expired" -> PollResult.Expired
"already_linked" -> PollResult.AlreadyLinked
else -> PollResult.Pending
}
}
}
suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/home").get().build()
parseObject(execute(req)) { o ->
HomePayload(
continueWatching = o.optJSONArray("continueWatching").toItems(),
shelves = o.optJSONArray("shelves").toShelves(),
)
}
}
suspend fun movie(id: String): JSONObject = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/movies/$id").get().build()
JSONObject(execute(req))
}
suspend fun series(id: String): JSONObject = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/series/$id").get().build()
JSONObject(execute(req))
}
suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/play").post(body).build()
parseObject(execute(req)) { o ->
PlayResult(
streamUrl = o.getString("streamUrl"),
sessionId = o.optString("sessionId"),
mediaFileId = o.optString("mediaFileId"),
)
}
}
suspend fun postProgress(
mediaType: String,
movieId: String? = null,
episodeId: String? = null,
seriesId: String? = null,
positionMs: Long,
durationMs: Long,
) = withContext(Dispatchers.IO) {
val o = JSONObject()
.put("mediaType", mediaType)
.put("positionMs", positionMs)
.put("durationMs", durationMs)
if (movieId != null) o.put("movieId", movieId)
if (episodeId != null) o.put("episodeId", episodeId)
if (seriesId != null) o.put("seriesId", seriesId)
val req = authed("$baseUrl/api/v1/client/progress")
.post(o.toString().toRequestBody(jsonMedia))
.build()
execute(req)
Unit
}
suspend fun getShelfPrefs(): List<ShelfPref> = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/shelves/prefs").get().build()
parseObject(execute(req)) { o ->
val arr = o.optJSONArray("shelves") ?: JSONArray()
buildList {
for (i in 0 until arr.length()) {
val s = arr.getJSONObject(i)
add(
ShelfPref(
shelfId = s.getString("shelfId"),
name = s.getString("name"),
visible = s.optBoolean("visible", true),
sortOrder = s.optInt("sortOrder", i),
)
)
}
}
}
}
suspend fun saveShelfPrefs(prefs: List<ShelfPref>) = withContext(Dispatchers.IO) {
val arr = JSONArray()
prefs.forEachIndexed { index, p ->
arr.put(
JSONObject()
.put("shelfId", p.shelfId)
.put("visible", p.visible)
.put("sortOrder", p.sortOrder.takeIf { it >= 0 } ?: index)
)
}
val body = JSONObject().put("shelves", arr).toString().toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/shelves/prefs").put(body).build()
execute(req)
Unit
}
private fun authed(url: String): Request.Builder {
val b = Request.Builder().url(url)
val t = token
if (!t.isNullOrBlank()) b.header("Authorization", "Bearer $t")
return b
}
private fun execute(req: Request): String {
http.newCall(req).execute().use { res ->
val text = res.body?.string().orEmpty()
if (!res.isSuccessful) {
val msg = runCatching {
JSONObject(text).optJSONObject("error")?.optString("message")
}.getOrNull() ?: "HTTP ${res.code}"
throw ApiException(msg)
}
return text
}
}
private fun <T> parseObject(text: String, block: (JSONObject) -> T): T = block(JSONObject(text))
}
class ApiException(message: String) : Exception(message)
data class DeviceCodeResponse(
val code: String,
val expiresAt: String,
val pollIntervalSeconds: Int,
val linkUrl: String,
val qrPayload: String,
)
sealed class PollResult {
data object Pending : PollResult()
data object Expired : PollResult()
data object AlreadyLinked : PollResult()
data class Linked(val refreshToken: String, val viewerName: String) : PollResult()
}
data class HomePayload(
val continueWatching: List<MediaItem>,
val shelves: List<ShelfRow>,
)
data class ShelfRow(
val id: String,
val title: String,
val kind: String,
val items: List<MediaItem>,
)
data class MediaItem(
val type: String,
val id: String,
val title: String,
val subtitle: String? = null,
val year: Int? = null,
val posterUrl: String? = null,
val backdropUrl: String? = null,
val mediaFileId: String? = null,
val positionMs: Long = 0,
val durationMs: Long = 0,
val progressId: String? = null,
)
data class PlayResult(
val streamUrl: String,
val sessionId: String,
val mediaFileId: String,
)
data class ShelfPref(
val shelfId: String,
val name: String,
val visible: Boolean,
val sortOrder: Int,
)
private fun JSONArray?.toItems(): List<MediaItem> {
if (this == null) return emptyList()
return buildList {
for (i in 0 until length()) {
val o = getJSONObject(i)
add(
MediaItem(
type = o.optString("type"),
id = o.optString("id"),
title = o.optString("title"),
subtitle = o.optString("subtitle").ifBlank { null },
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
posterUrl = o.optString("posterUrl").ifBlank { null },
backdropUrl = o.optString("backdropUrl").ifBlank { null },
mediaFileId = o.optString("mediaFileId").ifBlank { null },
positionMs = o.optLong("positionMs"),
durationMs = o.optLong("durationMs"),
progressId = o.optString("progressId").ifBlank { null },
)
)
}
}
}
private fun JSONArray?.toShelves(): List<ShelfRow> {
if (this == null) return emptyList()
return buildList {
for (i in 0 until length()) {
val o = getJSONObject(i)
add(
ShelfRow(
id = o.optString("id"),
title = o.optString("title"),
kind = o.optString("kind"),
items = o.optJSONArray("items").toItems(),
)
)
}
}
}

View file

@ -0,0 +1,31 @@
package nl.vonas.mediacluster.tv.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import nl.vonas.mediacluster.tv.BuildConfig
private val Context.dataStore by preferencesDataStore("mc_tv")
class SessionStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("refresh_token")
private val apiKey = stringPreferencesKey("api_base")
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
val apiBase: Flow<String> = context.dataStore.data.map {
it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
}
suspend fun setToken(token: String?) {
context.dataStore.edit { prefs ->
if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token
}
}
suspend fun setApiBase(url: String) {
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
}
}

View file

@ -0,0 +1,692 @@
package nl.vonas.mediacluster.tv.ui
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.tv.material3.Switch
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.ApiException
import nl.vonas.mediacluster.tv.data.HomePayload
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.data.PollResult
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ShelfPref
import org.json.JSONObject
@Composable
fun McNav() {
val context = LocalContext.current
val store = remember { SessionStore(context) }
val api = remember { ApiClient(baseUrl = "") }
val nav = rememberNavController()
var ready by remember { mutableStateOf(false) }
var authed by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val base = store.apiBase.first()
val token = store.refreshToken.first()
api.configure(base, token)
authed = !token.isNullOrBlank()
ready = true
}
if (!ready) {
Box(Modifier.fillMaxSize().background(Color(0xFF04060A)), contentAlignment = Alignment.Center) {
Text("Media Cluster", color = Color.White, fontSize = 28.sp)
}
return
}
NavHost(navController = nav, startDestination = if (authed) "home" else "link") {
composable("link") {
LinkScreen(
api = api,
store = store,
onLinked = {
authed = true
nav.navigate("home") {
popUpTo("link") { inclusive = true }
}
},
)
}
composable("home") {
HomeScreen(
api = api,
onPlayMovie = { id -> nav.navigate("movie/$id") },
onPlaySeries = { id -> nav.navigate("series/$id") },
onPlayDirect = { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
val m = movieId?.ifBlank { null } ?: "-"
val e = episodeId?.ifBlank { null } ?: "-"
val s = seriesId?.ifBlank { null } ?: "-"
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
},
onSettings = { nav.navigate("settings") },
onLogout = {
kotlinx.coroutines.MainScope().launch {
store.setToken(null)
api.configure(store.apiBase.first(), null)
authed = false
nav.navigate("link") {
popUpTo(0) { inclusive = true }
}
}
},
)
}
composable(
"movie/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
DetailScreen(
load = { api.movie(id) },
onPlay = { mediaFileId ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/0")
},
onBack = { nav.popBackStack() },
)
}
composable(
"series/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
SeriesScreen(
load = { api.series(id) },
onPlay = { mediaFileId, episodeId ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
},
onBack = { nav.popBackStack() },
)
}
composable(
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
arguments = listOf(
navArgument("mediaFileId") { type = NavType.StringType },
navArgument("type") { type = NavType.StringType },
navArgument("movieId") { type = NavType.StringType },
navArgument("episodeId") { type = NavType.StringType },
navArgument("seriesId") { type = NavType.StringType },
navArgument("startMs") { type = NavType.LongType },
),
) { entry ->
fun dash(v: String?) = v?.takeIf { it.isNotBlank() && it != "-" }
PlayerScreen(
api = api,
mediaFileId = entry.arguments?.getString("mediaFileId").orEmpty(),
mediaType = entry.arguments?.getString("type") ?: "MOVIE",
movieId = dash(entry.arguments?.getString("movieId")),
episodeId = dash(entry.arguments?.getString("episodeId")),
seriesId = dash(entry.arguments?.getString("seriesId")),
startMs = entry.arguments?.getLong("startMs") ?: 0L,
onBack = { nav.popBackStack() },
)
}
composable("settings") {
SettingsScreen(
api = api,
store = store,
onBack = { nav.popBackStack() },
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LinkScreen(
api: ApiClient,
store: SessionStore,
onLinked: () -> Unit,
) {
val scope = rememberCoroutineScope()
var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var status by remember { mutableStateOf("Code ophalen…") }
LaunchedEffect(Unit) {
val base = store.apiBase.first()
api.configure(base, null)
try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
val created = api.createDeviceCode(deviceName.ifBlank { "Android TV" })
code = created.code
linkUrl = created.linkUrl
status = "Wachten op koppeling…"
while (true) {
delay((created.pollIntervalSeconds.coerceAtLeast(2)) * 1000L)
when (val poll = api.pollDeviceCode(created.code)) {
is PollResult.Linked -> {
store.setToken(poll.refreshToken)
api.configure(base, poll.refreshToken)
status = "Welkom ${poll.viewerName}"
delay(600)
onLinked()
break
}
PollResult.Expired -> {
status = "Code verlopen — herstart de app"
break
}
PollResult.AlreadyLinked -> {
status = "Al gekoppeld — herstart de app"
break
}
PollResult.Pending -> Unit
}
}
} catch (e: Exception) {
error = e.message ?: "Kon niet verbinden met master"
}
}
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF04060A))
.padding(48.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Media Cluster", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text("Koppel dit apparaat", fontSize = 22.sp, color = Color(0xFFB8C0D0))
Spacer(Modifier.height(12.dp))
Text(code ?: "····-····", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = Color(0xFF6EA8FF))
if (linkUrl.isNotBlank()) {
Text("Ga naar $linkUrl", fontSize = 18.sp, color = Color.White)
}
Text(status, fontSize = 16.sp, color = Color(0xFF9AA3B5))
if (error != null) {
Text(error!!, color = Color(0xFFFF8B8B), fontSize = 16.sp)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun HomeScreen(
api: ApiClient,
onPlayMovie: (String) -> Unit,
onPlaySeries: (String) -> Unit,
onPlayDirect: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onSettings: () -> Unit,
onLogout: () -> Unit,
) {
var home by remember { mutableStateOf<HomePayload?>(null) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
try {
home = api.home()
} catch (e: Exception) {
error = e.message
}
}
LazyColumn(
Modifier
.fillMaxSize()
.background(Color(0xFF04060A)),
contentPadding = PaddingValues(32.dp),
verticalArrangement = Arrangement.spacedBy(28.dp),
) {
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Media Cluster", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
TvButton("Planken", onSettings)
TvButton("Uitloggen", onLogout)
}
}
}
if (error != null) {
item { Text(error!!, color = Color(0xFFFF8B8B)) }
}
val continueItems = home?.continueWatching.orEmpty()
if (continueItems.isNotEmpty()) {
item {
ShelfSection("Verder kijken", continueItems) { item ->
val fileId = item.mediaFileId
if (!fileId.isNullOrBlank()) {
onPlayDirect(
fileId,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
null,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
)
} else if (item.type == "series" || item.type == "episode") {
// fall through
} else {
onPlayMovie(item.id)
}
}
}
}
items(home?.shelves.orEmpty()) { shelf ->
ShelfSection(shelf.title, shelf.items) { item ->
when (item.type) {
"movie" -> onPlayMovie(item.id)
else -> onPlaySeries(item.id)
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
items(items, key = { "${it.type}:${it.id}" }) { item ->
PosterCard(item, onClick = { onClick(item) })
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun PosterCard(item: MediaItem, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.width(140.dp),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF121826),
focusedContainerColor = Color(0xFF1A2438),
),
) {
Column {
AsyncImage(
model = item.posterUrl,
contentDescription = item.title,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(Color(0xFF1A2233)),
contentScale = ContentScale.Crop,
)
Text(
item.title,
modifier = Modifier.padding(8.dp),
color = Color.White,
fontSize = 13.sp,
maxLines = 2,
)
if (!item.subtitle.isNullOrBlank()) {
Text(
item.subtitle,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
color = Color(0xFF9AA3B5),
fontSize = 11.sp,
maxLines = 1,
)
}
Spacer(Modifier.height(8.dp))
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun DetailScreen(
load: suspend () -> JSONObject,
onPlay: (String) -> Unit,
onBack: () -> Unit,
) {
var title by remember { mutableStateOf("…") }
var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) }
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
try {
val o = load()
title = o.optString("title")
overview = o.optString("overview")
backdrop = o.optString("backdropUrl").ifBlank { null }
val arr = o.optJSONArray("mediaFiles")
val list = mutableListOf<Pair<String, String>>()
if (arr != null) {
for (i in 0 until arr.length()) {
val f = arr.getJSONObject(i)
val label = listOfNotNull(
f.optString("resolution").ifBlank { null },
f.optString("hdrType").ifBlank { null },
f.optString("releaseName").ifBlank { null },
).joinToString(" · ")
list += f.getString("id") to label.ifBlank { "Afspelen" }
}
}
files = list
} catch (e: Exception) {
error = e.message
}
}
Box(Modifier.fillMaxSize().background(Color(0xFF04060A))) {
if (backdrop != null) {
AsyncImage(
model = backdrop,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = 0.35f,
)
}
Column(Modifier.padding(40.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
TvButton("Terug", onBack)
Text(title, fontSize = 36.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(overview, color = Color(0xFFD0D6E2), fontSize = 16.sp, maxLines = 6)
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(files) { (id, label) ->
TvButton(label.take(48), onClick = { onPlay(id) })
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SeriesScreen(
load: suspend () -> JSONObject,
onPlay: (mediaFileId: String, episodeId: String) -> Unit,
onBack: () -> Unit,
) {
var title by remember { mutableStateOf("…") }
var episodes by remember { mutableStateOf<List<Triple<String, String, String?>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
try {
val o = load()
title = o.optString("title")
val seasons = o.optJSONArray("seasons")
val list = mutableListOf<Triple<String, String, String?>>()
if (seasons != null) {
for (si in 0 until seasons.length()) {
val season = seasons.getJSONObject(si)
val eps = season.optJSONArray("episodes") ?: continue
for (ei in 0 until eps.length()) {
val e = eps.getJSONObject(ei)
if (!e.optBoolean("available", false)) continue
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} · ${e.optString("title")}"
list += Triple(e.getString("id"), label, e.optString("mediaFileId").ifBlank { null })
}
}
}
episodes = list
} catch (e: Exception) {
error = e.message
}
}
LazyColumn(
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
TvButton("Terug", onBack)
Spacer(Modifier.height(8.dp))
Text(title, fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
}
items(episodes) { (id, label, fileId) ->
if (fileId != null) {
TvButton(label, onClick = { onPlay(fileId, id) })
}
}
}
}
@Composable
private fun PlayerScreen(
api: ApiClient,
mediaFileId: String,
mediaType: String,
movieId: String?,
episodeId: String?,
seriesId: String?,
startMs: Long,
onBack: () -> Unit,
) {
val context = LocalContext.current
var error by remember { mutableStateOf<String?>(null) }
val player = remember {
ExoPlayer.Builder(context).build().apply {
playWhenReady = true
}
}
LaunchedEffect(mediaFileId) {
try {
val play = api.play(mediaFileId)
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare()
if (startMs > 0) player.seekTo(startMs)
} catch (e: Exception) {
error = e.message
}
}
LaunchedEffect(player) {
while (true) {
delay(15_000)
if (player.playbackState == Player.STATE_READY || player.isPlaying) {
try {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition,
durationMs = player.duration.coerceAtLeast(0),
)
} catch (_: ApiException) {
}
}
}
}
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose {
try {
kotlinx.coroutines.runBlocking {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition,
durationMs = player.duration.coerceAtLeast(0),
)
}
} catch (_: Exception) {
}
player.release()
}
}
Box(Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = true
}
},
modifier = Modifier.fillMaxSize(),
)
if (error != null) {
Text(error!!, color = Color(0xFFFF8B8B), modifier = Modifier.align(Alignment.Center))
}
Box(Modifier.padding(24.dp)) {
TvButton("Terug", onBack)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsScreen(
api: ApiClient,
store: SessionStore,
onBack: () -> Unit,
) {
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
try {
prefs = api.getShelfPrefs().sortedBy { it.sortOrder }
} catch (e: Exception) {
error = e.message
}
}
fun persist(next: List<ShelfPref>) {
prefs = next
scope.launch {
try {
api.saveShelfPrefs(next.mapIndexed { i, p -> p.copy(sortOrder = i) })
} catch (e: Exception) {
error = e.message
}
}
}
LazyColumn(
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
TvButton("Terug", onBack)
Spacer(Modifier.height(8.dp))
Text("Planken", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text("Zet planken aan/uit en verander de volgorde.", color = Color(0xFF9AA3B5))
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
}
items(prefs.size) { index ->
val p = prefs[index]
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.name, color = Color.White, fontSize = 18.sp, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
TvButton("Omhoog") {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
next[index - 1] = next[index]
next[index] = tmp
persist(next)
}
}
TvButton("Omlaag") {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
next[index + 1] = next[index]
next[index] = tmp
persist(next)
}
}
Switch(
checked = p.visible,
onCheckedChange = { checked ->
val next = prefs.toMutableList()
next[index] = p.copy(visible = checked)
persist(next)
},
)
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun TvButton(label: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A2438),
focusedContainerColor = Color(0xFF2A3A5A),
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = Color.White,
fontSize = 15.sp,
)
}
}

View file

@ -0,0 +1,24 @@
package nl.vonas.mediacluster.tv.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
private val Scheme = darkColorScheme(
primary = Color(0xFF6EA8FF),
onPrimary = Color(0xFF001B3D),
surface = Color(0xFF0A0E16),
onSurface = Color(0xFFF2F5FA),
border = Color(0x33FFFFFF),
)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun MediaClusterTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = Scheme,
content = content,
)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#0A0E16" />
<size android:width="320dp" android:height="180dp" />
</shape>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6EA8FF"
android:pathData="M30,34h48v8H30zM30,50h48v8H30zM30,66h32v8H30z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_bg">#0A0E16</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Media Cluster</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MediaClusterTv" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#04060A</item>
<item name="android:colorPrimary">#6EA8FF</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}

View file

@ -0,0 +1,9 @@
# Lichter voor ~16GB RAM + Android Studio open
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
org.gradle.parallel=false
org.gradle.workers.max=2
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
kotlin.daemon.jvmargs=-Xmx1024m

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
apps/android-tv/gradlew vendored Normal file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
apps/android-tv/gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MediaClusterTv"
include(":app")

View file

@ -0,0 +1,103 @@
-- Viewer accounts, device linking, watch progress, shelf prefs
CREATE TYPE "WatchMediaType" AS ENUM ('MOVIE', 'EPISODE');
CREATE TABLE IF NOT EXISTS "viewer_users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"app_access" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "viewer_users_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_users_email_key" ON "viewer_users"("email");
CREATE TABLE IF NOT EXISTS "viewer_devices" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"platform" TEXT NOT NULL DEFAULT 'android_tv',
"refresh_token_hash" TEXT NOT NULL,
"last_seen_at" TIMESTAMP(3),
"revoked" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "viewer_devices_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_devices_refresh_token_hash_key" ON "viewer_devices"("refresh_token_hash");
CREATE INDEX IF NOT EXISTS "viewer_devices_viewer_user_id_idx" ON "viewer_devices"("viewer_user_id");
CREATE TABLE IF NOT EXISTS "device_link_codes" (
"id" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"code_display" TEXT,
"device_name" TEXT NOT NULL,
"platform" TEXT NOT NULL DEFAULT 'android_tv',
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"viewer_user_id" TEXT,
"claim_token_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "device_link_codes_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_code_hash_key" ON "device_link_codes"("code_hash");
CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_claim_token_hash_key" ON "device_link_codes"("claim_token_hash");
CREATE INDEX IF NOT EXISTS "device_link_codes_expires_at_idx" ON "device_link_codes"("expires_at");
CREATE TABLE IF NOT EXISTS "watch_progress" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"media_type" "WatchMediaType" NOT NULL,
"movie_id" TEXT,
"episode_id" TEXT,
"series_id" TEXT,
"position_ms" INTEGER NOT NULL DEFAULT 0,
"duration_ms" INTEGER NOT NULL DEFAULT 0,
"completed" BOOLEAN NOT NULL DEFAULT false,
"hidden_from_continue" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "watch_progress_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_movie_id_key" ON "watch_progress"("viewer_user_id", "movie_id");
CREATE UNIQUE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_episode_id_key" ON "watch_progress"("viewer_user_id", "episode_id");
CREATE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_updated_at_idx" ON "watch_progress"("viewer_user_id", "updated_at");
CREATE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_hidden_from_continue_completed_idx" ON "watch_progress"("viewer_user_id", "hidden_from_continue", "completed");
CREATE TABLE IF NOT EXISTS "viewer_shelf_preferences" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"shelf_id" TEXT NOT NULL,
"visible" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_shelf_preferences_viewer_user_id_shelf_id_key" ON "viewer_shelf_preferences"("viewer_user_id", "shelf_id");
CREATE INDEX IF NOT EXISTS "viewer_shelf_preferences_viewer_user_id_sort_order_idx" ON "viewer_shelf_preferences"("viewer_user_id", "sort_order");
DO $$ BEGIN
ALTER TABLE "viewer_devices" ADD CONSTRAINT "viewer_devices_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
ALTER TABLE "device_link_codes" ADD CONSTRAINT "device_link_codes_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
ALTER TABLE "watch_progress" ADD CONSTRAINT "watch_progress_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
ALTER TABLE "viewer_shelf_preferences" ADD CONSTRAINT "viewer_shelf_preferences_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;

View file

@ -57,6 +57,105 @@ model User {
@@map("users")
}
/** App-kijker (Android TV / phone) — apart van admin User. */
model ViewerUser {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
name String?
enabled Boolean @default(true)
appAccess Boolean @default(true) @map("app_access")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
devices ViewerDevice[]
progress WatchProgress[]
shelfPrefs ViewerShelfPreference[]
linkCodes DeviceLinkCode[]
@@map("viewer_users")
}
model ViewerDevice {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
name String
platform String @default("android_tv")
refreshTokenHash String @unique @map("refresh_token_hash")
lastSeenAt DateTime? @map("last_seen_at")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@index([viewerUserId])
@@map("viewer_devices")
}
/** Kortstondige koppelcode (TV toont code → gebruiker claimt op /link). */
model DeviceLinkCode {
id String @id @default(uuid())
codeHash String @unique @map("code_hash")
/** Display-code (alleen tot claim; daarna null) */
codeDisplay String? @map("code_display")
deviceName String @map("device_name")
platform String @default("android_tv")
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
viewerUserId String? @map("viewer_user_id")
/** Eenmalig token voor device poll na claim */
claimTokenHash String? @unique @map("claim_token_hash")
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser? @relation(fields: [viewerUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
@@map("device_link_codes")
}
enum WatchMediaType {
MOVIE
EPISODE
}
model WatchProgress {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
mediaType WatchMediaType @map("media_type")
movieId String? @map("movie_id")
episodeId String? @map("episode_id")
seriesId String? @map("series_id")
positionMs Int @default(0) @map("position_ms")
durationMs Int @default(0) @map("duration_ms")
completed Boolean @default(false)
hiddenFromContinue Boolean @default(false) @map("hidden_from_continue")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, movieId])
@@unique([viewerUserId, episodeId])
@@index([viewerUserId, updatedAt])
@@index([viewerUserId, hiddenFromContinue, completed])
@@map("watch_progress")
}
model ViewerShelfPreference {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
shelfId String @map("shelf_id")
visible Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, shelfId])
@@index([viewerUserId, sortOrder])
@@map("viewer_shelf_preferences")
}
model AdminSession {
id String @id @default(uuid())
userId String @map("user_id")

View file

@ -12,6 +12,7 @@ import { registerAdminRoutes } from "./admin/routes";
import { registerInstallRoutes } from "./install/routes";
import { registerDownloadRoutes } from "./downloads/routes";
import { registerSettingsRoutes } from "./settings/routes";
import { registerViewerRoutes } from "./viewer/routes";
import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors";
@ -75,6 +76,7 @@ async function main() {
await registerInstallRoutes(app, config);
registerDownloadRoutes(app, config);
registerSettingsRoutes(app, config);
registerViewerRoutes(app, config);
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
void nodeConnectionManager.handleConnection(socket);
@ -295,6 +297,94 @@ async function main() {
console.warn("Could not ensure download tables:", err);
}
try {
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "WatchMediaType" AS ENUM ('MOVIE', 'EPISODE');
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "viewer_users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"app_access" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "viewer_users_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "viewer_users_email_key" ON "viewer_users"("email")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "viewer_devices" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"platform" TEXT NOT NULL DEFAULT 'android_tv',
"refresh_token_hash" TEXT NOT NULL,
"last_seen_at" TIMESTAMP(3),
"revoked" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "viewer_devices_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "viewer_devices_refresh_token_hash_key" ON "viewer_devices"("refresh_token_hash")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "device_link_codes" (
"id" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"code_display" TEXT,
"device_name" TEXT NOT NULL,
"platform" TEXT NOT NULL DEFAULT 'android_tv',
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"viewer_user_id" TEXT,
"claim_token_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "device_link_codes_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_code_hash_key" ON "device_link_codes"("code_hash")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "watch_progress" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"media_type" "WatchMediaType" NOT NULL,
"movie_id" TEXT,
"episode_id" TEXT,
"series_id" TEXT,
"position_ms" INTEGER NOT NULL DEFAULT 0,
"duration_ms" INTEGER NOT NULL DEFAULT 0,
"completed" BOOLEAN NOT NULL DEFAULT false,
"hidden_from_continue" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "watch_progress_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "viewer_shelf_preferences" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"shelf_id" TEXT NOT NULL,
"visible" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id")
)
`);
} catch (err) {
console.warn("Could not ensure viewer client tables:", err);
}
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
try {
await prisma.$executeRawUnsafe(`

View file

@ -11,6 +11,8 @@ const configSchema = z.object({
ADMIN_PASSWORD: z.string().min(8).optional(),
TMDB_API_KEY: z.string().optional(),
PUBLIC_URL: z.string().url().default("http://localhost:3000"),
/** Base URL shown on TV for /link (often admin UI). Falls back to PUBLIC_URL. */
CLIENT_LINK_BASE_URL: z.string().url().optional(),
PLAYBACK_IDLE_TIMEOUT_SECONDS: z.coerce.number().default(1200),
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200),
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),

View file

@ -0,0 +1,187 @@
import type { FastifyInstance } from "fastify";
import { AppError } from "../security/errors";
import { requireAdmin } from "../auth/routes";
import { PlaybackService } from "../playback/service";
import type { Config } from "../config";
import { ViewerService } from "./service";
export function registerViewerRoutes(app: FastifyInstance, config: Config) {
const playback = new PlaybackService(config);
const viewers = new ViewerService(config, playback);
// ——— Admin: kijkers ———
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
return { viewers: await viewers.listViewers() };
});
app.post("/api/v1/admin/viewers", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
email?: string;
password?: string;
name?: string;
appAccess?: boolean;
};
const viewer = await viewers.createViewer({
email: body.email || "",
password: body.password || "",
name: body.name,
appAccess: body.appAccess,
});
return { viewer };
});
app.patch("/api/v1/admin/viewers/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
name?: string | null;
enabled?: boolean;
appAccess?: boolean;
password?: string;
};
const viewer = await viewers.updateViewer(id, body);
return { viewer };
});
app.post(
"/api/v1/admin/viewers/devices/:deviceId/revoke",
{ preHandler: requireAdmin },
async (request) => {
const { deviceId } = request.params as { deviceId: string };
return viewers.revokeDevice(deviceId);
}
);
// ——— Device link (TV) ———
app.post(
"/api/v1/client/device/code",
{
config: {
rateLimit: { max: 20, timeWindow: "1 minute" },
},
},
async (request) => {
const body = (request.body || {}) as { deviceName?: string; platform?: string };
return viewers.createDeviceCode(body.deviceName || "Android TV", body.platform || "android_tv");
}
);
app.get(
"/api/v1/client/device/poll",
{
config: {
rateLimit: { max: 120, timeWindow: "1 minute" },
},
},
async (request) => {
const q = request.query as { code?: string };
if (!q.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400);
return viewers.pollDeviceCode(q.code);
}
);
// ——— Link page (browser) ———
app.post(
"/api/v1/link/claim",
{
config: {
rateLimit: { max: 15, timeWindow: "1 minute" },
},
},
async (request) => {
const body = request.body as { email?: string; password?: string; code?: string };
if (!body.email || !body.password || !body.code) {
throw new AppError("INVALID_REQUEST", "E-mail, wachtwoord en code verplicht", 400);
}
return viewers.claimCode({
email: body.email,
password: body.password,
code: body.code,
});
}
);
// ——— Authenticated client ———
app.get("/api/v1/client/me", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId };
});
app.get("/api/v1/client/home", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return viewers.home(auth.viewerId);
});
app.get("/api/v1/client/movies/:id", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
return viewers.movieDetail(id);
});
app.get("/api/v1/client/series/:id", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
return viewers.seriesDetail(id);
});
app.post("/api/v1/client/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const body = request.body as { mediaFileId?: string };
if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
return viewers.play(body.mediaFileId, auth.viewerId);
});
app.post("/api/v1/client/progress", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const body = request.body as {
mediaType?: "MOVIE" | "EPISODE";
movieId?: string;
episodeId?: string;
seriesId?: string;
positionMs?: number;
durationMs?: number;
completed?: boolean;
hiddenFromContinue?: boolean;
};
if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400);
const row = await viewers.upsertProgress(auth.viewerId, {
mediaType: body.mediaType,
movieId: body.movieId,
episodeId: body.episodeId,
seriesId: body.seriesId,
positionMs: body.positionMs ?? 0,
durationMs: body.durationMs ?? 0,
completed: body.completed,
hiddenFromContinue: body.hiddenFromContinue,
});
return { progress: row };
});
app.post("/api/v1/client/progress/mark-watched", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const body = request.body as { movieId?: string; episodeId?: string };
const row = await viewers.markWatched(auth.viewerId, body);
return { progress: row };
});
app.post("/api/v1/client/progress/:id/dismiss", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
return viewers.dismissContinue(auth.viewerId, id);
});
app.get("/api/v1/client/shelves/prefs", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return { shelves: await viewers.getShelfPrefs(auth.viewerId) };
});
app.put("/api/v1/client/shelves/prefs", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const body = request.body as {
shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>;
};
if (!Array.isArray(body.shelves)) {
throw new AppError("INVALID_REQUEST", "shelves array verplicht", 400);
}
return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) };
});
}

View file

@ -0,0 +1,717 @@
import { randomBytes } from "crypto";
import { prisma } from "../database/client";
import {
generateSecureToken,
hashPassword,
hashToken,
verifyPassword,
} from "../security/crypto";
import { AppError } from "../security/errors";
import { PlaybackService } from "../playback/service";
import type { Config } from "../config";
import type { FastifyRequest } from "fastify";
const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const POLL_INTERVAL_SEC = 2;
export type AuthedViewer = {
viewerId: string;
deviceId: string;
email: string;
name: string | null;
};
export function generateLinkCode(length = 8): string {
const bytes = randomBytes(length);
let out = "";
for (let i = 0; i < length; i++) {
out += CODE_ALPHABET[bytes[i]! % CODE_ALPHABET.length];
}
return `${out.slice(0, 4)}-${out.slice(4)}`;
}
/** Normalize to ABCD-EFGH then hash — must match createDeviceCode. */
export function hashLinkCode(raw: string): string {
const n = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
const display = n.length === 8 ? `${n.slice(0, 4)}-${n.slice(4)}` : raw.trim().toUpperCase();
return hashToken(display);
}
export class ViewerService {
constructor(
private readonly config: Config,
private readonly playback: PlaybackService
) {}
async createViewer(input: {
email: string;
password: string;
name?: string | null;
appAccess?: boolean;
}) {
const email = input.email.trim().toLowerCase();
if (!email || !input.password || input.password.length < 8) {
throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord (min. 8) verplicht", 400);
}
const existing = await prisma.viewerUser.findUnique({ where: { email } });
if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409);
return prisma.viewerUser.create({
data: {
email,
passwordHash: await hashPassword(input.password),
name: input.name?.trim() || null,
appAccess: input.appAccess !== false,
},
select: {
id: true,
email: true,
name: true,
enabled: true,
appAccess: true,
createdAt: true,
},
});
}
async listViewers() {
const rows = await prisma.viewerUser.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: { select: { devices: true } },
devices: {
where: { revoked: false },
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
orderBy: { lastSeenAt: "desc" },
},
},
});
return rows.map((v) => ({
id: v.id,
email: v.email,
name: v.name,
enabled: v.enabled,
appAccess: v.appAccess,
createdAt: v.createdAt,
deviceCount: v._count.devices,
devices: v.devices,
}));
}
async updateViewer(
id: string,
data: {
name?: string | null;
enabled?: boolean;
appAccess?: boolean;
password?: string;
}
) {
const patch: {
name?: string | null;
enabled?: boolean;
appAccess?: boolean;
passwordHash?: string;
} = {};
if (data.name !== undefined) patch.name = data.name?.trim() || null;
if (data.enabled !== undefined) patch.enabled = data.enabled;
if (data.appAccess !== undefined) patch.appAccess = data.appAccess;
if (data.password?.trim()) {
if (data.password.length < 8) {
throw new AppError("INVALID_REQUEST", "Wachtwoord minstens 8 tekens", 400);
}
patch.passwordHash = await hashPassword(data.password);
}
return prisma.viewerUser.update({
where: { id },
data: patch,
select: {
id: true,
email: true,
name: true,
enabled: true,
appAccess: true,
createdAt: true,
},
});
}
async revokeDevice(deviceId: string) {
await prisma.viewerDevice.update({
where: { id: deviceId },
data: { revoked: true, refreshTokenHash: hashToken(`revoked:${deviceId}:${Date.now()}`) },
});
return { ok: true };
}
async createDeviceCode(deviceName: string, platform = "android_tv") {
const name = deviceName.trim().slice(0, 80) || "Android TV";
const code = generateLinkCode(8);
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
await prisma.deviceLinkCode.create({
data: {
codeHash: hashLinkCode(code),
codeDisplay: code,
deviceName: name,
platform,
expiresAt,
},
});
const linkUrl = `${this.publicBase()}/link`;
return {
code,
expiresAt: expiresAt.toISOString(),
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
pollIntervalSeconds: POLL_INTERVAL_SEC,
linkUrl,
qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`,
};
}
async pollDeviceCode(codeRaw: string) {
const row = await prisma.deviceLinkCode.findUnique({
where: { codeHash: hashLinkCode(codeRaw) },
});
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
if (row.expiresAt.getTime() < Date.now() && !row.consumedAt) {
return { status: "expired" as const };
}
if (!row.consumedAt || !row.viewerUserId) {
return { status: "pending" as const };
}
if (!row.claimTokenHash) {
return { status: "already_linked" as const };
}
const refreshToken = generateSecureToken(32);
const viewer = await prisma.viewerUser.findUnique({ where: { id: row.viewerUserId } });
if (!viewer || !viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Account heeft geen app-toegang", 403);
}
const device = await prisma.$transaction(async (tx) => {
const claimed = await tx.deviceLinkCode.updateMany({
where: { id: row.id, claimTokenHash: row.claimTokenHash },
data: { claimTokenHash: null, codeDisplay: null },
});
if (claimed.count === 0) return null;
return tx.viewerDevice.create({
data: {
viewerUserId: viewer.id,
name: row.deviceName,
platform: row.platform,
refreshTokenHash: hashToken(refreshToken),
lastSeenAt: new Date(),
},
});
});
if (!device) return { status: "already_linked" as const };
return {
status: "linked" as const,
refreshToken,
device: { id: device.id, name: device.name, platform: device.platform },
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
};
}
async claimCode(opts: {
email: string;
password: string;
code: string;
}) {
const email = opts.email.trim().toLowerCase();
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) {
throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 401);
}
if (!viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
}
const row = await prisma.deviceLinkCode.findUnique({
where: { codeHash: hashLinkCode(opts.code) },
});
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409);
if (row.expiresAt.getTime() < Date.now()) {
throw new AppError("EXPIRED", "Code verlopen — start opnieuw op de TV", 410);
}
const claimToken = generateSecureToken(24);
await prisma.deviceLinkCode.update({
where: { id: row.id },
data: {
consumedAt: new Date(),
viewerUserId: viewer.id,
claimTokenHash: hashToken(claimToken),
},
});
return {
ok: true,
deviceName: row.deviceName,
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
};
}
async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
if (!authHeader?.startsWith("Bearer ")) {
throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);
}
const token = authHeader.slice("Bearer ".length).trim();
if (!token) throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);
const device = await prisma.viewerDevice.findUnique({
where: { refreshTokenHash: hashToken(token) },
include: { viewer: true },
});
if (!device || device.revoked) {
throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401);
}
if (!device.viewer.enabled || !device.viewer.appAccess) {
throw new AppError("FORBIDDEN", "Account uitgeschakeld", 403);
}
await prisma.viewerDevice.update({
where: { id: device.id },
data: { lastSeenAt: new Date() },
});
return {
viewerId: device.viewer.id,
deviceId: device.id,
email: device.viewer.email,
name: device.viewer.name,
};
}
async home(viewerId: string) {
const [shelves, prefs, continueItems] = await Promise.all([
prisma.libraryShelf.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
}),
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
this.continueWatching(viewerId),
]);
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
const ordered = shelves
.map((s, idx) => {
const pref = prefMap.get(s.id);
return {
id: s.id,
name: s.name,
slug: s.slug,
kind: s.kind,
visible: pref?.visible ?? true,
sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx,
};
})
.filter((s) => s.visible)
.sort((a, b) => a.sortOrder - b.sortOrder);
const rows = [];
for (const shelf of ordered) {
const items = await this.shelfItems(shelf.id, shelf.kind, 24);
if (items.length) {
rows.push({
id: shelf.id,
title: shelf.name,
kind: shelf.kind,
slug: shelf.slug,
items,
});
}
}
return {
continueWatching: continueItems,
shelves: rows,
};
}
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
if (kind === "MOVIE") {
const files = await prisma.mediaFile.findMany({
where: { shelfId, available: true, movieId: { not: null } },
include: { movie: true },
orderBy: { updatedAt: "desc" },
take: take * 3,
});
const seen = new Set<string>();
const out = [];
for (const f of files) {
if (!f.movie || seen.has(f.movie.id)) continue;
seen.add(f.movie.id);
out.push({
type: "movie" as const,
id: f.movie.id,
title: f.movie.title,
year: f.movie.year,
posterUrl: f.movie.posterUrl,
backdropUrl: f.movie.backdropUrl,
mediaFileId: f.id,
});
if (out.length >= take) break;
}
return out;
}
const files = await prisma.mediaFile.findMany({
where: { shelfId, available: true, episodeId: { not: null } },
include: { episode: { include: { series: true } } },
orderBy: { updatedAt: "desc" },
take: take * 5,
});
const seen = new Set<string>();
const out = [];
for (const f of files) {
const series = f.episode?.series;
if (!series || seen.has(series.id)) continue;
seen.add(series.id);
out.push({
type: "series" as const,
id: series.id,
title: series.title,
year: series.year,
posterUrl: series.posterUrl,
backdropUrl: series.backdropUrl,
});
if (out.length >= take) break;
}
return out;
}
async continueWatching(viewerId: string) {
const rows = await prisma.watchProgress.findMany({
where: {
viewerUserId: viewerId,
hiddenFromContinue: false,
completed: false,
positionMs: { gt: 30_000 },
},
orderBy: { updatedAt: "desc" },
take: 24,
});
const out = [];
for (const p of rows) {
if (p.mediaType === "MOVIE" && p.movieId) {
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
if (!movie) continue;
const file = await prisma.mediaFile.findFirst({
where: { movieId: movie.id, available: true },
orderBy: { updatedAt: "desc" },
});
out.push({
type: "movie" as const,
id: movie.id,
title: movie.title,
year: movie.year,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
mediaFileId: file?.id ?? null,
positionMs: p.positionMs,
durationMs: p.durationMs,
progressId: p.id,
});
} else if (p.mediaType === "EPISODE" && p.episodeId) {
const episode = await prisma.episode.findUnique({
where: { id: p.episodeId },
include: { series: true },
});
if (!episode) continue;
const file = await prisma.mediaFile.findFirst({
where: { episodeId: episode.id, available: true },
orderBy: { updatedAt: "desc" },
});
out.push({
type: "episode" as const,
id: episode.id,
seriesId: episode.seriesId,
title: episode.series.title,
subtitle: `S${episode.seasonNumber}E${episode.episodeNumber}${
episode.title ? ` · ${episode.title}` : ""
}`,
year: episode.series.year,
posterUrl: episode.stillUrl || episode.series.posterUrl,
backdropUrl: episode.series.backdropUrl,
mediaFileId: file?.id ?? null,
positionMs: p.positionMs,
durationMs: p.durationMs,
progressId: p.id,
});
}
}
return out;
}
async upsertProgress(
viewerId: string,
body: {
mediaType: "MOVIE" | "EPISODE";
movieId?: string;
episodeId?: string;
seriesId?: string;
positionMs: number;
durationMs: number;
completed?: boolean;
hiddenFromContinue?: boolean;
}
) {
const positionMs = Math.max(0, Math.floor(body.positionMs || 0));
const durationMs = Math.max(0, Math.floor(body.durationMs || 0));
const autoComplete =
body.completed === true ||
(durationMs > 0 && positionMs / durationMs >= 0.9);
if (body.mediaType === "MOVIE") {
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
return prisma.watchProgress.upsert({
where: {
viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId },
},
create: {
viewerUserId: viewerId,
mediaType: "MOVIE",
movieId: body.movieId,
positionMs,
durationMs,
completed: autoComplete,
hiddenFromContinue: body.hiddenFromContinue ?? false,
},
update: {
positionMs,
durationMs,
completed: body.completed ?? autoComplete,
...(body.hiddenFromContinue !== undefined
? { hiddenFromContinue: body.hiddenFromContinue }
: autoComplete
? { hiddenFromContinue: true }
: {}),
},
});
}
if (!body.episodeId) throw new AppError("INVALID_REQUEST", "episodeId verplicht", 400);
let seriesId = body.seriesId ?? null;
if (!seriesId) {
const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
seriesId = ep?.seriesId ?? null;
}
return prisma.watchProgress.upsert({
where: {
viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId },
},
create: {
viewerUserId: viewerId,
mediaType: "EPISODE",
episodeId: body.episodeId,
seriesId,
positionMs,
durationMs,
completed: autoComplete,
hiddenFromContinue: body.hiddenFromContinue ?? false,
},
update: {
positionMs,
durationMs,
seriesId,
completed: body.completed ?? autoComplete,
...(body.hiddenFromContinue !== undefined
? { hiddenFromContinue: body.hiddenFromContinue }
: autoComplete
? { hiddenFromContinue: true }
: {}),
},
});
}
async markWatched(viewerId: string, opts: { movieId?: string; episodeId?: string }) {
if (opts.movieId) {
return this.upsertProgress(viewerId, {
mediaType: "MOVIE",
movieId: opts.movieId,
positionMs: 1,
durationMs: 1,
completed: true,
hiddenFromContinue: true,
});
}
if (opts.episodeId) {
return this.upsertProgress(viewerId, {
mediaType: "EPISODE",
episodeId: opts.episodeId,
positionMs: 1,
durationMs: 1,
completed: true,
hiddenFromContinue: true,
});
}
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
}
async dismissContinue(viewerId: string, progressId: string) {
const row = await prisma.watchProgress.findFirst({
where: { id: progressId, viewerUserId: viewerId },
});
if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404);
await prisma.watchProgress.update({
where: { id: progressId },
data: { hiddenFromContinue: true },
});
return { ok: true };
}
async getShelfPrefs(viewerId: string) {
const shelves = await prisma.libraryShelf.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
});
const prefs = await prisma.viewerShelfPreference.findMany({
where: { viewerUserId: viewerId },
});
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
return shelves.map((s, idx) => {
const pref = prefMap.get(s.id);
return {
shelfId: s.id,
name: s.name,
slug: s.slug,
kind: s.kind,
visible: pref?.visible ?? true,
sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx,
};
});
}
async setShelfPrefs(
viewerId: string,
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
) {
for (const item of items) {
await prisma.viewerShelfPreference.upsert({
where: {
viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId },
},
create: {
viewerUserId: viewerId,
shelfId: item.shelfId,
visible: item.visible,
sortOrder: item.sortOrder,
},
update: {
visible: item.visible,
sortOrder: item.sortOrder,
},
});
}
return this.getShelfPrefs(viewerId);
}
async movieDetail(movieId: string) {
const movie = await prisma.movie.findUnique({
where: { id: movieId },
include: {
mediaFiles: {
where: { available: true },
include: { node: { select: { id: true, name: true, status: true } } },
orderBy: { updatedAt: "desc" },
},
},
});
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404);
return {
type: "movie" as const,
id: movie.id,
title: movie.title,
year: movie.year,
overview: movie.overview,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
runtime: movie.runtime,
genres: movie.genres,
mediaFiles: movie.mediaFiles.map((f) => ({
id: f.id,
resolution: f.resolution,
hdrType: f.hdrType,
videoCodec: f.videoCodec,
releaseName: f.releaseName,
nodeName: f.node.name,
nodeOnline: f.node.status === "ONLINE",
})),
};
}
async seriesDetail(seriesId: string) {
const series = await prisma.series.findUnique({
where: { id: seriesId },
include: {
seasons: {
orderBy: { seasonNumber: "asc" },
include: {
episodes: {
orderBy: { episodeNumber: "asc" },
include: {
mediaFiles: {
where: { available: true },
select: { id: true },
take: 1,
},
},
},
},
},
},
});
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
return {
type: "series" as const,
id: series.id,
title: series.title,
year: series.year,
overview: series.overview,
posterUrl: series.posterUrl,
backdropUrl: series.backdropUrl,
seasons: series.seasons.map((s) => ({
seasonNumber: s.seasonNumber,
episodes: s.episodes.map((e) => ({
id: e.id,
seasonNumber: e.seasonNumber,
episodeNumber: e.episodeNumber,
title: e.title,
overview: e.overview,
stillUrl: e.stillUrl,
runtime: e.runtime,
mediaFileId: e.mediaFiles[0]?.id ?? null,
available: e.mediaFiles.length > 0,
})),
})),
};
}
async play(mediaFileId: string, viewerId: string) {
const session = await this.playback.createSession(mediaFileId, viewerId, undefined);
return {
streamUrl: session.streamUrl,
sessionId: session.sessionId,
mediaFileId: session.mediaFileId,
};
}
private publicBase(): string {
const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, "");
return base;
}
}
export async function requireViewer(
request: FastifyRequest,
service: ViewerService
): Promise<AuthedViewer> {
return service.authFromBearer(request.headers.authorization);
}