# Wegwichtel REST-API

Diese Datei dokumentiert die vollständige HTTP-Schnittstelle des Wegwichtel-Servers. Sämtliche JSON-Ressourcen und aktiven GPX-, Bild- und Audiodateien werden unter `/api` bereitgestellt. Ein separates öffentliches `/media`-URL-Schema wird nicht verwendet.

## 1. Grundlagen

### Basisadressen

Lokaler Standard:

```text
http://127.0.0.1:47145
```

API-Basis:

```text
http://127.0.0.1:47145/api
```

Bei einer Nginx-Installation bleibt der Pfad gleich, beispielsweise:

```text
https://wegwichtel.example.org/api
```

### Formate

- Listen- und Metadatenzugriffe liefern JSON.
- Die Einzelendpunkte für GPX, Bilder und Audio liefern die jeweilige Datei direkt aus. Mit `?metadata=true` liefern die Bild- und Audioendpunkte stattdessen JSON-Metadaten.
- Schreibzugriffe ohne Dateien akzeptieren `application/json`, `application/x-www-form-urlencoded` oder `multipart/form-data`.
- GPX-Dateien werden ausschließlich als `multipart/form-data` übertragen.
- Bild- und Audioendpunkte akzeptieren wahlweise `multipart/form-data` oder ein JSON-Objekt mit Base64-kodierten Dateidaten.
- Der Server ermittelt den tatsächlichen MIME-Typ aus dem Dateiinhalt. Dateiname, Dateiendung, Multipart-`Content-Type`, JSON-Felder und Data-URL-Präfixe werden nicht als Typnachweis verwendet.
- Für JSON wird `Content-Type: application/json` empfohlen. `Content-Type: text/json` wird aus Kompatibilitätsgründen ebenfalls akzeptiert.
- Ein leeres JSON-Objekt (`{}`) enthält keine Datei und kann deshalb keinen Bild- oder Audio-Upload ausführen.
- Pro Medien-Request wird genau eine Bild- beziehungsweise Audiodatei verarbeitet.
- Base64 vergrößert die Requestgröße um ungefähr ein Drittel und benötigt beim Verarbeiten zusätzlichen Arbeitsspeicher. Für große Dateien ist `multipart/form-data` vorzuziehen.
- Zeitstempel werden als SQLite- oder ISO-8601-Text ausgegeben.
- Die Node.js-Anwendung besitzt keine eigene Authentifizierung. Ein vorgeschalteter Webserver kann Basic Auth erzwingen; die mitgelieferten Python-Werkzeuge erkennen `401 Unauthorized`, fragen Zugangsdaten ab und wiederholen den Request.

### Uploadgrenzen und Dateitypen

Das Dateilimit pro Datei wird durch `MAX_UPLOAD_MB` festgelegt und beträgt standardmäßig `50 MB`.

Unterstützte Dateitypen:

| Ressource | Dateiendungen beziehungsweise MIME-Typen |
|---|---|
| GPX | GPX-XML mit `<gpx>`-Wurzelelement |
| Bilder | JPEG, PNG, WebP |
| Audio | MP3, MP4/M4A, AAC, Ogg, WAV, WebM |

Die Uploadfelder heißen:

| Ressource | Multipart-Feld beziehungsweise JSON-Eigenschaft |
|---|---|
| GPX | `gpx` |
| einzelnes Bild | `picture` |
| einzelne Audiodatei | `audio` |

Bei einem JSON-Medienupload ist `picture` beziehungsweise `audio` ein Objekt:

| Eigenschaft | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `filename` | Text | nein | ursprünglicher Dateiname für Protokollierung; die Endung wird nicht zur Typbestimmung verwendet |
| `base64` | Text | ja, sofern `dataUrl` fehlt | reiner Base64-Inhalt ohne Präfix |
| `dataUrl` | Text | alternativ zu `base64` | vollständige Base64-Data-URL; der dort genannte Medientyp wird ignoriert |

