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" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Alleen echte kijkers (bytes stromen). Stopt iemand → verdwijnt binnen ~3 minuten uit deze lijst.
|
||||
Voortgang is een schatting op basis van range-positie in het bestand.
|
||||
Eén rij per kijker + titel. Alleen echte streams (>512 KB). Stopt iemand → verdwijnt
|
||||
binnen ~3 minuten. Voortgang is een schatting op range-positie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
revoked: false,
|
||||
absoluteExpiresAt: { gt: new Date() },
|
||||
lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) },
|
||||
bytesSent: { gt: 0 },
|
||||
bytesSent: { gt: 512n * 1024n },
|
||||
},
|
||||
}),
|
||||
prisma.node.findMany({
|
||||
|
|
@ -253,15 +253,16 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
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 minBytes = 512n * 1024n;
|
||||
const sessions = await prisma.playbackSession.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
revoked: false,
|
||||
absoluteExpiresAt: { gt: now },
|
||||
lastActivity: { gt: liveSince },
|
||||
bytesSent: { gt: 0 },
|
||||
bytesSent: { gt: minBytes },
|
||||
},
|
||||
include: {
|
||||
node: { select: { id: true, name: true } },
|
||||
|
|
@ -279,8 +280,23 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
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 {
|
||||
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 offset = Number(s.lastByteOffset || 0);
|
||||
const progressPercent =
|
||||
|
|
|
|||
|
|
@ -8,10 +8,42 @@ import { AppError } from "../security/errors";
|
|||
import { nodeConnectionManager } from "../websocket/manager";
|
||||
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 {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
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({
|
||||
where: { id: mediaFileId },
|
||||
include: { node: true },
|
||||
|
|
@ -29,10 +61,65 @@ export class PlaybackService {
|
|||
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 sessionId = generateSessionId();
|
||||
const tokenHash = hashToken(token);
|
||||
const now = new Date();
|
||||
const absoluteExpiresAt = new Date(
|
||||
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);
|
||||
}
|
||||
|
||||
playTokens.set(key, { token, sessionId });
|
||||
const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`;
|
||||
|
||||
return {
|
||||
|
|
@ -97,6 +185,10 @@ export class PlaybackService {
|
|||
data: { status: "REVOKED", revoked: true },
|
||||
});
|
||||
|
||||
for (const [key, val] of playTokens) {
|
||||
if (val.sessionId === sessionId) playTokens.delete(key);
|
||||
}
|
||||
|
||||
nodeConnectionManager.revokePlaybackSession(session.nodeId, sessionId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ WORKDIR /src
|
|||
RUN apk add --no-cache git ca-certificates
|
||||
COPY node/media-node/ ./
|
||||
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=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=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.9" -o /media-node-linux-arm64 ./cmd/media-node
|
||||
|
||||
FROM base AS builder
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
|
|
|
|||
|
|
@ -295,17 +295,17 @@ func (s *Server) noteProgress(sessionID string, offset, fileSize, deltaBytes int
|
|||
}
|
||||
if deltaBytes > 0 {
|
||||
st.bytesSent += deltaBytes
|
||||
} else if st.bytesSent == 0 && force {
|
||||
// Mark as live even before first chunk (Range open).
|
||||
st.bytesSent = 1
|
||||
}
|
||||
if offset >= st.lastOffset {
|
||||
st.lastOffset = offset
|
||||
// Alleen offset bijwerken bij echte data — voorkomt 100% door 1-byte end-of-file probes.
|
||||
if offset > st.lastOffset {
|
||||
st.lastOffset = offset
|
||||
}
|
||||
}
|
||||
if fileSize > 0 {
|
||||
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
|
||||
lastOffset := st.lastOffset
|
||||
size := st.fileSize
|
||||
|
|
|
|||
Loading…
Reference in a new issue