Compare commits
9
Commits
v0.7.1
...
605afe556d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605afe556d | ||
|
|
39098cd201 | ||
|
|
fd1ab2918a | ||
|
|
40fb49fb21 | ||
|
|
5673055216 | ||
|
|
853d56f764 | ||
|
|
9d4e419cd1 | ||
|
|
a853b066b5 | ||
|
|
4ad5b244bc |
@@ -96,6 +96,9 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
|||||||
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
|
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
|
||||||
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
|
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
|
||||||
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
|
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
|
||||||
|
Achtung: das Skript meldet sich **nicht** an — bei geschützten Seiten entsteht
|
||||||
|
kommentarlos ein Bild der Anmeldemaske. Für angemeldete Ansichten den
|
||||||
|
Login-Weg aus `scripts/screenshots.mjs` nachbauen.
|
||||||
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
|
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
|
||||||
|
|
||||||
## Fortschritt
|
## Fortschritt
|
||||||
@@ -119,6 +122,18 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
|||||||
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
||||||
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
||||||
Write-Werkzeug benutzen.
|
Write-Werkzeug benutzen.
|
||||||
|
- **Klickwege niemals gegen `npm run dev` prüfen.** Unter headless Chromium
|
||||||
|
scheitert der HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`), dadurch hydratisiert
|
||||||
|
**keine** Seite und kein Knopf reagiert — ohne Seitenfehler, ohne
|
||||||
|
fehlgeschlagene Anfrage, es passiert einfach nichts. Sieht aus wie ein Fehler
|
||||||
|
im eigenen Code, ist aber die Umgebung. Stattdessen
|
||||||
|
`npm run build` und `PORT=3012 npm start`, dann dagegen klicken.
|
||||||
|
Formulare täuschen zusätzlich: die Anmeldung funktioniert auch ohne
|
||||||
|
JavaScript, wirkt also intakt.
|
||||||
|
- Vor `npm start` prüfen, ob der Port noch von einem früheren Lauf belegt ist —
|
||||||
|
sonst startet der neue Server gar nicht und man testet den alten Bau.
|
||||||
|
- Testdaten nach Klickproben wieder herstellen: die Proben schreiben in die
|
||||||
|
geseedete Entwicklungsdatenbank.
|
||||||
|
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
||||||
# docker compose -f docker-compose.dev.yml up -d
|
# docker compose -f docker-compose.dev.yml up -d
|
||||||
|
#
|
||||||
|
# Eigener Projektname: sonst teilt sich dieser Stack den Namen mit der
|
||||||
|
# Produktiv-Compose (beide leiten ihn sonst aus dem Verzeichnis ab), und ein
|
||||||
|
# `docker compose down -v` dort nimmt die Entwicklungsdatenbank mit.
|
||||||
|
name: flimmer-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
|
|||||||
+31
-5
@@ -116,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso
|
|||||||
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
||||||
Markierung dieser Episoden im aktuellen Durchlauf.
|
Markierung dieser Episoden im aktuellen Durchlauf.
|
||||||
|
|
||||||
|
Für Filme gibt es `{"action":"movie"}` als Gegenstück zum gleichnamigen POST;
|
||||||
|
dafür braucht man die `watchIds` nicht zu kennen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||||
|
-d '{"action":"movie"}' \
|
||||||
|
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurückgenommen wird dabei **nur der laufende Durchlauf**. Wer einen Film dreimal
|
||||||
|
gesehen hat, verliert also nicht die Sichtungen der früheren Durchläufe — die
|
||||||
|
zählen weiter in die Statistik.
|
||||||
|
|
||||||
### Suche
|
### Suche
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -145,15 +158,28 @@ Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
|
|||||||
### Universen
|
### Universen
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/v1/universen
|
GET /api/v1/universen
|
||||||
POST /api/v1/universen
|
POST /api/v1/universen
|
||||||
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||||
POST /api/v1/universen/{id}
|
POST /api/v1/universen/{id}
|
||||||
|
PATCH /api/v1/universen/{id}
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||||
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
||||||
Erscheinungsreihenfolge.
|
Standardreihenfolge des Universums — in `orders` trägt sie `isDefault`.
|
||||||
|
|
||||||
|
`PATCH` legt fest, welche das ist. Die Reihenfolge muss zu diesem Universum
|
||||||
|
gehören, und es braucht Schreibrecht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||||
|
-d '{"defaultOrderId": "…"}' \
|
||||||
|
https://flimmer.example.com/api/v1/universen/$ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Je Universum trägt immer genau eine Reihenfolge den Standard. Wird die
|
||||||
|
Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
startRewatch,
|
startRewatch,
|
||||||
undoWatches,
|
undoWatches,
|
||||||
unmarkEpisodes,
|
unmarkEpisodes,
|
||||||
|
unmarkMovie,
|
||||||
unmarkSeason,
|
unmarkSeason,
|
||||||
updateEntryDetails,
|
updateEntryDetails,
|
||||||
watchNextEpisode,
|
watchNextEpisode,
|
||||||
@@ -175,6 +176,13 @@ export async function markMovieAction(entryId: string) {
|
|||||||
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function unmarkMovieAction(entryId: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await unmarkMovie(userId, cuid.parse(entryId));
|
||||||
|
return { message: "Markierung entfernt" };
|
||||||
|
}, PATHS);
|
||||||
|
}
|
||||||
|
|
||||||
export async function undoAction(entryId: string, watchIds: string[]) {
|
export async function undoAction(entryId: string, watchIds: string[]) {
|
||||||
return run(async (userId) => {
|
return run(async (userId) => {
|
||||||
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
removeTitle,
|
removeTitle,
|
||||||
renameOrder,
|
renameOrder,
|
||||||
renameUniverse,
|
renameUniverse,
|
||||||
|
setDefaultOrder,
|
||||||
setOrderPositions,
|
setOrderPositions,
|
||||||
shareUniverse,
|
shareUniverse,
|
||||||
trashUniverse,
|
trashUniverse,
|
||||||
@@ -124,6 +125,13 @@ export async function deleteOrderAction(orderId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setDefaultOrderAction(orderId: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await setDefaultOrder(userId, id.parse(orderId));
|
||||||
|
return { message: "Standardreihenfolge gesetzt" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function reorderAction(orderId: string, itemIds: string[]) {
|
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||||
return run(async (userId) => {
|
return run(async (userId) => {
|
||||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
markUpTo,
|
markUpTo,
|
||||||
undoWatches,
|
undoWatches,
|
||||||
unmarkEpisodes,
|
unmarkEpisodes,
|
||||||
|
unmarkMovie,
|
||||||
watchNextEpisode,
|
watchNextEpisode,
|
||||||
} from "@/lib/library";
|
} from "@/lib/library";
|
||||||
|
|
||||||
@@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => {
|
|||||||
const deleteSchema = z.union([
|
const deleteSchema = z.union([
|
||||||
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||||
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||||
|
z.object({ action: z.literal("movie") }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
|
/**
|
||||||
|
* DELETE — Sichtungen zurücknehmen.
|
||||||
|
*
|
||||||
|
* Drei Wege: gezielt über `watchIds` (die Antwort eines POST), über
|
||||||
|
* `episodeIds`, oder mit `{"action":"movie"}` als Gegenstück zum
|
||||||
|
* gleichnamigen POST — dafür braucht man die `watchIds` nicht zu kennen.
|
||||||
|
* Zurückgenommen wird immer nur der laufende Durchlauf.
|
||||||
|
*/
|
||||||
export const DELETE = withApi(async (request, user, context) => {
|
export const DELETE = withApi(async (request, user, context) => {
|
||||||
const { entryId } = await context.params;
|
const { entryId } = await context.params;
|
||||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
@@ -86,8 +95,10 @@ export const DELETE = withApi(async (request, user, context) => {
|
|||||||
|
|
||||||
if ("watchIds" in body) {
|
if ("watchIds" in body) {
|
||||||
await undoWatches(user.id, entryId, body.watchIds);
|
await undoWatches(user.id, entryId, body.watchIds);
|
||||||
} else {
|
} else if ("episodeIds" in body) {
|
||||||
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||||
|
} else {
|
||||||
|
await unmarkMovie(user.id, entryId);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export function GET() {
|
|||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
summary: "Sichtungen zurücknehmen",
|
summary: "Sichtungen zurücknehmen",
|
||||||
|
description:
|
||||||
|
"Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " +
|
||||||
|
"zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " +
|
||||||
|
"Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " +
|
||||||
|
"bleiben für die Statistik erhalten.",
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -226,6 +231,11 @@ export function GET() {
|
|||||||
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
||||||
required: ["episodeIds"],
|
required: ["episodeIds"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { action: { const: "movie" } },
|
||||||
|
required: ["action"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -287,6 +297,9 @@ export function GET() {
|
|||||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||||
get: {
|
get: {
|
||||||
summary: "Universum mit Reihenfolge und Fortschritt",
|
summary: "Universum mit Reihenfolge und Fortschritt",
|
||||||
|
description:
|
||||||
|
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
|
||||||
|
"'orders' das Kennzeichen 'isDefault'.",
|
||||||
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||||
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||||
},
|
},
|
||||||
@@ -306,6 +319,33 @@ export function GET() {
|
|||||||
},
|
},
|
||||||
responses: { "200": { description: "Aufgenommen" } },
|
responses: { "200": { description: "Aufgenommen" } },
|
||||||
},
|
},
|
||||||
|
patch: {
|
||||||
|
summary: "Standardreihenfolge festlegen",
|
||||||
|
description:
|
||||||
|
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
|
||||||
|
"Universum trägt immer genau eine Reihenfolge den Standard.",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
defaultOrderId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Muss eine Reihenfolge dieses Universums sein.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["defaultOrderId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Gesetzt" },
|
||||||
|
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/export": {
|
"/export": {
|
||||||
get: {
|
get: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiError, readJson, withApi } from "@/lib/api";
|
import { apiError, readJson, withApi } from "@/lib/api";
|
||||||
import { addTitle, getUniverse } from "@/lib/universes";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { addTitle, getUniverse, setDefaultOrder } from "@/lib/universes";
|
||||||
|
|
||||||
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||||
export const GET = withApi(async (request, user, context) => {
|
export const GET = withApi(async (request, user, context) => {
|
||||||
@@ -25,3 +26,25 @@ export const POST = withApi(async (request, user, context) => {
|
|||||||
await addTitle(user.id, id, body.titleId);
|
await addTitle(user.id, id, body.titleId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const patchSchema = z.object({ defaultOrderId: z.string().min(1) });
|
||||||
|
|
||||||
|
/** PATCH /api/v1/universen/{id} — festlegen, welche Reihenfolge beim Öffnen erscheint. */
|
||||||
|
export const PATCH = withApi(async (request, user, context) => {
|
||||||
|
const { id } = await context.params;
|
||||||
|
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||||
|
|
||||||
|
const body = await readJson(request, patchSchema);
|
||||||
|
|
||||||
|
// Die Reihenfolge muss zu genau diesem Universum gehören. Ohne diese Prüfung
|
||||||
|
// setzte ein PATCH auf Universum A den Standard in Universum B — die
|
||||||
|
// Domänenschicht leitet das Universum ja aus der Reihenfolge ab.
|
||||||
|
const order = await prisma.universeOrder.findFirst({
|
||||||
|
where: { id: body.defaultOrderId, universeId: id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!order) return apiError("Diese Reihenfolge gehört nicht zu diesem Universum.", 404);
|
||||||
|
|
||||||
|
await setDefaultOrder(user.id, order.id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
|||||||
>
|
>
|
||||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||||
<div className="mt-2 space-y-0.5">
|
<div className="mt-2 space-y-0.5">
|
||||||
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||||
{item.title}
|
{item.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--fl-faint)]">
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
|
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug">{item.title}</p>
|
||||||
<p className="text-xs text-[var(--fl-faint)]">
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||||
{item.year ? ` · ${item.year}` : ""}
|
{item.year ? ` · ${item.year}` : ""}
|
||||||
@@ -74,17 +74,28 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showProgress && (
|
<div className="pt-0.5">
|
||||||
<div className="pt-0.5">
|
{showProgress ? (
|
||||||
<ProgressBar
|
<>
|
||||||
percent={item.percent}
|
<ProgressBar
|
||||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
percent={item.percent}
|
||||||
/>
|
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
/>
|
||||||
{item.watched}/{item.total} Episoden
|
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||||
</p>
|
{item.watched}/{item.total} Episoden
|
||||||
</div>
|
</p>
|
||||||
)}
|
</>
|
||||||
|
) : (
|
||||||
|
// Filme und Watchlist-Einträge haben keinen Balken. Der Platz wird
|
||||||
|
// trotzdem freigehalten — sonst fallen sie kürzer aus als Serien und
|
||||||
|
// das Raster franst reihenweise aus. Der Nachbau statt einer festen
|
||||||
|
// Pixelhöhe hält sich von selbst nach, wenn sich der Balken ändert.
|
||||||
|
<div className="invisible" aria-hidden>
|
||||||
|
<div className="fl-progress" />
|
||||||
|
<p className="mt-1 text-xs"> </p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
|
import {
|
||||||
|
markMovieAction,
|
||||||
|
undoAction,
|
||||||
|
unmarkMovieAction,
|
||||||
|
watchNextAction,
|
||||||
|
} from "@/app/actions/library";
|
||||||
import { useToasts } from "@/components/Toasts";
|
import { useToasts } from "@/components/Toasts";
|
||||||
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
|
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
|
||||||
|
|
||||||
@@ -93,14 +98,31 @@ export function WatchMovieButton({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Im Zustand „gesehen" bleibt die grüne Plakette stehen, ist aber klickbar —
|
||||||
|
// so wie sich Episodenhäkchen auch wieder abwählen lassen. Vorher stand hier
|
||||||
|
// ein <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
|
||||||
if (watched) {
|
if (watched) {
|
||||||
return (
|
return (
|
||||||
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
title="Markierung wieder entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await unmarkMovieAction(entryId));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="group inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)] transition-colors hover:border-[color-mix(in_oklab,var(--fl-success)_70%,transparent)] hover:bg-[color-mix(in_oklab,var(--fl-success)_20%,transparent)] disabled:opacity-60"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
|
||||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Gesehen
|
Gesehen
|
||||||
</p>
|
<span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
|
||||||
|
— Markierung entfernen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createOrderAction,
|
createOrderAction,
|
||||||
createUniverseAction,
|
createUniverseAction,
|
||||||
deleteOrderAction,
|
deleteOrderAction,
|
||||||
|
setDefaultOrderAction,
|
||||||
shareUniverseAction,
|
shareUniverseAction,
|
||||||
trashUniverseAction,
|
trashUniverseAction,
|
||||||
unshareUniverseAction,
|
unshareUniverseAction,
|
||||||
@@ -109,6 +110,24 @@ export function OrderTabs({
|
|||||||
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||||
>
|
>
|
||||||
{order.name}
|
{order.name}
|
||||||
|
{order.isDefault && (
|
||||||
|
<>
|
||||||
|
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
|
||||||
|
Wort dahinter steht nur für Screenreader — sichtbar würde
|
||||||
|
es die Reiter auseinanderziehen. */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only"> (Standard)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -158,6 +177,21 @@ export function OrderTabs({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{active && !active.isDefault && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setDefaultOrderAction(active.id));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||||
|
>
|
||||||
|
Als Standard festlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{active && active.kind === "CUSTOM" && (
|
{active && active.kind === "CUSTOM" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -338,6 +338,27 @@ export async function markMovieWatched(
|
|||||||
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
|
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück.
|
||||||
|
*
|
||||||
|
* Das Gegenstück zu `markMovieWatched()` — und das Pendant zu
|
||||||
|
* `unmarkEpisodes()` bei Serien.
|
||||||
|
*
|
||||||
|
* Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen
|
||||||
|
* hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und
|
||||||
|
* verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im
|
||||||
|
* laufenden Durchlauf abgehakt wurde.
|
||||||
|
*/
|
||||||
|
export async function unmarkMovie(userId: string, entryId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
|
||||||
|
|
||||||
|
await prisma.watch.deleteMany({
|
||||||
|
where: { userId, entryId, episodeId: null, run: entry.currentRun },
|
||||||
|
});
|
||||||
|
return reconcileStatus(userId, entryId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Macht eine Sichtungsaktion rückgängig. */
|
/** Macht eine Sichtungsaktion rückgängig. */
|
||||||
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
|
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
|
||||||
await requireEntry(userId, entryId);
|
await requireEntry(userId, entryId);
|
||||||
|
|||||||
+43
-2
@@ -244,13 +244,54 @@ export async function renameOrder(userId: string, orderId: string, name: string)
|
|||||||
export async function deleteOrder(userId: string, orderId: string) {
|
export async function deleteOrder(userId: string, orderId: string) {
|
||||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
select: { universeId: true, kind: true },
|
select: { universeId: true, kind: true, isDefault: true },
|
||||||
});
|
});
|
||||||
await requireWrite(userId, order.universeId);
|
await requireWrite(userId, order.universeId);
|
||||||
if (order.kind === "RELEASE") {
|
if (order.kind === "RELEASE") {
|
||||||
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
||||||
}
|
}
|
||||||
await prisma.universeOrder.delete({ where: { id: orderId } });
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.universeOrder.delete({ where: { id: orderId } });
|
||||||
|
|
||||||
|
// War die gelöschte der Standard, stünde das Universum ohne einen da und
|
||||||
|
// die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die
|
||||||
|
// Erscheinungsreihenfolge gehört immer dazu, sie übernimmt.
|
||||||
|
if (order.isDefault) {
|
||||||
|
await tx.universeOrder.updateMany({
|
||||||
|
where: { universeId: order.universeId, kind: "RELEASE" },
|
||||||
|
data: { isDefault: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint.
|
||||||
|
*
|
||||||
|
* Der Standard gehört dem Universum, nicht der einzelnen Person: wer
|
||||||
|
* Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die
|
||||||
|
* Reihenfolgen selbst auch geteilt sind.
|
||||||
|
*
|
||||||
|
* Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden
|
||||||
|
* dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()`
|
||||||
|
* griffe sich nach Sortierzufall einen davon.
|
||||||
|
*/
|
||||||
|
export async function setDefaultOrder(userId: string, orderId: string) {
|
||||||
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { universeId: true, isDefault: true },
|
||||||
|
});
|
||||||
|
await requireWrite(userId, order.universeId);
|
||||||
|
if (order.isDefault) return;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.universeOrder.updateMany({
|
||||||
|
where: { universeId: order.universeId, isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
startRewatch,
|
startRewatch,
|
||||||
undoWatches,
|
undoWatches,
|
||||||
unmarkEpisodes,
|
unmarkEpisodes,
|
||||||
|
unmarkMovie,
|
||||||
updateEntryDetails,
|
updateEntryDetails,
|
||||||
watchNextEpisode,
|
watchNextEpisode,
|
||||||
} from "../src/lib/library";
|
} from "../src/lib/library";
|
||||||
@@ -388,3 +389,56 @@ test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
|
|||||||
|
|
||||||
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
|
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal((await getProgress(userId, entry.id)).percent, 100);
|
||||||
|
|
||||||
|
await unmarkMovie(userId, entry.id);
|
||||||
|
|
||||||
|
const zurueck = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null");
|
||||||
|
assert.equal(zurueck.minutesWatched, 0);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.watch.count({ where: { userId, entryId: entry.id } }),
|
||||||
|
0,
|
||||||
|
"die Sichtung ist weg",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Und danach laesst es sich erneut abhaken — kein Einbahnzustand.
|
||||||
|
const erneut = await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal(erneut.watchIds.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
await startRewatch(userId, entry.id);
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2);
|
||||||
|
|
||||||
|
await unmarkMovie(userId, entry.id);
|
||||||
|
|
||||||
|
const rest = await prisma.watch.findMany({
|
||||||
|
where: { userId, entryId: entry.id },
|
||||||
|
select: { run: true },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
rest.map((watch) => watch.run),
|
||||||
|
[0],
|
||||||
|
"die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await getProgress(userId, entry.id)).percent,
|
||||||
|
0,
|
||||||
|
"der laufende Durchlauf steht wieder auf null",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmarkMovie weist Serien ab", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||||
|
await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
addTitle,
|
addTitle,
|
||||||
createOrder,
|
createOrder,
|
||||||
createUniverse,
|
createUniverse,
|
||||||
|
deleteOrder,
|
||||||
getAccess,
|
getAccess,
|
||||||
getUniverse,
|
getUniverse,
|
||||||
listUniverses,
|
listUniverses,
|
||||||
|
setDefaultOrder,
|
||||||
setOrderPositions,
|
setOrderPositions,
|
||||||
shareUniverse,
|
shareUniverse,
|
||||||
trashUniverse,
|
trashUniverse,
|
||||||
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
|
|||||||
const list = await listUniverses(ownerId);
|
const list = await listUniverses(ownerId);
|
||||||
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Standardreihenfolge ------------------------------------------------------
|
||||||
|
|
||||||
|
test("die Standardreihenfolge lässt sich umstellen und bleibt eindeutig", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Standardwechsel");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
|
||||||
|
// Frisch angelegt führt die Erscheinungsreihenfolge.
|
||||||
|
const vorher = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(vorher?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
|
||||||
|
|
||||||
|
await setDefaultOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
// Ohne Angabe in der Adresse erscheint jetzt die eigene Reihenfolge.
|
||||||
|
const nachher = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(nachher?.activeOrderId, order.id);
|
||||||
|
|
||||||
|
const standards = nachher!.orders.filter((o) => o.isDefault);
|
||||||
|
assert.equal(standards.length, 1, "es steht immer genau ein Standard");
|
||||||
|
assert.equal(standards[0]!.id, order.id, "und zwar der zuletzt gesetzte");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Adresse schlägt die Standardreihenfolge", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Adressvorrang");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
await setDefaultOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
const release = (await getUniverse(ownerId, universe.id))!.orders.find(
|
||||||
|
(o) => o.kind === "RELEASE",
|
||||||
|
)!;
|
||||||
|
const view = await getUniverse(ownerId, universe.id, release.id);
|
||||||
|
|
||||||
|
assert.equal(view?.activeOrderId, release.id, "die angeforderte gewinnt gegen den Standard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wird die Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Rueckfall");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
await setDefaultOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
await deleteOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
const view = await getUniverse(ownerId, universe.id);
|
||||||
|
const standards = view!.orders.filter((o) => o.isDefault);
|
||||||
|
assert.equal(standards.length, 1, "das Universum bleibt nicht ohne Standard zurück");
|
||||||
|
assert.equal(standards[0]!.kind, "RELEASE");
|
||||||
|
assert.equal(view?.activeOrderId, standards[0]!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("den Standard setzen darf nur, wer schreiben darf", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Rechteprobe");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
|
||||||
|
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
|
||||||
|
await assert.rejects(() => setDefaultOrder(guestId, order.id), /nur ansehen/);
|
||||||
|
|
||||||
|
// Der Standard steht danach unverändert auf der Erscheinungsreihenfolge.
|
||||||
|
const unveraendert = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(unveraendert?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
|
||||||
|
|
||||||
|
// Mit Schreibrecht geht es dann.
|
||||||
|
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
|
||||||
|
await setDefaultOrder(guestId, order.id);
|
||||||
|
assert.equal((await getUniverse(guestId, universe.id))?.activeOrderId, order.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten
|
|||||||
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
|
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
|
||||||
Container beim Start selbst ein.
|
Container beim Start selbst ein.
|
||||||
|
|
||||||
|
Geprüft wird zuerst http://127.0.0.1:APP_PORT/api/health. Ist der Port gar
|
||||||
|
nicht auf dem Host veröffentlicht — üblich hinter einem Reverse Proxy —,
|
||||||
|
zählt stattdessen der Healthcheck des Containers.
|
||||||
|
|
||||||
Optionen:
|
Optionen:
|
||||||
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
|
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
|
||||||
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
|
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
|
||||||
@@ -196,31 +200,95 @@ gut "Container laufen."
|
|||||||
schritt "Auf die Instanz warten"
|
schritt "Auf die Instanz warten"
|
||||||
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
|
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
|
||||||
|
|
||||||
|
# Der Container meldet seinen Zustand selbst — der HEALTHCHECK im Image fragt
|
||||||
|
# /api/health von innen ab. Das ist der verlässlichere Weg: hinter einem
|
||||||
|
# Reverse Proxy ist der App-Port oft gar nicht auf dem Host veröffentlicht,
|
||||||
|
# und dann sagt ein fehlschlagendes curl nichts über die Instanz aus.
|
||||||
|
# Gibt immer 0 zurück: die Funktion läuft auch im Fehlerpfad, und unter
|
||||||
|
# `set -e` dürfte eine Auskunft über den Zustand nicht selbst das Skript
|
||||||
|
# abbrechen, bevor der Weg zurück ausgegeben wurde.
|
||||||
|
container_zustand() {
|
||||||
|
local id
|
||||||
|
id=$($COMPOSE ps -q app 2>/dev/null || true)
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
printf 'kein-container'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Ohne HEALTHCHECK im Image ist .State.Health leer — dann bleibt der
|
||||||
|
# Laufzustand (running/exited) als grobe Auskunft.
|
||||||
|
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}ohne-healthcheck:{{.State.Status}}{{end}}' \
|
||||||
|
"$id" 2>/dev/null || printf 'unbekannt'
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
GESUND=0
|
GESUND=0
|
||||||
|
UEBER_CONTAINER=0
|
||||||
for _ in $(seq 1 60); do
|
for _ in $(seq 1 60); do
|
||||||
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
|
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
|
||||||
GESUND=1
|
GESUND=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
if [ "$(container_zustand)" = "healthy" ]; then
|
||||||
|
GESUND=1
|
||||||
|
UEBER_CONTAINER=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$GESUND" -eq 1 ]; then
|
if [ "$GESUND" -eq 1 ]; then
|
||||||
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
|
if [ "$UEBER_CONTAINER" -eq 1 ]; then
|
||||||
gut "Flimmer antwortet auf Port ${PORT}."
|
gut "Der Container meldet sich gesund."
|
||||||
leise "$ANTWORT"
|
leise "Auf Port ${PORT} war nichts zu erreichen — das ist normal, wenn die App"
|
||||||
|
leise "nur über einen Reverse Proxy erreichbar ist und den Port nicht veröffentlicht."
|
||||||
|
else
|
||||||
|
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
|
||||||
|
gut "Flimmer antwortet auf Port ${PORT}."
|
||||||
|
leise "$ANTWORT"
|
||||||
|
fi
|
||||||
|
|
||||||
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
|
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
|
||||||
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
|
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
|
||||||
info "Was sich geändert hat, steht in CHANGELOG.md."
|
info "Was sich geändert hat, steht in CHANGELOG.md."
|
||||||
fi
|
fi
|
||||||
[ -n "$SICHERUNG" ] && leise "Sicherung: $SICHERUNG"
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
leise "Sicherung: $SICHERUNG"
|
||||||
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
|
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
|
||||||
|
|
||||||
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
|
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
|
||||||
|
|
||||||
|
# Der Container-Zustand grenzt ein, wo es klemmt — ohne ihn sähe ein nicht
|
||||||
|
# veröffentlichter Port genauso aus wie eine abgestürzte App.
|
||||||
|
ZUSTAND=$(container_zustand)
|
||||||
|
case "$ZUSTAND" in
|
||||||
|
kein-container)
|
||||||
|
info "Es läuft gar kein app-Container. Der Start ist offenbar fehlgeschlagen."
|
||||||
|
;;
|
||||||
|
starting)
|
||||||
|
info "Der Container startet noch. Möglicherweise dauern die Migrationen länger —"
|
||||||
|
info "sieh im Log nach und gib ihm etwas Zeit."
|
||||||
|
;;
|
||||||
|
unhealthy)
|
||||||
|
info "Der Container läuft, meldet sich aber als nicht gesund. Der Fehler liegt"
|
||||||
|
info "also in der Anwendung, nicht am Port."
|
||||||
|
;;
|
||||||
|
ohne-healthcheck:running)
|
||||||
|
info "Der Container läuft, das Image bringt aber keinen Healthcheck mit (ältere"
|
||||||
|
info "Fassung). Ob die App wirklich antwortet, verrät nur das Log."
|
||||||
|
;;
|
||||||
|
ohne-healthcheck:*)
|
||||||
|
info "Der Container ist nicht gelaufen: ${ZUSTAND#ohne-healthcheck:}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
info "Container-Zustand: ${ZUSTAND}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
info "Log ansehen:"
|
info "Log ansehen:"
|
||||||
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
|
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user