Add viewer accounts, device linking, and Android TV client foundation.
This commit is contained in:
parent
d233878e6d
commit
0b107515e4
33 changed files with 3173 additions and 0 deletions
142
apps/admin-ui/src/app/link/page.tsx
Normal file
142
apps/admin-ui/src/app/link/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
apps/admin-ui/src/app/viewers/page.tsx
Normal file
225
apps/admin-ui/src/app/viewers/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
12
apps/android-tv/.gitignore
vendored
Normal 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
30
apps/android-tv/README.md
Normal 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`
|
||||
66
apps/android-tv/app/build.gradle.kts
Normal file
66
apps/android-tv/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
1
apps/android-tv/app/proguard-rules.pro
vendored
Normal file
1
apps/android-tv/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
35
apps/android-tv/app/src/main/AndroidManifest.xml
Normal file
35
apps/android-tv/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package nl.vonas.mediacluster.tv
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class McApp : Application()
|
||||
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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('/') }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
5
apps/android-tv/app/src/main/res/drawable/app_banner.xml
Normal file
5
apps/android-tv/app/src/main/res/drawable/app_banner.xml
Normal 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>
|
||||
10
apps/android-tv/app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
10
apps/android-tv/app/src/main/res/drawable/ic_launcher_fg.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
4
apps/android-tv/app/src/main/res/values/colors.xml
Normal file
4
apps/android-tv/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_bg">#0A0E16</color>
|
||||
</resources>
|
||||
4
apps/android-tv/app/src/main/res/values/strings.xml
Normal file
4
apps/android-tv/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Media Cluster</string>
|
||||
</resources>
|
||||
7
apps/android-tv/app/src/main/res/values/themes.xml
Normal file
7
apps/android-tv/app/src/main/res/values/themes.xml
Normal 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>
|
||||
5
apps/android-tv/build.gradle.kts
Normal file
5
apps/android-tv/build.gradle.kts
Normal 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
|
||||
}
|
||||
9
apps/android-tv/gradle.properties
Normal file
9
apps/android-tv/gradle.properties
Normal 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
|
||||
BIN
apps/android-tv/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
apps/android-tv/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
apps/android-tv/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
apps/android-tv/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
apps/android-tv/gradlew
vendored
Normal 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
93
apps/android-tv/gradlew.bat
vendored
Normal 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
|
||||
16
apps/android-tv/settings.gradle.kts
Normal file
16
apps/android-tv/settings.gradle.kts
Normal 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")
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
187
apps/master-api/src/viewer/routes.ts
Normal file
187
apps/master-api/src/viewer/routes.ts
Normal 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) };
|
||||
});
|
||||
}
|
||||
717
apps/master-api/src/viewer/service.ts
Normal file
717
apps/master-api/src/viewer/service.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue