Dedupe live streams to one row per viewer and ignore probe sessions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0f7b566626
commit
359a3c6883
5 changed files with 124 additions and 16 deletions
|
|
@ -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 (>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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ./
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
if offset >= st.lastOffset {
|
|
||||||
st.lastOffset = offset
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue