Dedupe live streams to one row per viewer and ignore probe sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-29 01:35:20 +02:00
parent 0f7b566626
commit 359a3c6883
5 changed files with 124 additions and 16 deletions

View file

@ -84,8 +84,8 @@ export default function StreamsPage() {
<div className="card" style={{ marginBottom: "1rem" }}> <div className="card" style={{ marginBottom: "1rem" }}>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
Alleen echte kijkers (bytes stromen). Stopt iemand → verdwijnt binnen ~3 minuten uit deze lijst. Eén rij per kijker + titel. Alleen echte streams (&gt;512 KB). Stopt iemand → verdwijnt
Voortgang is een schatting op basis van range-positie in het bestand. binnen ~3 minuten. Voortgang is een schatting op range-positie.
</p> </p>
</div> </div>

View file

@ -43,7 +43,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
revoked: false, revoked: false,
absoluteExpiresAt: { gt: new Date() }, absoluteExpiresAt: { gt: new Date() },
lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) }, lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) },
bytesSent: { gt: 0 }, bytesSent: { gt: 512n * 1024n },
}, },
}), }),
prisma.node.findMany({ prisma.node.findMany({
@ -253,15 +253,16 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
data: { status: "EXPIRED" }, data: { status: "EXPIRED" },
}); });
// Only recently active + actually streamed bytes (no prefetch ghosts). // Alleen echte kijkers: >512KB gestreamd + recent actief.
const liveSince = new Date(now.getTime() - 3 * 60 * 1000); const liveSince = new Date(now.getTime() - 3 * 60 * 1000);
const minBytes = 512n * 1024n;
const sessions = await prisma.playbackSession.findMany({ const sessions = await prisma.playbackSession.findMany({
where: { where: {
status: "ACTIVE", status: "ACTIVE",
revoked: false, revoked: false,
absoluteExpiresAt: { gt: now }, absoluteExpiresAt: { gt: now },
lastActivity: { gt: liveSince }, lastActivity: { gt: liveSince },
bytesSent: { gt: 0 }, bytesSent: { gt: minBytes },
}, },
include: { include: {
node: { select: { id: true, name: true } }, node: { select: { id: true, name: true } },
@ -279,8 +280,23 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
take: 100, take: 100,
}); });
// Eén rij per kijker+titel (hoogste bytes wint) — niet per mediabestand.
const best = new Map<string, (typeof sessions)[number]>();
for (const s of sessions) {
const titleKey =
s.mediaFile.movieId ??
(s.mediaFile.episode
? `ep:${s.mediaFile.episode.seriesId ?? ""}:${s.mediaFile.episode.seasonNumber}:${s.mediaFile.episode.episodeNumber}`
: s.mediaFileId);
const key = `${s.addonTokenId ?? s.id}:${titleKey}`;
const prev = best.get(key);
if (!prev || s.bytesSent > prev.bytesSent) best.set(key, s);
}
return { return {
streams: sessions.map((s) => { streams: [...best.values()]
.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime())
.map((s) => {
const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0); const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0);
const offset = Number(s.lastByteOffset || 0); const offset = Number(s.lastByteOffset || 0);
const progressPercent = const progressPercent =

View file

@ -8,10 +8,42 @@ import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager"; import { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config"; import type { Config } from "../config";
type SessionResult = {
sessionId: string;
streamUrl: string;
token: string;
nodeId: string;
mediaFileId: string;
};
/** In-memory play tokens so concurrent Stremio probes reuse one session. */
const playTokens = new Map<string, { token: string; sessionId: string }>();
const createLocks = new Map<string, Promise<SessionResult>>();
function sessionKey(addonTokenId: string | undefined, mediaFileId: string): string {
return `${addonTokenId || "anon"}:${mediaFileId}`;
}
export class PlaybackService { export class PlaybackService {
constructor(private readonly config: Config) {} constructor(private readonly config: Config) {}
async createSession(mediaFileId: string, userId?: string, addonTokenId?: string) { async createSession(mediaFileId: string, userId?: string, addonTokenId?: string) {
const key = sessionKey(addonTokenId, mediaFileId);
const existingLock = createLocks.get(key);
if (existingLock) return existingLock;
const work = this.createSessionUnlocked(mediaFileId, userId, addonTokenId).finally(() => {
createLocks.delete(key);
});
createLocks.set(key, work);
return work;
}
private async createSessionUnlocked(
mediaFileId: string,
userId?: string,
addonTokenId?: string
): Promise<SessionResult> {
const mediaFile = await prisma.mediaFile.findUnique({ const mediaFile = await prisma.mediaFile.findUnique({
where: { id: mediaFileId }, where: { id: mediaFileId },
include: { node: true }, include: { node: true },
@ -29,10 +61,65 @@ export class PlaybackService {
throw new AppError("NODE_OFFLINE", "Selected media node is unavailable", 503); throw new AppError("NODE_OFFLINE", "Selected media node is unavailable", 503);
} }
const key = sessionKey(addonTokenId, mediaFileId);
const now = new Date();
// Reuse one live session per kijker + bestand (Stremio opent vaak meerdere Range/HEAD hits).
if (addonTokenId) {
const active = await prisma.playbackSession.findFirst({
where: {
addonTokenId,
mediaFileId: mediaFile.id,
status: "ACTIVE",
revoked: false,
absoluteExpiresAt: { gt: now },
},
orderBy: { lastActivity: "desc" },
});
const cached = playTokens.get(key);
if (
active &&
cached &&
cached.sessionId === active.id &&
hashToken(cached.token) === active.tokenHash
) {
await prisma.playbackSession.update({
where: { id: active.id },
data: { lastActivity: now },
});
const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${cached.token}`;
return {
sessionId: active.id,
streamUrl,
token: cached.token,
nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id,
};
}
// Oude parallelle sessies opruimen voordat we een nieuwe maken.
const stale = await prisma.playbackSession.findMany({
where: {
addonTokenId,
mediaFileId: mediaFile.id,
status: "ACTIVE",
revoked: false,
},
select: { id: true, nodeId: true },
});
for (const s of stale) {
await prisma.playbackSession.update({
where: { id: s.id },
data: { status: "COMPLETED" },
});
nodeConnectionManager.revokePlaybackSession(s.nodeId, s.id);
}
playTokens.delete(key);
}
const token = generateSecureToken(32); const token = generateSecureToken(32);
const sessionId = generateSessionId(); const sessionId = generateSessionId();
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
const now = new Date();
const absoluteExpiresAt = new Date( const absoluteExpiresAt = new Date(
now.getTime() + this.config.PLAYBACK_ABSOLUTE_LIFETIME_SECONDS * 1000 now.getTime() + this.config.PLAYBACK_ABSOLUTE_LIFETIME_SECONDS * 1000
); );
@ -69,6 +156,7 @@ export class PlaybackService {
throw new AppError("NODE_OFFLINE", "Failed to register session on node", 503); throw new AppError("NODE_OFFLINE", "Failed to register session on node", 503);
} }
playTokens.set(key, { token, sessionId });
const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`; const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`;
return { return {
@ -97,6 +185,10 @@ export class PlaybackService {
data: { status: "REVOKED", revoked: true }, data: { status: "REVOKED", revoked: true },
}); });
for (const [key, val] of playTokens) {
if (val.sessionId === sessionId) playTokens.delete(key);
}
nodeConnectionManager.revokePlaybackSession(session.nodeId, sessionId); nodeConnectionManager.revokePlaybackSession(session.nodeId, sessionId);
} }

View file

@ -7,8 +7,8 @@ WORKDIR /src
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
COPY node/media-node/ ./ COPY node/media-node/ ./
RUN go mod tidy \ RUN go mod tidy \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.8" -o /media-node-linux-amd64 ./cmd/media-node \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.9" -o /media-node-linux-amd64 ./cmd/media-node \
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.8" -o /media-node-linux-arm64 ./cmd/media-node && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.9" -o /media-node-linux-arm64 ./cmd/media-node
FROM base AS builder FROM base AS builder
COPY package.json pnpm-workspace.yaml ./ COPY package.json pnpm-workspace.yaml ./

View file

@ -295,17 +295,17 @@ func (s *Server) noteProgress(sessionID string, offset, fileSize, deltaBytes int
} }
if deltaBytes > 0 { if deltaBytes > 0 {
st.bytesSent += deltaBytes st.bytesSent += deltaBytes
} else if st.bytesSent == 0 && force { // Alleen offset bijwerken bij echte data — voorkomt 100% door 1-byte end-of-file probes.
// Mark as live even before first chunk (Range open). if offset > st.lastOffset {
st.bytesSent = 1 st.lastOffset = offset
} }
if offset >= st.lastOffset {
st.lastOffset = offset
} }
if fileSize > 0 { if fileSize > 0 {
st.fileSize = fileSize st.fileSize = fileSize
} }
shouldReport := force || time.Since(st.lastReport) >= 3*time.Second shouldReport := (deltaBytes > 0 || force) &&
st.bytesSent > 0 &&
(force || time.Since(st.lastReport) >= 3*time.Second)
bytesSent := st.bytesSent bytesSent := st.bytesSent
lastOffset := st.lastOffset lastOffset := st.lastOffset
size := st.fileSize size := st.fileSize