`base64` und `dataUrl` sind Alternativen. Der Server erkennt Format und Speicherendung ausschließlich anhand der dekodierten Bytes. Ein vom Client mitgesendetes `contentType`- oder `mimeType`-Feld wird ignoriert und ist nicht erforderlich.

### Inhaltsbasierte Dateityperkennung

Binäre Bild- und Audiodateien werden mit `file-type` anhand ihrer Magic Bytes analysiert. Für Audio-/Video-Container wird zusätzlich `@file-type/av` verwendet, damit beispielsweise M4A und WebM möglichst zuverlässig als Audio oder Video unterschieden werden. GPX ist ein textbasiertes XML-Format und wird deshalb separat als UTF-8 gelesen und anhand des `<gpx>`-Wurzelelements validiert; anschließend übernimmt der vorhandene GPX-Parser die fachliche Prüfung.

Daraus folgen zwei wichtige Regeln:

- Eine als `image/png` deklarierte Textdatei wird mit `415 Unsupported Media Type` abgewiesen.
- Eine echte PNG-Datei wird auch dann akzeptiert, wenn sie `datei.bin` heißt oder im Multipart-Request kein Datei-`Content-Type` angegeben ist.

### Fehlerformat

```json
{
  "error": "HttpError",
  "message": "Strecke nicht gefunden."
}
```

Typische Statuscodes:

| Status | Bedeutung |
|---:|---|
| `200` | Anfrage erfolgreich |
| `201` | Ressource wurde angelegt |
| `400` | Parameter oder Upload fehlt beziehungsweise ist ungültig |
| `401` | vorgeschalteter Webserver verlangt Authentifizierung |
| `404` | Route, POI oder Medienressource wurde nicht gefunden |
| `409` | Ressource existiert bereits oder Dateisystemzustand verhindert die Operation |
| `413` | Datei überschreitet `MAX_UPLOAD_MB` |
| `415` | Dateityp wird nicht unterstützt |
| `500` | Interner Serverfehler |

### IDs

Alle IDs sind positive, von SQLite erzeugte Ganzzahlen.

- `:id` bezeichnet bei `/routes/:id/...` die Route.
- `:pictureId` bezeichnet einen Datensatz aus `poi_images`.
- `:poiId` bezeichnet den POI und zugleich seine höchstens eine Audioressource.

Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`.

## 2. Datenmodelle

### Route

```json
{
  "id": 1,
  "slug": "schulwald-runde",
  "name": "Schulwald-Runde",
  "description": "Naturkundlicher Rundweg",
  "schoolName": "Beispielschule",
  "status": "active",
  "start": { "lat": 52.5208, "lon": 13.407 },
  "center": { "lat": 52.521, "lon": 13.408 },
  "bounds": {
    "minLat": 52.5208,
    "minLon": 13.407,
    "maxLat": 52.5212,
    "maxLon": 13.409
  },
  "distanceM": 842.6,
  "elevationGainM": 14.2,
  "pointCount": 87,
  "gpxUrl": "/api/routes/1/gpx",
  "createdAt": "2026-06-16 12:00:00",
  "updatedAt": "2026-06-16 12:00:00",
  "deletedAt": null,
  "proximityM": 324.8
}
```

### POI

```json
{
  "id": 7,
  "routeId": 1,
  "title": "Die alte Eiche",
  "description": "Hier wird das Alter der Eiche erklärt.",
  "lat": 52.5208,
  "lon": 13.407,
  "triggerRadiusM": 60,
  "sequence": 2,
  "audioUrl": "/api/routes/1/pois/7/audio",
  "images": [
    {
      "id": 15,
      "caption": "Blick auf die Baumkrone",
      "sequence": 0,
      "url": "/api/routes/1/pois/7/pictures/15"
    }
  ]
}
```

### Bildressource

```json
{
  "id": 15,
  "routeId": 1,
  "poiId": 7,
  "poiTitle": "Die alte Eiche",
  "caption": "Blick auf die Baumkrone",
  "sequence": 0,
  "url": "/api/routes/1/pois/7/pictures/15",
  "createdAt": "2026-06-16 12:30:00"
}
```

### Audioressource

Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressource über die `poiId` adressiert.

```json
{
  "routeId": 1,
  "poiId": 7,
  "poiTitle": "Die alte Eiche",
  "url": "/api/routes/1/pois/7/audio",
  "updatedAt": "2026-06-16 12:35:00"
}
```

## 3. Endpunktübersicht

| Methode | Pfad | Zweck |
|---|---|---|
| `GET` | `/api/health` | Server- und SQLite-Zustand |
| `GET` | `/api/routes` | Routen auflisten |
| `GET` | `/api/routes/:id` | Route mit Punkten und POIs lesen |
| `GET` | `/api/routes/:id/gpx` | GPX-Datei der Route ausliefern |
| `POST` | `/api/routes` | Route anlegen |
| `PUT` | `/api/routes/:id` | Route aktualisieren |
| `POST` | `/api/routes/:id/append` | GPX-Punkte anhängen |
| `DELETE` | `/api/routes/:id` | Route weich löschen |
| `POST` | `/api/routes/:id/restore` | Route wiederherstellen |
| `GET` | `/api/routes/:id/pois` | POIs einer Route auflisten |
| `GET` | `/api/routes/:routeId/pois/:poiId` | einzelnen POI der Route lesen |
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
| `PUT` | `/api/routes/:routeId/pois/:poiId` | POI-Metadaten aktualisieren |
| `DELETE` | `/api/routes/:routeId/pois/:poiId` | POI einschließlich Bildern und Audio löschen |
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten |
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen |
| `PUT` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
| `DELETE` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | einzelnes Bild löschen |
| `GET` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei für den POI anlegen |
| `PUT` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs ersetzen |
| `DELETE` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs löschen |

## 4. Systemzustand

### `GET /api/health`

Parameter: keine.

```bash
curl http://127.0.0.1:47145/api/health
```

```json
{
  "ok": true,
  "service": "wegwichtel",
  "socket": "127.0.0.1:47145",
  "sqliteVersion": "3.46.1",
  "timestamp": "2026-06-16T12:00:00.000Z"
}
```

## 5. Routen lesen

### `GET /api/routes`

Ohne Positionsparameter werden alle aktiven Routen alphabetisch geliefert. Mit `lat` und `lon` wird die Entfernung zum Routenstart berechnet, anhand `radiusKm` gefiltert und nach Entfernung sortiert.

#### Query-Parameter

| Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `lat` | Dezimalzahl | nein | – | Breitengrad; nur zusammen mit `lon` wirksam |
| `lon` | Dezimalzahl | nein | – | Längengrad; nur zusammen mit `lat` wirksam |
| `radiusKm` | Dezimalzahl | nein | `DEFAULT_ROUTE_RADIUS_KM`, standardmäßig `25` | maximaler Abstand zum Routenstart |
| `includeDeleted` | Boolean-Text | nein | `false` | der exakte Wert `true` schließt gelöschte Routen ein |

Alle aktiven Routen:

```bash
curl http://127.0.0.1:47145/api/routes
```

Mit allen Query-Parametern:

```bash
curl --get http://127.0.0.1:47145/api/routes \
  --data-urlencode 'lat=52.5208' \
  --data-urlencode 'lon=13.4070' \
  --data-urlencode 'radiusKm=12.5' \
  --data-urlencode 'includeDeleted=true'
```

### `GET /api/routes/:id`

Liefert Route, GPX-Punkte und POIs einschließlich Medien-URLs.

#### Pfadparameter

| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `id` | Ganzzahl | ja | Route |

#### Query-Parameter

| Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `includeDeleted` | Boolean-Text | nein | `false` | mit `true` kann eine gelöschte Route gelesen werden |

```bash
curl http://127.0.0.1:47145/api/routes/1
```

```bash
curl 'http://127.0.0.1:47145/api/routes/1?includeDeleted=true'
```

### `GET /api/routes/:id/gpx`

Liefert die aktive GPX-Datei der Route direkt mit `Content-Type: application/gpx+xml` aus. Der in einer Routenressource enthaltene Wert `gpxUrl` verweist auf diesen Endpunkt.

```bash
curl http://127.0.0.1:47145/api/routes/1/gpx \
  --output schulwald-runde.gpx
```

Parameter außer der Routen-ID sind nicht vorgesehen. Gelöschte oder unbekannte Routen antworten mit `404`.

## 6. Route anlegen und bearbeiten

### `POST /api/routes`

Legt eine Route aus einer GPX-Datei an.

Content-Type: `multipart/form-data`

| Feld | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---|---|
| `gpx` | Datei | ja | – | GPX-Datei mit mindestens einem Trackpunkt; der Dateiname ist unerheblich |
| `name` | Text | bedingt | Name aus GPX | erforderlich, wenn GPX keinen Namen enthält |
| `slug` | Text | nein | aus `name` | interne URL-freundliche Kennung |
| `description` | Text | nein | leer | Routenbeschreibung |
| `schoolName` | Text | nein | leer | Schule oder Einrichtung |

```bash
curl -X POST http://127.0.0.1:47145/api/routes \
  -F 'name=Schulwald-Runde' \
  -F 'slug=schulwald-runde-klasse-7a' \
  -F 'description=Naturkundlicher Rundweg der Klasse 7a' \
  -F 'schoolName=Beispielschule' \
  -F 'gpx=@examples/sample-route.gpx'
```

Erfolg: `201 Created` und `Location: /api/routes/<id>`.

### `PUT /api/routes/:id`

Aktualisiert Metadaten. Eine optionale GPX-Datei ersetzt alle bisherigen Trackpunkte.

| Feld | Typ | Pflicht | Verhalten ohne Feld |
|---|---|---:|---|
| `gpx` | Datei | nein | bisherige GPX-Punkte bleiben erhalten |
| `name` | Text | nein | bisheriger Wert bleibt |
| `description` | Text | nein | bisheriger Wert bleibt |
| `schoolName` | Text | nein | bisheriger Wert bleibt |

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1 \
  -F 'name=Schulwald-Runde 2026' \
  -F 'description=Überarbeitete Strecke' \
  -F 'schoolName=Beispielschule' \
  -F 'gpx=@route-neu.gpx'
```

### `POST /api/routes/:id/append`

Hängt alle Trackpunkte einer GPX-Datei an die Route an und berechnet Streckenwerte neu.

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `gpx` | Datei | ja | anzuhängende GPX-Datei |

```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/append \
  -F 'gpx=@verlaengerung.gpx'
```

## 7. POIs lesen und bearbeiten

### `GET /api/routes/:id/pois`

Liefert alle POIs einer aktiven Route nach `sequence` und `id`.

```bash
curl http://127.0.0.1:47145/api/routes/1/pois
```

### `GET /api/routes/:routeId/pois/:poiId`

Liefert einen einzelnen POI einschließlich seiner aktuellen Bild- und Audio-URLs. Route und POI werden gemeinsam geprüft; der POI muss zur angegebenen aktiven Route gehören.

| Pfadparameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | Ganzzahl | ja | ID der aktiven Route |
| `poiId` | Ganzzahl | ja | ID des POIs innerhalb dieser Route |

```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7
```

Eine unbekannte Route, ein unbekannter POI oder eine falsche Route-POI-Kombination liefert `404 Not Found`.

### `POST /api/routes/:id/pois`

Legt ausschließlich die POI-Metadaten an. Bilder und Audio werden anschließend über die gesonderten Medienendpunkte hochgeladen.

| Feld | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `title` | Text | nein | `Unbenannter POI` | Stationsname |
| `description` | Text | nein | leer | Beschreibung |
| `lat` | Dezimalzahl | ja | – | Breitengrad |
| `lon` | Dezimalzahl | ja | – | Längengrad |
| `triggerRadiusM` | Dezimalzahl | nein | `DEFAULT_POI_TRIGGER_METERS`, standardmäßig `80` | Aktivierungsradius in Metern |
| `sequence` | Ganzzahl | nein | `0` | Reihenfolge in der Stationsliste |

JSON-Beispiel mit allen Parametern:

```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pois \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Die alte Eiche",
    "description": "Hier wird das Alter der Eiche erklärt.",
    "lat": 52.5208,
    "lon": 13.4070,
    "triggerRadiusM": 60,
    "sequence": 2
  }'
```

Erfolg: `201 Created` und `Location: /api/routes/<routeId>/pois/<poiId>`.

### `PUT /api/routes/:routeId/pois/:poiId`

Aktualisiert ausschließlich POI-Metadaten. Nicht übergebene Werte bleiben erhalten. Route und POI werden gemeinsam validiert; ein POI kann über diesen Endpunkt keiner anderen Route zugeordnet werden.

| Pfadparameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | Ganzzahl | ja | ID der aktiven Route |
| `poiId` | Ganzzahl | ja | ID des POIs innerhalb dieser Route |

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `title` | Text | nein | neuer Stationsname |
| `description` | Text | nein | neue Beschreibung |
| `lat` | Dezimalzahl | nein | neuer Breitengrad |
| `lon` | Dezimalzahl | nein | neuer Längengrad |
| `triggerRadiusM` | Dezimalzahl | nein | neuer Aktivierungsradius |
| `sequence` | nichtnegative Ganzzahl | nein | neue Reihenfolge |

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7 \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Die sehr alte Eiche",
    "description": "Aktualisierte Beschreibung",
    "lat": 52.5209,
    "lon": 13.4071,
    "triggerRadiusM": 45,
    "sequence": 3
  }'
```

### `DELETE /api/routes/:routeId/pois/:poiId`

Löscht den POI-Datensatz sowie alle zugehörigen Bilddatensätze, Bilddateien und die optionale Audiodatei. Dieser Vorgang ist im Gegensatz zum Soft Delete einer vollständigen Route nicht wiederherstellbar.

```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7
```

```json
{
  "id": 7,
  "routeId": 1,
  "deleted": true
}
```

## 8. Bilder eines POIs einzeln verwalten

Die Route und der POI sind Bestandteil jedes Bildpfades. Dadurch ist die Zuordnung eindeutig und beim Upload muss keine zusätzliche `poiId` übergeben werden.

### Gemeinsame Pfadparameter

| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | positive Ganzzahl | ja | ID der aktiven Route |
| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört |
| `pictureId` | positive Ganzzahl | nur bei Einzelressourcen | ID des Bildes, das zu diesem POI gehört |

### `GET /api/routes/:routeId/pois/:poiId/pictures`

Liefert alle Bilder des angegebenen POIs in Diashow-Reihenfolge.

```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures
```

```json
{
  "pictures": [
    {
      "id": 15,
      "routeId": 1,
      "poiId": 7,
      "poiTitle": "Die alte Eiche",
      "caption": "Blick auf die Baumkrone",
      "sequence": 0,
      "url": "/api/routes/1/pois/7/pictures/15",
      "createdAt": "2026-06-16 12:30:00"
    }
  ]
}
```

### `GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId`

Liefert standardmäßig die Bilddatei direkt aus. Genau dieser Pfad wird im Feld `url` der Bildressource und unter `pois[].images[].url` ausgegeben.

Bild speichern:

```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
  --output bild-15.jpg
```

Metadaten statt Dateidaten abrufen:

```bash
curl --get http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
  --data-urlencode 'metadata=true'
```

| Query-Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Bilddatei liefern |

Die API antwortet mit `404`, wenn Route, POI oder Bild nicht zusammengehören.

### `POST /api/routes/:routeId/pois/:poiId/pictures`

Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Zulässig sind zwei Übertragungsformen.

#### Variante A: `multipart/form-data`

| Feld | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `picture` | Datei | ja | – | JPEG-, PNG- oder WebP-Datei; der Typ wird aus dem Inhalt erkannt |
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext |
| `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |

```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
  -F 'caption=Blick auf die Baumkrone' \
  -F 'sequence=0' \
  -F 'picture=@eiche.jpg'
```

#### Variante B: JSON mit Base64

Empfohlener Content-Type: `application/json`. Der Server akzeptiert zusätzlich `text/json`.

```json
{
  "caption": "Blick auf die Baumkrone",
  "sequence": 0,
  "picture": {
    "filename": "eiche.jpg",
    "base64": "/9j/4AAQSkZJRgABAQ..."
  }
}
```

Beispiel mit `text/json` und einer separat erzeugten Payload-Datei:

```bash
base64 < eiche.jpg | tr -d '\n' > eiche.jpg.b64

jq -n \
  --arg caption 'Blick auf die Baumkrone' \
  --argjson sequence 0 \
  --rawfile data eiche.jpg.b64 \
  '{
    caption: $caption,
    sequence: $sequence,
    picture: {
      filename: "eiche.jpg",
      base64: $data
    }
  }' > picture.json

curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
  -H 'Content-Type: text/json' \
  --data-binary @picture.json
```

Alternativ kann eine Data-URL übertragen werden:

```json
{
  "caption": "Blick auf die Baumkrone",
  "picture": {
    "filename": "eiche.png",
    "dataUrl": "data:image/png;base64,iVBORw0KGgoAAA..."
  }
}
```

Ein Request mit `-d '{}'` schlägt mit `400 Bad Request` fehl, weil weder eine Multipart-Datei noch ein JSON-Dateiobjekt enthalten ist.

Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/pictures/<pictureId>`.

### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId`

Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten. Ein Bild kann über diesen Endpunkt nicht einem anderen POI zugeordnet werden; dafür muss es beim bisherigen POI gelöscht und beim Ziel-POI neu angelegt werden.

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `picture` | Multipart-Datei oder JSON-Dateiobjekt | nein | ersetzt die bisherige Bilddatei |
| `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |

Nur Metadaten per JSON ändern:

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
  -H 'Content-Type: application/json' \
  -d '{
    "caption": "Nahaufnahme der Eichenblätter",
    "sequence": 1
  }'
```

Datei und Metadaten per Multipart ersetzen:

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
  -F 'caption=Neue Aufnahme der Eiche' \
  -F 'sequence=2' \
  -F 'picture=@eiche-neu.webp'
```

Datei und Metadaten per JSON ersetzen:

```json
{
  "caption": "Neue Aufnahme der Eiche",
  "sequence": 2,
  "picture": {
    "filename": "eiche-neu.webp",
    "base64": "UklGRiQAAABXRUJQVlA4..."
  }
}
```

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
  -H 'Content-Type: application/json' \
  --data-binary @picture-update.json
```

Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung.

### `DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId`

Entfernt Bilddatensatz und Datei.

```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15
```

```json
{
  "id": 15,
  "routeId": 1,
  "poiId": 7,
  "deleted": true
}
```

## 9. Audiodatei eines POIs verwalten

Pro POI ist höchstens eine Audiodatei vorgesehen. Daher ist `/audio` selbst die Einzelressource; eine zusätzliche Audio-ID ist nicht erforderlich.

### Gemeinsame Pfadparameter

| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `routeId` | positive Ganzzahl | ja | ID der aktiven Route |
| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört |

### `GET /api/routes/:routeId/pois/:poiId/audio`

Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` der Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb der Audiodatei springen können.

Audiodatei speichern:

```bash
curl http://127.0.0.1:47145/api/routes/1/pois/7/audio \
  --output ansage-7.mp3
```

Metadaten statt Dateidaten abrufen:

```bash
curl --get http://127.0.0.1:47145/api/routes/1/pois/7/audio \
  --data-urlencode 'metadata=true'
```

| Query-Parameter | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Audiodatei liefern |

Antwortet mit `404`, wenn Route und POI nicht zusammengehören oder der POI keine Audiodatei besitzt.

### `POST /api/routes/:routeId/pois/:poiId/audio`

Legt die Audiodatei des im Pfad angegebenen POIs an. Zulässig sind `multipart/form-data` und JSON mit Base64.

#### Variante A: `multipart/form-data`

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei; der Typ wird aus dem Inhalt erkannt |

```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
  -F 'audio=@ansage.mp3'
```

#### Variante B: JSON mit Base64

```json
{
  "audio": {
    "filename": "ansage.mp3",
    "base64": "SUQzBAAAAAAAI1RTU0UAAA..."
  }
}
```

```bash
base64 < ansage.mp3 | tr -d '\n' > ansage.mp3.b64

jq -n --rawfile data ansage.mp3.b64 \
  '{
    audio: {
      filename: "ansage.mp3",
      base64: $data
    }
  }' > audio.json

curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
  -H 'Content-Type: application/json' \
  --data-binary @audio.json
```

Auch hier wird `Content-Type: text/json` akzeptiert. Ein leeres `{}` enthält keine Audiodatei und liefert `400 Bad Request`.

Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/audio`.

Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden.

### `PUT /api/routes/:routeId/pois/:poiId/audio`

Ersetzt die vorhandene Audiodatei des POIs. Die Datei kann als Multipart-Upload oder als JSON-Dateiobjekt übertragen werden.

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `audio` | Multipart-Datei oder JSON-Dateiobjekt | ja | neue Audiodatei |

Multipart-Beispiel:

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
  -F 'audio=@ansage-neu.ogg'
```

JSON-Beispiel:

```json
{
  "audio": {
    "filename": "ansage-neu.ogg",
    "base64": "T2dnUwACAAAAAAAAAAB..."
  }
}
```

```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
  -H 'Content-Type: text/json' \
  --data-binary @audio-update.json
```

Der Server entfernt die bisherige Datei nach erfolgreicher Aktualisierung. Besitzt der POI noch keine Audiodatei, antwortet der Server mit `404`; zum erstmaligen Anlegen ist `POST` zu verwenden.

### `DELETE /api/routes/:routeId/pois/:poiId/audio`

Entfernt die Audiodatei und setzt `audioUrl` des POIs auf `null`.

```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/audio
```

```json
{
  "routeId": 1,
  "poiId": 7,
  "deleted": true
}
```

## 10. Route weich löschen und wiederherstellen

### `DELETE /api/routes/:id`

Markiert die Route als gelöscht und verschiebt das vollständige Streckenverzeichnis mit GPX, Bildern und Audio nach `storage/trash/routes/`. Die Datenbankeinträge bleiben erhalten und ihre Pfade werden auf den Papierkorb umgeschrieben.

```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1
```

```json
{
  "route": {
    "id": 1,
    "status": "deleted",
    "gpxUrl": null
  },
  "softDeleted": true
}
```

### `POST /api/routes/:id/restore`

Verschiebt eine gelöschte Route zurück in den aktiven Speicher und schreibt alle GPX-, Bild- und Audiopfade zurück.

Parameter: keine.

```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/restore
```

## 11. Dateien über REST ausliefern

Die API veröffentlicht keine internen Speicherpfade und keine `/media/...`-Adressen. Alle in JSON ausgegebenen Datei-URLs verweisen auf stabile REST-Endpunkte:

```text
GET /api/routes/1/gpx
GET /api/routes/1/pois/7/pictures/15
GET /api/routes/1/pois/7/audio
```

Die Zuordnung lautet:

| JSON-Feld | Datei-Endpunkt |
|---|---|
| `route.gpxUrl` | `/api/routes/:id/gpx` |
| `poi.audioUrl` | `/api/routes/:routeId/pois/:poiId/audio` |
| `poi.images[].url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `picture.url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `audio.url` | `/api/routes/:routeId/pois/:poiId/audio` |

Die Dateinamen und relativen Pfade unter `storage/` bleiben ausschließlich interne Implementierungsdetails. Dateien gelöschter Routen liegen im Papierkorb und sind über keinen Datei-Endpunkt erreichbar.


## 12. Python-Werkzeuge

Unter `tools/python/` liegen interaktive Skripte für Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audio. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden abgefragt. Antwortet ein vorgeschalteter Webserver mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den ursprünglichen Request.

Details und Aufrufbeispiele stehen in [`tools/python/README.md`](../tools/python/README.md).
