Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80852374a8 | ||
|
|
e3c6789776 | ||
|
|
e2417cb5ab |
+63
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.17.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.19.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,68 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.19.1] — 2026-09-23
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#110 — lisibilité et positionnement du lecteur média** : en mode mobile, la
|
||||
barre audio passe en grille (barre de progression pleine largeur sur sa propre
|
||||
ligne) et les actions secondaires (précédent/suivant/agrandir/volume) sont
|
||||
masquées pour éviter tout chevauchement ; le volume est aussi masqué sur les
|
||||
écrans intermédiaires en desktop, et `overflow: hidden` empêche le débordement
|
||||
hors de la pilule. La mini-fenêtre vidéo n'est plus ré-aimantée sur les bords :
|
||||
elle se déplace et se redimensionne **librement** (position absolue mémorisée,
|
||||
centre autorisé), le glisser fonctionnant désormais depuis n'importe quel point
|
||||
de la fenêtre (boutons/curseurs/poignée exclus, seuil de 4 px pour préserver
|
||||
les contrôles natifs de la vidéo).
|
||||
|
||||
---
|
||||
|
||||
## [2.19.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#110 — Lecteur média persistant « Now Playing »** : les fichiers audio et
|
||||
vidéo continuent de jouer pendant la navigation grâce à un contrôleur global
|
||||
`frontend/js/now-playing.js` qui possède **un seul** élément `<audio>`/`<video>`
|
||||
téléporté entre la vue inline (onglet/panneau) et un dock flottant
|
||||
(`<body>`), sans interruption de lecture. Dock desktop : pilule verre dépoli
|
||||
(artwork, titre, voûte, play/pause, précédent/suivant, barre de progression,
|
||||
volume, retour au média, agrandir, fermer). Mini-fenêtre vidéo flottante
|
||||
déplaçable/redimensionnable avec aimantation aux coins et bouton
|
||||
Picture-in-Picture natif. Mode mobile : mini-player fixé au-dessus de la barre
|
||||
d'outils (safe-area `viewport-fit=cover`). Intégration **Media Session** (écran
|
||||
verrouillé, touches matérielles, Windows SMTC), panneau étendu façon « Now
|
||||
Playing », lecture auto du média suivant/précédent du dossier, reprise après
|
||||
rechargement, notification quand l'onglet est fermé pendant la lecture. Fiche :
|
||||
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md) (§ Now
|
||||
Playing, #110).
|
||||
|
||||
---
|
||||
|
||||
## [2.18.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#109 — Support audio & vidéo (lecteurs HTML5 intégrés)** : les fichiers audio
|
||||
(`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`)
|
||||
apparaissent désormais dans l'arborescence et l'index (nom/taille/date
|
||||
uniquement, contenu jamais lu, intégrés à `SUPPORTED_EXTENSIONS` via
|
||||
`media_types.py`). Nouvel endpoint `GET /api/media/{vault}?path=…` avec support
|
||||
HTTP `Range` / `206 Partial Content` (`Content-Range`, `Accept-Ranges`, `416`
|
||||
sur plage invalide, `413` au-delà de `OBSIGATE_MEDIA_MAX_INLINE_MB`, défaut
|
||||
500 Mo) — le helper Range de `pdf/stream` a été extrait en
|
||||
`_stream_file_with_range()` et est partagé. `api_file_view()` expose
|
||||
`is_audio`/`is_video`/`stream_url`/`media_mime` avant toute lecture texte.
|
||||
Frontend : lecteur audio dédié (artwork, durée via `loadedmetadata`) et lecteur
|
||||
vidéo (`playsinline`, scène noire letterboxée), icônes Lucide `audio-lines` /
|
||||
`video`, pause à la réinitialisation de la vue, repli téléchargement si le codec
|
||||
n'est pas lisible ou le fichier trop volumineux. Les médias sont exclus du
|
||||
contexte textuel BooksLM et du cache hors-ligne du service worker. Fiche :
|
||||
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.17.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -83,6 +83,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
|
||||
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
@@ -296,6 +297,7 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
||||
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Taille max pour la lecture audio/vidéo intégrée (au-delà : téléchargement) | `500` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
||||
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Fournisseurs de recherche web à clé (essayés avant SearXNG) | — |
|
||||
| `OBSIGATE_WEB_PROVIDERS` | Ordre des fournisseurs de recherche (ex. `brave,searxng`) | — |
|
||||
@@ -964,8 +966,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.17.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.19.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.17.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.19.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -82,6 +82,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
|
||||
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
@@ -340,6 +341,7 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
|
||||
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Max size for inline audio/video playback (above: download) | `500` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
|
||||
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Keyed web-search providers (tried before SearXNG) | — |
|
||||
| `OBSIGATE_WEB_PROVIDERS` | Search provider order (e.g. `brave,searxng`) | — |
|
||||
@@ -1140,8 +1142,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.17.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.19.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.17.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.19.1 | Last updated: September 2026*
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.media_types import is_media
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm")
|
||||
@@ -185,6 +186,10 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
|
||||
def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
|
||||
"""Read, redact and truncate a single file into a context entry."""
|
||||
suffix = target.suffix.lower()
|
||||
# #109-D3 — audio/video (and images) carry no extractable text; never feed
|
||||
# raw bytes to the model. Images are handled separately via vision data URLs.
|
||||
if is_media(suffix):
|
||||
return None
|
||||
try:
|
||||
if suffix == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
import frontmatter
|
||||
|
||||
from backend.media_types import IMAGE_EXTENSIONS, is_media
|
||||
from backend.media_types import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, is_media
|
||||
|
||||
logger = logging.getLogger("obsigate.indexer")
|
||||
|
||||
@@ -71,7 +71,7 @@ SUPPORTED_EXTENSIONS = {
|
||||
".dockerfile", ".makefile", ".cmake",
|
||||
".excalidraw",
|
||||
".excalidraw.md",
|
||||
} | set(IMAGE_EXTENSIONS)
|
||||
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
|
||||
|
||||
|
||||
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
|
||||
|
||||
+160
-28
@@ -53,7 +53,7 @@ from backend.indexer import (
|
||||
update_single_file,
|
||||
)
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
from backend.media_types import IMAGE_EXTENSIONS, is_image, media_mime_type
|
||||
from backend.media_types import IMAGE_EXTENSIONS, is_audio, is_image, is_video, media_mime_type
|
||||
from backend.openapi_docs import (
|
||||
API_DESCRIPTION,
|
||||
TAGS_METADATA,
|
||||
@@ -205,6 +205,11 @@ class FileContentResponse(BaseModel):
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
||||
is_pdf: bool | None = Field(default=None, description="True for PDF files")
|
||||
is_image: bool | None = Field(default=None, description="True for image files")
|
||||
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
|
||||
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
|
||||
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
|
||||
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
|
||||
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||
is_json: bool | None = Field(default=None, description="True for JSON files")
|
||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
||||
@@ -1037,6 +1042,27 @@ def _content_disposition(disposition: str, filename: str) -> str:
|
||||
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
||||
|
||||
|
||||
def _media_max_inline_bytes() -> int:
|
||||
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
|
||||
|
||||
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
|
||||
above the limit are not streamed in the viewer (the UI falls back to the
|
||||
download button), which keeps a single uvicorn worker from being pinned by
|
||||
multi-gigabyte media. Invalid or non-positive values fall back to default.
|
||||
"""
|
||||
default_mb = 500
|
||||
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
|
||||
if not raw:
|
||||
return default_mb * 1024 * 1024
|
||||
try:
|
||||
mb = float(raw)
|
||||
except ValueError:
|
||||
return default_mb * 1024 * 1024
|
||||
if mb <= 0:
|
||||
return default_mb * 1024 * 1024
|
||||
return int(mb * 1024 * 1024)
|
||||
|
||||
|
||||
def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a relative path safely within the vault root.
|
||||
|
||||
@@ -2443,6 +2469,61 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
|
||||
if is_audio(ext) or is_video(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
media_kind = "audio" if is_audio(ext) else "video"
|
||||
|
||||
# #109-A3 — beyond the inline limit the viewer falls back to download
|
||||
# (a single uvicorn worker must not be pinned by multi-GB media).
|
||||
if size > _media_max_inline_bytes():
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"media_too_large": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
|
||||
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
if media_kind == "audio":
|
||||
html = (
|
||||
f'<div class="audio-viewer">'
|
||||
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
html = (
|
||||
f'<div class="video-viewer">'
|
||||
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_audio": media_kind == "audio",
|
||||
"is_video": media_kind == "video",
|
||||
"media_mime": mime,
|
||||
"stream_url": stream_url,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
@@ -2618,32 +2699,21 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
|
||||
async def api_pdf_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
|
||||
def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
|
||||
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
|
||||
|
||||
Supports HTTP Range requests (206 Partial Content) so browsers can
|
||||
progressively render large PDFs in the native viewer.
|
||||
Shared by ``pdf/stream`` and ``/api/media``: a plain :class:`FileResponse`
|
||||
with ``Accept-Ranges: bytes`` when no range is requested, or a
|
||||
:class:`StreamingResponse` (206 Partial Content, 64 KiB chunks) for a valid
|
||||
single range. An unsatisfiable range yields ``416`` with a
|
||||
``Content-Range: bytes */size`` header.
|
||||
|
||||
Reads are offloaded to threads so the event loop is never blocked
|
||||
(ASYNC230), matching the previous inline implementation.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
disposition = _content_disposition("inline", file_path.name)
|
||||
|
||||
if range_header:
|
||||
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
|
||||
@@ -2671,7 +2741,6 @@ async def api_pdf_stream(
|
||||
chunk_size = end - start + 1
|
||||
|
||||
async def _partial():
|
||||
# Open + reads offloaded to threads (avoid blocking the event loop — ASYNC230)
|
||||
f = await asyncio.to_thread(open, str(file_path), "rb")
|
||||
try:
|
||||
await asyncio.to_thread(f.seek, start)
|
||||
@@ -2688,18 +2757,45 @@ async def api_pdf_stream(
|
||||
return StreamingResponse(
|
||||
_partial(),
|
||||
status_code=206,
|
||||
media_type="application/pdf",
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Disposition": _content_disposition("inline", file_path.name),
|
||||
"Content-Disposition": disposition,
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(str(file_path), media_type="application/pdf", headers={
|
||||
return FileResponse(str(file_path), media_type=media_type, headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": _content_disposition("inline", file_path.name)})
|
||||
"Content-Disposition": disposition})
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
|
||||
async def api_pdf_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
|
||||
|
||||
Supports HTTP Range requests (206 Partial Content) so browsers can
|
||||
progressively render large PDFs in the native viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
return _stream_file_with_range(file_path, request, "application/pdf")
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
|
||||
@@ -3207,6 +3303,42 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
|
||||
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
|
||||
|
||||
|
||||
@app.get("/api/media/{vault_name}", response_class=FileResponse)
|
||||
async def api_media_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to audio/video file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
|
||||
|
||||
Serves the bytes with the correct MIME type and honours ``Range`` requests
|
||||
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
|
||||
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
|
||||
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
|
||||
refused with ``413`` — the viewer falls back to the download button.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Media not found: {path}")
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
if not (is_audio(ext) or is_video(ext)):
|
||||
raise HTTPException(status_code=400, detail="Not an audio/video file")
|
||||
|
||||
if file_path.stat().st_size > _media_max_inline_bytes():
|
||||
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
|
||||
|
||||
return _stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
|
||||
|
||||
|
||||
@app.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
|
||||
async def api_media_thumb(
|
||||
vault_name: str,
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.17.0"
|
||||
version = "2.19.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.17.0"
|
||||
version = "2.19.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.17.0",
|
||||
"version": "2.19.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+5
-58
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.17.0 | **Dernière mise à jour :** 2026-09-23
|
||||
> **Version :** 2.19.1 | **Dernière mise à jour :** 2026-09-23
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -80,60 +80,6 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Média (images, audio, vidéo)
|
||||
|
||||
### 109. Support audio & vidéo — lecteurs intégrés HTML5
|
||||
|
||||
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** backend (media streaming) + frontend (viewer)
|
||||
- **Statut :** ⚪ Prévu — s'appuie sur le socle `media_types.py` livré par #108
|
||||
- **Description :** prise en charge des fichiers audio (`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`) avec la même parité que les autres fichiers : apparition dans l'arborescence, indexation du nom, lecture directe dans le viewer via les balises HTML5 `<audio>` / `<video>`. Actuellement ces fichiers tombent dans le chemin binaire « Ce fichier est binaire et ne peut pas être affiché » + bouton download (`frontend/js/viewer.js:622`).
|
||||
- **Choix technique — lecteurs HTML5 natifs, pas de transcodage :**
|
||||
- `<video>`/`<audio>` sont natifs partout (Chrome, Firefox, Edge, Safari, mobile) : play, scrub, volume, vitesse 0,25–2×, PiP, plein écran — sans une ligne de JS de contrôle.
|
||||
- **Zéro dépendance lourde** : pas de ffmpeg dans l'image Docker, pas de file de transcodage. Les formats web-natifs (MP4/H.264, WebM, MP3, AAC, Opus, FLAC, WAV) couvrent l'immense majorité des notes vocales et screencasts.
|
||||
- Le vrai travail serveur est le **support HTTP `Range` / 206 Partial Content** : sans lui, pas de scrub (le navigateur retélécharge tout pour se positionner) et Safari refuse le MP4. Bonne nouvelle : `pdf/stream` (`backend/main.py:2617`) **implémente déjà les Range** (206, `Content-Range`, 416) — extraire cette logique en helper `stream_file_with_range(file_path, request, media_type)` et s'en servir pour les trois médias.
|
||||
- Métadonnées (durée, résolution) lues **côté client** via `loadedmetadata` (`duration`, `videoWidth/videoHeight`) — aucun ffprobe serveur nécessaire.
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Backend — socle média partagé (1 j) — bénéficie aussi à #74/#108
|
||||
- [ ] **A1.** Ajouter `AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS` dans `backend/media_types.py` (#108-A1) et les intégrer à `SUPPORTED_EXTENSIONS` avec la branche « contenu non parsé » (#108-A2).
|
||||
- [ ] **A2.** Extraire le helper Range de `pdf/stream` en fonction réutilisable ; nouvel endpoint `GET /api/media/{vault}?path=…` : streaming par chunks 64 ko (`aiofiles`), 206 + `Content-Range` + `Accept-Ranges`, 416 sur Range invalide, MIME via `mimetypes` + map de secours (`.m4a→audio/mp4`, `.opus→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
|
||||
- [ ] **A3.** Gardes-fous : `_resolve_safe_path`, `check_vault_access`, taille inline max `OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut 500 — au-delà, UI download).
|
||||
- [ ] **A4.** `api_file_view()` : branches AVANT le `read_text()` → `is_audio: true` / `is_video: true` (+ `size_bytes`, `stream_url` vers `/api/media`). `pdf/stream` bascule sur le helper A2 (comportement inchangé, tests de régression).
|
||||
|
||||
##### B. Frontend — lecteur audio (0,5 j)
|
||||
- [ ] **B1.** Dispatch `data.is_audio` dans `viewer.js` : `<audio controls src=stream_url>` pleine largeur, artwork placeholder (icône Lucide `music`), titre + voûte + taille dans la toolbar, durée formatée via `loadedmetadata`.
|
||||
- [ ] **B2.** `EXT_ICONS` : audio → Lucide `audio-lines` ; vidéo → `video`.
|
||||
- [ ] **B3.** Pause au changement d'onglet (comportement fichier standard, cohérence #75) — pas de lecture persistante en v1.
|
||||
|
||||
##### C. Frontend — lecteur vidéo (0,5 j)
|
||||
- [ ] **C1.** Dispatch `data.is_video` : `<video controls playsinline>` centré, `max-height: calc(100vh - header)`, fond noir, letterboxing.
|
||||
- [ ] **C2.** Vérifier le bouton pop-out avec médias (médias same-origin, `X-Frame-Options: SAMEORIGIN` compatible).
|
||||
- [ ] **C3.** Codec non supporté (`.mkv`, `.avi`, HEVC…) : sur l'événement `error` de l'élément média → remplacer le lecteur par l'UI binaire existante + message « Ce format ne peut pas être lu dans votre navigateur » + Télécharger / Ouvrir dans un nouvel onglet (le player OS prend le relais).
|
||||
|
||||
##### D. Recherche & intégrations (0,5 j)
|
||||
- [ ] **D1.** Filtres `ext:mp3`, `ext:mp4`, etc. (mécanisme #108-A4).
|
||||
- [ ] **D2.** Fichiers récents (#35) / dashboards : exclure les médias des previews de contenu (aucun texte).
|
||||
- [ ] **D3.** BooksLM (#76) : exclure les médias du contexte collecté (pas de texte extractible).
|
||||
- [ ] **D4.** (Hors scope, noter la porte) Transcription audio via Whisper sur le provider AI existant.
|
||||
|
||||
##### E. Mobile & PWA (0,25 j)
|
||||
- [ ] **E1.** Tester le scrub sur iOS Safari (exige un Range strictement correct) et Android Chrome.
|
||||
- [ ] **E2.** Service worker (#59) : exclure les médias du cache offline (taille), stratégie Network First inchangée pour le reste.
|
||||
|
||||
##### F. Tests (0,5 j)
|
||||
- [ ] **F1.** `test_media_stream.py` : `bytes=0-1023` → 206 + Content-Range + 1024 octets ; Range hors borne → 416 ; sans Range → 200 + Accept-Ranges ; path traversal refusé ; voûte sans accès refusée. Régression : `pdf/stream` inchangé après extraction du helper.
|
||||
- [ ] **F2.** `test_media_indexing.py` : un `.mp3` apparaît dans l'arborescence, indexé sans contenu, watcher sans erreur.
|
||||
- [ ] **F3.** Playwright : fixtures binaires minimaux commités dans `tests/fixtures/` (mini `.mp3` sine wave ~2 ko, mini `.mp4`) → `<audio>` et `<video>` rendus.
|
||||
|
||||
- **Points d'attention / risques**
|
||||
- **Range est LE prérequis** (sous-tâche la plus testée, pas une option) : sans 206 correct, Safari casse et le scrub hache la bande passante.
|
||||
- **Formats hors web-natifs** (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans transcodage (ffmpeg hors scope) — le fallback C3 les rend téléchargeables proprement au lieu de silencieusement cassés.
|
||||
- **Poids disque** : une vidéo de 1 Go dans un vault ralentit les backups (#17, gzip) — envisager d'exclure les gros médias de la compression gzip.
|
||||
- **Sous uvicorn (worker unique)** : lectures vidéo simultanées = file descriptors + bande passante ; surveiller via le health check enrichi (#68).
|
||||
- **Hors scope v1** : HLS, sous-titres (`<track src=.vtt>`), thumbnails vidéo (première frame) — extensions naturelles documentées ici.
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
|
||||
|
||||
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
|
||||
@@ -237,6 +183,8 @@
|
||||
| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) |
|
||||
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
|
||||
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
|
||||
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
|
||||
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -244,12 +192,11 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–108, #92 | ~123 jours réalisés |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–110, #92 | ~130 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||
| ⚪ Média | #109 Audio/vidéo (lecteurs HTML5, Range) — s'appuie sur le socle média de #108 | 2-3 jours |
|
||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
|
||||
| **Total restant** | **7 items + finitions** | **~25-40 jours** |
|
||||
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
|
||||
|
||||
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
|
||||
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Le socle média de #108 (`backend/media_types.py`) exposait déjà
|
||||
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
|
||||
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
|
||||
et ne peut pas être affiché » + bouton download.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Backend
|
||||
|
||||
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
|
||||
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
|
||||
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
|
||||
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
|
||||
suivent automatiquement.
|
||||
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
|
||||
`_stream_file_with_range(file_path, request, media_type)` (206 +
|
||||
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
|
||||
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
|
||||
l'utilise désormais aussi (comportement inchangé, tests de régression).
|
||||
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
|
||||
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
|
||||
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
|
||||
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
|
||||
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
|
||||
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
|
||||
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
|
||||
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
|
||||
`unsupported: true` + `media_too_large: true`.
|
||||
|
||||
### B/C. Frontend — lecteurs
|
||||
|
||||
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
|
||||
`data.is_video` → `renderVideoViewer()`.
|
||||
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
|
||||
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
|
||||
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
|
||||
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
|
||||
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
|
||||
avec durée + résolution.
|
||||
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
|
||||
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
|
||||
remplace le lecteur par l'UI binaire + message
|
||||
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
|
||||
**Ouvrir dans un nouvel onglet**.
|
||||
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
|
||||
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
|
||||
— pas de lecture persistante en v1 (cohérence #75).
|
||||
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
|
||||
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
|
||||
|
||||
### D. Recherche & intégrations
|
||||
|
||||
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
|
||||
l'index).
|
||||
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
|
||||
naturel de la branche binaire.
|
||||
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
|
||||
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
|
||||
`load_vault_image_data_url` (vision).
|
||||
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
|
||||
|
||||
### E. Mobile & PWA
|
||||
|
||||
- Le service worker ne met **jamais** en cache le flux média
|
||||
(`/api/media/{vault}`), tout en conservant le cache des miniatures
|
||||
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
|
||||
requêtes `Range` étaient déjà exclues.
|
||||
|
||||
### F. Tests
|
||||
|
||||
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
|
||||
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
|
||||
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
|
||||
`.mov`, régression `pdf/stream`.
|
||||
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
|
||||
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
|
||||
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
|
||||
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
|
||||
fallback, CSS, icônes, i18n, service worker, endpoints backend).
|
||||
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
|
||||
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
|
||||
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
|
||||
(VP8 64×64).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
|
||||
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
|
||||
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
|
||||
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
|
||||
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.
|
||||
|
||||
---
|
||||
|
||||
## #110 — Lecteur média persistant « Now Playing »
|
||||
|
||||
> **Version livrée :** 2.19.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** frontend (`now-playing.js`, `viewer.js`, `ui.js`, `pane-manager.js`,
|
||||
> `app.js`, `style.css`, `index.html`, locales).
|
||||
|
||||
### Principe : un seul média, téléporté
|
||||
|
||||
`frontend/js/now-playing.js` est un contrôleur singleton qui possède **l'unique
|
||||
élément `<audio>`/`<video>`** de l'application. Il est déplacé par `appendChild`
|
||||
(sans recréation, donc sans couper la lecture) entre :
|
||||
|
||||
- la **vue inline** — l'onglet/panneau du média, via la surface
|
||||
`NowPlaying.attachInline(area, data)` (appelée par `renderAudioViewer` /
|
||||
`renderVideoViewer` de `viewer.js`) ; et
|
||||
- le **dock global** — un enfant direct de `<body>` (`#now-playing-host`), monté
|
||||
hors de `.content-wrapper` pour survivre à `renderFile()`, aux onglets, aux
|
||||
panneaux et à la reconstruction de la grille split.
|
||||
|
||||
`renderFile()` appelle `NowPlaying.handleRender(area, data)` : si la zone qui va
|
||||
être réécrite contient l'élément média, celui-ci est renvoyé au dock. Les autres
|
||||
points qui vident le contenu (dashboard `_showDashboard`, `showWelcome`,
|
||||
`PaneManager._buildGrid` / `_collapseToSingle`) appellent le même hook via le
|
||||
global `window.NowPlaying`.
|
||||
|
||||
### Surfaces et ergonomie
|
||||
|
||||
- **Dock audio (desktop)** : pilule flottante verre dépoli centrée en bas
|
||||
(`.np-dock--audio`) — artwork, titre, voûte, durée, play/pause,
|
||||
précédent/suivant, barre de progression (seek), volume, **revenir au média**,
|
||||
agrandir, fermer.
|
||||
- **Panneau étendu** : carte centrale (bottom-sheet sur mobile) avec artwork,
|
||||
scrub large, volume, vitesse 0,5–2×, précédent/suivant et actions
|
||||
ouvrir/télécharger/fermer.
|
||||
- **Mini-vidéo flottante** (`.np-dock--video`) : déplaçable **librement** depuis
|
||||
n'importe quel point de la fenêtre (position absolue mémorisée, centre autorisé
|
||||
— aucune aimantation aux bords) et redimensionnable, géométrie persistée ; sur
|
||||
mobile elle se fixe au-dessus de la barre d'outils.
|
||||
- **Mobile** : mini-player au-dessus de la barre 64 px
|
||||
(`bottom: calc(64px + env(safe-area-inset-bottom))`), `viewport-fit=cover`
|
||||
ajouté, et `body.np-active` ajoute le décalage du contenu. La barre audio passe
|
||||
en grille (progression sur sa propre ligne) et masque les actions secondaires
|
||||
pour éviter tout chevauchement.
|
||||
|
||||
### Comportements
|
||||
|
||||
- Naviguer (onglet, panneau, dashboard, split) **ne coupe pas** la lecture ; le
|
||||
dock apparaît.
|
||||
- **Revenir au média** : `NowPlaying.focus()` rouvre/focalise l'onglet
|
||||
`vault::path` (`window.getActiveTabManager().open`).
|
||||
- **Fermer** : `NowPlaying.stop()` met en pause, libère l'élément et masque le
|
||||
dock.
|
||||
- **Fermer l'onglet** du média en cours : la lecture continue et un toast
|
||||
`player.continues` le signale.
|
||||
- **Media Session** : métadonnées (`MediaMetadata`) + actions
|
||||
play/pause/stop/seek/nexttrack/previoustrack → écran verrouillé, casque
|
||||
Bluetooth, touches média, **Windows SMTC** (WebView2).
|
||||
- **Picture-in-Picture** natif pour la vidéo (`requestPictureInPicture`), bouton
|
||||
masqué si non supporté.
|
||||
- **Reprise après rechargement** : état (fichier, position, pause, préférences
|
||||
volume/vitesse) persisté en `localStorage` (`obsigate-now-playing`,
|
||||
`obsigate-player-prefs`, `obsigate-player-pos`).
|
||||
|
||||
### Fichiers modifiés / ajoutés
|
||||
|
||||
- Nouveau : `frontend/js/now-playing.js` (contrôleur, dock, session, PiP,
|
||||
persistance), `tests/e2e/media-viewer.spec.js` (dock/retour/fermeture/mini-vidéo).
|
||||
- Modifiés : `viewer.js` (délégation inline + `handleRender`), `ui.js`
|
||||
(dashboard + toast de fermeture d'onglet), `pane-manager.js` (grille/collapse),
|
||||
`app.js` (`initNowPlaying`), `style.css` (dock/étendu/vidéo/mobile, et
|
||||
correction des variables `--surface1`/`--text-dim` non définies),
|
||||
`index.html` (`viewport-fit=cover`), locales FR/EN (`player.*`).
|
||||
|
||||
### Correctifs annexes
|
||||
|
||||
- Les variables CSS `--surface1` et `--text-dim`, utilisées mais **jamais
|
||||
définies** depuis #108/#109, sont remplacées par `--surface` et
|
||||
`--text-secondary` (+ `--text-dim` dans toute la feuille).
|
||||
|
||||
### Hors scope (porte notée)
|
||||
|
||||
- Fenêtre vidéo détachée **native** Tauri (`WebviewWindowBuilder` +
|
||||
`always_on_top`) : non implémentée, à faire dans une itération dédiée (Rust,
|
||||
capabilities, route `/player`).
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title data-i18n="header.logo">ObsiGate</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n, t } from './i18n.js';
|
||||
import { initAIFab } from './ai-fab.js';
|
||||
import { initNowPlaying } from './now-playing.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
@@ -113,6 +114,7 @@ async function init() {
|
||||
setupFocusMode();
|
||||
Utils.safeCreateIcons();
|
||||
initAIFab();
|
||||
initNowPlaying();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,9 +197,13 @@ function createPaneTabManager(paneId) {
|
||||
close(tabId) {
|
||||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
const closingTab = this._tabs[idx];
|
||||
this._tabs.splice(idx, 1);
|
||||
delete this._tabCache[tabId];
|
||||
this._dirtyTabs.delete(tabId);
|
||||
if (window.NowPlaying && closingTab) {
|
||||
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
|
||||
}
|
||||
if (this._tabs.length === 0) {
|
||||
this._activeTabId = null;
|
||||
// If this pane has no more tabs and isn't the last pane, close it
|
||||
@@ -826,6 +830,8 @@ const PaneManager = {
|
||||
grid.className = 'pane-grid';
|
||||
this._applyGridTemplate(grid, n);
|
||||
|
||||
// #110 — keep playing media alive across a pane-grid rebuild.
|
||||
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
|
||||
wrapper.innerHTML = '';
|
||||
wrapper.appendChild(grid);
|
||||
this.panes = [];
|
||||
@@ -1216,6 +1222,8 @@ const PaneManager = {
|
||||
if (!grid) return;
|
||||
const wrapper = grid.parentElement;
|
||||
const pane0 = this.panes[0];
|
||||
// #110 — keep playing media alive across a pane collapse.
|
||||
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
|
||||
wrapper.innerHTML = '';
|
||||
let tabBar = null, content = null;
|
||||
if (pane0 && pane0.element) {
|
||||
|
||||
@@ -2080,11 +2080,16 @@ export const TabManager = {
|
||||
}
|
||||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
const closingTab = this._tabs[idx];
|
||||
|
||||
this._tabs.splice(idx, 1);
|
||||
delete this._tabCache[tabId];
|
||||
this._dirtyTabs.delete(tabId);
|
||||
|
||||
if (window.NowPlaying && closingTab) {
|
||||
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
|
||||
}
|
||||
|
||||
if (this._tabs.length === 0) {
|
||||
this._activeTabId = null;
|
||||
this._showDashboard();
|
||||
@@ -2207,6 +2212,8 @@ export const TabManager = {
|
||||
|
||||
_showDashboard() {
|
||||
const area = document.getElementById("content-area");
|
||||
// #110 — move any playing media to the persistent dock before wiping.
|
||||
if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null);
|
||||
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
|
||||
let dashboard = document.getElementById("dashboard-home");
|
||||
if (!dashboard) {
|
||||
|
||||
+20
-18
@@ -213,25 +213,27 @@ const EXT_ICONS = {
|
||||
".tiff": "image",
|
||||
".tif": "image",
|
||||
|
||||
// Audio files
|
||||
".mp3": "file-music",
|
||||
".wav": "file-music",
|
||||
".flac": "file-music",
|
||||
".aac": "file-music",
|
||||
".ogg": "file-music",
|
||||
".m4a": "file-music",
|
||||
".wma": "file-music",
|
||||
// Audio files (roadmap #109-B2)
|
||||
".mp3": "audio-lines",
|
||||
".wav": "audio-lines",
|
||||
".flac": "audio-lines",
|
||||
".aac": "audio-lines",
|
||||
".ogg": "audio-lines",
|
||||
".oga": "audio-lines",
|
||||
".opus": "audio-lines",
|
||||
".m4a": "audio-lines",
|
||||
".wma": "audio-lines",
|
||||
|
||||
// Video files
|
||||
".mp4": "play",
|
||||
".avi": "play",
|
||||
".mov": "play",
|
||||
".wmv": "play",
|
||||
".flv": "play",
|
||||
".webm": "play",
|
||||
".mkv": "play",
|
||||
".m4v": "play",
|
||||
".3gp": "play",
|
||||
// Video files (roadmap #109-B2)
|
||||
".mp4": "video",
|
||||
".avi": "video",
|
||||
".mov": "video",
|
||||
".wmv": "video",
|
||||
".flv": "video",
|
||||
".webm": "video",
|
||||
".mkv": "video",
|
||||
".m4v": "video",
|
||||
".3gp": "video",
|
||||
|
||||
// Archive files
|
||||
".zip": "file-archive",
|
||||
|
||||
+35
-1
@@ -14,6 +14,7 @@ import { openShareDialog } from './config.js';
|
||||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||
import { t } from './i18n.js';
|
||||
import { onFileRender } from './plugins.js';
|
||||
import { NowPlaying } from './now-playing.js';
|
||||
|
||||
// ── Multi-format export ────────────────────────────────────────────────────
|
||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||||
@@ -782,6 +783,24 @@ export function renderImageViewer(area, data) {
|
||||
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// #110 — Audio/video are handled by the global Now Playing controller
|
||||
// (frontend/js/now-playing.js): a single shared media element is teleported
|
||||
// between this inline view and a body-level dock, so playback survives tab,
|
||||
// pane and page navigation. The inline branches below just hand over the
|
||||
// surface; the legacy "stop on re-render" cleanup no longer applies.
|
||||
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
|
||||
|
||||
/** Audio viewer (roadmap #109-B, made persistent by #110). */
|
||||
export function renderAudioViewer(area, data) {
|
||||
NowPlaying.attachInline(area, data);
|
||||
}
|
||||
|
||||
/** Video viewer (roadmap #109-C, made persistent by #110). */
|
||||
export function renderVideoViewer(area, data) {
|
||||
NowPlaying.attachInline(area, data);
|
||||
}
|
||||
|
||||
|
||||
export function renderFile(data) {
|
||||
// #93 — An inline edition session (#editor-container mounted in the content
|
||||
// area) is destroyed by this very re-render: release it first so the editor
|
||||
@@ -792,6 +811,9 @@ export function renderFile(data) {
|
||||
// the content area (otherwise they leak on every re-render).
|
||||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||||
const area = getContentArea();
|
||||
// #110 — if this render is about to replace the surface that currently hosts
|
||||
// the shared media element, hand it back to the persistent dock first.
|
||||
NowPlaying.handleRender(area, data);
|
||||
|
||||
// Handle PDF files — render in iframe with TOC sidebar
|
||||
if (data.is_pdf) {
|
||||
@@ -847,6 +869,16 @@ export function renderFile(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle audio / video — native HTML5 players (roadmap #109)
|
||||
if (data.is_audio) {
|
||||
renderAudioViewer(area, data);
|
||||
return;
|
||||
}
|
||||
if (data.is_video) {
|
||||
renderVideoViewer(area, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unsupported (binary) files
|
||||
if (data.unsupported) {
|
||||
const sizeStr = data.size_bytes
|
||||
@@ -858,7 +890,7 @@ export function renderFile(data) {
|
||||
<div class="unsupported-file">
|
||||
<i data-lucide="file" style="width:48px;height:48px"></i>
|
||||
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
|
||||
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
|
||||
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
|
||||
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
|
||||
<button class="btn-action" id="unsupported-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||||
@@ -1523,6 +1555,8 @@ export function showWelcome() {
|
||||
|
||||
// Restore or rebuild the dashboard with tabbed sections
|
||||
const area = getContentArea();
|
||||
// #110 — keep playing media alive if the dashboard replaces its surface.
|
||||
NowPlaying.handleRender(area, null);
|
||||
const home = document.getElementById("dashboard-home");
|
||||
|
||||
if (area && !home) {
|
||||
|
||||
@@ -1839,6 +1839,8 @@
|
||||
"viewer.loaded_from_cache": "File loaded from offline cache",
|
||||
"viewer.loading": "Loading file...",
|
||||
"viewer.markdown": "Markdown",
|
||||
"viewer.media_too_large": "File too large for inline playback. Download it to watch.",
|
||||
"viewer.media_unsupported": "This format cannot be played in your browser.",
|
||||
"viewer.metadata_created": "Created",
|
||||
"viewer.metadata_modified": "Modified",
|
||||
"viewer.metadata_path": "Path",
|
||||
@@ -2026,6 +2028,23 @@
|
||||
"mfa.webauthn_btn": "Verify with my key",
|
||||
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
|
||||
"mfa.webauthn_no_key": "No security key registered for this account.",
|
||||
"player.now_playing": "Now playing",
|
||||
"player.play": "Play",
|
||||
"player.pause": "Pause",
|
||||
"player.previous": "Previous",
|
||||
"player.next": "Next",
|
||||
"player.seek": "Seek",
|
||||
"player.volume": "Volume",
|
||||
"player.speed": "Playback speed",
|
||||
"player.expand": "Expand player",
|
||||
"player.minimize": "Minimize player",
|
||||
"player.close": "Stop and close player",
|
||||
"player.continues": "Playback continues — use the player to stop it.",
|
||||
"player.reopen_tab": "Return to media",
|
||||
"player.pip": "Picture in picture",
|
||||
"player.move": "Move",
|
||||
"player.resize": "Resize",
|
||||
"player.resume": "Resuming last playback",
|
||||
"plugins.title": "🧩 Plugins",
|
||||
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
|
||||
"plugins.install": "Install Plugin",
|
||||
@@ -2127,7 +2146,7 @@
|
||||
"guide105.lib_h3_conflicts": "Sync conflicts",
|
||||
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
|
||||
"guide105.lib_h3_attach": "Attachments & media",
|
||||
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); the \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
|
||||
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
|
||||
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
|
||||
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
|
||||
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
|
||||
|
||||
@@ -1839,6 +1839,8 @@
|
||||
"viewer.loaded_from_cache": "Fichier chargé depuis le cache hors-ligne",
|
||||
"viewer.loading": "Chargement du fichier...",
|
||||
"viewer.markdown": "Markdown",
|
||||
"viewer.media_too_large": "Fichier trop volumineux pour la lecture intégrée. Téléchargez-le pour le lire.",
|
||||
"viewer.media_unsupported": "Ce format ne peut pas être lu dans votre navigateur.",
|
||||
"viewer.metadata_created": "Créé",
|
||||
"viewer.metadata_modified": "Modifié",
|
||||
"viewer.metadata_path": "Chemin",
|
||||
@@ -2026,6 +2028,23 @@
|
||||
"mfa.webauthn_btn": "Valider avec ma clé",
|
||||
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
|
||||
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
|
||||
"player.now_playing": "Lecture en cours",
|
||||
"player.play": "Lecture",
|
||||
"player.pause": "Pause",
|
||||
"player.previous": "Précédent",
|
||||
"player.next": "Suivant",
|
||||
"player.seek": "Position de lecture",
|
||||
"player.volume": "Volume",
|
||||
"player.speed": "Vitesse de lecture",
|
||||
"player.expand": "Agrandir le lecteur",
|
||||
"player.minimize": "Réduire le lecteur",
|
||||
"player.close": "Arrêter et fermer le lecteur",
|
||||
"player.continues": "Lecture en cours — utilisez le lecteur pour l'arrêter.",
|
||||
"player.reopen_tab": "Revenir au média",
|
||||
"player.pip": "Image dans l'image",
|
||||
"player.move": "Déplacer",
|
||||
"player.resize": "Redimensionner",
|
||||
"player.resume": "Reprise de la dernière lecture",
|
||||
"plugins.title": "🧩 Plugins",
|
||||
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
|
||||
"plugins.install": "Installer le plugin",
|
||||
@@ -2127,7 +2146,7 @@
|
||||
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
|
||||
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
|
||||
"guide105.lib_h3_attach": "Fichiers joints & médias",
|
||||
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
|
||||
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
|
||||
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
|
||||
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
|
||||
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
|
||||
|
||||
+539
-17
@@ -9569,14 +9569,14 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdf-info {
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pdf-iframe {
|
||||
flex: 1;
|
||||
@@ -9601,7 +9601,7 @@ body.desktop-mode .editor-container {
|
||||
.pdf-toc h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pdf-toc ul {
|
||||
list-style: none;
|
||||
@@ -9620,7 +9620,7 @@ body.desktop-mode .editor-container {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.toc-page {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -9631,7 +9631,7 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
padding: 20px;
|
||||
}
|
||||
.image-viewer-body img {
|
||||
@@ -9651,7 +9651,7 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -9669,7 +9669,7 @@ body.desktop-mode .editor-container {
|
||||
.image-zoom-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -9698,7 +9698,7 @@ body.desktop-mode .editor-container {
|
||||
will-change: transform;
|
||||
}
|
||||
.image-error {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.image-nav-strip {
|
||||
@@ -9706,7 +9706,7 @@ body.desktop-mode .editor-container {
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -9732,10 +9732,10 @@ body.desktop-mode .editor-container {
|
||||
}
|
||||
.image-meta-panel {
|
||||
padding: 10px 16px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.image-meta-panel[hidden] {
|
||||
@@ -9748,7 +9748,7 @@ body.desktop-mode .editor-container {
|
||||
margin: 0;
|
||||
}
|
||||
.image-meta-panel dt {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.image-meta-panel dd {
|
||||
margin: 0;
|
||||
@@ -9766,6 +9766,528 @@ body.desktop-mode .editor-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Audio / Video viewers (roadmap #109) ── */
|
||||
.audio-viewer-container,
|
||||
.video-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.media-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.media-toolbar .media-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.media-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.media-duration-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
.media-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.audio-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 32px 24px;
|
||||
min-height: 50vh;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.audio-artwork {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.audio-player {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
.video-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
min-height: 60vh;
|
||||
}
|
||||
.video-player {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 180px);
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-fallback-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Now Playing — persistent media dock (roadmap #110) ── */
|
||||
.np-inline-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.np-inline-slot .np-media--audio {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
.np-media--video {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
.np-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
pointer-events: none;
|
||||
}
|
||||
.np-dock {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.np-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.np-ctrl:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.np-ctrl:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.np-ctrl:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.np-dock--audio {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 20px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.np-dock--audio .np-media {
|
||||
display: none;
|
||||
}
|
||||
.np-dock-art {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-dock-info {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-dock-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.np-dock-sub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.np-dock-controls {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.np-dock-progress {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.np-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
.np-seek {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-seek::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.np-seek::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.np-dock-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.np-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.np-volume-range {
|
||||
width: 70px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.np-volume-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.np-volume-range::-moz-range-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Video dock — floating, draggable, resizable mini-window */
|
||||
.np-dock--video {
|
||||
position: fixed;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.np-dock-video-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.np-dock-video-slot {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.np-dock-video-slot .np-media--video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
max-height: none;
|
||||
}
|
||||
.np-dock-video-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
.np-dock-video-bar .np-ctrl,
|
||||
.np-dock-video-bar .np-time {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.np-dock-video-bar .np-seek {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.np-dock-video-bar .np-ctrl:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
}
|
||||
.np-video-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.np-video-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.np-video-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.np-video-resize::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-right: 2px solid rgba(255, 255, 255, 0.55);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.np-dock--error .np-dock-error-msg {
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Expanded audio panel (bottom sheet on mobile) */
|
||||
.np-expanded {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 960;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.np-expanded[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.np-expanded-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.np-exp-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 96px;
|
||||
transform: translateX(-50%);
|
||||
width: min(460px, calc(100vw - 32px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 22px 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.np-exp-art {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.np-exp-title {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.np-exp-sub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.np-exp-seek {
|
||||
width: 100%;
|
||||
}
|
||||
.np-exp-times {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.np-exp-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.np-exp-controls .np-ctrl {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.np-exp-play {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.np-exp-play:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.np-exp-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.np-rate {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Mid-size screens: drop the volume slider before the row gets cramped. */
|
||||
@media (max-width: 1100px) {
|
||||
.np-dock--audio .np-volume {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.np-dock--audio {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 8px);
|
||||
transform: none;
|
||||
width: auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"progress progress progress progress"
|
||||
"art info controls actions";
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.np-dock--audio .np-dock-art {
|
||||
grid-area: art;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.np-dock--audio .np-dock-info {
|
||||
grid-area: info;
|
||||
}
|
||||
.np-dock--audio .np-dock-controls {
|
||||
grid-area: controls;
|
||||
}
|
||||
.np-dock--audio .np-dock-progress {
|
||||
grid-area: progress;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.np-dock--audio .np-dock-actions {
|
||||
grid-area: actions;
|
||||
}
|
||||
/* Compact mobile: keep play / return / close; expand via the artwork. */
|
||||
.np-dock--audio [data-np="prev"],
|
||||
.np-dock--audio [data-np="next"],
|
||||
.np-dock--audio [data-np="expand"] {
|
||||
display: none;
|
||||
}
|
||||
.np-dock--video {
|
||||
left: 8px !important;
|
||||
right: 8px;
|
||||
top: auto !important;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 8px) !important;
|
||||
width: auto !important;
|
||||
height: 200px !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.np-video-resize {
|
||||
display: none;
|
||||
}
|
||||
.np-exp-card {
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 12px);
|
||||
}
|
||||
body.np-active .content-area {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 76px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.np-ctrl,
|
||||
.np-dock,
|
||||
.np-exp-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Text / CSV Viewer ── */
|
||||
.text-viewer-container {
|
||||
display: flex;
|
||||
@@ -9777,14 +10299,14 @@ body.desktop-mode .editor-container {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-info {
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.text-viewer-body {
|
||||
flex: 1;
|
||||
@@ -9814,8 +10336,8 @@ body.desktop-mode .editor-container {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.csv-table th {
|
||||
background: var(--surface1);
|
||||
color: var(--text-dim);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
@@ -9828,7 +10350,7 @@ body.desktop-mode .editor-container {
|
||||
border-bottom: 1px solid var(--border-light, var(--border));
|
||||
}
|
||||
.csv-table tr:hover td {
|
||||
background: var(--surface1);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── JSON Viewer ── */
|
||||
|
||||
@@ -109,6 +109,13 @@ self.addEventListener('fetch', (event) => {
|
||||
// Let the browser handle range requests (PDF/streamed media) directly.
|
||||
if (request.headers.has('range')) return;
|
||||
|
||||
// Streamed audio/video is large and range-driven — never cache it
|
||||
// (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached.
|
||||
if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) {
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirst(request, API_CACHE, () =>
|
||||
new Response(JSON.stringify({ error: 'Offline' }), {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.17.0",
|
||||
"version": "2.19.1",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -106,6 +106,16 @@ def test_vault_dir(tmp_path: Path) -> str:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Audio/video media (roadmap #109) — indexed as metadata-only binaries,
|
||||
# streamed via /api/media. Prefer the committed E2E fixture when present.
|
||||
repo_media = Path(__file__).resolve().parent.parent / "test_vault" / "sample-audio.mp3"
|
||||
if repo_media.exists():
|
||||
(vault / "sample-audio.mp3").write_bytes(repo_media.read_bytes())
|
||||
else:
|
||||
(vault / "sample-audio.mp3").write_bytes(
|
||||
b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
)
|
||||
|
||||
# File with accents in title
|
||||
(vault / "café_crème.md").write_text(
|
||||
"---\ntitle: Café Crème\n---\n# Café Crème\nUn bon café.\n",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* E2E tests for the ObsiGate media viewers & persistent player (roadmap #109/#110).
|
||||
*
|
||||
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine) + `test_vault/sample-video.webm`.
|
||||
*
|
||||
* Run (local):
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
|
||||
const CREDS = {
|
||||
username: process.env.OBSIGATE_USER || 'admin',
|
||||
password: process.env.OBSIGATE_PASS || 'test123',
|
||||
};
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(BASE);
|
||||
const loginForm = page.locator('#login-screen');
|
||||
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
|
||||
if (await loginForm.isVisible()) {
|
||||
await page.fill('#login-username', CREDS.username);
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Media viewers — HTML5 audio/video (#109)', () => {
|
||||
|
||||
test('rend un lecteur audio natif branché sur /api/media', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
|
||||
const audio = page.locator('#content-area .audio-viewer-container audio.np-media--audio');
|
||||
await expect(audio).toBeVisible({ timeout: 10000 });
|
||||
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(audio).toHaveAttribute('controls', '');
|
||||
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('rend un lecteur vidéo natif branché sur /api/media', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-video.webm');
|
||||
|
||||
const video = page.locator('#content-area .video-viewer-container video.np-media--video');
|
||||
await expect(video).toBeVisible({ timeout: 10000 });
|
||||
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(video).toHaveAttribute('playsinline', '');
|
||||
});
|
||||
|
||||
test('le streaming média honore les requêtes Range (206)', async ({ page }) => {
|
||||
await login(page);
|
||||
const resp = await page.request.get(
|
||||
`${BASE}/api/media/TestVault?path=${encodeURIComponent('sample-video.webm')}`,
|
||||
{ headers: { Range: 'bytes=0-99' } },
|
||||
);
|
||||
expect(resp.status()).toBe(206);
|
||||
expect(resp.headers()['content-range']).toMatch(/^bytes 0-99\/\d+$/);
|
||||
expect(resp.headers()['accept-ranges']).toBe('bytes');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Now Playing — lecture persistante (#110)', () => {
|
||||
|
||||
test('affiche le dock et continue la lecture quand on navigue ailleurs', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Naviguer vers une note : le média doit passer dans le dock.
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
const dock = page.locator('#now-playing-host .np-dock--audio');
|
||||
await expect(dock).toBeVisible({ timeout: 10000 });
|
||||
await expect(dock.locator('.np-dock-title')).toHaveText('sample-audio.mp3');
|
||||
|
||||
// S'assurer de la lecture (l'autoplay peut être bloqué sans geste).
|
||||
const media = page.locator('#now-playing-host audio.np-media--audio');
|
||||
if (await media.evaluate((el) => el.paused)) {
|
||||
await dock.locator('[data-np="play"]').click();
|
||||
}
|
||||
await expect.poll(() => media.evaluate((el) => !el.paused), { timeout: 5000 }).toBe(true);
|
||||
|
||||
// Naviguer encore : toujours en lecture.
|
||||
await openFile(page, 'TestVault', 'Accueil.md');
|
||||
await expect(dock).toBeVisible();
|
||||
expect(await media.evaluate((el) => !el.paused)).toBe(true);
|
||||
});
|
||||
|
||||
test('revient sur le média depuis le dock', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const dock = page.locator('#now-playing-host .np-dock--audio');
|
||||
await expect(dock).toBeVisible({ timeout: 10000 });
|
||||
await dock.locator('[data-np="reopen"]').click();
|
||||
|
||||
await expect(page.locator('#content-area .audio-viewer-container audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
await expect(dock).toBeHidden();
|
||||
});
|
||||
|
||||
test('ferme la lecture depuis le dock', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const dock = page.locator('#now-playing-host .np-dock--audio');
|
||||
await expect(dock).toBeVisible({ timeout: 10000 });
|
||||
await dock.locator('[data-np="close"]').click();
|
||||
await expect(page.locator('#now-playing-host .np-dock--audio')).toBeHidden();
|
||||
});
|
||||
|
||||
test('le mini-player vidéo flotte et reste visible en naviguant', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-video.webm');
|
||||
await expect(page.locator('#content-area video.np-media--video')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const mini = page.locator('#now-playing-host .np-dock--video');
|
||||
await expect(mini).toBeVisible({ timeout: 10000 });
|
||||
await expect(mini.locator('video.np-media--video')).toBeVisible();
|
||||
});
|
||||
|
||||
test('la mini-fenêtre vidéo peut être déplacée librement (centre)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-video.webm');
|
||||
await expect(page.locator('#content-area video.np-media--video')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const mini = page.locator('#now-playing-host .np-dock--video');
|
||||
await expect(mini).toBeVisible({ timeout: 10000 });
|
||||
const before = await mini.evaluate((el) => ({ left: parseFloat(el.style.left), top: parseFloat(el.style.top) }));
|
||||
|
||||
const box = await mini.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 12);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(640, 360, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
const after = await mini.evaluate((el) => ({ left: parseFloat(el.style.left), top: parseFloat(el.style.top) }));
|
||||
expect(after.left).not.toBe(before.left);
|
||||
expect(after.top).not.toBe(before.top);
|
||||
// Doit pouvoir rester au centre (pas de re-aimantation sur les bords).
|
||||
expect(after.left).toBeGreaterThan(50);
|
||||
expect(after.left).toBeLessThan(900);
|
||||
expect(after.top).toBeGreaterThan(20);
|
||||
expect(after.top).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Media viewers tests (roadmap #109 + #110).
|
||||
*
|
||||
* Pure helpers are evaluated in a VM sandbox to avoid importing the
|
||||
* browser-only module chain. The rest are static checks on the source, CSS,
|
||||
* i18n and backend wiring.
|
||||
*
|
||||
* Usage: node tests/frontend/media-viewer.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
|
||||
const np = readFileSync(path.join(ROOT, "frontend", "js", "now-playing.js"), "utf8");
|
||||
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
|
||||
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
|
||||
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
|
||||
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" \u2713 " + label);
|
||||
} catch (err) {
|
||||
console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
|
||||
const begin = np.indexOf("const AUDIO_EXTS");
|
||||
const end = np.indexOf("function downloadUrl");
|
||||
assert.ok(begin !== -1 && end > begin, "now-playing helper block not found");
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
`${np.slice(begin, end).replace(/\bexport /g, "")}\n` +
|
||||
`globalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;` +
|
||||
`globalThis.isAudioPath = isAudioPath; globalThis.isVideoPath = isVideoPath;`,
|
||||
sandbox,
|
||||
);
|
||||
const { formatMediaDuration, buildMediaUrl, isAudioPath, isVideoPath } = sandbox;
|
||||
|
||||
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
|
||||
assert.equal(formatMediaDuration(0), "0:00");
|
||||
assert.equal(formatMediaDuration(65), "1:05");
|
||||
assert.equal(formatMediaDuration(3725), "1:02:05");
|
||||
assert.equal(formatMediaDuration(NaN), "");
|
||||
assert.equal(formatMediaDuration(-1), "");
|
||||
});
|
||||
|
||||
test("buildMediaUrl encodes vault and path", () => {
|
||||
const url = buildMediaUrl("My Vault", "café clip.mp4");
|
||||
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
|
||||
});
|
||||
|
||||
test("isAudioPath / isVideoPath recognise media extensions", () => {
|
||||
for (const p of ["a.mp3", "a.M4A", "dir/b.flac", "c.opus", "d.oga"]) assert.equal(isAudioPath(p), true, p);
|
||||
for (const p of ["a.mp4", "a.WEBM", "dir/b.mov", "c.m4v"]) assert.equal(isVideoPath(p), true, p);
|
||||
for (const p of ["a.md", "a.png", "noext", ""]) {
|
||||
assert.equal(isAudioPath(p), false, p);
|
||||
assert.equal(isVideoPath(p), false, p);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Static checks: viewer delegates to the persistent controller ──────────
|
||||
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
|
||||
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
|
||||
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
|
||||
});
|
||||
|
||||
test("viewer delegates inline rendering to NowPlaying.attachInline", () => {
|
||||
const start = viewer.indexOf("export function renderAudioViewer");
|
||||
const stop = viewer.indexOf("export function renderFile");
|
||||
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /NowPlaying\.attachInline\(area, data\)/);
|
||||
});
|
||||
|
||||
test("renderFile hands the shared element to the dock before a re-render", () => {
|
||||
assert.match(viewer, /NowPlaying\.handleRender\(area, data\)/);
|
||||
assert.doesNotMatch(viewer, /_mediaViewerCleanup/);
|
||||
});
|
||||
|
||||
// ── Static checks: global Now Playing controller (#110) ───────────────────
|
||||
test("now-playing owns ONE media element reused across surfaces", () => {
|
||||
assert.match(np, /media\.dataset\.npKey === `\$\{data\.vault\}::\$\{data\.path\}`/);
|
||||
assert.match(np, /function ensureMedia\(kind\)/);
|
||||
assert.match(np, /slot\.appendChild\(media\)/);
|
||||
assert.match(np, /dock\.appendChild\(S\.media\)/);
|
||||
});
|
||||
|
||||
test("now-playing shows an unsupported fallback on media error", () => {
|
||||
assert.match(np, /media\.addEventListener\('error',\s*\(\)\s*=>\s*onMediaError\(\)\)/);
|
||||
assert.match(np, /export function renderFallback/);
|
||||
});
|
||||
|
||||
test("now-playing wires Media Session, PiP and persistence", () => {
|
||||
assert.match(np, /navigator\.mediaSession\.metadata/);
|
||||
assert.match(np, /setActionHandler\('nexttrack'/);
|
||||
assert.match(np, /requestPictureInPicture/);
|
||||
assert.match(np, /const LS_KEY = 'obsigate-now-playing'/);
|
||||
assert.match(np, /function restore\(\)/);
|
||||
});
|
||||
|
||||
test("now-playing exposes the public controller API and is initialised at boot", () => {
|
||||
assert.match(np, /export const NowPlaying = \{/);
|
||||
assert.match(np, /attachInline/);
|
||||
assert.match(np, /notifyTabClosed/);
|
||||
const app = readFileSync(path.join(ROOT, "frontend", "js", "app.js"), "utf8");
|
||||
assert.match(app, /initNowPlaying\(\)/);
|
||||
});
|
||||
|
||||
test("video mini-window is freely positioned (no corner snapping)", () => {
|
||||
assert.doesNotMatch(np, /nearestCorner/);
|
||||
assert.match(np, /function clampToViewport/);
|
||||
assert.match(np, /left: Math\.round\(r\.left\),\s*\n\s*top: Math\.round\(r\.top\)/);
|
||||
assert.match(np, /bindVideoDrag\(dock\);/);
|
||||
});
|
||||
|
||||
// ── Static checks: CSS ─────────────────────────────────────────────────────
|
||||
test("style.css defines the media viewers and the Now Playing dock", () => {
|
||||
assert.match(css, /\.audio-viewer-container/);
|
||||
assert.match(css, /\.video-stage\s*\{/);
|
||||
assert.match(css, /\.np-dock--audio\s*\{/);
|
||||
assert.match(css, /\.np-dock--video\s*\{/);
|
||||
assert.match(css, /\.np-expanded\s*\{/);
|
||||
assert.match(css, /body\.np-active \.content-area/);
|
||||
});
|
||||
|
||||
test("mobile dock uses a grid so actions never overlap the progress bar", () => {
|
||||
assert.match(css, /grid-template-areas:\s*\n\s*"progress progress progress progress"/);
|
||||
assert.match(css, /\.np-dock--audio \[data-np="expand"\]/);
|
||||
});
|
||||
|
||||
test("style.css no longer references undefined --surface1 / --text-dim vars", () => {
|
||||
assert.doesNotMatch(css, /var\(--surface1\)/);
|
||||
assert.doesNotMatch(css, /var\(--text-dim\)/);
|
||||
});
|
||||
|
||||
// ── Static checks: icons, i18n, service worker ─────────────────────────────
|
||||
test("utils.js maps media extensions to audio-lines / video icons", () => {
|
||||
assert.match(utils, /"\.mp3": "audio-lines"/);
|
||||
assert.match(utils, /"\.flac": "audio-lines"/);
|
||||
assert.match(utils, /"\.mp4": "video"/);
|
||||
assert.match(utils, /"\.webm": "video"/);
|
||||
});
|
||||
|
||||
test("player/viewer media strings exist in FR and EN", () => {
|
||||
for (const key of [
|
||||
"viewer.media_unsupported",
|
||||
"viewer.media_too_large",
|
||||
"player.now_playing",
|
||||
"player.close",
|
||||
"player.reopen_tab",
|
||||
"player.continues",
|
||||
]) {
|
||||
assert.match(fr, new RegExp(`"${key}"`));
|
||||
assert.match(en, new RegExp(`"${key}"`));
|
||||
}
|
||||
});
|
||||
|
||||
test("service worker never caches streamed media", () => {
|
||||
assert.match(sw, /startsWith\('\/api\/media\/'\)/);
|
||||
assert.match(sw, /endsWith\('\/thumb'\)/);
|
||||
});
|
||||
|
||||
// ── Static checks: backend ─────────────────────────────────────────────────
|
||||
test("backend exposes /api/media and the shared Range helper", () => {
|
||||
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
|
||||
assert.match(main, /def _stream_file_with_range\(/);
|
||||
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nMedia viewer tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll media viewer tests passed.");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# tests/test_media_indexing.py — Audio/video in the tree & index (roadmap #109-A/F2)
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import _index_single_file_sync, _scan_vault
|
||||
from backend.services.vaults import browse_directory
|
||||
from backend.watcher import VaultEventHandler
|
||||
|
||||
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
|
||||
|
||||
def _make_vault(tmp_path: Path) -> Path:
|
||||
vault = tmp_path / "Vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
|
||||
(vault / "note-vocale.mp3").write_bytes(MEDIA_BYTES)
|
||||
(vault / "clip.mp4").write_bytes(MEDIA_BYTES)
|
||||
assets = vault / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "ambiance.flac").write_bytes(MEDIA_BYTES)
|
||||
return vault
|
||||
|
||||
|
||||
class TestScanVaultMedia:
|
||||
def test_audio_video_indexed_without_content(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _scan_vault("V", str(vault))
|
||||
by_path = {f["path"]: f for f in result["files"]}
|
||||
for path in ("note-vocale.mp3", "clip.mp4", "assets/ambiance.flac"):
|
||||
assert path in by_path, path
|
||||
entry = by_path[path]
|
||||
assert entry["content"] == "" # never read the bytes (#109-A1)
|
||||
assert entry["content_preview"] == ""
|
||||
assert entry["size"] > 0
|
||||
|
||||
def test_media_present_in_paths(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _scan_vault("V", str(vault))
|
||||
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
|
||||
assert "note-vocale.mp3" in files
|
||||
assert "clip.mp4" in files
|
||||
assert "assets/ambiance.flac" in files
|
||||
|
||||
def test_no_binary_content_leaks(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _scan_vault("V", str(vault))
|
||||
for f in result["files"]:
|
||||
if f["extension"] in (".mp3", ".mp4", ".flac"):
|
||||
assert f["content"] == ""
|
||||
|
||||
|
||||
class TestSingleFileMedia:
|
||||
def test_metadata_only(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
info = _index_single_file_sync("V", str(vault), str(vault / "note-vocale.mp3"))
|
||||
assert info is not None
|
||||
assert info["content"] == ""
|
||||
assert info["extension"] == ".mp3"
|
||||
assert info["path"] == "note-vocale.mp3"
|
||||
|
||||
|
||||
class TestWatcherRelevance:
|
||||
def test_media_extensions_relevant(self, tmp_path):
|
||||
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
|
||||
for name in ("a.mp3", "a.flac", "a.opus", "a.mp4", "a.webm", "a.mov"):
|
||||
assert handler._is_relevant(str(tmp_path / name)) is True, name
|
||||
assert handler._is_relevant(str(tmp_path / "a.mkv")) is False
|
||||
|
||||
|
||||
class TestBrowseAndSearch:
|
||||
def test_browse_lists_media(self, client):
|
||||
resp = client.get("/api/browse/TestVault")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
names = {i["name"]: i for i in items}
|
||||
assert "sample-audio.mp3" in names
|
||||
assert names["sample-audio.mp3"]["type"] == "file"
|
||||
assert names["sample-audio.mp3"]["extension"] == ".mp3"
|
||||
|
||||
def test_ext_filter_returns_audio(self, client):
|
||||
resp = client.get("/api/search/advanced", params={"q": "ext:mp3"})
|
||||
assert resp.status_code == 200
|
||||
paths = [r["path"] for r in resp.json()["results"]]
|
||||
assert any(p.endswith(".mp3") for p in paths)
|
||||
|
||||
|
||||
class TestBrowseService:
|
||||
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
|
||||
data = browse_directory("TestVault", "")
|
||||
names = [i["name"] for i in data["items"]]
|
||||
assert "sample-audio.mp3" in names
|
||||
@@ -0,0 +1,168 @@
|
||||
# tests/test_media_stream.py — Audio/video streaming & HTTP Range (roadmap #109-A/F1)
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 4 KiB of pseudo-ID3 bytes — the endpoint never decodes the payload, it only
|
||||
# has to serve bytes with the right MIME and honour Range.
|
||||
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
PDF_BYTES = b"%PDF-1.4\n" + b"x" * 2048 + b"\n%%EOF"
|
||||
|
||||
|
||||
def _write(vault_dir: str, name: str, content: bytes) -> None:
|
||||
(Path(vault_dir) / name).write_bytes(content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/file — audio/video metadata returned before read_text (#109-A4)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaFileView:
|
||||
def test_audio_metadata(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "note-vocale.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "note-vocale.mp3"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_audio"] is True
|
||||
assert data["is_video"] is not True
|
||||
assert data["media_mime"] == "audio/mpeg"
|
||||
assert data["stream_url"] == "/api/media/TestVault?path=note-vocale.mp3"
|
||||
assert data["size_bytes"] == len(MEDIA_BYTES)
|
||||
assert data["unsupported"] is not True
|
||||
|
||||
def test_video_metadata(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "clip.mp4", MEDIA_BYTES)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "clip.mp4"})
|
||||
data = resp.json()
|
||||
assert data["is_video"] is True
|
||||
assert data["is_audio"] is not True
|
||||
assert data["media_mime"] == "video/mp4"
|
||||
assert data["stream_url"] == "/api/media/TestVault?path=clip.mp4"
|
||||
|
||||
def test_large_media_falls_back_to_download(self, client, test_vault_dir, monkeypatch):
|
||||
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
|
||||
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
|
||||
resp = client.get("/api/file/TestVault", params={"path": "huge.mp3"})
|
||||
data = resp.json()
|
||||
assert data["unsupported"] is True
|
||||
assert data["media_too_large"] is True
|
||||
assert not data.get("is_audio")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/media — byte-range streaming (#109-A2/F1)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaStreamRange:
|
||||
def test_no_range_returns_200_and_accept_ranges(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "song.mp3"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-type"].startswith("audio/mpeg")
|
||||
assert resp.content == MEDIA_BYTES
|
||||
|
||||
def test_first_kibibyte_returns_206(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": "bytes=0-1023"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-range"] == f"bytes 0-1023/{len(MEDIA_BYTES)}"
|
||||
assert len(resp.content) == 1024
|
||||
assert resp.content == MEDIA_BYTES[:1024]
|
||||
|
||||
def test_suffix_range(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": "bytes=-100"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == MEDIA_BYTES[-100:]
|
||||
|
||||
def test_unsatisfiable_range_returns_416(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": f"bytes={len(MEDIA_BYTES) + 10}-"},
|
||||
)
|
||||
assert resp.status_code == 416
|
||||
assert resp.headers["content-range"] == f"bytes */{len(MEDIA_BYTES)}"
|
||||
|
||||
def test_mov_mime_override(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "clip.mov", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "clip.mov"})
|
||||
assert resp.headers["content-type"].startswith("video/quicktime")
|
||||
|
||||
def test_rejects_non_media(self, client, test_vault_dir):
|
||||
resp = client.get("/api/media/TestVault", params={"path": "note1.md"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_missing_media_404(self, client):
|
||||
resp = client.get("/api/media/TestVault", params={"path": "nope.mp3"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_unknown_vault_404(self, client):
|
||||
resp = client.get("/api/media/NoSuchVault", params={"path": "song.mp3"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_path_traversal_rejected(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "../../../etc/passwd.mp3"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_media_too_large_returns_413(self, client, test_vault_dir, monkeypatch):
|
||||
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
|
||||
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
|
||||
resp = client.get("/api/media/TestVault", params={"path": "huge.mp3"})
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault access control (#109-A3)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaAccess:
|
||||
def test_normaluser_cannot_stream_other_vault(self, admin_client):
|
||||
resp = admin_client.post(
|
||||
"/api/auth/login", json={"username": "normaluser", "password": "normal123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
resp = admin_client.get(
|
||||
"/api/media/OtherVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Regression: pdf/stream unchanged after extracting the shared helper (#109-A2)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPdfStreamRegression:
|
||||
def test_pdf_range_still_works(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
|
||||
resp = client.get(
|
||||
"/api/file/TestVault/pdf/stream",
|
||||
params={"path": "doc.pdf"},
|
||||
headers={"Range": "bytes=0-9"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["content-type"].startswith("application/pdf")
|
||||
assert resp.headers["content-range"] == f"bytes 0-9/{len(PDF_BYTES)}"
|
||||
assert resp.content == PDF_BYTES[:10]
|
||||
|
||||
def test_pdf_without_range_returns_200(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
|
||||
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": "doc.pdf"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
Reference in New Issue
Block a user