mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ce2ac1a7e | ||
|
|
b3bcd12a12 |
No files matched your search
@@ -30,6 +30,7 @@
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
|
||||
@@ -12,11 +12,6 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Transcoding for media playback with quality presets -- requires ffmpeg.
|
||||
- Hardware Acceleration device selector (Software / detected GPU devices)
|
||||
- More operations use native api operations rather than ffmpeg, improving performance in many cases. Added temporary config option to force ffmpeg to be used
|
||||
- HLS keyframe indexes for MP4/MOV/M4V/MKV/WebM are read from the container index with go-media instead of demuxing every packet with ffprobe; ffprobe still runs when go-media cannot read the file or reports a keyframe gap that would rule out stream copy.
|
||||
- HEIC/AVIF rotation (irot) for the ffmpeg decode fallback is read with go-media instead of a full `ffmpeg -f null` decode.
|
||||
- AVIF images get previews through the HEIC preview path (go-media decode, ffmpeg fallback) when HEIC previews are enabled.
|
||||
- WMV/ASF video thumbnails use go-media for WMV3; WMV1/WMV2 still use ffmpeg.
|
||||
- ffmpeg and go-media share one media metadata cache keyed by path, size and modification time, so a probe, duration or subtitle result from either backend is reused by the other.
|
||||
- Document enhancements
|
||||
- Official Collabora support using WOPI config.
|
||||
- Document previews use a pure-go in-process package (all versions get it now).
|
||||
|
||||
+18
-31
@@ -1,11 +1,20 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# GPU-capable media image: mostly-static glibc FFmpeg + dynamic VA/Vulkan/OpenCL drivers (amd64).
|
||||
# Codecs/TLS are linked into gtstef/ffmpeg:<ver> (full track, not *-static).
|
||||
# GPU-capable media image: Debian 13 Trixie GPU FFmpeg as runtime base
|
||||
# (x86_64 only). Track G in gtsteffaniak/ffmpeg (docker/dockerfile.gpu,
|
||||
# ARG DEBIAN_VERSION=trixie): mostly-static codecs + dynamic
|
||||
# VA/Vulkan/OpenCL/NVIDIA userspace. Pi 4 (V4L2) stays on the static
|
||||
# Alpine image (`Dockerfile`, not this file).
|
||||
# NOTE: the golang:bookworm stage below is build-time only — the Go
|
||||
# binary is built with CGO_ENABLED=0 (static), so it runs unchanged
|
||||
# on the Trixie runtime. No structural change needed for the Trixie
|
||||
# retarget of the FFmpeg GPU image (PR gtsteffaniak/ffmpeg#49).
|
||||
#
|
||||
# amd64: libva/Mesa/Vulkan/OpenCL userspace below; NVIDIA libs come from the host toolkit.
|
||||
# arm64 (e.g. Raspberry Pi): h264_v4l2m2m uses kernel V4L2 only — no extra GPU .debs; pass
|
||||
# /dev/video10 or /dev/video11 (and group video). Pi 5 VAAPI via /dev/dri is optional (mesa on arm64).
|
||||
FROM gtstef/ffmpeg:9.0.2 AS ffmpeg
|
||||
# Build after the ffmpeg GPU image is published and smoke-tested:
|
||||
# docker build -f _docker/Dockerfile.media --build-arg VERSION=... --build-arg REVISION=... .
|
||||
# Runtime flags: Intel/AMD need /dev/dri (see _docker/docker-compose.media.yaml);
|
||||
# NVIDIA needs the host toolkit (`--gpus all` / `runtime: nvidia`).
|
||||
|
||||
ARG FFMPEG_GPU_IMAGE=gtstef/ffmpeg:9.0.2-gpu
|
||||
|
||||
FROM golang:bookworm AS base
|
||||
ARG VERSION
|
||||
@@ -29,36 +38,14 @@ COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
FROM ${FFMPEG_GPU_IMAGE} AS runtime
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
mailcap \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (dpkg --print-architecture | grep -q amd64 \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libva2 \
|
||||
libva-drm2 \
|
||||
libdrm2 \
|
||||
libvulkan1 \
|
||||
mesa-va-drivers \
|
||||
mesa-vulkan-drivers \
|
||||
ocl-icd-libopencl1 \
|
||||
intel-media-va-driver \
|
||||
libmfx1 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|| true)
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin"
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,video,utility,graphics
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video,graphics
|
||||
|
||||
RUN useradd -m -u 1000 -s /usr/sbin/nologin filebrowser
|
||||
USER filebrowser
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Filebrowser media image (GPU)
|
||||
|
||||
`Dockerfile.media` builds a media image on top of the Debian 13 Trixie GPU
|
||||
FFmpeg image (`gtstef/ffmpeg:<version>-gpu`, Track G in
|
||||
`gtsteffaniak/ffmpeg`, `ARG DEBIAN_VERSION=trixie`). The
|
||||
production Pi 4 setup stays on the static Alpine `Dockerfile` (V4L2).
|
||||
|
||||
## Prereq (sequencing)
|
||||
|
||||
The media image is built only **after** the FFmpeg GPU image is published
|
||||
(`gtstef/ffmpeg:<version>-gpu`) and smoke-tested. The `FFMPEG_GPU_IMAGE`
|
||||
build-arg pins the exact published tag; if the tag is not smoke-tested, do
|
||||
not merge the media PR.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
docker build -f _docker/Dockerfile.media \
|
||||
--build-arg FFMPEG_GPU_IMAGE=gtstef/ffmpeg:9.0.2-gpu \
|
||||
--build-arg VERSION=latest --build-arg REVISION=$(git rev-parse HEAD) \
|
||||
-t filebrowser-media:latest .
|
||||
```
|
||||
|
||||
## Smoke
|
||||
|
||||
```bash
|
||||
docker run --rm filebrowser-media:latest ffmpeg -hwaccels | grep -E "vaapi|cuda|vulkan|opencl"
|
||||
docker run --rm --device=/dev/dri/renderD128 filebrowser-media:latest \
|
||||
-vaapi_device /dev/dri/renderD128 -f lavfi -i testsrc=size=640x480:rate=1 \
|
||||
-c:v h264_vaapi -t 1 -f null -
|
||||
docker run --rm --gpus all filebrowser-media:latest \
|
||||
-f lavfi -i testsrc=size=640x480:rate=1 -c:v h264_nvenc -t 1 -f null -
|
||||
```
|
||||
|
||||
## Compose
|
||||
|
||||
See `docker-compose.media.yaml` (intel/AMD and NVIDIA snippets; Pi stays
|
||||
static).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Compose snippets for the GPU media image (`_docker/Dockerfile.media`).
|
||||
#
|
||||
# filebrowser-media runs on the Debian GPU FFmpeg image
|
||||
# (gtstef/ffmpeg:<version>-gpu). Pi 4 production stays on the static
|
||||
# Alpine `Dockerfile` (V4L2). Copy the service you need into your
|
||||
# docker-compose.yaml.
|
||||
#
|
||||
# docker compose -f _docker/docker-compose.media.yaml run --rm filebrowser-media \
|
||||
# -f lavfi -i testsrc=size=640x480:rate=1 -c:v h264_vulkan -t 1 -f null -
|
||||
# (replace service flags with your image name)
|
||||
|
||||
services:
|
||||
# Intel / AMD VAAPI (needs host /dev/dri)
|
||||
# Find render group GID: getent group render
|
||||
filebrowser-media-intel:
|
||||
image: filebrowser-media:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.media
|
||||
args:
|
||||
VERSION: ${VERSION:-latest}
|
||||
REVISION: ${REVISION:-local}
|
||||
FFMPEG_GPU_IMAGE: ${FFMPEG_GPU_IMAGE:-gtstef/ffmpeg:9.0.2-gpu}
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
- "993" # render group GID placeholder — set to `getent group render`
|
||||
environment:
|
||||
# fallback when /dev/dri is not bind-mounted: LIBVA_DRIVER_NAME=radeonsi
|
||||
LIBVA_DRIVER_NAME: ${LIBVA_DRIVER_NAME:-}
|
||||
ports:
|
||||
- "80:80"
|
||||
|
||||
# NVIDIA NVENC/NVDEC (needs NVIDIA driver + nvidia-container-toolkit on host)
|
||||
# `runtime: nvidia` or `deploy.resources.reservations.devices` + NVIDIA_* env.
|
||||
filebrowser-media-nvidia:
|
||||
image: filebrowser-media:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./_docker/Dockerfile.media
|
||||
args:
|
||||
VERSION: ${VERSION:-latest}
|
||||
REVISION: ${REVISION:-local}
|
||||
FFMPEG_GPU_IMAGE: ${FFMPEG_GPU_IMAGE:-gtstef/ffmpeg:9.0.2-gpu}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility,video,graphics
|
||||
# runtime: nvidia (legacy alternative to deploy above)
|
||||
ports:
|
||||
- "80:80"
|
||||
|
||||
# Pi 4 V4L2 — NOT the GPU image. Production Pi stays on the static Alpine
|
||||
# Dockerfile (h264_v4l2m2m via kernel V4L2, /dev/video10 or /dev/video11):
|
||||
filebrowser-pi-static:
|
||||
image: filebrowser-static:latest # placeholder for the existing Alpine build
|
||||
devices:
|
||||
- /dev/video10:/dev/video10
|
||||
- /dev/video11:/dev/video11
|
||||
ports:
|
||||
- "80:80"
|
||||
+1
-1
@@ -22,7 +22,7 @@ require (
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/gtsteffaniak/go-media v1.3.0
|
||||
github.com/gtsteffaniak/go-media v1.2.2
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
|
||||
+2
-2
@@ -165,8 +165,8 @@ github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmm
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-media v1.3.0 h1:EOxoaOQq05V3OUPo5NndnPZ0/VYt4gFEwa6l7MdR6rk=
|
||||
github.com/gtsteffaniak/go-media v1.3.0/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
|
||||
github.com/gtsteffaniak/go-media v1.2.2 h1:CgdXyqiZGa81vZUGL1AXGxQe+8owkUkX6roNACGQJ0w=
|
||||
github.com/gtsteffaniak/go-media v1.2.2/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
@@ -53,7 +52,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
} else {
|
||||
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, sharedFFmpegService); err != nil {
|
||||
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -381,7 +380,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt, nil)
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s (%s): %v", info.RealPath, info.Name, err)
|
||||
} else {
|
||||
@@ -394,8 +393,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
subtitles, ok := gomedia.EmbeddedSubtitles(info.RealPath, info.ModTime, func() ([]utils.SubtitleTrack, bool) {
|
||||
// Shadow run: bypass the shared cache so ffprobe is compared fresh.
|
||||
return ffmpeg.DetectEmbeddedSubtitlesContext(mediacache.WithoutCache(context.Background()), info.RealPath), true
|
||||
return ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime), true
|
||||
})
|
||||
if !ok {
|
||||
subtitles = ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
@@ -473,11 +471,23 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
// go-media fast path: tags, lyrics availability, and artwork. Unsupported or
|
||||
// failed reads fall through to the existing dhowden/tag code below.
|
||||
if info, ok := gomedia.Audio(realPath, getArt, func() (gomedia.AudioInfo, bool) { return legacyAudioInfo(realPath, getArt) }); ok {
|
||||
item.Metadata = &iteminfo.MediaMetadata{HasLyrics: info.HasLyrics}
|
||||
applyMediaTags(item.Metadata, info, realPath)
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Title: info.Tags.Title,
|
||||
Artist: info.Tags.Artist,
|
||||
Album: info.Tags.Album,
|
||||
Year: info.Tags.Year,
|
||||
Genre: info.Tags.Genre,
|
||||
Track: info.Tags.Track,
|
||||
HasLyrics: info.HasLyrics,
|
||||
}
|
||||
if getDuration {
|
||||
audioDuration(ctx, item, realPath, ffmpegService)
|
||||
}
|
||||
if n := len(info.Artwork); n > 0 && n <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = info.Artwork
|
||||
} else if n > 5*1024*1024 {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -531,9 +541,8 @@ func audioDuration(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPat
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
}
|
||||
// The shadow run can outlive the request, so it uses a context that is not
|
||||
// cancelled with it. It bypasses the shared cache so ffmpeg is compared fresh.
|
||||
shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx))
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacy := func() (float64, bool) {
|
||||
d, err := service.GetMediaDuration(shadowCtx, realPath)
|
||||
return d, err == nil
|
||||
@@ -698,15 +707,14 @@ func legacyLyrics(realPath string) (gomedia.LyricsResult, bool) {
|
||||
|
||||
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
// go-media fast path. Works without ffmpeg installed.
|
||||
probeService := ffmpegService
|
||||
if probeService == nil {
|
||||
probeService = ffmpeg.Get()
|
||||
}
|
||||
// The shadow run can outlive the request, so it uses a context that is not
|
||||
// cancelled with it. It bypasses the shared cache so ffprobe is compared fresh.
|
||||
shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx))
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacyProbe := func() (gomedia.ProbeResult, bool) {
|
||||
info, err := probeService.ProbeFile(shadowCtx, realPath)
|
||||
if err != nil || info == nil {
|
||||
@@ -751,33 +759,12 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
if info.FormatName != "" {
|
||||
meta.Container = info.FormatName
|
||||
}
|
||||
if ok {
|
||||
if tags, tagsOK := gomedia.VideoTags(realPath, getArt); tagsOK {
|
||||
applyMediaTags(meta, tags, realPath)
|
||||
}
|
||||
}
|
||||
if meta.Duration > 0 || meta.VideoCodec != "" || meta.AudioCodec != "" || meta.Container != "" {
|
||||
item.Metadata = meta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMediaTags copies go-media tags and embedded cover art into meta. Covers
|
||||
// over 8MB are skipped.
|
||||
func applyMediaTags(meta *iteminfo.MediaMetadata, info gomedia.AudioInfo, realPath string) {
|
||||
meta.Title = info.Tags.Title
|
||||
meta.Artist = info.Tags.Artist
|
||||
meta.Album = info.Tags.Album
|
||||
meta.Year = info.Tags.Year
|
||||
meta.Genre = info.Tags.Genre
|
||||
meta.Track = info.Tags.Track
|
||||
if n := len(info.Artwork); n > 0 && n <= 8*1024*1024 {
|
||||
meta.AlbumArt = info.Artwork
|
||||
} else if n > 8*1024*1024 {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFiles deletes the file or directory at indexPath. boundIndexPath limits
|
||||
// symlink resolution to the caller's scope ("" or "/" = source root only). The
|
||||
// entry itself is removed: a symlink leaf is deleted, never followed, and an
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitle track lists from ffprobe
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // extracted embedded subtitle content
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration from ffprobe
|
||||
)
|
||||
@@ -9,51 +9,13 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
// HEICDisplayRotationReader reads the display rotation that a HEIC/HEIF/AVIF
|
||||
// file's item properties (irot) ask for, without decoding it. The rotation is
|
||||
// in degrees counter-clockwise, normalized to (-180, 180], which is the value
|
||||
// ffmpeg prints as "Display Matrix: rotation of ..." for the same file. ok is
|
||||
// false when the reader could not tell; the ffmpeg probe is used instead.
|
||||
type HEICDisplayRotationReader func(path string) (rotation float64, ok bool)
|
||||
|
||||
// heicDisplayRotationReader is the header reader set by
|
||||
// SetHEICDisplayRotationReader, or nil.
|
||||
var heicDisplayRotationReader atomic.Pointer[HEICDisplayRotationReader]
|
||||
|
||||
// SetHEICDisplayRotationReader installs the reader HEICDisplayRotation tries
|
||||
// before running ffmpeg. The gomedia package installs go-media's reader when it
|
||||
// is loaded; it cannot be called from here directly because gomedia imports
|
||||
// this package. Passing nil removes the reader.
|
||||
func SetHEICDisplayRotationReader(fn HEICDisplayRotationReader) {
|
||||
if fn == nil {
|
||||
heicDisplayRotationReader.Store(nil)
|
||||
return
|
||||
}
|
||||
heicDisplayRotationReader.Store(&fn)
|
||||
}
|
||||
|
||||
// HEICDisplayRotation returns the display rotation of heicPath and whether it is
|
||||
// known. The installed header reader (go-media) answers first; the
|
||||
// "ffmpeg -f null" stderr probe runs only when no reader is installed or the
|
||||
// reader could not read the file.
|
||||
func (s *Service) HEICDisplayRotation(ctx context.Context, heicPath string) (float64, bool) {
|
||||
if p := heicDisplayRotationReader.Load(); p != nil {
|
||||
if rotation, ok := (*p)(heicPath); ok {
|
||||
return rotation, true
|
||||
}
|
||||
}
|
||||
return s.GetHEICDisplayMatrixRotation(ctx, heicPath)
|
||||
}
|
||||
|
||||
// GetHEICDisplayMatrixRotation returns the largest rotation (degrees) ffmpeg reports for a
|
||||
// HEIC display matrix. The second return value is false when the probe could not run or
|
||||
// parse stderr — callers must not treat that as “no rotation was applied”.
|
||||
// HEICDisplayRotation is preferred; it only runs this probe as a fallback.
|
||||
func (s *Service) GetHEICDisplayMatrixRotation(ctx context.Context, heicPath string) (float64, bool) {
|
||||
if s == nil {
|
||||
return 0, false
|
||||
@@ -74,8 +36,6 @@ func (s *Service) GetHEICDisplayMatrixRotation(ctx context.Context, heicPath str
|
||||
return rotation, ok
|
||||
}
|
||||
|
||||
// parseDisplayMatrixRotation returns the largest-magnitude "Display Matrix"
|
||||
// rotation in ffmpeg stderr, and whether any was found.
|
||||
func parseDisplayMatrixRotation(stderr string) (float64, bool) {
|
||||
var best float64
|
||||
var found bool
|
||||
@@ -105,7 +65,6 @@ func parseDisplayMatrixRotation(stderr string) (float64, bool) {
|
||||
return best, found
|
||||
}
|
||||
|
||||
// isNormalOrientation reports whether an EXIF orientation needs no change.
|
||||
func isNormalOrientation(orientation string) bool {
|
||||
switch orientation {
|
||||
case "", "Horizontal (normal)", "Top-left", "1":
|
||||
@@ -115,8 +74,6 @@ func isNormalOrientation(orientation string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// isPureRotationOrientation reports whether an EXIF orientation is a rotation
|
||||
// without a mirror.
|
||||
func isPureRotationOrientation(orientation string) bool {
|
||||
switch orientation {
|
||||
case "Rotate 90 CW", "Right-top", "6",
|
||||
@@ -143,8 +100,6 @@ func orientationNeedsPostCorrection(orientation string, displayRotation float64,
|
||||
return true
|
||||
}
|
||||
|
||||
// applyOrientationToJPEG rotates or flips JPEG bytes for an EXIF orientation.
|
||||
// The input is returned unchanged when it cannot be decoded or re-encoded.
|
||||
func applyOrientationToJPEG(jpegBytes []byte, orientation string) []byte {
|
||||
if len(jpegBytes) < 100 || isNormalOrientation(orientation) {
|
||||
return jpegBytes
|
||||
|
||||
@@ -50,35 +50,3 @@ func TestOrientationNeedsPostCorrection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHEICDisplayRotationPrefersReader(t *testing.T) {
|
||||
prev := heicDisplayRotationReader.Load()
|
||||
t.Cleanup(func() { heicDisplayRotationReader.Store(prev) })
|
||||
|
||||
var calls int
|
||||
SetHEICDisplayRotationReader(func(path string) (float64, bool) {
|
||||
calls++
|
||||
if path == "known.heic" {
|
||||
return -90, true
|
||||
}
|
||||
return 0, false
|
||||
})
|
||||
var svc *Service // nil: the ffmpeg probe cannot run
|
||||
got, ok := svc.HEICDisplayRotation(t.Context(), "known.heic")
|
||||
if !ok || got != -90 {
|
||||
t.Fatalf("HEICDisplayRotation(known) = %v, %v; want -90, true", got, ok)
|
||||
}
|
||||
// The reader cannot tell: the ffmpeg probe runs, and with no service it
|
||||
// reports unknown rather than "no rotation".
|
||||
if _, ok := svc.HEICDisplayRotation(t.Context(), "other.heic"); ok {
|
||||
t.Fatal("HEICDisplayRotation(other) ok = true, want the ffmpeg probe's false")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("reader calls = %d, want 2", calls)
|
||||
}
|
||||
|
||||
SetHEICDisplayRotationReader(nil)
|
||||
if _, ok := svc.HEICDisplayRotation(t.Context(), "known.heic"); ok {
|
||||
t.Fatal("with no reader, HEICDisplayRotation must use the ffmpeg probe")
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,14 @@ func (s *Service) ConvertHEICToJPEG(ctx context.Context, heicPath string, target
|
||||
return s.CorrectHEICJPEGOrientation(ctx, heicPath, jpegBytes), nil
|
||||
}
|
||||
|
||||
// CorrectHEICJPEGOrientation applies EXIF orientation after a filter-free ffmpeg HEIC decode
|
||||
// when the pixels are still in coded orientation. The display rotation (irot) comes from
|
||||
// HEICDisplayRotation: go-media reads it from the item properties, and the ffmpeg stderr probe
|
||||
// is only a fallback.
|
||||
// CorrectHEICJPEGOrientation applies irot / display-matrix correction after a full-resolution
|
||||
// HEIC decode when pixels are still in coded orientation (go-media and ffmpeg filter-free paths).
|
||||
func (s *Service) CorrectHEICJPEGOrientation(ctx context.Context, heicPath string, jpegBytes []byte) []byte {
|
||||
orientation, err := s.GetHEICOrientation(heicPath)
|
||||
if err != nil {
|
||||
orientation = "Horizontal (normal)"
|
||||
}
|
||||
displayRotation, displayKnown := s.HEICDisplayRotation(ctx, heicPath)
|
||||
displayRotation, displayKnown := s.GetHEICDisplayMatrixRotation(ctx, heicPath)
|
||||
if orientationNeedsPostCorrection(orientation, displayRotation, displayKnown) {
|
||||
return applyOrientationToJPEG(jpegBytes, orientation)
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
)
|
||||
|
||||
const cacheFixtureMP4 = "../../../frontend/tests/playwright-files/myfolder/testdata/sample-640x480.mp4"
|
||||
|
||||
var (
|
||||
testServiceOnce sync.Once
|
||||
testServiceErr error
|
||||
)
|
||||
|
||||
// initTestService starts the ffmpeg service once, without hardware probes, or
|
||||
// skips.
|
||||
func initTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("starts ffmpeg")
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
t.Skip("ffprobe not installed")
|
||||
}
|
||||
testServiceOnce.Do(func() {
|
||||
testServiceErr = Initialize(context.Background(), InitOptions{SkipHWTests: true, CacheDir: os.TempDir()})
|
||||
})
|
||||
if testServiceErr != nil {
|
||||
t.Skipf("ffmpeg init failed: %v", testServiceErr)
|
||||
}
|
||||
svc := Get()
|
||||
if svc == nil {
|
||||
t.Skip("ffmpeg service unavailable")
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// copyFixture copies the MP4 fixture so the test owns its cache key.
|
||||
func copyFixture(t *testing.T) (string, mediacache.Key) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(cacheFixtureMP4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
if err = os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := mediacache.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mediacache.Probes.Delete(key.String())
|
||||
mediacache.Durations.Delete(key.String())
|
||||
})
|
||||
return path, key
|
||||
}
|
||||
|
||||
// TestProbeFileUsesSharedCache checks that ffprobe results go into the shared
|
||||
// cache, cached values are reused, and WithoutCache bypasses both.
|
||||
func TestProbeFileUsesSharedCache(t *testing.T) {
|
||||
svc := initTestService(t)
|
||||
path, key := copyFixture(t)
|
||||
|
||||
got, err := svc.ProbeFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cached, ok := mediacache.Probes.Get(key.String())
|
||||
if !ok || cached != *got {
|
||||
t.Fatalf("ProbeFile must store its result in the shared cache: cached=%+v ok=%v got=%+v", cached, ok, *got)
|
||||
}
|
||||
|
||||
sentinel := FileProbeInfo{Duration: 1, VideoCodec: "sentinel"}
|
||||
mediacache.Probes.Set(key.String(), sentinel)
|
||||
got, err = svc.ProbeFile(context.Background(), path)
|
||||
if err != nil || *got != sentinel {
|
||||
t.Fatalf("ProbeFile must reuse a cached value (e.g. from go-media): got=%+v err=%v", got, err)
|
||||
}
|
||||
|
||||
fresh, err := svc.ProbeFile(mediacache.WithoutCache(context.Background()), path)
|
||||
if err != nil || *fresh == sentinel {
|
||||
t.Fatalf("WithoutCache must probe fresh: got=%+v err=%v", fresh, err)
|
||||
}
|
||||
if cached, _ := mediacache.Probes.Get(key.String()); cached != sentinel {
|
||||
t.Fatal("WithoutCache must not overwrite the shared cache")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMediaDurationUsesSharedCache checks that cached durations are reused
|
||||
// and WithoutCache probes fresh.
|
||||
func TestGetMediaDurationUsesSharedCache(t *testing.T) {
|
||||
svc := initTestService(t)
|
||||
path, key := copyFixture(t)
|
||||
|
||||
mediacache.Durations.Set(key.String(), 4242)
|
||||
d, err := svc.GetMediaDuration(context.Background(), path)
|
||||
if err != nil || d != 4242 {
|
||||
t.Fatalf("GetMediaDuration must reuse a cached value: d=%v err=%v", d, err)
|
||||
}
|
||||
d, err = svc.GetMediaDuration(mediacache.WithoutCache(context.Background()), path)
|
||||
if err != nil || d == 4242 || d <= 0 {
|
||||
t.Fatalf("WithoutCache must probe fresh: d=%v err=%v", d, err)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
)
|
||||
|
||||
@@ -50,29 +49,24 @@ func resolveLocalMediaPath(mediaPath string) (string, os.FileInfo, error) {
|
||||
return clean, info, nil
|
||||
}
|
||||
|
||||
// FileProbeInfo holds probe results for a local media file. It is the shared
|
||||
// cache type, so go-media and ffprobe results are interchangeable.
|
||||
type FileProbeInfo = mediacache.ProbeInfo
|
||||
// FileProbeInfo holds ffprobe results for a local media file.
|
||||
type FileProbeInfo struct {
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
FormatName string
|
||||
}
|
||||
|
||||
// ProbeFile extracts duration and codec info from a media file. Results are
|
||||
// stored in the shared media cache, which the go-media probe also reads, unless
|
||||
// ctx is mediacache.WithoutCache.
|
||||
// ProbeFile extracts duration and codec info from a media file.
|
||||
func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeInfo, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return nil, fmt.Errorf("ffmpeg service not available")
|
||||
}
|
||||
|
||||
localPath, fileInfo, err := resolveLocalMediaPath(mediaPath)
|
||||
localPath, _, err := resolveLocalMediaPath(mediaPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String()
|
||||
if useCache {
|
||||
if cached, ok := mediacache.Probes.Get(cacheKey); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
info, err := s.inner.ProbeStream(ctx, goffmpeg.ProbeStreamOptions{
|
||||
URL: localPath,
|
||||
@@ -83,20 +77,15 @@ func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeIn
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := FileProbeInfo{
|
||||
return &FileProbeInfo{
|
||||
Duration: info.Duration,
|
||||
VideoCodec: info.VideoCodec,
|
||||
AudioCodec: info.AudioCodec,
|
||||
FormatName: info.FormatName,
|
||||
}
|
||||
if useCache {
|
||||
mediacache.Probes.Set(cacheKey, res)
|
||||
}
|
||||
return &res, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMediaDuration extracts the duration of a media file in seconds. Results
|
||||
// are stored in the shared media cache unless ctx is mediacache.WithoutCache.
|
||||
// GetMediaDuration extracts the duration of a media file in seconds.
|
||||
func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return 0, fmt.Errorf("ffmpeg service not available")
|
||||
@@ -106,12 +95,9 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String()
|
||||
if useCache {
|
||||
if duration, found := mediacache.Durations.Get(cacheKey); found {
|
||||
return duration, nil
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%s:%d", localPath, fileInfo.ModTime().Unix())
|
||||
if duration, found := MetadataCache.Get(cacheKey); found {
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
duration, err := s.inner.GetMediaDuration(ctx, localPath)
|
||||
@@ -119,8 +105,6 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if useCache {
|
||||
mediacache.Durations.Set(cacheKey, duration)
|
||||
}
|
||||
MetadataCache.Set(cacheKey, duration)
|
||||
return duration, nil
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/ops"
|
||||
@@ -461,8 +460,7 @@ func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.Previe
|
||||
return s.inner.CaptureFrame(ctx, w, frame)
|
||||
}
|
||||
|
||||
// ExtractSubtitle returns embedded subtitle content as WebVTT. Results are
|
||||
// stored in the shared media cache unless ctx is mediacache.WithoutCache.
|
||||
// ExtractSubtitle returns embedded subtitle content as WebVTT.
|
||||
func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamIndex int) (string, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return "", fmt.Errorf("ffmpeg service not available")
|
||||
@@ -472,12 +470,9 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
cacheKey := mediacache.FromFileInfo(videoPath, fileInfo).Stream(streamIndex)
|
||||
if useCache {
|
||||
if content, ok := mediacache.SubtitleContent.Get(cacheKey); ok {
|
||||
return content, nil
|
||||
}
|
||||
cacheKey := fmt.Sprintf("subtitle_content:%s:%d:%d", videoPath, streamIndex, fileInfo.ModTime().Unix())
|
||||
if content, ok := SubtitleContentCache.Get(cacheKey); ok {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
content, err := s.inner.ExtractSubtitle(ctx, videoPath, streamIndex)
|
||||
@@ -485,9 +480,7 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
|
||||
return "", err
|
||||
}
|
||||
|
||||
if useCache {
|
||||
mediacache.SubtitleContent.Set(cacheKey, content)
|
||||
}
|
||||
SubtitleContentCache.Set(cacheKey, content)
|
||||
return content, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,30 +7,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// DetectEmbeddedSubtitles detects embedded subtitle streams. Results are stored
|
||||
// in the shared media cache, keyed by the file's current size and modification
|
||||
// time; modtime is kept for API compatibility and is not used for the key.
|
||||
// DetectEmbeddedSubtitles detects embedded subtitle streams.
|
||||
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
|
||||
_ = modtime
|
||||
return DetectEmbeddedSubtitlesContext(context.Background(), videoPath)
|
||||
}
|
||||
|
||||
// DetectEmbeddedSubtitlesContext is DetectEmbeddedSubtitles with a context. The
|
||||
// shared media cache is skipped when ctx is mediacache.WithoutCache.
|
||||
func DetectEmbeddedSubtitlesContext(ctx context.Context, videoPath string) []utils.SubtitleTrack {
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
key, statErr := mediacache.Stat(videoPath)
|
||||
useCache = useCache && statErr == nil
|
||||
if useCache {
|
||||
if cached, ok := mediacache.SubtitleTracks.Get(key.String()); ok {
|
||||
return cached
|
||||
}
|
||||
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
if cached, ok := MediaCache.Get(key); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
svc := Get()
|
||||
@@ -38,16 +24,14 @@ func DetectEmbeddedSubtitlesContext(ctx context.Context, videoPath string) []uti
|
||||
return nil
|
||||
}
|
||||
|
||||
tracks, err := svc.inner.DetectSubtitles(ctx, videoPath)
|
||||
tracks, err := svc.inner.DetectSubtitles(context.Background(), videoPath)
|
||||
if err != nil {
|
||||
logger.Debug("ffprobe failed for file: " + videoPath + ", error: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
subtitles := mapSubtitleTracks(tracks)
|
||||
if useCache {
|
||||
mediacache.SubtitleTracks.Set(key.String(), subtitles)
|
||||
}
|
||||
MediaCache.Set(key, subtitles)
|
||||
return subtitles
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const fixtureWMV2 = fixtureDir + "/file_example_WMV_480_1_2MB.wmv"
|
||||
|
||||
// TestContainerGates pins which extensions go-media handles for probes and
|
||||
// for thumbnails.
|
||||
func TestContainerGates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
probe, thumbnail bool
|
||||
}{
|
||||
{"a.mp4", true, true},
|
||||
{"a.M4V", true, true},
|
||||
{"a.mov", true, true},
|
||||
{"a.mkv", true, true},
|
||||
{"a.webm", true, true},
|
||||
{"a.wmv", false, true},
|
||||
{"a.ASF", false, true},
|
||||
{"a.avi", false, false},
|
||||
{"a.ts", false, false},
|
||||
{"a.m2ts", false, false},
|
||||
{"a.mts", false, false},
|
||||
{"a.mpg", false, false},
|
||||
{"a.mpeg", false, false},
|
||||
{"a.vob", false, false},
|
||||
{"a.flv", false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
require.Equal(t, c.probe, supportsVideoContainer(c.name), "probe gate for %s", c.name)
|
||||
require.Equal(t, c.thumbnail, supportsVideoThumbnail(c.name), "thumbnail gate for %s", c.name)
|
||||
}
|
||||
for _, name := range []string{"a.heic", "a.HEIF", "a.heics", "a.avif"} {
|
||||
require.True(t, supportsHEIFImage(name), name)
|
||||
}
|
||||
require.False(t, supportsHEIFImage("a.jpg"))
|
||||
}
|
||||
|
||||
// TestWMV2FallsBackToFFmpeg checks that a WMV2 file is not probed by go-media
|
||||
// and that its thumbnail falls back to ffmpeg (go-media has no WMV1/WMV2
|
||||
// decoder).
|
||||
func TestWMV2FallsBackToFFmpeg(t *testing.T) {
|
||||
withGate(t, false)
|
||||
_, ok := Probe(fixtureWMV2, nil)
|
||||
require.False(t, ok, "WMV probes stay on ffprobe")
|
||||
_, ok = VideoThumbnail(fixtureWMV2, 320, 240, 80, 10, nil)
|
||||
require.False(t, ok, "WMV2 thumbnails must fall back to ffmpeg")
|
||||
}
|
||||
|
||||
// TestWMV1FallsBackToFFmpeg is TestWMV2FallsBackToFFmpeg for a WMV1 stream
|
||||
// encoded on the fly.
|
||||
func TestWMV1FallsBackToFFmpeg(t *testing.T) {
|
||||
withGate(t, false)
|
||||
requireTool(t, "ffmpeg")
|
||||
path := filepath.Join(t.TempDir(), "wmv1.wmv")
|
||||
out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=2",
|
||||
"-c:v", "wmv1", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("ffmpeg cannot encode wmv1: %v: %s", err, out)
|
||||
}
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
_, ok := VideoThumbnail(path, 160, 120, 80, 10, nil)
|
||||
require.False(t, ok, "WMV1 thumbnails must fall back to ffmpeg")
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
@@ -29,27 +28,20 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Probe, duration, and subtitle results are cached in the mediacache package,
|
||||
// which the ffmpeg code path shares. A directory listing probes every video
|
||||
// child, and the preview flow probes the focused file again; the cache keeps
|
||||
// each request from re-reading the same containers. Keys include size and
|
||||
// modification time, so an edited file is read again.
|
||||
var (
|
||||
subtitleListCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour)
|
||||
subtitleContentCache = cache.NewCache[string](24 * time.Hour)
|
||||
|
||||
// probeGroup coalesces concurrent probes of the same file so a burst of
|
||||
// requests for one container reads it once.
|
||||
var probeGroup singleflight.Group
|
||||
// probeCache caches successful probe results by file identity (path, modtime,
|
||||
// size). A directory listing probes every video child, and the preview flow
|
||||
// probes the focused file again; without this each request re-read the same
|
||||
// containers. Keying on modtime+size means an edited file is re-probed.
|
||||
probeCache = cache.NewCache[ProbeResult](24 * time.Hour)
|
||||
|
||||
// tagsCache caches the embedded part of a tag read by file identity. A listing
|
||||
// reads tags for every media child on each uncached request. Sidecar lyrics
|
||||
// are not cached: adding an .lrc next to a file does not change its modtime.
|
||||
var tagsCache = cache.NewCache[AudioInfo](24 * time.Hour)
|
||||
|
||||
func init() {
|
||||
// The ffmpeg HEIC decode path corrects orientation after decoding and needs
|
||||
// the file's irot. internal/ffmpeg cannot import this package, so go-media's
|
||||
// header reader is installed here.
|
||||
ffmpeg.SetHEICDisplayRotationReader(HEICDisplayRotation)
|
||||
}
|
||||
// probeGroup coalesces concurrent probes of the same file so a burst of
|
||||
// requests for one container reads it once.
|
||||
probeGroup singleflight.Group
|
||||
)
|
||||
|
||||
// Enabled reports whether the go-media fast path may run. The admin
|
||||
// "Always use ffmpeg" setting turns it off.
|
||||
@@ -68,15 +60,9 @@ func videoDimensions(info *media.ProbeInfo) (w, h int, ok bool) {
|
||||
}
|
||||
|
||||
// videoContainerExts are the containers go-media parses for video probes,
|
||||
// thumbnails, embedded subtitles, and keyframe indexes. go-media reads MOV as
|
||||
// MP4.
|
||||
//
|
||||
// go-media also demuxes AVI, ASF/WMV, MPEG-TS/M2TS, and MPEG-PS (VOB), but their
|
||||
// probes are left to ffmpeg on purpose: go-media's results for those containers
|
||||
// do not yet match ffprobe (TS/M2TS and PS list no audio streams, PS durations
|
||||
// are missing or wrong, AVI misnames MPEG-4 Part 2 and AAC, ASF reports WMA Pro
|
||||
// as wmav2). Those files go straight to ffmpeg with no file open and no log
|
||||
// line. See videoThumbnailExts for the thumbnail exception.
|
||||
// thumbnails, and embedded subtitles. Other video containers (AVI, WMV, TS, ...)
|
||||
// are not go-media targets, so they go straight to ffmpeg with no file open and
|
||||
// no log line. go-media reads MOV as MP4.
|
||||
var videoContainerExts = map[string]bool{
|
||||
".mp4": true,
|
||||
".m4v": true,
|
||||
@@ -85,31 +71,11 @@ var videoContainerExts = map[string]bool{
|
||||
".webm": true,
|
||||
}
|
||||
|
||||
// videoThumbnailExts are the containers go-media decodes video thumbnails from:
|
||||
// videoContainerExts plus ASF/WMV, whose WMV3 keyframes go-media decodes
|
||||
// bit-exact with ffmpeg. WMV1, WMV2, MS-MPEG4, and WVC1 Advanced Profile return
|
||||
// an unsupported error and fall back to ffmpeg. AVI, TS/M2TS, and PS stay on
|
||||
// ffmpeg: go-media's TS and PS thumbnails scan the whole file for the duration
|
||||
// (several times slower than ffmpeg on large files), and AVI seeks by a
|
||||
// duration that can be wrong.
|
||||
var videoThumbnailExts = map[string]bool{
|
||||
".mp4": true,
|
||||
".m4v": true,
|
||||
".mov": true,
|
||||
".mkv": true,
|
||||
".webm": true,
|
||||
".wmv": true,
|
||||
".asf": true,
|
||||
}
|
||||
|
||||
// heifImageExts are still-image containers go-media decodes for HEIC preview.
|
||||
// AVIF is a HEIF container with an AV1 image item; go-media decodes it in pure
|
||||
// Go and applies its irot/imir, like HEIC.
|
||||
var heifImageExts = map[string]bool{
|
||||
".heic": true,
|
||||
".heif": true,
|
||||
".heics": true,
|
||||
".avif": true,
|
||||
}
|
||||
|
||||
const heicDecodeMaxEdge = 16384
|
||||
@@ -127,27 +93,15 @@ var cameraRawExts = map[string]bool{
|
||||
".dc2": true, ".x3f": true, ".iiq": true, ".nkc": true, ".r3d": true,
|
||||
}
|
||||
|
||||
// supportsVideoContainer reports whether go-media targets path's container for
|
||||
// probes, embedded subtitles, and keyframe indexes.
|
||||
// supportsVideoContainer reports whether go-media targets path's container.
|
||||
func supportsVideoContainer(path string) bool {
|
||||
return videoContainerExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// supportsVideoThumbnail reports whether go-media targets path's container for
|
||||
// video thumbnails.
|
||||
func supportsVideoThumbnail(path string) bool {
|
||||
return videoThumbnailExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// supportsHEIFImage reports whether path is a HEIF-family still image
|
||||
// (HEIC/HEIF/AVIF) go-media decodes.
|
||||
|
||||
func supportsHEIFImage(path string) bool {
|
||||
return heifImageExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// supportsCameraRAW reports whether path is a camera RAW format go-media
|
||||
// extracts a preview from.
|
||||
func supportsCameraRAW(path string) bool {
|
||||
return cameraRawExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
@@ -179,26 +133,16 @@ func logUnsupported(op, path, reason string) {
|
||||
|
||||
// openFile opens path for go-media, which reads through io.ReaderAt.
|
||||
func openFile(path string) (*os.File, int64, error) {
|
||||
f, info, err := openFileInfo(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// openFileInfo opens path and returns its FileInfo, which also gives the
|
||||
// shared cache key.
|
||||
func openFileInfo(path string) (*os.File, os.FileInfo, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info, nil
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// ffprobeFormatNames maps go-media containers to the format_name strings ffprobe
|
||||
@@ -235,25 +179,30 @@ func probeFast(path string) (ProbeResult, bool) {
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
|
||||
f, info, err := openFileInfo(path)
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("probe", path, err)
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cacheKey := mediacache.FromFileInfo(path, info).String()
|
||||
if res, ok := mediacache.Probes.Get(cacheKey); ok {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
logFallback("probe", path, err)
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
cacheKey := probeCacheKey(path, info.ModTime().UnixNano(), size)
|
||||
if res, ok := probeCache.Get(cacheKey); ok {
|
||||
return res, true
|
||||
}
|
||||
|
||||
// Coalesce concurrent probes of the same file into one container read.
|
||||
v, err, _ := probeGroup.Do(cacheKey, func() (any, error) {
|
||||
res, ok := parseProbe(path, f, info.Size())
|
||||
res, ok := parseProbe(path, f, size)
|
||||
if !ok {
|
||||
return nil, errProbeUnsupported
|
||||
}
|
||||
mediacache.Probes.Set(cacheKey, res)
|
||||
probeCache.Set(cacheKey, res)
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -270,6 +219,12 @@ func probeFast(path string) (ProbeResult, bool) {
|
||||
// no detail because parseProbe already logged the specific reason.
|
||||
var errProbeUnsupported = errors.New("probe unsupported")
|
||||
|
||||
// probeCacheKey identifies a file version for caching: path plus modtime and size.
|
||||
// An edited file gets a new key, so stale metadata is never served.
|
||||
func probeCacheKey(path string, modTimeNanos, size int64) string {
|
||||
return fmt.Sprintf("probe:%s:%d:%d", path, modTimeNanos, size)
|
||||
}
|
||||
|
||||
// parseProbe reads a container and builds a ProbeResult. It reports false when
|
||||
// go-media should defer to ffmpeg, having logged why.
|
||||
func parseProbe(path string, f *os.File, size int64) (ProbeResult, bool) {
|
||||
@@ -325,18 +280,14 @@ func durationFast(path string) (float64, bool) {
|
||||
if !Enabled() {
|
||||
return 0, false
|
||||
}
|
||||
f, info, err := openFileInfo(path)
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("duration", path, err)
|
||||
return 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cacheKey := mediacache.FromFileInfo(path, info).String()
|
||||
if d, ok := mediacache.Durations.Get(cacheKey); ok {
|
||||
return d, true
|
||||
}
|
||||
res, err := media.Duration(f, info.Size())
|
||||
res, err := media.Duration(f, size)
|
||||
if err != nil {
|
||||
logFallback("duration", path, err)
|
||||
return 0, false
|
||||
@@ -345,9 +296,7 @@ func durationFast(path string) (float64, bool) {
|
||||
// Unknown duration is not an error; ffmpeg may still find one.
|
||||
return 0, false
|
||||
}
|
||||
d := res.Duration.Seconds()
|
||||
mediacache.Durations.Set(cacheKey, d)
|
||||
return d, true
|
||||
return res.Duration.Seconds(), true
|
||||
}
|
||||
|
||||
// AudioInfo is the tag, lyrics availability, and cover art FileBrowser reads
|
||||
@@ -362,60 +311,38 @@ type AudioInfo struct {
|
||||
// is true. A missing cover is not an error.
|
||||
// legacy is the dhowden tag read, run only in shadow mode.
|
||||
func Audio(path string, withArt bool, legacy Legacy[AudioInfo]) (AudioInfo, bool) {
|
||||
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt, true) })
|
||||
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt) })
|
||||
}
|
||||
|
||||
func audioFast(path string, withArt, withSidecar bool) (AudioInfo, bool) {
|
||||
func audioFast(path string, withArt bool) (AudioInfo, bool) {
|
||||
if !Enabled() {
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
f, st, err := openFileInfo(path)
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("tags", path, err)
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
size := st.Size()
|
||||
cacheKey := tagsCacheKey(path, st.ModTime().UnixNano(), size)
|
||||
var info AudioInfo
|
||||
cached := false
|
||||
if !withArt {
|
||||
info, cached = tagsCache.Get(cacheKey)
|
||||
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
|
||||
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
|
||||
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
|
||||
if err != nil {
|
||||
logFallback("tags", path, err)
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
if !cached {
|
||||
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
|
||||
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
|
||||
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
|
||||
if err != nil {
|
||||
logFallback("tags", path, err)
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
info = AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
|
||||
stored := info
|
||||
stored.Tags.Raw = nil
|
||||
tagsCache.Set(cacheKey, stored)
|
||||
if withArt {
|
||||
info.Artwork = audio.Artwork
|
||||
}
|
||||
}
|
||||
if withSidecar && !info.HasLyrics {
|
||||
info := AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
|
||||
if !info.HasLyrics {
|
||||
// Sidecar lyrics are only checked when the file has no embedded lyrics.
|
||||
_, _, info.HasLyrics = media.LyricsSidecar(path)
|
||||
}
|
||||
if withArt {
|
||||
info.Artwork = audio.Artwork
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// tagsCacheKey identifies a file version for the tag cache: path plus modtime and size.
|
||||
func tagsCacheKey(path string, modTimeNanos, size int64) string {
|
||||
return fmt.Sprintf("tags:%s:%d:%d", path, modTimeNanos, size)
|
||||
}
|
||||
|
||||
// VideoTags reads embedded tags and cover from a video container
|
||||
func VideoTags(path string, withArt bool) (AudioInfo, bool) {
|
||||
return timed("tags", path, nil, nil, func() (AudioInfo, bool) { return audioFast(path, withArt, false) })
|
||||
}
|
||||
|
||||
// Lyrics returns lyrics from a sidecar file, or else from embedded tags. The
|
||||
// format is "embedded" for tags or the sidecar extension. An empty result with
|
||||
// ok=true means go-media checked and found no lyrics.
|
||||
@@ -457,9 +384,7 @@ func lyricsFast(path string) (text, format string, ok bool) {
|
||||
|
||||
// EmbeddedSubtitles lists embedded subtitle tracks. go-media supports MKV, WebM,
|
||||
// and MP4 only. Other containers are reported as unsupported, not as "no
|
||||
// subtitles", because go-media returns an empty list for them. modtime is kept
|
||||
// for API compatibility; the cache key comes from the file's current size and
|
||||
// modification time.
|
||||
// subtitles", because go-media returns an empty list for them.
|
||||
// legacy is the ffmpeg subtitle detection, run only in shadow mode.
|
||||
func EmbeddedSubtitles(path string, modtime time.Time, legacy Legacy[[]utils.SubtitleTrack]) ([]utils.SubtitleTrack, bool) {
|
||||
return timed("subtitle list", path, legacy, subtitleListDiff, func() ([]utils.SubtitleTrack, bool) {
|
||||
@@ -474,20 +399,19 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
_ = modtime
|
||||
f, st, err := openFileInfo(path)
|
||||
key := fmt.Sprintf("embedded_subtitles:%s:%d", path, modtime.Unix())
|
||||
if cached, ok := subtitleListCache.Get(key); ok {
|
||||
return cached, true
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle list", path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
key := mediacache.FromFileInfo(path, st).String()
|
||||
if cached, ok := mediacache.SubtitleTracks.Get(key); ok {
|
||||
return cached, true
|
||||
}
|
||||
|
||||
info, err := media.Probe(f, st.Size())
|
||||
info, err := media.Probe(f, size)
|
||||
if err != nil {
|
||||
logFallback("subtitle list", path, err)
|
||||
return nil, false
|
||||
@@ -509,7 +433,7 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac
|
||||
})
|
||||
}
|
||||
tracks := ffmpeg.BuildSubtitleTracks(streams)
|
||||
mediacache.SubtitleTracks.Set(key, tracks)
|
||||
subtitleListCache.Set(key, tracks)
|
||||
return tracks, true
|
||||
}
|
||||
|
||||
@@ -529,19 +453,24 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) {
|
||||
if !supportsVideoContainer(path) {
|
||||
return "", false
|
||||
}
|
||||
f, st, err := openFileInfo(path)
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
key := fmt.Sprintf("subtitle_content:%s:%d:%d", path, streamIndex, st.ModTime().Unix())
|
||||
if content, ok := subtitleContentCache.Get(key); ok {
|
||||
return content, true
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
key := mediacache.FromFileInfo(path, st).Stream(streamIndex)
|
||||
if content, ok := mediacache.SubtitleContent.Get(key); ok {
|
||||
return content, true
|
||||
}
|
||||
|
||||
vtt, err := media.ExtractEmbeddedSubtitle(f, st.Size(), streamIndex)
|
||||
vtt, err := media.ExtractEmbeddedSubtitle(f, size, streamIndex)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
@@ -550,7 +479,7 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) {
|
||||
logFallback("subtitle extract", path, errors.New("empty subtitle output"))
|
||||
return "", false
|
||||
}
|
||||
mediacache.SubtitleContent.Set(key, vtt)
|
||||
subtitleContentCache.Set(key, vtt)
|
||||
return vtt, true
|
||||
}
|
||||
|
||||
@@ -572,7 +501,7 @@ func videoThumbnailFast(path string, width, height, quality int, seekPercent flo
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
if !supportsVideoThumbnail(path) {
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -133,78 +131,27 @@ func TestProbeUsesCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
|
||||
key, err := mediacache.Stat(path)
|
||||
st, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
key := probeCacheKey(path, st.ModTime().UnixNano(), st.Size())
|
||||
sentinel := ProbeResult{Duration: 123, VideoCodec: "sentinel", AudioCodec: "sentinel", FormatName: "sentinel"}
|
||||
// The ffmpeg probe stores into the same shared cache, so this also stands in
|
||||
// for a value ffprobe produced earlier.
|
||||
mediacache.Probes.Set(key.String(), sentinel)
|
||||
t.Cleanup(func() { mediacache.Probes.Delete(key.String()) })
|
||||
probeCache.Set(key, sentinel)
|
||||
t.Cleanup(func() { probeCache.Delete(key) })
|
||||
|
||||
got, ok := Probe(path, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sentinel, got, "probe must return the cached value for an unchanged file")
|
||||
|
||||
// An edit (new size and mtime) must miss the cached value.
|
||||
require.NoError(t, os.WriteFile(path, append(data, 0), 0o644))
|
||||
future := time.Now().Add(time.Hour)
|
||||
require.NoError(t, os.Chtimes(path, future, future))
|
||||
got, ok = Probe(path, nil)
|
||||
require.True(t, ok)
|
||||
require.NotEqual(t, sentinel, got, "an edited file must be probed again")
|
||||
edited, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { mediacache.Probes.Delete(edited.String()) })
|
||||
}
|
||||
|
||||
// TestDurationUsesSharedCache checks that Duration reads and fills the shared
|
||||
// cache that ffmpeg's GetMediaDuration also uses.
|
||||
func TestDurationUsesSharedCache(t *testing.T) {
|
||||
withGate(t, false)
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
data, err := os.ReadFile(fixtureMP4)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
key, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { mediacache.Durations.Delete(key.String()) })
|
||||
|
||||
d, ok := Duration(path, nil)
|
||||
require.True(t, ok)
|
||||
cached, found := mediacache.Durations.Get(key.String())
|
||||
require.True(t, found, "go-media duration must be stored in the shared cache")
|
||||
require.Equal(t, d, cached)
|
||||
|
||||
mediacache.Durations.Set(key.String(), 4242)
|
||||
d, ok = Duration(path, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 4242.0, d, "a cached duration (from either backend) must be reused")
|
||||
}
|
||||
|
||||
// TestSubtitleCachesUseFileIdentity checks the subtitle list cache is keyed by
|
||||
// the file's real size and mtime, not by the caller's modtime argument.
|
||||
func TestSubtitleCachesUseFileIdentity(t *testing.T) {
|
||||
withGate(t, false)
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
data, err := os.ReadFile(fixtureMP4)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
key, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
sentinel := []utils.SubtitleTrack{{Name: "sentinel"}}
|
||||
mediacache.SubtitleTracks.Set(key.String(), sentinel)
|
||||
t.Cleanup(func() { mediacache.SubtitleTracks.Delete(key.String()) })
|
||||
|
||||
// The modtime argument is ignored for the key.
|
||||
got, ok := EmbeddedSubtitles(path, time.Unix(1, 0), nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sentinel, got)
|
||||
|
||||
mediacache.SubtitleContent.Set(key.Stream(7), "WEBVTT\n\nsentinel")
|
||||
t.Cleanup(func() { mediacache.SubtitleContent.Delete(key.Stream(7)) })
|
||||
vtt, ok := ExtractSubtitle(path, 7, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "WEBVTT\n\nsentinel", vtt)
|
||||
// TestProbeCacheKeyTracksFileIdentity checks that path, modtime, and size all
|
||||
// participate in the cache key, so an edited file is never served stale metadata.
|
||||
func TestProbeCacheKeyTracksFileIdentity(t *testing.T) {
|
||||
base := probeCacheKey("f.mp4", 100, 200)
|
||||
require.Equal(t, base, probeCacheKey("f.mp4", 100, 200), "identical identity must hash the same")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 101, 200), "modtime change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "size change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("g.mp4", 100, 200), "path change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "key must not collide across fields")
|
||||
}
|
||||
|
||||
func TestProbeWebMMatchesFFprobe(t *testing.T) {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
)
|
||||
|
||||
// heicRotationShadowTimeout bounds the dev-mode ffmpeg comparison probe.
|
||||
const heicRotationShadowTimeout = time.Minute
|
||||
|
||||
// HEICDisplayRotation returns the display rotation that a HEIC/HEIF/AVIF
|
||||
// file's irot property asks for, read from the item properties without
|
||||
// decoding. The value is degrees counter-clockwise in (-180, 180], the same
|
||||
// number ffmpeg prints as "Display Matrix: rotation of ..." for the file. A
|
||||
// file with no irot returns 0 and ok=true. ok is false when go-media is
|
||||
// disabled or cannot read the item properties; the caller then runs the ffmpeg
|
||||
// probe.
|
||||
//
|
||||
// It is installed as ffmpeg's HEIC display rotation reader. In shadow mode the
|
||||
// ffmpeg stderr probe runs for comparison.
|
||||
func HEICDisplayRotation(path string) (float64, bool) {
|
||||
return timed("heic rotation", path, heicRotationLegacy(path), heicRotationDiff, func() (float64, bool) {
|
||||
return heicDisplayRotationFast(path)
|
||||
})
|
||||
}
|
||||
|
||||
// heicRotationLegacy returns the ffmpeg stderr probe for shadow mode, or nil
|
||||
// when ffmpeg is not available.
|
||||
func heicRotationLegacy(path string) Legacy[float64] {
|
||||
if !ShadowEnabled() {
|
||||
return nil
|
||||
}
|
||||
svc := ffmpeg.Get()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return func() (float64, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), heicRotationShadowTimeout)
|
||||
defer cancel()
|
||||
return svc.GetHEICDisplayMatrixRotation(ctx, path)
|
||||
}
|
||||
}
|
||||
|
||||
// heicDisplayRotationFast reads the primary item's irot with go-media and
|
||||
// converts it to ffmpeg's display rotation.
|
||||
func heicDisplayRotationFast(path string) (float64, bool) {
|
||||
if !Enabled() {
|
||||
return 0, false
|
||||
}
|
||||
// No extension check: the resolved path can have another name (symlink
|
||||
// target). go-media sniffs the content and reports other files unsupported.
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("heic rotation", path, err)
|
||||
return 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
t, err := media.HEIFTransform(f, size)
|
||||
if err != nil {
|
||||
logFallback("heic rotation", path, err)
|
||||
return 0, false
|
||||
}
|
||||
return displayRotation(t), true
|
||||
}
|
||||
|
||||
// displayRotation converts go-media's counter-clockwise irot angle to ffmpeg's
|
||||
// display matrix convention: counter-clockwise degrees normalized to
|
||||
// (-180, 180]. ffmpeg builds the matrix with av_display_rotation_set(-irot)
|
||||
// and prints av_display_rotation_get, so irot 270 is reported as -90. A mirror
|
||||
// does not change the rotation value.
|
||||
func displayRotation(t media.ImageTransform) float64 {
|
||||
r := ((t.Rotation % 360) + 360) % 360
|
||||
if r > 180 {
|
||||
r -= 360
|
||||
}
|
||||
return float64(r)
|
||||
}
|
||||
|
||||
// heicRotationDiff compares go-media's display rotation with ffmpeg's. 180 and
|
||||
// -180 are the same rotation.
|
||||
func heicRotationDiff(fast, legacy float64) []string {
|
||||
norm := func(v float64) float64 {
|
||||
v = math.Mod(v, 360)
|
||||
if v <= -180 {
|
||||
v += 360
|
||||
} else if v > 180 {
|
||||
v -= 360
|
||||
}
|
||||
return v
|
||||
}
|
||||
if math.Abs(norm(fast)-norm(legacy)) > 0.5 {
|
||||
return []string{fmt.Sprintf("display rotation go-media=%.2f ffmpeg=%.2f", fast, legacy)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fixtureAVIFRotated has irot=1 (90° counter-clockwise) and imir axis 0.
|
||||
const fixtureAVIFRotated = "testdata/irot1_imir0.avif"
|
||||
|
||||
// TestDisplayRotation checks the irot to ffmpeg display rotation mapping.
|
||||
func TestDisplayRotation(t *testing.T) {
|
||||
cases := []struct {
|
||||
rotation int
|
||||
want float64
|
||||
}{
|
||||
{0, 0}, {90, 90}, {180, 180}, {270, -90}, {-90, -90}, {360, 0}, {450, 90},
|
||||
}
|
||||
for _, c := range cases {
|
||||
require.Equal(t, c.want, displayRotation(media.ImageTransform{Rotation: c.rotation}), "rotation %d", c.rotation)
|
||||
}
|
||||
require.Equal(t, 90.0, displayRotation(media.ImageTransform{Rotation: 90, Mirror: true}), "a mirror does not change the rotation")
|
||||
}
|
||||
|
||||
// TestHEICDisplayRotationReadsIrot checks the rotation read from a fixture
|
||||
// with irot=1.
|
||||
func TestHEICDisplayRotationReadsIrot(t *testing.T) {
|
||||
withGate(t, false)
|
||||
got, ok := HEICDisplayRotation(fixtureAVIFRotated)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 90.0, got)
|
||||
}
|
||||
|
||||
// TestHEICDisplayRotationFallsBack checks that non-HEIF input, broken input,
|
||||
// and the Always use ffmpeg setting leave the answer to ffmpeg.
|
||||
func TestHEICDisplayRotationFallsBack(t *testing.T) {
|
||||
withGate(t, false)
|
||||
_, ok := HEICDisplayRotation(fixtureMP4)
|
||||
require.False(t, ok, "a non-HEIF file must fall back to the ffmpeg probe")
|
||||
|
||||
junk := filepath.Join(t.TempDir(), "junk.heic")
|
||||
require.NoError(t, os.WriteFile(junk, []byte("not heif"), 0o644))
|
||||
_, ok = HEICDisplayRotation(junk)
|
||||
require.False(t, ok)
|
||||
|
||||
withGate(t, true)
|
||||
_, ok = HEICDisplayRotation(fixtureAVIFRotated)
|
||||
require.False(t, ok, "the Always use ffmpeg setting must skip go-media")
|
||||
}
|
||||
|
||||
// TestHEICDisplayRotationInstalled checks that loading this package installs
|
||||
// go-media as the ffmpeg package's rotation reader.
|
||||
func TestHEICDisplayRotationInstalled(t *testing.T) {
|
||||
withGate(t, false)
|
||||
var svc *ffmpeg.Service // nil: the ffmpeg probe cannot run, so a result proves the reader answered
|
||||
got, ok := svc.HEICDisplayRotation(t.Context(), fixtureAVIFRotated)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 90.0, got)
|
||||
}
|
||||
|
||||
// TestHEICRotationDiff checks the shadow comparison of display rotations.
|
||||
func TestHEICRotationDiff(t *testing.T) {
|
||||
require.Empty(t, heicRotationDiff(-90, -90))
|
||||
require.Empty(t, heicRotationDiff(180, -180))
|
||||
require.Empty(t, heicRotationDiff(0, math.Copysign(0, -1)))
|
||||
require.Len(t, heicRotationDiff(90, -90), 1)
|
||||
}
|
||||
|
||||
// TestAVIFDecodesThroughHEICPath checks that an AVIF file is accepted by the
|
||||
// HEIC decode path.
|
||||
func TestAVIFDecodesThroughHEICPath(t *testing.T) {
|
||||
withGate(t, false)
|
||||
data, ok := HEICJPEG(fixtureAVIFRotated, 80, nil)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, data)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
)
|
||||
|
||||
// keyframeTimeDedupeSec matches go-ffmpeg's ffprobe keyframe probe, which
|
||||
// drops keyframes within 1 ms of the previous one.
|
||||
const keyframeTimeDedupeSec = 0.001
|
||||
|
||||
// keyframeShadowTolerance is how far apart two backends' keyframe times can be
|
||||
// before the shadow comparison reports them. ffprobe prints six decimals.
|
||||
const keyframeShadowTolerance = 0.002
|
||||
|
||||
// KeyframeTimes returns the presentation times, in seconds, of the first video
|
||||
// stream's keyframes, for HLS segment planning. The result is sorted, has no
|
||||
// negative times, and drops keyframes within 1 ms of the previous one, the same
|
||||
// as go-ffmpeg's ffprobe probe. go-media reads only the container index (MP4
|
||||
// stss/stts/ctts, Matroska Cues or block headers), so it is much faster than
|
||||
// demuxing every packet with ffprobe.
|
||||
//
|
||||
// ok is false when go-media is disabled, the container is not one go-media
|
||||
// indexes, or the read fails; the caller then runs ffprobe. ctx cancels the
|
||||
// file reads. legacy is the ffprobe probe, run only in shadow mode.
|
||||
//
|
||||
// go-media can report fewer keyframes than ffprobe (ffprobe's parsers also flag
|
||||
// IDR frames the container index omits, and some Matroska muxers index only
|
||||
// part of the keyframes in Cues). It never reports a keyframe ffprobe would not.
|
||||
func KeyframeTimes(ctx context.Context, path string, legacy Legacy[[]float64]) ([]float64, bool) {
|
||||
return timed("keyframe index", path, legacy, keyframeDiff, func() ([]float64, bool) {
|
||||
return keyframeTimesFast(ctx, path, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// KeyframeTimesWithin is KeyframeTimes limited to keyframes within windowSec
|
||||
// seconds of the first keyframe. It is the pre-flight check that can rule out
|
||||
// stream copy without reading the whole index (Matroska files without Cues).
|
||||
// It has no shadow comparison.
|
||||
func KeyframeTimesWithin(ctx context.Context, path string, windowSec float64) ([]float64, bool) {
|
||||
return timed("keyframe preflight", path, nil, nil, func() ([]float64, bool) {
|
||||
return keyframeTimesFast(ctx, path, windowSec)
|
||||
})
|
||||
}
|
||||
|
||||
// keyframeTimesFast reads the keyframe index with go-media. windowSec > 0
|
||||
// limits it to keyframes within that many seconds of the first one.
|
||||
func keyframeTimesFast(ctx context.Context, path string, windowSec float64) ([]float64, bool) {
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("keyframe index", path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r := ctxReaderAt{ctx: ctx, r: f}
|
||||
var times []float64
|
||||
if windowSec > 0 {
|
||||
times, err = media.KeyframeTimesWithin(r, size, windowSec)
|
||||
} else {
|
||||
times, err = media.KeyframeTimes(r, size)
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
logFallback("keyframe index", path, err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
times = dedupeKeyframeTimes(times)
|
||||
if len(times) == 0 {
|
||||
logUnsupported("keyframe index", path, "no keyframes in container index")
|
||||
return nil, false
|
||||
}
|
||||
return times, true
|
||||
}
|
||||
|
||||
// dedupeKeyframeTimes sorts times, drops negative values, and drops times
|
||||
// within keyframeTimeDedupeSec of the previous kept time, as go-ffmpeg does for
|
||||
// ffprobe output.
|
||||
func dedupeKeyframeTimes(times []float64) []float64 {
|
||||
sorted := append([]float64(nil), times...)
|
||||
sort.Float64s(sorted)
|
||||
out := make([]float64, 0, len(sorted))
|
||||
for _, t := range sorted {
|
||||
if t < 0 || math.IsNaN(t) || math.IsInf(t, 0) {
|
||||
continue
|
||||
}
|
||||
if len(out) == 0 || t > out[len(out)-1]+keyframeTimeDedupeSec {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ctxReaderAt fails reads once ctx is done, so a cancelled request or the plan
|
||||
// timeout stops a long index walk on a slow share.
|
||||
type ctxReaderAt struct {
|
||||
ctx context.Context
|
||||
r io.ReaderAt
|
||||
}
|
||||
|
||||
// ReadAt reads from the wrapped reader unless the context is done.
|
||||
func (c ctxReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.r.ReadAt(p, off)
|
||||
}
|
||||
|
||||
// keyframeDiff compares go-media's keyframe index with ffprobe's. go-media
|
||||
// reporting a subset of ffprobe's keyframes is expected (parser-flagged IDR
|
||||
// frames, partial Cues) and is logged at info level.
|
||||
func keyframeDiff(fast, legacy []float64) []string {
|
||||
missing := 0
|
||||
j := 0
|
||||
for _, t := range fast {
|
||||
for j < len(legacy) && legacy[j] < t-keyframeShadowTolerance {
|
||||
j++
|
||||
missing++
|
||||
}
|
||||
if j >= len(legacy) || math.Abs(legacy[j]-t) > keyframeShadowTolerance {
|
||||
return []string{fmt.Sprintf("keyframe at %.6fs from go-media not in ffmpeg index (go-media=%d ffmpeg=%d keyframes)", t, len(fast), len(legacy))}
|
||||
}
|
||||
j++
|
||||
}
|
||||
missing += len(legacy) - j
|
||||
if missing > 0 {
|
||||
return []string{fmt.Sprintf("%sgo-media found %d of %d ffmpeg keyframes (keyframes outside the container index)", expectedDiffPrefix, len(fast), len(legacy))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ffprobeKeyframes runs the same packet probe as go-ffmpeg's
|
||||
// ProbeVideoKeyframeTimes: key packets of v:0, pts_time falling back to
|
||||
// dts_time, sorted and de-duplicated within 1 ms.
|
||||
func ffprobeKeyframes(t *testing.T, path string) []float64 {
|
||||
t.Helper()
|
||||
requireTool(t, "ffprobe")
|
||||
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "packet=pts_time,dts_time,flags", "-of", "csv=p=0", path).Output()
|
||||
require.NoError(t, err)
|
||||
var times []float64
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(strings.TrimSpace(line), ",")
|
||||
if len(f) < 3 || !strings.Contains(f[2], "K") {
|
||||
continue
|
||||
}
|
||||
v, perr := strconv.ParseFloat(f[0], 64)
|
||||
if perr != nil {
|
||||
v, perr = strconv.ParseFloat(f[1], 64)
|
||||
}
|
||||
if perr == nil {
|
||||
times = append(times, v)
|
||||
}
|
||||
}
|
||||
sort.Float64s(times)
|
||||
return dedupeKeyframeTimes(times)
|
||||
}
|
||||
|
||||
// encodeKeyframeClip makes a short clip with a fixed GOP. B-frames give MP4
|
||||
// files a ctts table and an edit list, the hard case for the timestamp model.
|
||||
func encodeKeyframeClip(t *testing.T, name string, args ...string) string {
|
||||
t.Helper()
|
||||
requireTool(t, "ffmpeg")
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
full := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=6"}
|
||||
full = append(full, args...)
|
||||
full = append(full, path)
|
||||
if out, err := exec.Command("ffmpeg", full...).CombinedOutput(); err != nil {
|
||||
t.Skipf("ffmpeg cannot encode %s: %v: %s", name, err, out)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// requireSameKeyframes fails unless got matches want within the shadow
|
||||
// tolerance.
|
||||
func requireSameKeyframes(t *testing.T, want, got []float64) {
|
||||
t.Helper()
|
||||
require.Equal(t, len(want), len(got), "keyframe count: ffprobe=%v go-media=%v", want, got)
|
||||
for i := range want {
|
||||
require.InDelta(t, want[i], got[i], keyframeShadowTolerance, "keyframe %d: ffprobe=%v go-media=%v", i, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyframeTimesMatchFFprobe compares go-media keyframe indexes with
|
||||
// ffprobe for fixtures and encoded clips, including B-frame MP4/MOV.
|
||||
func TestKeyframeTimesMatchFFprobe(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("encodes clips with ffmpeg")
|
||||
}
|
||||
withGate(t, false)
|
||||
x264 := []string{"-c:v", "libx264", "-g", "50", "-keyint_min", "50", "-sc_threshold", "0", "-bf", "2", "-pix_fmt", "yuv420p"}
|
||||
cases := map[string]string{
|
||||
"fixture.mp4": fixtureMP4,
|
||||
"fixture.webm": fixtureWebM,
|
||||
"bframes.mp4": encodeKeyframeClip(t, "bframes.mp4", x264...),
|
||||
"bframes.mov": encodeKeyframeClip(t, "bframes.mov", x264...),
|
||||
"bframes.mkv": encodeKeyframeClip(t, "bframes.mkv", x264...),
|
||||
"mpeg4.m4v": encodeKeyframeClip(t, "mpeg4.m4v", "-c:v", "mpeg4", "-g", "30", "-f", "mp4"),
|
||||
}
|
||||
for name, path := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
want := ffprobeKeyframes(t, path)
|
||||
got, ok := KeyframeTimes(context.Background(), path, nil)
|
||||
require.True(t, ok)
|
||||
requireSameKeyframes(t, want, got)
|
||||
|
||||
within, ok := KeyframeTimesWithin(context.Background(), path, 2.5)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, within)
|
||||
require.LessOrEqual(t, len(within), len(got))
|
||||
for i := range within {
|
||||
require.Equal(t, got[i], within[i])
|
||||
require.LessOrEqual(t, within[i]-got[0], 2.5+1e-9)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyframeTimesFallsBack checks the cases that must leave the index to
|
||||
// ffprobe.
|
||||
func TestKeyframeTimesFallsBack(t *testing.T) {
|
||||
withGate(t, false)
|
||||
dir := t.TempDir()
|
||||
|
||||
// Not an indexed container: no file open, ffprobe answers.
|
||||
ts := filepath.Join(dir, "clip.ts")
|
||||
require.NoError(t, os.WriteFile(ts, []byte{0x47}, 0o644))
|
||||
_, ok := KeyframeTimes(context.Background(), ts, nil)
|
||||
require.False(t, ok)
|
||||
|
||||
// Indexed extension but not a real container.
|
||||
junk := filepath.Join(dir, "junk.mp4")
|
||||
require.NoError(t, os.WriteFile(junk, []byte("not a video"), 0o644))
|
||||
_, ok = KeyframeTimes(context.Background(), junk, nil)
|
||||
require.False(t, ok)
|
||||
|
||||
// Missing file.
|
||||
_, ok = KeyframeTimes(context.Background(), filepath.Join(dir, "missing.mp4"), nil)
|
||||
require.False(t, ok)
|
||||
|
||||
// Cancelled context.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, ok = KeyframeTimes(ctx, fixtureMP4, nil)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestKeyframeTimesGate checks that the Always use ffmpeg setting disables
|
||||
// the go-media keyframe index.
|
||||
func TestKeyframeTimesGate(t *testing.T) {
|
||||
withGate(t, true)
|
||||
_, ok := KeyframeTimes(context.Background(), fixtureMP4, nil)
|
||||
require.False(t, ok, "KeyframeTimes must skip go-media when the gate is on")
|
||||
_, ok = KeyframeTimesWithin(context.Background(), fixtureMP4, 10)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestDedupeKeyframeTimes checks sorting, filtering, and the 1 ms dedupe.
|
||||
func TestDedupeKeyframeTimes(t *testing.T) {
|
||||
got := dedupeKeyframeTimes([]float64{2, -1, 0, 0.0005, 1, 1.001, 1.0021, math.NaN(), math.Inf(1)})
|
||||
require.Equal(t, []float64{0, 1, 1.0021, 2}, got)
|
||||
require.Empty(t, dedupeKeyframeTimes(nil))
|
||||
}
|
||||
|
||||
// TestCtxReaderAt checks that reads stop once the context is cancelled.
|
||||
func TestCtxReaderAt(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := ctxReaderAt{ctx: ctx, r: strings.NewReader("abcdef")}
|
||||
buf := make([]byte, 3)
|
||||
n, err := r.ReadAt(buf, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "bcd", string(buf[:n]))
|
||||
cancel()
|
||||
_, err = r.ReadAt(buf, 0)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
// TestKeyframeDiff checks the shadow comparison: a subset is expected, an
|
||||
// extra keyframe is a real difference.
|
||||
func TestKeyframeDiff(t *testing.T) {
|
||||
require.Empty(t, keyframeDiff([]float64{0, 2, 4}, []float64{0, 2.001, 4}))
|
||||
|
||||
subset := keyframeDiff([]float64{0, 4}, []float64{0, 2, 4, 6})
|
||||
require.Len(t, subset, 1)
|
||||
require.True(t, strings.HasPrefix(subset[0], expectedDiffPrefix), subset[0])
|
||||
require.Contains(t, subset[0], "2 of 4")
|
||||
|
||||
extra := keyframeDiff([]float64{0, 1, 2}, []float64{0, 2})
|
||||
require.Len(t, extra, 1)
|
||||
require.False(t, strings.HasPrefix(extra[0], expectedDiffPrefix), extra[0])
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,109 +0,0 @@
|
||||
// Package mediacache holds the media metadata caches shared by the ffmpeg and
|
||||
// go-media code paths. Both backends read and write the same entries, so a
|
||||
// value found by one is reused by the other instead of being probed again.
|
||||
//
|
||||
// Every entry is keyed by file identity: path, size, and modification time in
|
||||
// nanoseconds. An edited file gets a new key, so a stale value is never served
|
||||
// for it; the old entry ages out after TTL.
|
||||
package mediacache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
// TTL is how long a cached media value is kept after it is stored.
|
||||
const TTL = 24 * time.Hour
|
||||
|
||||
// ProbeInfo is the container summary both backends produce: ffprobe through
|
||||
// go-ffmpeg, and go-media. FormatName uses ffprobe's format_name strings.
|
||||
type ProbeInfo struct {
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
FormatName string
|
||||
}
|
||||
|
||||
var (
|
||||
// Probes caches container probes (duration, first video and audio codec,
|
||||
// format name).
|
||||
Probes = cache.NewCache[ProbeInfo](TTL)
|
||||
// Durations caches media length in seconds.
|
||||
Durations = cache.NewCache[float64](TTL)
|
||||
// SubtitleTracks caches the embedded subtitle track list of a video.
|
||||
SubtitleTracks = cache.NewCache[[]utils.SubtitleTrack](TTL)
|
||||
// SubtitleContent caches one embedded subtitle stream converted to WebVTT.
|
||||
// Use Key.Stream for its key.
|
||||
SubtitleContent = cache.NewCache[string](TTL)
|
||||
)
|
||||
|
||||
// Key identifies one version of a file. The zero Key is never produced for a
|
||||
// real file, because Path is always set.
|
||||
type Key struct {
|
||||
Path string
|
||||
Size int64
|
||||
ModTime int64 // modification time in Unix nanoseconds
|
||||
}
|
||||
|
||||
// FromFileInfo returns the Key for path using an already-read FileInfo, so
|
||||
// callers that stat or open the file anyway do not stat it twice.
|
||||
func FromFileInfo(path string, info fs.FileInfo) Key {
|
||||
return Key{Path: path, Size: info.Size(), ModTime: info.ModTime().UnixNano()}
|
||||
}
|
||||
|
||||
// Stat returns the Key for path. It follows symlinks, like os.Stat.
|
||||
func Stat(path string) (Key, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
return FromFileInfo(path, info), nil
|
||||
}
|
||||
|
||||
// String returns the cache key for k. The path is quoted so a path containing
|
||||
// the separator cannot collide with another file's key.
|
||||
func (k Key) String() string {
|
||||
return fmt.Sprintf("%q:%d:%d", k.Path, k.Size, k.ModTime)
|
||||
}
|
||||
|
||||
// Stream returns the cache key for one stream of k, used by SubtitleContent.
|
||||
func (k Key) Stream(index int) string {
|
||||
return fmt.Sprintf("%s:%d", k.String(), index)
|
||||
}
|
||||
|
||||
// ClearAll drops every cached media value. It is used when the backend that
|
||||
// produced the values is switched (the admin "Always use ffmpeg" setting), so
|
||||
// the newly selected backend answers fresh.
|
||||
func ClearAll() {
|
||||
Probes.ClearAll()
|
||||
Durations.ClearAll()
|
||||
SubtitleTracks.ClearAll()
|
||||
SubtitleContent.ClearAll()
|
||||
}
|
||||
|
||||
// bypassKey is the context key set by WithoutCache.
|
||||
type bypassKey struct{}
|
||||
|
||||
// WithoutCache returns a context under which the ffmpeg code paths neither read
|
||||
// nor write the shared caches. Dev-mode shadow runs use it, so the ffmpeg result
|
||||
// they compare against go-media is computed fresh rather than read back from the
|
||||
// value go-media just stored.
|
||||
func WithoutCache(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, bypassKey{}, true)
|
||||
}
|
||||
|
||||
// Bypassed reports whether ctx was returned by WithoutCache. A nil context is
|
||||
// not bypassed.
|
||||
func Bypassed(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(bypassKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package mediacache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStatKeyChangesWithSizeAndModTime checks on disk that a size change
|
||||
// or an mtime change gives a new key. The mtimes differ by whole seconds so
|
||||
// the check holds on filesystems with coarse timestamps; the 1 ns case is
|
||||
// covered by TestFromFileInfoNanosecondModTime.
|
||||
func TestStatKeyChangesWithSizeAndModTime(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.mp4")
|
||||
if err := os.WriteFile(path, []byte("one"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k1, err := Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k1.Path != path || k1.Size != 3 || k1.ModTime == 0 {
|
||||
t.Fatalf("unexpected key %+v", k1)
|
||||
}
|
||||
|
||||
// Same size, different mtime.
|
||||
mt := time.Unix(1_700_000_000, 0)
|
||||
if err = os.Chtimes(path, mt, mt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k2, err := Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k2.String() == k1.String() {
|
||||
t.Fatalf("mtime change kept key %s", k2)
|
||||
}
|
||||
mt2 := mt.Add(time.Second)
|
||||
if err = os.Chtimes(path, mt2, mt2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k3, _ := Stat(path)
|
||||
if k3.String() == k2.String() {
|
||||
t.Fatalf("1s mtime change kept key %s", k3)
|
||||
}
|
||||
|
||||
// Same mtime, different size.
|
||||
if err = os.WriteFile(path, []byte("three"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.Chtimes(path, mt2, mt2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k4, _ := Stat(path)
|
||||
if k4.String() == k3.String() {
|
||||
t.Fatalf("size change kept key %s", k4)
|
||||
}
|
||||
}
|
||||
|
||||
// stubFileInfo is an fs.FileInfo with a fixed size and mtime.
|
||||
type stubFileInfo struct {
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// Name implements fs.FileInfo.
|
||||
func (s stubFileInfo) Name() string { return "a.mp4" }
|
||||
|
||||
// Size implements fs.FileInfo.
|
||||
func (s stubFileInfo) Size() int64 { return s.size }
|
||||
|
||||
// Mode implements fs.FileInfo.
|
||||
func (s stubFileInfo) Mode() fs.FileMode { return 0o600 }
|
||||
|
||||
// ModTime implements fs.FileInfo.
|
||||
func (s stubFileInfo) ModTime() time.Time { return s.modTime }
|
||||
|
||||
// IsDir implements fs.FileInfo.
|
||||
func (s stubFileInfo) IsDir() bool { return false }
|
||||
|
||||
// Sys implements fs.FileInfo.
|
||||
func (s stubFileInfo) Sys() any { return nil }
|
||||
|
||||
// TestFromFileInfoNanosecondModTime checks that mtimes 1 ns apart give
|
||||
// different keys, independent of what a filesystem can store.
|
||||
func TestFromFileInfoNanosecondModTime(t *testing.T) {
|
||||
a := FromFileInfo("/m/a.mp4", stubFileInfo{size: 3, modTime: time.Unix(1_700_000_000, 123)})
|
||||
b := FromFileInfo("/m/a.mp4", stubFileInfo{size: 3, modTime: time.Unix(1_700_000_000, 124)})
|
||||
if a.String() == b.String() {
|
||||
t.Fatalf("1ns mtime change kept key %s", a)
|
||||
}
|
||||
if a.ModTime != time.Unix(1_700_000_000, 123).UnixNano() {
|
||||
t.Fatalf("ModTime = %d, want nanoseconds", a.ModTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatMissingFile checks that Stat reports a missing file.
|
||||
func TestStatMissingFile(t *testing.T) {
|
||||
if _, err := Stat(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected an error for a missing file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyStringDoesNotCollide checks that keys of different files, and of
|
||||
// different streams of one file, never collide.
|
||||
func TestKeyStringDoesNotCollide(t *testing.T) {
|
||||
a := Key{Path: "a:1", Size: 2, ModTime: 3}
|
||||
b := Key{Path: "a", Size: 1, ModTime: 2}
|
||||
if a.String() == b.String() {
|
||||
t.Fatalf("keys collide: %s", a)
|
||||
}
|
||||
if a.Stream(1) == a.Stream(2) || a.Stream(1) == a.String() {
|
||||
t.Fatalf("stream keys collide: %s %s", a.Stream(1), a.Stream(2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearAll checks that ClearAll empties every shared cache.
|
||||
func TestClearAll(t *testing.T) {
|
||||
k := Key{Path: "/clear", Size: 1, ModTime: 1}
|
||||
Probes.Set(k.String(), ProbeInfo{Duration: 1})
|
||||
Durations.Set(k.String(), 1)
|
||||
SubtitleContent.Set(k.Stream(0), "x")
|
||||
ClearAll()
|
||||
if _, ok := Probes.Get(k.String()); ok {
|
||||
t.Fatal("probe survived ClearAll")
|
||||
}
|
||||
if _, ok := Durations.Get(k.String()); ok {
|
||||
t.Fatal("duration survived ClearAll")
|
||||
}
|
||||
if _, ok := SubtitleContent.Get(k.Stream(0)); ok {
|
||||
t.Fatal("subtitle content survived ClearAll")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithoutCache checks the cache bypass marker and that derived contexts
|
||||
// keep it.
|
||||
func TestWithoutCache(t *testing.T) {
|
||||
if Bypassed(context.Background()) {
|
||||
t.Fatal("background context must not bypass")
|
||||
}
|
||||
//nolint:staticcheck // nil is part of the documented contract.
|
||||
if Bypassed(nil) {
|
||||
t.Fatal("nil context must not bypass")
|
||||
}
|
||||
ctx := WithoutCache(context.Background())
|
||||
if !Bypassed(ctx) {
|
||||
t.Fatal("WithoutCache context must bypass")
|
||||
}
|
||||
child, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
if !Bypassed(child) {
|
||||
t.Fatal("bypass must survive derived contexts")
|
||||
}
|
||||
}
|
||||
@@ -272,9 +272,8 @@ func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
|
||||
return previewTypeOffice
|
||||
}
|
||||
|
||||
// Check HEIC with conversion enabled. AVIF is a HEIF container too and uses
|
||||
// the same path (go-media decode, ffmpeg fallback) under the HEIC setting.
|
||||
if (strings.HasPrefix(file.Type, "image/heic") || file.Type == "image/avif") &&
|
||||
// Check HEIC with conversion enabled
|
||||
if strings.HasPrefix(file.Type, "image/heic") &&
|
||||
settings.CanConvertImage(string(settings.HEICImagePreview)) {
|
||||
return previewTypeHEIC
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// withHEICConversion sets ffmpeg availability and the HEIC preview setting for
|
||||
// one test.
|
||||
func withHEICConversion(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
prevAvail := settings.Env.FFmpegAvailable
|
||||
prevPreview := settings.Config.Integrations.Media.Convert.ImagePreview
|
||||
t.Cleanup(func() {
|
||||
settings.Env.FFmpegAvailable = prevAvail
|
||||
settings.Config.Integrations.Media.Convert.ImagePreview = prevPreview
|
||||
})
|
||||
settings.Env.FFmpegAvailable = true
|
||||
settings.Config.Integrations.Media.Convert.ImagePreview = map[settings.ImagePreviewType]*bool{
|
||||
settings.HEICImagePreview: &enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// previewFile returns a file info with only a name and MIME type set.
|
||||
func previewFile(name, mimeType string) iteminfo.ExtendedFileInfo {
|
||||
var f iteminfo.ExtendedFileInfo
|
||||
f.Name = name
|
||||
f.Type = mimeType
|
||||
return f
|
||||
}
|
||||
|
||||
// TestDeterminePreviewTypeAVIF checks that AVIF uses the HEIF decode path when
|
||||
// HEIC conversion is enabled, and the plain image path otherwise.
|
||||
func TestDeterminePreviewTypeAVIF(t *testing.T) {
|
||||
withHEICConversion(t, true)
|
||||
if got := determinePreviewType(previewFile("a.avif", "image/avif")); got != previewTypeHEIC {
|
||||
t.Fatalf("avif with HEIC conversion = %v, want previewTypeHEIC", got)
|
||||
}
|
||||
if got := determinePreviewType(previewFile("a.heic", "image/heic")); got != previewTypeHEIC {
|
||||
t.Fatalf("heic with HEIC conversion = %v, want previewTypeHEIC", got)
|
||||
}
|
||||
|
||||
withHEICConversion(t, false)
|
||||
if got := determinePreviewType(previewFile("a.avif", "image/avif")); got != previewTypeImage {
|
||||
t.Fatalf("avif without HEIC conversion = %v, want previewTypeImage", got)
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,8 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// go-media fast path: fit within the box, no crop. go-media decodes H.264,
|
||||
// HEVC, VP8, VP9, AV1, MPEG-4 Part 2, and WMV3 keyframes in pure Go. It falls
|
||||
// back to ffmpeg when the container is not a go-media target (AVI, MPEG-TS,
|
||||
// MPEG-PS, ...), the codec or profile is unsupported (WMV1/WMV2, VC-1
|
||||
// Advanced, AV1 above 4K, ...), or the decode fails.
|
||||
// go-media fast path: fit within the box, no crop. Falls back to ffmpeg when
|
||||
// unsupported (e.g. HEVC, VP9, AV1) or failed.
|
||||
// In shadow mode (dev only) the ffmpeg preview also runs, for comparison.
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
|
||||
@@ -133,7 +133,7 @@ func (e *MediaEngine) BuildPlan(ctx context.Context, realPath string, profile Pr
|
||||
copyImpossible := false
|
||||
plan.segmentSec = defaults.SegmentDurationSec
|
||||
if plan.remux || plan.videoCopy {
|
||||
rawKeyframes, kfErr := probeKeyframesCached(ctx, svc, realPath, stat.Size(), stat.ModTime().UnixNano(), plan.segmentSec, mediaSpan{durationSec: info.Duration, startTimeSec: info.StartTime}, true)
|
||||
rawKeyframes, kfErr := probeKeyframesCached(ctx, svc, realPath, stat.Size(), stat.ModTime().UnixNano(), plan.segmentSec, true)
|
||||
if kfErr != nil {
|
||||
copyImpossible = true
|
||||
logger.Warningf("transcode: keyframe probe failed on %s (%v) — forcing video transcode for HLS", realPath, kfErr)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -56,9 +55,6 @@ type keyframeCache struct {
|
||||
|
||||
var defaultKeyframeCache = &keyframeCache{entries: map[keyframeKey]*keyframeEntry{}}
|
||||
|
||||
// get returns the keyframe timeline for key from memory, then disk, then
|
||||
// probe. Concurrent callers for one key wait on a single probe; failures are
|
||||
// not cached.
|
||||
func (c *keyframeCache) get(ctx context.Context, key keyframeKey, probe func(context.Context) ([]float64, error)) ([]float64, error) {
|
||||
c.mu.Lock()
|
||||
e := c.entries[key]
|
||||
@@ -109,15 +105,12 @@ func keyframeDiskCacheEnabled() bool {
|
||||
return settings.Config.Server.CacheDir != ""
|
||||
}
|
||||
|
||||
// keyframeDiskPath returns the disk cache file for key's path.
|
||||
func keyframeDiskPath(key keyframeKey) string {
|
||||
sum := sha256.Sum256([]byte(key.path))
|
||||
name := hex.EncodeToString(sum[:])[:32] + ".json"
|
||||
return filepath.Join(settings.TranscodeKeyframeCacheDir(), name)
|
||||
}
|
||||
|
||||
// readKeyframeDiskCache returns the persisted timeline for key when it exists
|
||||
// and matches the file's size, mtime, and the cache version.
|
||||
func readKeyframeDiskCache(key keyframeKey) ([]float64, bool) {
|
||||
if !keyframeDiskCacheEnabled() {
|
||||
return nil, false
|
||||
@@ -139,8 +132,6 @@ func readKeyframeDiskCache(key keyframeKey) ([]float64, bool) {
|
||||
return stamp.Times, true
|
||||
}
|
||||
|
||||
// writeKeyframeDiskCache persists a timeline for key atomically. Errors are
|
||||
// ignored; the cache is an optimization.
|
||||
func writeKeyframeDiskCache(key keyframeKey, times []float64) {
|
||||
if len(times) == 0 || !keyframeDiskCacheEnabled() {
|
||||
return
|
||||
@@ -228,88 +219,19 @@ func probeKeyframesWithPreflight(ctx context.Context, svc *ffmpeg.Service, realP
|
||||
)
|
||||
}
|
||||
|
||||
// mediaSpan is the ffprobe format duration and start time of a file, the
|
||||
// values the HLS planner turns into its media timeline. The zero value means
|
||||
// unknown.
|
||||
type mediaSpan struct {
|
||||
durationSec float64
|
||||
startTimeSec float64
|
||||
}
|
||||
|
||||
// trailingGapExceedsCopyCap reports whether the stretch from the last
|
||||
// keyframe to the end of the media is longer than the copy cap, measured on
|
||||
// the same normalized timeline the planner builds: the final segment runs
|
||||
// from the last keyframe to the media end, so such a gap forces a transcode
|
||||
// just like a gap between two keyframes. An unknown span reports false.
|
||||
func trailingGapExceedsCopyCap(keyframes []float64, span mediaSpan, segmentSec float64) bool {
|
||||
if len(keyframes) == 0 || span.durationSec <= 0 {
|
||||
return false
|
||||
}
|
||||
kf, mediaDur := ffmpeg.PrepareHLSMediaTimeline(keyframes, span.durationSec, span.startTimeSec)
|
||||
if len(kf) == 0 || mediaDur <= 0 {
|
||||
return false
|
||||
}
|
||||
return mediaDur-kf[len(kf)-1] > maxCopySegmentDurationSec(segmentSec)
|
||||
}
|
||||
|
||||
// keyframeProbeTimeout bounds a single full-file keyframe probe. The default
|
||||
// demux has no deadline of its own, so a slow network share would otherwise
|
||||
// stall plan building (and the client's session POST) indefinitely.
|
||||
const keyframeProbeTimeout = 2 * time.Minute
|
||||
|
||||
// probeKeyframesCached returns the keyframe timeline of a file version from
|
||||
// the memory or disk cache, or probes it: go-media reads the container index
|
||||
// first, and ffprobe runs only when go-media cannot answer or reports a gap
|
||||
// that would rule out stream copy. span is the file's duration and start
|
||||
// time, used to check the gap after the last keyframe; the zero value skips
|
||||
// that check.
|
||||
func probeKeyframesCached(ctx context.Context, svc *ffmpeg.Service, realPath string, size, modTime int64, segmentSec float64, span mediaSpan, preflight bool) ([]float64, error) {
|
||||
func probeKeyframesCached(ctx context.Context, svc *ffmpeg.Service, realPath string, size, modTime int64, segmentSec float64, preflight bool) ([]float64, error) {
|
||||
key := keyframeKey{path: realPath, size: size, modTime: modTime}
|
||||
return defaultKeyframeCache.get(ctx, key, func(ctx context.Context) ([]float64, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, keyframeProbeTimeout)
|
||||
defer cancel()
|
||||
if times, ok := gomediaKeyframes(probeCtx, svc, realPath, segmentSec, span, preflight); ok {
|
||||
return times, nil
|
||||
}
|
||||
if preflight {
|
||||
return probeKeyframesWithPreflight(probeCtx, svc, realPath, segmentSec)
|
||||
}
|
||||
return svc.ProbeVideoKeyframeTimes(probeCtx, realPath)
|
||||
})
|
||||
}
|
||||
|
||||
// gomediaKeyframes reads the keyframe timeline from the container index with
|
||||
// go-media. It reports ok=false when go-media cannot answer, and also when
|
||||
// preflight is set and the timeline has a gap past the copy cap, including
|
||||
// the gap from the last keyframe to the end of span: go-media can miss
|
||||
// keyframes that ffprobe's parsers flag (IDR frames not in the index,
|
||||
// partial Matroska Cues) but never adds one, so only ffprobe may decide that
|
||||
// stream copy is impossible. A bounded go-media read runs first, so a file
|
||||
// whose head already shows such a gap does not pay for a full index walk.
|
||||
func gomediaKeyframes(ctx context.Context, svc *ffmpeg.Service, realPath string, segmentSec float64, span mediaSpan, preflight bool) ([]float64, bool) {
|
||||
if preflight {
|
||||
if head, ok := gomedia.KeyframeTimesWithin(ctx, realPath, keyframeBoundedPreflightWindowSec); ok && keyframesExceedCopyCap(head, segmentSec) {
|
||||
logger.Debugf("transcode: go-media keyframe pre-flight shows a gap past the copy cap on %s; confirming with ffprobe", realPath)
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
legacy := func() ([]float64, bool) {
|
||||
shadowCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), keyframeProbeTimeout)
|
||||
defer cancel()
|
||||
times, err := svc.ProbeVideoKeyframeTimes(shadowCtx, realPath)
|
||||
return times, err == nil
|
||||
}
|
||||
times, ok := gomedia.KeyframeTimes(ctx, realPath, legacy)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if preflight && keyframesExceedCopyCap(times, segmentSec) {
|
||||
logger.Debugf("transcode: go-media keyframe index shows a gap past the copy cap on %s; confirming with ffprobe", realPath)
|
||||
return nil, false
|
||||
}
|
||||
if preflight && trailingGapExceedsCopyCap(times, span, segmentSec) {
|
||||
logger.Debugf("transcode: go-media keyframe index ends more than the copy cap before the end of %s; confirming with ffprobe", realPath)
|
||||
return nil, false
|
||||
}
|
||||
return times, true
|
||||
}
|
||||
@@ -177,33 +177,3 @@ func TestKeyframeCacheDoesNotCacheFailures(t *testing.T) {
|
||||
t.Fatalf("retry after failure: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrailingGapExceedsCopyCap checks the gap from the last keyframe to the
|
||||
// end of the media, on the planner's start-time-normalized timeline.
|
||||
func TestTrailingGapExceedsCopyCap(t *testing.T) {
|
||||
every2 := func(from, to float64) []float64 {
|
||||
var out []float64
|
||||
for v := from; v < to; v += 2 {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
kf []float64
|
||||
span mediaSpan
|
||||
want bool
|
||||
}{
|
||||
{"partial index", []float64{0, 2}, mediaSpan{durationSec: 60}, true},
|
||||
{"full index", every2(0, 60), mediaSpan{durationSec: 60}, false},
|
||||
{"unknown span", []float64{0, 2}, mediaSpan{}, false},
|
||||
{"start time, partial", []float64{10, 12}, mediaSpan{durationSec: 70, startTimeSec: 10}, true},
|
||||
{"start time, full", every2(10, 70), mediaSpan{durationSec: 70, startTimeSec: 10}, false},
|
||||
{"no keyframes", nil, mediaSpan{durationSec: 60}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := trailingGapExceedsCopyCap(c.kf, c.span, 4); got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package transcoding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
const keyframeFixtureMP4 = "../../../frontend/tests/playwright-files/myfolder/testdata/sample-640x480.mp4"
|
||||
|
||||
// TestProbeKeyframesCachedUsesGoMedia checks that an MP4 keyframe timeline is
|
||||
// built without ffprobe: with no ffmpeg service, only go-media can answer.
|
||||
func TestProbeKeyframesCachedUsesGoMedia(t *testing.T) {
|
||||
if settings.AlwaysUseFfmpegEnabled() {
|
||||
t.Skip("go-media disabled by settings")
|
||||
}
|
||||
prevCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = "" // keep the disk cache out of the test
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = prevCacheDir })
|
||||
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
data, err := os.ReadFile(keyframeFixtureMP4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
times, err := probeKeyframesCached(context.Background(), nil, path, st.Size(), st.ModTime().UnixNano(), 4, mediaSpan{}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("probeKeyframesCached: %v", err)
|
||||
}
|
||||
if len(times) == 0 || times[0] < 0 {
|
||||
t.Fatalf("unexpected keyframes %v", times)
|
||||
}
|
||||
for i := 1; i < len(times); i++ {
|
||||
if times[i] <= times[i-1] {
|
||||
t.Fatalf("keyframes not strictly increasing: %v", times)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGomediaKeyframesDefersCopyDecision checks that a go-media timeline with a
|
||||
// gap past the copy cap is not used for the pre-flight decision (ffprobe must
|
||||
// confirm it), while a plain probe still returns it.
|
||||
func TestGomediaKeyframesDefersCopyDecision(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("encodes a clip with ffmpeg")
|
||||
}
|
||||
if settings.AlwaysUseFfmpegEnabled() {
|
||||
t.Skip("go-media disabled by settings")
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "longgop.mp4")
|
||||
out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=128x96:rate=25:duration=40",
|
||||
"-c:v", "libx264", "-g", "750", "-keyint_min", "750", "-sc_threshold", "0", "-pix_fmt", "yuv420p", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("ffmpeg cannot encode libx264: %v: %s", err, out)
|
||||
}
|
||||
|
||||
if times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{}, true); ok {
|
||||
t.Fatalf("pre-flight must defer a 30s GOP to ffprobe, got %v", times)
|
||||
}
|
||||
times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{}, false)
|
||||
if !ok {
|
||||
t.Fatal("a plain probe must return the go-media timeline")
|
||||
}
|
||||
if len(times) != 2 || times[0] != 0 || times[1] < 29.9 || times[1] > 30.1 {
|
||||
t.Fatalf("unexpected keyframes %v, want [0 30]", times)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGomediaKeyframesDefersTrailingGap checks that a go-media index whose
|
||||
// last keyframe is more than the copy cap before the end of the media is not
|
||||
// used for the pre-flight decision, so ffprobe can confirm it, while the
|
||||
// same index is used when it reaches the end.
|
||||
func TestGomediaKeyframesDefersTrailingGap(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("encodes a clip with ffmpeg")
|
||||
}
|
||||
if settings.AlwaysUseFfmpegEnabled() {
|
||||
t.Skip("go-media disabled by settings")
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "short.mp4")
|
||||
out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=128x96:rate=25:duration=4",
|
||||
"-c:v", "libx264", "-g", "50", "-keyint_min", "50", "-sc_threshold", "0", "-pix_fmt", "yuv420p", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("ffmpeg cannot encode libx264: %v: %s", err, out)
|
||||
}
|
||||
// The index ends at 2 s; a 60 s span leaves a 58 s final segment.
|
||||
if times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{durationSec: 60}, true); ok {
|
||||
t.Fatalf("pre-flight must defer a 58s trailing gap to ffprobe, got %v", times)
|
||||
}
|
||||
times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{durationSec: 4}, true)
|
||||
if !ok || len(times) != 2 {
|
||||
t.Fatalf("index reaching the end must be used, got %v, %v", times, ok)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
@@ -124,8 +123,7 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
}
|
||||
// Shadow ffmpeg runs after the fast path; bound it so a detached request cannot
|
||||
// leave extraction running indefinitely (sync fallback still uses r.Context()).
|
||||
// WithoutCache: compare against a fresh extraction, not the value go-media stored.
|
||||
legacyCtx, cancel := context.WithTimeout(mediacache.WithoutCache(context.WithoutCancel(r.Context())), time.Minute)
|
||||
legacyCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), time.Minute)
|
||||
defer cancel()
|
||||
vtt, extractErr := svc.ExtractSubtitle(legacyCtx, fileInfo.RealPath, *track.Index)
|
||||
return vtt, extractErr == nil
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/encode"
|
||||
@@ -289,11 +288,6 @@ func ApplyMediaSettings(s FullAdminMedia) {
|
||||
Config.Integrations.Media.Debug = s.Debug
|
||||
Config.Integrations.Media.HardwareAcceleration = s.HardwareAcceleration
|
||||
Config.Integrations.Media.AccelDevice = s.AccelDevice
|
||||
if Config.Integrations.Media.AlwaysUseFfmpeg != s.AlwaysUseFfmpeg {
|
||||
// The shared media cache holds values from whichever backend answered
|
||||
// first. Switching backends drops them so the selected one answers fresh.
|
||||
mediacache.ClearAll()
|
||||
}
|
||||
Config.Integrations.Media.AlwaysUseFfmpeg = s.AlwaysUseFfmpeg
|
||||
Config.Integrations.Media.AlwaysUseOnlyOffice = s.AlwaysUseOnlyOffice
|
||||
Config.Integrations.Media.Transcode = s.Transcode
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
)
|
||||
|
||||
func withMediaConfig(t *testing.T) {
|
||||
@@ -310,26 +308,3 @@ func TestInitFFmpegIsIdempotent(t *testing.T) {
|
||||
t.Fatal("InitFFmpeg must not run the probe during tests")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyMediaSettingsClearsMediaCacheOnBackendSwitch checks that toggling
|
||||
// "Always use ffmpeg" drops values the other backend cached, and that an
|
||||
// unrelated settings change keeps them.
|
||||
func TestApplyMediaSettingsClearsMediaCacheOnBackendSwitch(t *testing.T) {
|
||||
withMediaConfig(t)
|
||||
key := mediacache.Key{Path: "/settings-test.mp4", Size: 1, ModTime: 1}.String()
|
||||
t.Cleanup(func() { mediacache.Durations.Delete(key) })
|
||||
|
||||
media := CurrentMediaSettings()
|
||||
mediacache.Durations.Set(key, 1)
|
||||
media.Debug = !media.Debug
|
||||
ApplyMediaSettings(media)
|
||||
if _, ok := mediacache.Durations.Get(key); !ok {
|
||||
t.Fatal("an unrelated media settings change must keep the shared media cache")
|
||||
}
|
||||
|
||||
media.AlwaysUseFfmpeg = !media.AlwaysUseFfmpeg
|
||||
ApplyMediaSettings(media)
|
||||
if _, ok := mediacache.Durations.Get(key); ok {
|
||||
t.Fatal("switching Always use ffmpeg must clear the shared media cache")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22.22.2
|
||||
22
|
||||
+134
-258
@@ -1,264 +1,140 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"root": "../"
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!public/config.generated.yaml",
|
||||
"!public/index.html",
|
||||
"!node_modules",
|
||||
"!test-results"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!public/config.generated.yaml",
|
||||
"!public/index.html",
|
||||
"!node_modules",
|
||||
"!test-results"
|
||||
]
|
||||
},
|
||||
"formatter": { "enabled": false },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noAutofocus": "off",
|
||||
"noNoninteractiveElementToInteractiveRole": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useAriaPropsSupportedByRole": "off",
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
"noImportantStyles": "off",
|
||||
"noRedundantDefaultExport": "warn",
|
||||
"noUnnecessaryTemplateExpression": "error",
|
||||
"noUselessStringConcat": "warn",
|
||||
"useMathMinMax": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownPseudoClass": "off",
|
||||
"noVueImportCompilerMacros": "error",
|
||||
"noVueRefAsOperand": "error",
|
||||
"noVueVOnNumberValues": "error",
|
||||
"useVueValidVFor": "error",
|
||||
"noVueVIfWithVFor": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error",
|
||||
"noImpliedEval": "error",
|
||||
"noJsonUnsafeValues": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"noVueDeprecatedScopedSlots": "error",
|
||||
"noVueUndeclaredDirectives": "off",
|
||||
"useConsistentObjectKeys": "error",
|
||||
"useVueBaseImport": "warn",
|
||||
"noDoubleComparison": "on",
|
||||
"noNestedSwitch": "on",
|
||||
"noVueLifecycleAfterAwait": "error",
|
||||
"noVueShadowNativeEvents": "warn",
|
||||
"noVueVHtml": "error",
|
||||
"noVueVText": "warn",
|
||||
"noVueVTextVHtmlOnComponent": "error",
|
||||
"useSimplifiedBooleanReturn": "warn",
|
||||
"useVueConsistentEventHyphenation": "warn",
|
||||
"noPromiseExecutorReturn": "warn",
|
||||
"noArgumentsCallerOrCallee": "warn",
|
||||
"noVueRequiredPropWithDefault": "warn",
|
||||
"noBooleanSortComparator": "error",
|
||||
"noObsoleteAttributes": "error",
|
||||
"noUnsafeReturn": "warn",
|
||||
"noUselessSplice": "warn",
|
||||
"noVueWatchAfterAwait": "error",
|
||||
"useMathAbs": "warn",
|
||||
"useMathTrunc": "warn",
|
||||
"useVueComponentIs": "error",
|
||||
"useVueTypedRef": "error",
|
||||
"noReturnArrayPush": "warn",
|
||||
"noUselessRecursion": "error",
|
||||
"noVueComputedPropertiesInData": "error",
|
||||
"noVueDeprecatedDeleteSet": "error",
|
||||
"noVueDeprecatedRouterLinkTagProp": "error",
|
||||
"noVueMutatingProps": "error",
|
||||
"noVueMultipleSlotArgs": "warn",
|
||||
"noVueSideEffectsInComputed": "error",
|
||||
"noVueUnusedRefs": "error",
|
||||
"useVueTemplateRef": "warn",
|
||||
"useVueTypedProps": "warn",
|
||||
"useVueVModel": "warn",
|
||||
"useVueValidDefineEmits": "error",
|
||||
"useVueValidDefineProps": "error",
|
||||
"useVueValidVIs": "error",
|
||||
"useVueValidVModel": "error",
|
||||
"useVueValidVShow": "error",
|
||||
"useVueValidVSlot": "error",
|
||||
"noVueDeprecatedListeners": "error",
|
||||
"noArrayForEach": "error",
|
||||
"noChainedComparison": "error",
|
||||
"noDynamicDelete": "warn",
|
||||
"noLateEventControl": "error",
|
||||
"noObjectLiteralComparison": "error",
|
||||
"noPlaywrightUnsafeReferences": "error",
|
||||
"noUnnecessaryStringTrim": "warn",
|
||||
"noUselessLengthCheck": "info",
|
||||
"noUselessTypeAssertion": "warn",
|
||||
"noVueChildContent": "error",
|
||||
"noVueDeprecatedConfigKeyCodes": "info",
|
||||
"noVueDeprecatedEventsApi": "error",
|
||||
"noVueDeprecatedHtmlElementIs": "error",
|
||||
"noVueDeprecatedModelDefinition": "error",
|
||||
"noVueDeprecatedPropsDefaultThis": "error",
|
||||
"noVueDeprecatedVBindSync": "error",
|
||||
"noVueLoneTemplate": "error",
|
||||
"noVueMixin": "error",
|
||||
"noVueMultipleObjectsInClass": "warn",
|
||||
"noVueRefObjectReactivityLoss": "error",
|
||||
"noVueRestrictedCallAfterAwait": "error",
|
||||
"noVueRestrictedVOn": "warn",
|
||||
"noVueTemplateKey": "error",
|
||||
"noVueTemplateShadow": "warn",
|
||||
"noVueThisInTemplate": "warn",
|
||||
"noVueUndeclaredEmits": "warn",
|
||||
"noVueUndeclaredProperties": "error",
|
||||
"noVueUndeclaredSlots": "warn",
|
||||
"noVueUselessVBind": "warn",
|
||||
"noVueUselessTemplateAttributes": "error",
|
||||
"noVueVForTemplateKeyOnChild": "error",
|
||||
"useObjectFromEntries": "warn",
|
||||
"useVueBlockOrder": {
|
||||
"level": "on",
|
||||
"options": {
|
||||
"order": [
|
||||
"template",
|
||||
"script",
|
||||
"style"
|
||||
]
|
||||
}
|
||||
},
|
||||
"useVueBooleanPropTypeFirst": "warn",
|
||||
"useVueComputedPropertyReturn": "error",
|
||||
"useVueEmitValidator": "warn",
|
||||
"useVueEmitsValidatorReturn": "error",
|
||||
"useVuePropTypeConstructor": "error",
|
||||
"useVueRenderReturn": "error",
|
||||
"useVueSingleEventPayload": "warn",
|
||||
"useVueTypedObjectProps": "warn",
|
||||
"useVueValidDefaultProp": "error",
|
||||
"useVueValidNextTick": "error",
|
||||
"useVueValidDefineOptions": "error",
|
||||
"noRedundantTypeConstituents": "warn",
|
||||
"noUselessSpread": "warn",
|
||||
"noVueExposeAfterAwait": "error"
|
||||
},
|
||||
"performance": { "useVueVapor": "off" },
|
||||
"security": {
|
||||
"preset": "recommended",
|
||||
"useIframeSandbox": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off",
|
||||
"noTopLevelLiterals": "error",
|
||||
"noVueOptionsApi": "off",
|
||||
"useVueConsistentDefinePropsDeclaration": "on",
|
||||
"useVueConsistentVBindStyle": "off",
|
||||
"useVueHyphenatedAttributes": "off",
|
||||
"useVueMultiWordComponentNames": "off",
|
||||
"useVueNextTickPromise": "warn",
|
||||
"useImportsFirst": "off",
|
||||
"useVarsOnTop": "on",
|
||||
"useDomQuerySelector": "warn",
|
||||
"useVueDefineMacrosOrder": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noEmptyObjectKeys": "error",
|
||||
"noExplicitAny": "warn",
|
||||
"noImportCycles": "off",
|
||||
"noNestedPromises": "error",
|
||||
"noParametersOnlyUsedInRecursion": "error",
|
||||
"noShadow": "off",
|
||||
"noUnnecessaryConditions": "off",
|
||||
"useArraySortCompare": "error",
|
||||
"useScopedStyles": "off",
|
||||
"noConfusingVoidType": "warn"
|
||||
}
|
||||
},
|
||||
"domains": { "vue": "all", "project": "all", "types": "all" }
|
||||
},
|
||||
"css": {
|
||||
"formatter": { "enabled": false },
|
||||
"linter": { "enabled": false },
|
||||
"assist": { "enabled": false }
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"parser": { "interpolation": true },
|
||||
"linter": { "enabled": true }
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noPlaywrightUselessAwait": "error" },
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off",
|
||||
"noPlaywrightMissingAwait": "error",
|
||||
"noUnresolvedImports": "off",
|
||||
"usePlaywrightValidDescribeCallback": "error"
|
||||
},
|
||||
"style": {
|
||||
"noPlaywrightElementHandle": "error",
|
||||
"noPlaywrightEval": "error",
|
||||
"noPlaywrightWaitForSelector": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noPlaywrightForceOption": "error",
|
||||
"noPlaywrightNetworkidle": "error",
|
||||
"noPlaywrightPagePause": "error",
|
||||
"noPlaywrightWaitForNavigation": "error",
|
||||
"noPlaywrightWaitForTimeout": "warn",
|
||||
"noEmptyObjectKeys": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.js"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"nursery": { "noUnsafeReturn": "off" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"scripts/postcss-strip-legacy.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.performance.config.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUndeclaredDependencies": "off",
|
||||
"noUnresolvedImports": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "off",
|
||||
"useGraphqlSortedDefinitions": "on"
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "off" } }
|
||||
},
|
||||
"css": {
|
||||
"linter": { "enabled": false },
|
||||
"formatter": { "enabled": false },
|
||||
"assist": { "enabled": false }
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"parser": { "interpolation": true },
|
||||
"linter": { "enabled": true }
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": { "vue": "all", "project": "all", "types": "all" },
|
||||
"rules": {
|
||||
"performance": {
|
||||
"useVueVapor": "off"
|
||||
},
|
||||
"security": {
|
||||
"preset": "recommended"
|
||||
},
|
||||
"a11y": {
|
||||
"noAutofocus": "off",
|
||||
"noNoninteractiveElementToInteractiveRole": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useAriaPropsSupportedByRole": "off",
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
"noImportantStyles": "off",
|
||||
"noRedundantDefaultExport": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownPseudoClass": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noEmptyObjectKeys": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noImpliedEval": "error",
|
||||
"noJsonUnsafeValues": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"noTopLevelLiterals": "error",
|
||||
"noVueDeprecatedScopedSlots": "error",
|
||||
"noVueImportCompilerMacros": "error",
|
||||
"noVueRefAsOperand": "error",
|
||||
"noVueUndeclaredDirectives": "off",
|
||||
"noVueVOnNumberValues": "error",
|
||||
"useConsistentObjectKeys": "error",
|
||||
"useVueBaseImport": "warn",
|
||||
"useVueConsistentDefinePropsDeclaration": "off",
|
||||
"useVueNextTickPromise": "warn",
|
||||
"useVueValidVFor": "error"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off",
|
||||
"noVueOptionsApi": "off",
|
||||
"useVueConsistentVBindStyle": "off",
|
||||
"useVueHyphenatedAttributes": "off",
|
||||
"useVueMultiWordComponentNames": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noImportCycles": "off",
|
||||
"noNestedPromises": "error",
|
||||
"noParametersOnlyUsedInRecursion": "error",
|
||||
"noShadow": "off",
|
||||
"noUnnecessaryConditions": "off",
|
||||
"useArraySortCompare": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off",
|
||||
"noUnresolvedImports": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noPlaywrightElementHandle": "error",
|
||||
"noPlaywrightEval": "error",
|
||||
"noPlaywrightForceOption": "error",
|
||||
"noPlaywrightMissingAwait": "error",
|
||||
"noPlaywrightNetworkidle": "error",
|
||||
"noPlaywrightPagePause": "error",
|
||||
"noPlaywrightUselessAwait": "error",
|
||||
"noPlaywrightWaitForNavigation": "error",
|
||||
"noPlaywrightWaitForSelector": "error",
|
||||
"noPlaywrightWaitForTimeout": "warn",
|
||||
"usePlaywrightValidDescribeCallback": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"scripts/postcss-strip-legacy.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.performance.config.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnresolvedImports": "off",
|
||||
"noUndeclaredDependencies": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
import * as tsParser from "@typescript-eslint/parser";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ["**/dist/**", "tests/playwright-files/**"] },
|
||||
|
||||
...pluginVue.configs["flat/essential"],
|
||||
...vueI18n.configs.recommended,
|
||||
|
||||
// js/ts is parsed for vue-i18n to check t() calls
|
||||
{ files: ["**/*.js", "**/*.ts"], languageOptions: { parser: tsParser } },
|
||||
{ files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tsParser } } },
|
||||
{
|
||||
settings: {
|
||||
"vue-i18n": {
|
||||
localeDir: "src/i18n/en.json",
|
||||
messageSyntaxVersion: "^11.0.0",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||
"@intlify/vue-i18n/no-unused-keys": ["error", {
|
||||
src: "./src",
|
||||
extensions: [".js", ".vue", ".ts"],
|
||||
ignores: ["/^languages\\./"],
|
||||
}],
|
||||
"@intlify/vue-i18n/no-raw-text": ["error", {
|
||||
ignoreNodes: ["i", "v-icon"],
|
||||
}],
|
||||
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.vue"],
|
||||
rules: {
|
||||
// this are the most common ones that likely biome also has
|
||||
// we can add more to the list or if some if buggy in biome better enable it here.
|
||||
"vue/no-duplicate-attributes": "off",
|
||||
"vue/no-use-v-if-with-v-for": "off",
|
||||
"vue/require-v-for-key": "off",
|
||||
"vue/valid-template-root": "off",
|
||||
"vue/valid-v-bind": "off",
|
||||
"vue/valid-v-cloak": "off",
|
||||
"vue/valid-v-else": "off",
|
||||
"vue/valid-v-else-if": "off",
|
||||
"vue/valid-v-for": "off",
|
||||
"vue/valid-v-html": "off",
|
||||
"vue/valid-v-if": "off",
|
||||
"vue/valid-v-on": "off",
|
||||
"vue/valid-v-once": "off",
|
||||
"vue/valid-v-pre": "off",
|
||||
"vue/valid-v-text": "off",
|
||||
"vue/no-deprecated-v-on-number-modifiers": "off",
|
||||
"vue/no-dupe-keys": "off",
|
||||
"vue/no-reserved-keys": "off",
|
||||
"vue/no-reserved-props": "off",
|
||||
"vue/no-deprecated-data-object-declaration": "off",
|
||||
"vue/no-arrow-functions-in-watch": "off",
|
||||
"vue/no-ref-as-operand": "off",
|
||||
"vue/prefer-import-from-vue": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/no-parsing-error": "off",
|
||||
"vue/no-unused-components": "warn",
|
||||
"vue/no-v-text-v-html-on-component": "warn",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+3090
-931
File diff suppressed because it is too large.
Load diff
+43
-37
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=12.0.0",
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
"npm": ">=9.0.0",
|
||||
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
@@ -13,71 +13,77 @@
|
||||
"dev": "vite",
|
||||
"watch": "vite build --watch",
|
||||
"typecheck": "vue-tsc --build",
|
||||
"lint": "biome check && npm run i18n:check",
|
||||
"lint:fix": "biome check --write",
|
||||
"lint": "biome check && eslint src/",
|
||||
"lint:fix": "biome check --write && eslint --fix src/",
|
||||
"lint:biome": "biome check",
|
||||
"lint:biome:fix": "biome check --write",
|
||||
"lint:eslint": "eslint src/",
|
||||
"format": "biome format --write",
|
||||
"i18n:check": "node scripts/i18n/index.ts check",
|
||||
"i18n:check:strict": "node scripts/i18n/index.ts check --strict",
|
||||
"lint:css": "stylelint \"**/*.{css,vue}\"",
|
||||
"lint:css:fix": "stylelint --fix \"**/*.{css,vue}\"",
|
||||
"i18n:types": "node scripts/i18n/index.ts types",
|
||||
"i18n:sync": "node scripts/i18n/index.ts sync",
|
||||
"i18n:cleanup": "node scripts/i18n/index.ts sync --cleanup",
|
||||
"i18n:enforce-order": "node scripts/i18n/index.ts sync --enforce-order",
|
||||
"i18n:sync": "node ./scripts/sync-translations.js",
|
||||
"i18n:check": "node ./scripts/sync-translations.js --check",
|
||||
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
|
||||
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
|
||||
"icons:sync": "node ./scripts/build-icon-subset.js",
|
||||
"icons:check": "node ./scripts/build-icon-subset.js --check",
|
||||
"test": "vitest run "
|
||||
},
|
||||
"dependencies": {
|
||||
"@likecoin/epub-ts": "^0.7.3",
|
||||
"@likecoin/epub-ts": "^0.7.2",
|
||||
"@onlyoffice/document-editor-vue": "^1.7.0",
|
||||
"ace-builds": "^1.45.2",
|
||||
"chart.js": "^4.6.1",
|
||||
"ace-builds": "^1.45.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"hls.js": "^1.7.4",
|
||||
"hls.js": "^1.7.2",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.2.0",
|
||||
"marked": "^18.1.1",
|
||||
"plyr": "^3.8.5",
|
||||
"qrcode.vue": "^3.11.0",
|
||||
"temml": "^0.14.0",
|
||||
"three": "^0.187.0",
|
||||
"vue": "^3.5.45",
|
||||
"vue-i18n": "^11.4.19",
|
||||
"vue-router": "^5.5.0"
|
||||
"three": "^0.186.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.16",
|
||||
"vue-router": "^5.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^11.0.8",
|
||||
"minimatch": "^10.2.6",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
"argparse": "^3.0.4"
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
"glob": "^14.0.6"
|
||||
"@intlify/eslint-plugin-vue-i18n": {
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.6.7",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.8",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/node": "^26.6.6",
|
||||
"@vitejs/plugin-vue": "^6.0.11",
|
||||
"deepl-node": "^1.29.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.5.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.7",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^31.0.1",
|
||||
"stylelint": "^18.0.0",
|
||||
"stylelint-config-standard": "^41.0.0",
|
||||
"jsdom": "^30.1.3",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.8",
|
||||
"vite-plugin-checker": "^1.0.0",
|
||||
"vite-plugin-compression2": "^2.5.4",
|
||||
"vitest": "^5.1.0",
|
||||
"vue-tsc": "^3.3.14",
|
||||
"vite": "^8.3.4",
|
||||
"vite-plugin-checker": "^0.14.5",
|
||||
"vite-plugin-compression2": "^2.5.3",
|
||||
"vitest": "^5.0.5",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.13",
|
||||
"wawoff2": "^2.0.1"
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ const EXTRA_ICONS = [
|
||||
'create_new_folder',
|
||||
'do_not_disturb_on',
|
||||
'done',
|
||||
'equalizer',
|
||||
'error_outline',
|
||||
'file',
|
||||
'file_copy',
|
||||
@@ -142,7 +141,7 @@ function scanMimetype(icons) {
|
||||
}
|
||||
|
||||
function scanConstants(icons) {
|
||||
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.ts'), 'utf8');
|
||||
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.js'), 'utf8');
|
||||
const iconField = new RegExp(`\\bicon:\\s*["'](${ICON_NAME})["']`, 'g');
|
||||
for (const match of content.matchAll(iconField)) addIcon(icons, match[1]);
|
||||
}
|
||||
@@ -185,7 +184,7 @@ function collectIcons() {
|
||||
scanSearchFilters(icons);
|
||||
scanMarkdownViewer(icons);
|
||||
icons.delete('close_back');
|
||||
return [...icons].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return [...icons].sort();
|
||||
}
|
||||
|
||||
function readInventory() {
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: the tests need it */
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { maskJs, maskSource } from "../shared.ts";
|
||||
import { syncTranslations } from "../sync.ts";
|
||||
import { literalCalls } from "../types.ts";
|
||||
|
||||
// The sync reads and writes the locales folder: point it at a temporary one.
|
||||
const dir = await vi.hoisted(async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
return mkdtempSync(join(tmpdir(), "i18n-test-"));
|
||||
});
|
||||
|
||||
vi.mock("../shared.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../shared.ts")>();
|
||||
const { readdirSync } = await import("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
localesDir: dir,
|
||||
translationFiles: () => readdirSync(dir).filter((file) => file.endsWith(".json") && file !== "en.json").sort(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("deepl-node", () => ({
|
||||
DeepLClient: class {
|
||||
async translateText(text: string) {
|
||||
return { text: `[${text}]` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const write = (file: string, content: unknown) => fs.writeFileSync(path.join(dir, file), JSON.stringify(content));
|
||||
const read = (file: string): unknown => JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
||||
|
||||
describe("sync with a different shape in a locale", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEEPL_API_KEY = "test-key";
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const file of fs.readdirSync(dir)) fs.rmSync(path.join(dir, file));
|
||||
});
|
||||
|
||||
const run = (options: { enforceOrder?: boolean; cleanup?: boolean } = {}) =>
|
||||
syncTranslations({ enforceOrder: false, cleanup: false, ...options });
|
||||
|
||||
describe("an object where en.json has a string", () => {
|
||||
beforeEach(() => {
|
||||
write("en.json", { general: { title: "Title" } });
|
||||
write("de.json", { general: { title: { old: "Alt" } } });
|
||||
});
|
||||
|
||||
it("replaces the subtree with a translation", async () => {
|
||||
expect(await run()).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: { title: "[Title]" } });
|
||||
});
|
||||
|
||||
it("removes the obsolete subtree on cleanup", async () => {
|
||||
expect(await run({ cleanup: true })).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: {} });
|
||||
});
|
||||
|
||||
it("removes the obsolete subtree when enforcing the order", async () => {
|
||||
expect(await run({ enforceOrder: true })).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a string where en.json has an object", () => {
|
||||
beforeEach(() => {
|
||||
write("en.json", { group: { a: "A" } });
|
||||
write("de.json", { group: "legacy" });
|
||||
});
|
||||
|
||||
it("replaces the string with the translated keys", async () => {
|
||||
await run();
|
||||
expect(read("de.json")).toEqual({ group: { a: "[A]" } });
|
||||
});
|
||||
|
||||
it("removes the obsolete string on cleanup", async () => {
|
||||
await run({ cleanup: true });
|
||||
expect(read("de.json")).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskJs", () => {
|
||||
it("blanks comments and keeps every offset and newline", () => {
|
||||
const code = `a(); // t("x")\n/* t("y")\n */ b();`;
|
||||
const masked = maskJs(code);
|
||||
expect(masked).toHaveLength(code.length);
|
||||
expect(masked).not.toContain("t(");
|
||||
expect(masked.split("\n")).toHaveLength(3);
|
||||
expect(masked).toContain("a();");
|
||||
expect(masked).toContain("b();");
|
||||
});
|
||||
|
||||
it("keeps `//` inside strings", () => {
|
||||
expect(maskJs(`const u = "https://example.com"; t("k");`)).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("blanks the content of quoted strings only on request, and keeps template literals", () => {
|
||||
const code = `x("a 't(' b", 'c'); y(\`\${t("k")}\`);`;
|
||||
const bare = maskJs(code, true);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("a 't(' b");
|
||||
expect(bare).toContain('t("k")');
|
||||
expect(maskJs(code)).toContain("a 't(' b");
|
||||
});
|
||||
|
||||
it("does not take an apostrophe left open on a line for a string", () => {
|
||||
expect(maskJs(`don't\nt("k")`, true)).toContain('t("');
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskJs with template literals", () => {
|
||||
const masked = (code: string) => maskJs(code, true);
|
||||
|
||||
it("template literal keeps its ${} expressions", () => {
|
||||
const code = 'const example = `t("example.only") ${t("real.key")} end`;';
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("example.only");
|
||||
expect(bare).toContain('t("real.key")');
|
||||
expect(bare.startsWith("const example = `")).toBe(true);
|
||||
expect(bare.endsWith("`;")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the text of a template literal when the strings are not masked", () => {
|
||||
const code = 'const example = `t("example.only") ${t("real.key")}`; // t("c.d")';
|
||||
const plain = maskJs(code);
|
||||
expect(plain).toContain('`t("example.only") ${t("real.key")}`');
|
||||
expect(plain).not.toContain("c.d");
|
||||
});
|
||||
|
||||
it("keeps the offsets and the newlines of a template literal that spans lines", () => {
|
||||
const code = "const a = `first t(\"x.y\")\nsecond ${\n t(\"real.key\")\n} third`;\nb();";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare.split("\n")).toHaveLength(code.split("\n").length);
|
||||
expect(bare.indexOf('t("real.key")')).toBe(code.indexOf('t("real.key")'));
|
||||
expect(bare).not.toContain("x.y");
|
||||
expect(bare).toContain("b();");
|
||||
});
|
||||
|
||||
it("does not take `//` or `/*` in the text of a template for a comment", () => {
|
||||
const code = "const u = `https://example.com/*path*/` + t('k');";
|
||||
expect(maskJs(code)).toBe(code);
|
||||
expect(masked(code)).toMatch(/` \+ t\(' '\);$/);
|
||||
});
|
||||
|
||||
it("masks a comment inside an expression and the text of a template nested in it", () => {
|
||||
const code = "`${ /* t('c.d') */ cond ? `t(\"nested.text\")` : t('real.key') }`";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("c.d");
|
||||
expect(bare).not.toContain("nested.text");
|
||||
expect(bare).toContain("t('real.key')");
|
||||
});
|
||||
|
||||
it("closes an expression at its own brace, not at the brace of an object inside it", () => {
|
||||
const code = "`a ${ fn({ x: 1 }) } t(\"after.only\") ${ t('real.key') }`";
|
||||
const bare = masked(code);
|
||||
expect(bare).not.toContain("after.only");
|
||||
expect(bare).toContain("t('real.key')");
|
||||
});
|
||||
|
||||
it("treats an escaped `${` and an escaped backtick as text", () => {
|
||||
const code = "`\\${t('escaped.key')} \\` t('after.escape')` + t('real.key')";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("escaped.key");
|
||||
expect(bare).not.toContain("after.escape");
|
||||
expect(bare).toMatch(/` \+ t\(' +'\)$/);
|
||||
});
|
||||
|
||||
it("copes with a template or an expression that is never closed", () => {
|
||||
for (const code of ["const a = `t('x.y')", "const a = `${t('x.y')", "const a = `trailing \\"]) {
|
||||
expect(masked(code)).toHaveLength(code.length);
|
||||
expect(maskJs(code)).toHaveLength(code.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSource for .vue files", () => {
|
||||
const vue = [
|
||||
"<template>",
|
||||
" <!-- {{ t('a.b') }} -->",
|
||||
" <p>{{ t('c.d') }}</p>",
|
||||
"</template>",
|
||||
'<script setup lang="ts">',
|
||||
"// t('e.f')",
|
||||
"const x = t('g.h');",
|
||||
"</script>",
|
||||
"<style>",
|
||||
".a::after { content: 't(\"i.j\")'; }",
|
||||
"</style>",
|
||||
].join("\n");
|
||||
|
||||
it("drops comments and styles but keeps the template and script code at the same offsets", () => {
|
||||
const masked = maskSource("A.vue", vue);
|
||||
expect(masked).toHaveLength(vue.length);
|
||||
for (const dropped of ["a.b", "e.f", "i.j"]) expect(masked).not.toContain(dropped);
|
||||
for (const kept of ["c.d", "g.h"]) expect(masked.indexOf(kept)).toBe(vue.indexOf(kept));
|
||||
});
|
||||
});
|
||||
|
||||
describe("literalCalls", () => {
|
||||
const calls = (source: string) => [...literalCalls(maskJs(source), maskJs(source, true))];
|
||||
|
||||
it("reads a parameter that holds an escaped quote", () => {
|
||||
const [call] = calls(`t("prompts.uploadSkipped", { count: "a\\"b".length })`);
|
||||
expect(call?.params).toEqual({ names: new Set(["count"]), dynamic: false });
|
||||
});
|
||||
|
||||
it("skips a key that is concatenated with an expression", () => {
|
||||
expect(calls(`t('prompts.' + name)`)).toEqual([]);
|
||||
expect(calls(`t("a" + b, { x: 1 })`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores calls in comments and strings, and finds those in template literals", () => {
|
||||
const found = calls(`// t('example.key')\nconst s = "t('example.str')";\nconst u = \`\${t("real.key")}\`;`);
|
||||
expect(found.map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("does not check the text of a template literal as a call", () => {
|
||||
expect(calls('const example = `t("example.only")`;')).toEqual([]);
|
||||
expect(calls('const example = `see t("example.only") and ${t("real.key")}`;').map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("finds a call in an expression nested in a template, not in the template nested in that", () => {
|
||||
const found = calls("const a = `${ cond ? `t('nested.text')` : t('real.key', { count: 1 }) }`;");
|
||||
expect(found.map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("is not confused by a comment in the params", () => {
|
||||
const [call] = calls(`t("k", { count, /* it's */ other })`);
|
||||
expect(call?.params.names).toEqual(new Set(["count", "other"]));
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import { isTestFile, maskSource, readLocale, rel, sourceFiles } from "./shared.ts";
|
||||
|
||||
const IGNORED_KEYS = [/^languages\./];
|
||||
const IGNORED_ELEMENTS = new Set(["i", "v-icon", "kbd"]);
|
||||
const IGNORE_COMMENT = "i18n-ignore";
|
||||
|
||||
// Vue's NodeTypes values
|
||||
const ELEMENT = 1;
|
||||
const TEXT = 2;
|
||||
const COMMENT = 3;
|
||||
|
||||
interface TemplateNode {
|
||||
type: number;
|
||||
tag?: string;
|
||||
content?: unknown;
|
||||
props?: { name?: string; value?: { content: string } }[];
|
||||
children?: TemplateNode[];
|
||||
loc: { start: { line: number } };
|
||||
}
|
||||
|
||||
/** The code of a source file that can use a key -- comments and <style> blocks are dropped */
|
||||
const codeOf = (file: string) => maskSource(file, fs.readFileSync(file, "utf8"));
|
||||
|
||||
/** Every key in en.json must appear as a string literal in src/ or match a dynamic prefix */
|
||||
export function checkUnusedKeys(): string[] {
|
||||
const code = [...sourceFiles()].filter((file) => !isTestFile(file)).map(codeOf).join("\n");
|
||||
const prefixes = [...code.matchAll(/\bt\(\s*["'`]([\w.-]+\.)(?:\$\{|["'`]\s*\+)/g)].map((match) => match[1] ?? "");
|
||||
const isUsed = (key: string) =>
|
||||
["'", '"', "`"].some((quote) => code.includes(`${quote}${key}${quote}`)) ||
|
||||
prefixes.some((prefix) => key.startsWith(prefix));
|
||||
return Object.keys(readLocale("en.json"))
|
||||
.filter((key) => !IGNORED_KEYS.some((pattern) => pattern.test(key)) && !isUsed(key))
|
||||
.map((key) => `en.json: unused key ${key}`);
|
||||
}
|
||||
|
||||
const isIgnored = (node: TemplateNode) =>
|
||||
IGNORED_ELEMENTS.has(node.tag ?? "") ||
|
||||
(node.props ?? []).some((prop) => prop.name === "class" && prop.value?.content.includes("material-symbols"));
|
||||
|
||||
/** Templates shouldn't have plain text, to ignore them we can use <!-- i18n-ignore --> */
|
||||
export function checkRawText(): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of sourceFiles()) {
|
||||
if (!file.endsWith(".vue")) continue;
|
||||
const ast = parse(fs.readFileSync(file, "utf8"), { filename: file }).descriptor.template?.ast;
|
||||
const walk = (node: TemplateNode) => {
|
||||
if (node.type === ELEMENT && isIgnored(node)) return;
|
||||
if (node.type === TEXT && typeof node.content === "string" && /\p{L}/u.test(node.content)) {
|
||||
problems.push(`${rel(file)}:${node.loc.start.line}: raw text "${node.content.trim()}", use t()`);
|
||||
}
|
||||
let skip = false;
|
||||
for (const child of node.children ?? []) {
|
||||
if (!skip) walk(child);
|
||||
if (child.type === COMMENT) skip = typeof child.content === "string" && child.content.trim() === IGNORE_COMMENT;
|
||||
else if (child.type === ELEMENT || (child.type === TEXT && String(child.content).trim())) skip = false;
|
||||
}
|
||||
};
|
||||
if (ast) walk(ast as unknown as TemplateNode);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* npm run i18n:types regenerate messages.generated.ts
|
||||
* npm run i18n:sync regenerate the types, translate new keys (DEEPL_API_KEY)
|
||||
* npm run i18n:enforce-order regenerate the types, reorder locales like en.json
|
||||
* npm run i18n:cleanup regenerate the types, remove obsolete keys
|
||||
* npm run i18n:check translations, types, unused keys and raw text
|
||||
* npm run i18n:check:strict checks for raw `$t` and 'vue-i18n' imports, including JS files
|
||||
*/
|
||||
|
||||
import { checkRawText, checkUnusedKeys } from "./check.ts";
|
||||
import { report } from "./shared.ts";
|
||||
import { checkTranslations, syncTranslations } from "./sync.ts";
|
||||
import { checkTypes, writeTypes } from "./types.ts";
|
||||
|
||||
const [command, ...flags] = process.argv.slice(2);
|
||||
const has = (flag: string) => flags.includes(flag);
|
||||
|
||||
try {
|
||||
if (command === "types") {
|
||||
process.exitCode = writeTypes();
|
||||
} else if (command === "sync") {
|
||||
process.exitCode = writeTypes() || (await syncTranslations({ enforceOrder: has("--enforce-order"), cleanup: has("--cleanup") }));
|
||||
} else if (command === "check") {
|
||||
const translations = checkTranslations();
|
||||
const problems = [...translations, ...checkTypes(has("--strict")), ...checkUnusedKeys(), ...checkRawText()];
|
||||
if (problems.length) {
|
||||
report(problems);
|
||||
if (translations.length) console.error("Run: npm run i18n:sync (or i18n:cleanup)");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("✅ i18n checks passed");
|
||||
}
|
||||
} else {
|
||||
console.error("Usage: node scripts/i18n/index.ts <types | sync [--enforce-order | --cleanup] | check [--strict]>");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("\n❌ i18n script failed:", error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
|
||||
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
export const srcDir = path.join(root, "src");
|
||||
export const i18nDir = path.join(srcDir, "i18n");
|
||||
export const localesDir = path.join(i18nDir, "locales");
|
||||
export const generatedFile = path.join(i18nDir, "messages.generated.ts");
|
||||
|
||||
export type Messages = Record<string, string>;
|
||||
|
||||
/** Tests t/$t with throwaway keys */
|
||||
export const isTestFile = (file: string) => /\.test\.[jt]s$/.test(file);
|
||||
export const rel = (file: string) => path.relative(root, file);
|
||||
export const readJson = (file: string): unknown => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
/** Flat nested messages into `{ "a.b.c": "message" }`; null counts as empty message. */
|
||||
export function flatten(tree: unknown, prefix = ""): Messages {
|
||||
const result: Messages = {};
|
||||
for (const [key, value] of Object.entries(tree as Record<string, unknown>)) {
|
||||
const fullKey = prefix + key;
|
||||
if (typeof value === "string") {
|
||||
result[fullKey] = value;
|
||||
} else if (value === null) {
|
||||
result[fullKey] = "";
|
||||
} else if (typeof value === "object") {
|
||||
Object.assign(result, flatten(value, `${fullKey}.`));
|
||||
} else {
|
||||
throw new Error(`${fullKey}: messages must be strings or objects`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const readLocale = (file: string): Messages => flatten(readJson(path.join(localesDir, file)));
|
||||
|
||||
/** File names of every locale except en.json, sorted */
|
||||
export const translationFiles = (): string[] =>
|
||||
fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith(".json") && file !== "en.json")
|
||||
.sort();
|
||||
|
||||
/** .vue, .ts and .js in src/ without the locales and the generated types */
|
||||
export function* sourceFiles(skipI18nDir = false, dir = srcDir): Generator<string> {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (full !== localesDir && !(skipI18nDir && full === i18nDir)) yield* sourceFiles(skipI18nDir, full);
|
||||
} else if (/\.(vue|ts|js)$/.test(entry.name) && full !== generatedFile) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function report(problems: string[]): void {
|
||||
for (const problem of problems) console.error(`✗ ${problem}`);
|
||||
console.error(`\n${problems.length} problem(s) found.`);
|
||||
}
|
||||
|
||||
const blank = (text: string) => text.replace(/[^\n]/g, " ");
|
||||
|
||||
/** Replaces the comments of JS/TS code by spaces and, with `strings`, the content of '...' and "..." literals too */
|
||||
export function maskJs(text: string, strings = false): string {
|
||||
const scan = (start: number, inExpression: boolean): [string, number] => {
|
||||
let out = "";
|
||||
let i = start;
|
||||
let depth = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
const next = text.charAt(i + 1);
|
||||
if (ch === "/" && (next === "/" || next === "*")) {
|
||||
const close = next === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2);
|
||||
const end = close === -1 ? text.length : next === "/" ? close : close + 2;
|
||||
out += blank(text.slice(i, end));
|
||||
i = end;
|
||||
} else if (ch === "`") {
|
||||
const [masked, end] = maskTemplate(i);
|
||||
out += masked;
|
||||
i = end;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
let end = i + 1;
|
||||
while (end < text.length && text.charAt(end) !== ch) {
|
||||
if (text.charAt(end) === "\\") end++;
|
||||
else if (text.charAt(end) === "\n") break; // it's not a string
|
||||
end++;
|
||||
}
|
||||
const closed = text.charAt(end) === ch;
|
||||
const literal = text.slice(i, closed ? end + 1 : end);
|
||||
out += strings && !inExpression && closed ? ch + blank(literal.slice(1, -1)) + ch : literal;
|
||||
i += literal.length;
|
||||
} else {
|
||||
if (inExpression && ch === "{") depth++;
|
||||
else if (inExpression && ch === "}") {
|
||||
if (depth === 0) return [out, i];
|
||||
depth--;
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return [out, i];
|
||||
};
|
||||
|
||||
// A template literal from its opening backtick -- its `${...}` expressions are scanned
|
||||
const maskTemplate = (start: number): [string, number] => {
|
||||
const keep = (chunk: string) => (strings ? blank(chunk) : chunk);
|
||||
let out = "`";
|
||||
let i = start + 1;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (ch === "\\") {
|
||||
out += keep(text.slice(i, i + 2));
|
||||
i += 2;
|
||||
} else if (ch === "`") {
|
||||
return [`${out}\``, i + 1];
|
||||
} else if (ch === "$" && text.charAt(i + 1) === "{") {
|
||||
const [code, end] = scan(i + 2, true);
|
||||
out += `\${${code}`;
|
||||
i = end;
|
||||
if (text.charAt(i) === "}") {
|
||||
out += "}";
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
out += keep(ch);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return [out, i];
|
||||
};
|
||||
|
||||
return scan(0, false)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The code of a source file without comments at the same offsets as the file. In .vue files only the template and the scripts
|
||||
* are kept while styles no
|
||||
*/
|
||||
export function maskSource(file: string, text: string, strings = false): string {
|
||||
if (!file.endsWith(".vue")) return maskJs(text, strings);
|
||||
const { descriptor } = parse(text, { filename: file });
|
||||
let out = text;
|
||||
// each replacement keeps the length of what it replaces, so the blocks can be masked one after the other
|
||||
const mask = ({ start, end }: { start: { offset: number }; end: { offset: number } }, apply: (code: string) => string) => {
|
||||
out = out.slice(0, start.offset) + apply(text.slice(start.offset, end.offset)) + out.slice(end.offset);
|
||||
};
|
||||
for (const style of descriptor.styles) mask(style.loc, blank);
|
||||
for (const script of [descriptor.script, descriptor.scriptSetup]) {
|
||||
if (script) mask(script.loc, (code) => maskJs(code, strings));
|
||||
}
|
||||
if (descriptor.template) mask(descriptor.template.loc, (code) => code.replace(/<!--[\s\S]*?-->/g, blank));
|
||||
return out;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DeepLClient, type TargetLanguageCode, type TranslateTextOptions } from "deepl-node";
|
||||
import { localesDir, readJson, readLocale, translationFiles } from "./shared.ts";
|
||||
|
||||
type Tree = { [key: string]: unknown };
|
||||
|
||||
const MASTER_LANGUAGE = "en";
|
||||
|
||||
const DEEPL_LANGUAGES: Record<string, string> = {
|
||||
"zh-cn": "ZH-HANS",
|
||||
"zh-tw": "ZH-HANT",
|
||||
pt: "PT-PT",
|
||||
"pt-br": "PT-BR",
|
||||
en: "EN",
|
||||
"en-us": "EN-US",
|
||||
"en-gb": "EN-GB",
|
||||
"sv-se": "SV",
|
||||
ua: "UK",
|
||||
"nl-be": "NL",
|
||||
cz: "CS",
|
||||
bg: "BG",
|
||||
};
|
||||
|
||||
let translator: DeepLClient | null = null;
|
||||
|
||||
const isTree = (value: unknown): value is Tree => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Reports what syncTranslations would change */
|
||||
export function checkTranslations(): string[] {
|
||||
const master = Object.keys(readLocale("en.json"));
|
||||
const problems: string[] = [];
|
||||
const translatable = (key: string) => !key.startsWith("languages.");
|
||||
for (const file of translationFiles()) {
|
||||
let target: Record<string, string>;
|
||||
try {
|
||||
target = readLocale(file);
|
||||
} catch (error) {
|
||||
problems.push(`${file}: cannot be read (${error instanceof Error ? error.message : error})`);
|
||||
continue;
|
||||
}
|
||||
for (const key of master.filter(translatable)) {
|
||||
if (!target[key]) problems.push(`${file}: missing "${key}"`);
|
||||
}
|
||||
for (const key of Object.keys(target).filter(translatable)) {
|
||||
if (!master.includes(key)) problems.push(`${file}: obsolete key "${key}"`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
async function translateText(text: string, targetLanguage: string, keyPath: string): Promise<string | null> {
|
||||
if (!text.trim()) {
|
||||
console.warn(`Skipping translation for empty text: "${text}"`);
|
||||
return text;
|
||||
}
|
||||
if (keyPath === "languages" || keyPath.startsWith("languages.")) return text;
|
||||
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options: TranslateTextOptions = {};
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, "<ph>$1</ph>");
|
||||
options.tagHandling = "xml";
|
||||
options.ignoreTags = ["ph"];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Translating "${text}" from '${MASTER_LANGUAGE}' to '${targetLanguage}'...`);
|
||||
const deeplTarget = (DEEPL_LANGUAGES[targetLanguage.toLowerCase()] ?? targetLanguage.toUpperCase()) as TargetLanguageCode;
|
||||
const result = await translator?.translateText(textToTranslate, MASTER_LANGUAGE, deeplTarget, options);
|
||||
await new Promise((resolve) => {setTimeout(resolve, 100)}); // avoid rate-limiting
|
||||
let translated = result?.text ?? "";
|
||||
if (hasPlaceholders) {
|
||||
// the translator might add spaces around the tags
|
||||
translated = translated.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, "$1");
|
||||
}
|
||||
if (!translated.trim()) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}): empty result`);
|
||||
return null;
|
||||
}
|
||||
return translated;
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorders `value` to match the key order of `master`, dropping keys `master` lacks */
|
||||
function reorderObject(value: unknown, master: unknown): unknown {
|
||||
if (!isTree(value) || !isTree(master)) return value;
|
||||
const ordered: Tree = {};
|
||||
for (const key of Object.keys(master)) {
|
||||
if (Object.hasOwn(value, key)) ordered[key] = reorderObject(value[key], master[key]);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Add missing keys, remove obsolete ones */
|
||||
async function processKeys(
|
||||
master: Tree,
|
||||
target: Tree,
|
||||
lang: string,
|
||||
parents: string[],
|
||||
enforceOrder: boolean,
|
||||
cleanup: boolean,
|
||||
): Promise<boolean | "UNSUPPORTED"> {
|
||||
let changed = false;
|
||||
|
||||
for (const key of Object.keys(master)) {
|
||||
const masterValue = master[key];
|
||||
const keyPath = [...parents, key].join(".");
|
||||
|
||||
// the "languages" block is always copied from master
|
||||
if (key === "languages" && parents.length === 0 && !cleanup) {
|
||||
if (!enforceOrder) console.log(`Copying entire "languages" object from master to ${lang}.json`);
|
||||
target[key] = structuredClone(masterValue);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (Object.hasOwn(target, key) && isTree(masterValue) !== isTree(target[key])) {
|
||||
console.log(`🗑️ Removing "${keyPath}" from ${lang}.json: its structure differs from en.json`);
|
||||
Reflect.deleteProperty(target, key);
|
||||
changed = true;
|
||||
}
|
||||
if (isTree(masterValue)) {
|
||||
if (!cleanup && !isTree(target[key])) {
|
||||
console.log(`Creating missing object structure for "${keyPath}" in ${lang}.json`);
|
||||
target[key] = {};
|
||||
changed = true;
|
||||
}
|
||||
const child = target[key];
|
||||
if (isTree(child)) {
|
||||
const result = await processKeys(masterValue, child, lang, [...parents, key], enforceOrder, cleanup);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${lang}" due to unsupported structure.`);
|
||||
return result;
|
||||
}
|
||||
if (result) changed = true;
|
||||
}
|
||||
} else if (!cleanup) {
|
||||
if (typeof masterValue === "string") {
|
||||
const existing = target[key];
|
||||
if (!Object.hasOwn(target, key) || existing === "" || existing === null) {
|
||||
if (enforceOrder) {
|
||||
console.log(`Skipping new key "${keyPath}" for ${lang}.json`);
|
||||
} else {
|
||||
const translated = await translateText(masterValue, lang, keyPath);
|
||||
if (translated === null) return "UNSUPPORTED";
|
||||
if (target[key] !== translated) {
|
||||
target[key] = translated;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!Object.hasOwn(target, key)) {
|
||||
console.log(`Key "${keyPath}" (non-string) missing in ${lang}.json. Copying from English.`);
|
||||
target[key] = masterValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(master, key)) {
|
||||
console.log(`🗑️ Removing obsolete key "${[...parents, key].join(".")}" from ${lang}.json`);
|
||||
Reflect.deleteProperty(target, key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function syncTranslations({ enforceOrder, cleanup }: { enforceOrder: boolean; cleanup: boolean }): Promise<number> {
|
||||
const reorder = enforceOrder && !cleanup;
|
||||
|
||||
if (!enforceOrder && !cleanup) {
|
||||
const apiKey = process.env.DEEPL_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
return 1;
|
||||
}
|
||||
translator = new DeepLClient(apiKey);
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
} else if (cleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
}
|
||||
|
||||
const master = readJson(path.join(localesDir, "en.json")) as Tree;
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const file of translationFiles()) {
|
||||
const targetFile = path.join(localesDir, file);
|
||||
const lang = path.basename(file, ".json");
|
||||
let original: Tree;
|
||||
try {
|
||||
const parsed = readJson(targetFile);
|
||||
if (!isTree(parsed)) throw new Error("the root must be an object");
|
||||
original = parsed;
|
||||
} catch (error) {
|
||||
console.error(`❌ Could not read ${targetFile}, leaving it unchanged: ${error instanceof Error ? error.message : error}`);
|
||||
failed.push(file);
|
||||
continue;
|
||||
}
|
||||
let target = structuredClone(original);
|
||||
|
||||
const result = await processKeys(master, target, lang, [], reorder, cleanup);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.error(`❌ ${targetFile} was left unchanged: a translation failed.`);
|
||||
failed.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
let orderChanged = false;
|
||||
if (reorder) {
|
||||
target = reorderObject(target, master) as Tree;
|
||||
orderChanged = JSON.stringify(original) !== JSON.stringify(target);
|
||||
if (orderChanged) console.log(`- Reordered keys in ${lang}.json`);
|
||||
}
|
||||
|
||||
if (result || orderChanged) {
|
||||
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`);
|
||||
if (!cleanup && !reorder) console.log(`Successfully updated ${targetFile}`);
|
||||
} else {
|
||||
console.log(`No changes needed for ${targetFile}`);
|
||||
}
|
||||
}
|
||||
if (failed.length) {
|
||||
console.error(`\n❌ Synchronization incomplete, ${failed.length} locale(s) were not changed: ${failed.join(", ")}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (cleanup) console.log("\n✅ Cleanup complete (obsolete keys removed).");
|
||||
else if (reorder) console.log("\n✅ Enforce order complete.");
|
||||
else console.log("\n✅ Translation synchronization complete (via DeepL).");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { generatedFile, isTestFile, maskSource, type Messages, readLocale, rel, sourceFiles, translationFiles } from "./shared.ts";
|
||||
|
||||
const OPTIONAL_PLACEHOLDERS = new Set(["suffix", "prefix"]);
|
||||
const NAMED_PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
|
||||
/** vue-i18n literal interpolation, like {'|'} to print a pipe or @ */
|
||||
const LITERAL_INTERPOLATION = /\{'[^']*'\}/g;
|
||||
const REGENERATE = "Run: npm run i18n:types";
|
||||
|
||||
/** Named placeholders of a message in order */
|
||||
function placeholders(message: string): string[] {
|
||||
const names: string[] = [];
|
||||
for (const match of message.matchAll(NAMED_PLACEHOLDER)) {
|
||||
const name = match[1] ?? "";
|
||||
if (!names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Returns a description of unsupported vue-i18n syntax, or null. */
|
||||
function unsupportedSyntax(message: string): string | null {
|
||||
const rest = message.replace(LITERAL_INTERPOLATION, "").replace(NAMED_PLACEHOLDER, "");
|
||||
if (/[{}]/.test(rest)) return "braces other than {name} placeholders";
|
||||
if (rest.includes("|")) return "plural separator '|'";
|
||||
if (/@[.:]/.test(rest)) return "linked message '@:' / '@.'";
|
||||
return null;
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const messages = readLocale("en.json");
|
||||
const problems: string[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const [key, message] of Object.entries(messages)) {
|
||||
const unsupported = unsupportedSyntax(message);
|
||||
if (unsupported) problems.push(`en.json: ${key} uses ${unsupported}, which the types do not support`);
|
||||
const names = placeholders(message);
|
||||
const params = names.length ? names.map((name) => JSON.stringify(name)).join(" | ") : "never";
|
||||
lines.push(` ${JSON.stringify(key)}: ${params};`);
|
||||
}
|
||||
const content = `// This file is generated by scripts/i18n from src/i18n/locales/en.json.
|
||||
// Run \`make generate-translation-types\` \`npm run i18n:types\` after changing en.json. Do not edit manually.
|
||||
|
||||
/** Every message in en.json mapped to the named placeholders. Used to type-check \`t()\` calls */
|
||||
export interface MessageParams {
|
||||
${lines.join("\n")}
|
||||
}
|
||||
|
||||
/** Placeholders that callers may omit */
|
||||
export type OptionalParam = ${[...OPTIONAL_PLACEHOLDERS].map((name) => JSON.stringify(name)).join(" | ")};
|
||||
`;
|
||||
return { content, messages, problems };
|
||||
}
|
||||
|
||||
/** Regenerates messages.generated.ts; returns the exit code. */
|
||||
export function writeTypes(): number {
|
||||
const { content, messages, problems } = generate();
|
||||
if (problems.length) {
|
||||
for (const problem of problems) console.error(`✗ ${problem}`);
|
||||
return 1;
|
||||
}
|
||||
fs.writeFileSync(generatedFile, content);
|
||||
console.log(`Wrote ${rel(generatedFile)} (${Object.keys(messages).length} messages).`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function checkGeneratedFile(expected: string): string[] {
|
||||
if (!fs.existsSync(generatedFile)) return [`${rel(generatedFile)} is missing. ${REGENERATE}`];
|
||||
if (fs.readFileSync(generatedFile, "utf8") !== expected) return [`${rel(generatedFile)} is out of date. ${REGENERATE}`];
|
||||
return [];
|
||||
}
|
||||
|
||||
function checkLocalePlaceholders(enMessages: Messages): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of translationFiles()) {
|
||||
for (const [key, message] of Object.entries(readLocale(file))) {
|
||||
const english = enMessages[key];
|
||||
if (english === undefined) continue;
|
||||
const expected = new Set(placeholders(english));
|
||||
const actual = new Set(placeholders(message));
|
||||
for (const name of actual) {
|
||||
if (!expected.has(name)) problems.push(`${file}: ${key} uses {${name}}, which en.json does not provide`);
|
||||
}
|
||||
for (const name of expected) {
|
||||
if (!actual.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`${file}: ${key} is missing {${name}}`);
|
||||
}
|
||||
const unsupported = unsupportedSyntax(message);
|
||||
if (unsupported) problems.push(`${file}: ${key} uses ${unsupported}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** .ts files and .vue files with <script lang="ts"> are checked by vue-tsc */
|
||||
function isTypeScript(file: string, text: string): boolean {
|
||||
if (file.endsWith(".ts")) return true;
|
||||
return file.endsWith(".vue") && /<script[^>]*lang=["']ts["']/.test(text);
|
||||
}
|
||||
|
||||
const BYPASS_RULES = [
|
||||
{
|
||||
pattern: /from\s+["']vue-i18n["']/,
|
||||
message: "import useI18n from '@/i18n/index.ts' instead of 'vue-i18n'",
|
||||
inStrings: true,
|
||||
},
|
||||
{
|
||||
pattern: /(^|[^\w$])\$t\b/,
|
||||
message: "use t(...) from useI18n() in '@/i18n/index.ts' instead of $t(...), so the key and params are type-checked",
|
||||
inStrings: false,
|
||||
},
|
||||
];
|
||||
|
||||
// The start of a t(...) call: t(, $t(, this.$t( or i18n.global.t(
|
||||
const CALL_START = /(?:(?<![\w$.])(?:\$t|t|i18n\.global\.t)|\bthis\.\$t)\(/g;
|
||||
|
||||
interface Params {
|
||||
names: Set<string>;
|
||||
dynamic: boolean;
|
||||
}
|
||||
|
||||
const lineOf = (text: string, index: number) => text.slice(0, index).split("\n").length;
|
||||
|
||||
function skipSpaces(text: string, index: number): number {
|
||||
let i = index;
|
||||
while (i < text.length && /\s/.test(text.charAt(i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Reads a string literal starting at `index`; returns null for anything else or a dynamic template */
|
||||
function readStringLiteral(text: string, index: number): { value: string; end: number } | null {
|
||||
const quote = text.charAt(index);
|
||||
if (quote !== '"' && quote !== "'" && quote !== "`") return null;
|
||||
let i = index + 1;
|
||||
let value = "";
|
||||
while (i < text.length && text.charAt(i) !== quote) {
|
||||
if (text.charAt(i) === "\\") {
|
||||
value += text.charAt(i + 1);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (quote === "`" && text.charAt(i) === "$" && text.charAt(i + 1) === "{") return null;
|
||||
value += text.charAt(i);
|
||||
i++;
|
||||
}
|
||||
return i < text.length ? { value, end: i + 1 } : null;
|
||||
}
|
||||
|
||||
const isQuote = (ch: string) => ch === '"' || ch === "'" || ch === "`";
|
||||
|
||||
function skipString(text: string, index: number): number {
|
||||
const quote = text.charAt(index);
|
||||
let i = index + 1;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) return i + 1;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const CLOSERS: Record<string, string> = { "{": "}", "[": "]", "(": ")" };
|
||||
|
||||
/** Finds the index of the bracket closing the one at `index`, skipping strings. */
|
||||
function findClosing(text: string, index: number): number {
|
||||
const stack = [CLOSERS[text.charAt(index)]];
|
||||
let i = index + 1;
|
||||
while (i < text.length && stack.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (isQuote(ch)) {
|
||||
const end = skipString(text, i);
|
||||
if (end === -1) return -1;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
const closer = CLOSERS[ch];
|
||||
if (closer) stack.push(closer);
|
||||
else if (ch === stack.at(-1)) stack.pop();
|
||||
i++;
|
||||
}
|
||||
return stack.length ? -1 : i - 1;
|
||||
}
|
||||
|
||||
/** Property names of the object literal at `index`; `dynamic` when a spread or computed key hides some */
|
||||
function readObjectKeys(text: string, index: number): Params | null {
|
||||
const close = findClosing(text, index);
|
||||
if (close === -1) return null;
|
||||
const names = new Set<string>();
|
||||
let dynamic = false;
|
||||
let segmentStart = index + 1;
|
||||
let i = index + 1;
|
||||
const flush = (end: number) => {
|
||||
const segment = text.slice(segmentStart, end).trim();
|
||||
if (!segment) return;
|
||||
if (segment.startsWith("...") || segment.startsWith("[")) {
|
||||
dynamic = true;
|
||||
return;
|
||||
}
|
||||
const match = segment.match(/^(?:([A-Za-z_$][\w$]*)|'([^']*)'|"([^"]*)")\s*(?::|$)/);
|
||||
const name = match?.[1] ?? match?.[2] ?? match?.[3];
|
||||
if (name === undefined) dynamic = true;
|
||||
else names.add(name);
|
||||
};
|
||||
while (i < close) {
|
||||
const ch = text.charAt(i);
|
||||
if (isQuote(ch)) {
|
||||
i = skipString(text, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === "{" || ch === "[" || ch === "(") {
|
||||
i = findClosing(text, i) + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ",") {
|
||||
flush(i);
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
flush(close);
|
||||
return { names, dynamic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields every t(...) call whose first argument is a literal key. `text` is the source without
|
||||
* comments, `bare` the same without the content of its strings
|
||||
*/
|
||||
export function* literalCalls(text: string, bare: string): Generator<{ key: string; params: Params; index: number }> {
|
||||
for (const match of bare.matchAll(CALL_START)) {
|
||||
const keyStart = skipSpaces(text, match.index + match[0].length);
|
||||
const key = readStringLiteral(text, keyStart);
|
||||
if (!key) continue; // only vue-tsc can check it
|
||||
let i = skipSpaces(text, key.end);
|
||||
if (text.charAt(i) !== "," && text.charAt(i) !== ")") continue; // 'prefix.' + name if the key isn't literal
|
||||
let params: Params = { names: new Set(), dynamic: false };
|
||||
if (text.charAt(i) === ",") {
|
||||
i = skipSpaces(text, i + 1);
|
||||
if (text.charAt(i) === "{") params = readObjectKeys(text, i) ?? params;
|
||||
else if (text.charAt(i) !== ")") params = { names: new Set(), dynamic: true };
|
||||
}
|
||||
yield { key: key.value, params, index: match.index };
|
||||
}
|
||||
}
|
||||
|
||||
function checkCall(key: string, params: Params, messages: Messages): string[] {
|
||||
const expected = messages[key];
|
||||
if (expected === undefined) return [`unknown translation key "${key}"`];
|
||||
if (params.dynamic) return []; // spread or variable: names unknown
|
||||
const known = new Set(placeholders(expected));
|
||||
const problems: string[] = [];
|
||||
for (const name of params.names) {
|
||||
if (!known.has(name)) problems.push(`"${key}" does not take a {${name}} param`);
|
||||
}
|
||||
for (const name of known) {
|
||||
if (!params.names.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`"${key}" is missing its {${name}} param`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function checkTypedUsage(messages: Messages, strict: boolean): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of sourceFiles(true)) {
|
||||
if (isTestFile(file)) continue; // tests t/$t with throwaway keys
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const code = maskSource(file, text);
|
||||
const bare = maskSource(file, text, true);
|
||||
if (strict || isTypeScript(file, text)) {
|
||||
const codeLines = code.split("\n");
|
||||
const bareLines = bare.split("\n");
|
||||
for (const rule of BYPASS_RULES) {
|
||||
const lines = rule.inStrings ? codeLines : bareLines;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (rule.pattern.test(line)) problems.push(`${rel(file)}:${index + 1}: ${rule.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const call of literalCalls(code, bare)) {
|
||||
for (const problem of checkCall(call.key, call.params, messages)) {
|
||||
problems.push(`${rel(file)}:${lineOf(text, call.index)}: ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Checks the generated types, the placeholders of every locale and the t() calls in src/. With `strict`, raw `$t` and 'vue-i18n' */
|
||||
export function checkTypes(strict: boolean): string[] {
|
||||
const { content, messages, problems } = generate();
|
||||
return [...problems, ...checkGeneratedFile(content), ...checkLocalePlaceholders(messages), ...checkTypedUsage(messages, strict)];
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// scripts/sync-translations.js
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Translator } from 'deepl-node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const checkOnly = args.includes('--check') || args.includes('-c');
|
||||
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
|
||||
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
|
||||
|
||||
// --- Configuration ---
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const localesDir = path.resolve(__dirname, '../src/i18n');
|
||||
const masterLocaleFile = path.join(localesDir, 'en.json');
|
||||
const masterLanguageCode = 'en';
|
||||
const targetLocaleFiles = globSync(path.join(localesDir, '*.json'))
|
||||
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
|
||||
|
||||
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
|
||||
if (requireApiKey && !DEEPL_API_KEY) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
'zh-tw': 'ZH-HANT',
|
||||
'pt': 'PT-PT', // or 'PT-BR' if you want Brazilian Portuguese
|
||||
'pt-br': 'PT-BR',
|
||||
'en': 'EN',
|
||||
'en-us': 'EN-US',
|
||||
'en-gb': 'EN-GB',
|
||||
'sv-se': 'SV',
|
||||
'ua': 'UK',
|
||||
'nl-be': 'NL',
|
||||
'cz': 'CS',
|
||||
'bg': 'BG',
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
// --- Translation Function using DeepL ---
|
||||
async function translateText(text, targetLanguage, keyPath = '') {
|
||||
if (checkOnly) {
|
||||
// In check mode, just return the original text to detect changes
|
||||
return text;
|
||||
}
|
||||
|
||||
if (!text || typeof text !== 'string' || text.trim() === '') {
|
||||
console.warn(`Skipping translation for empty or non-string text: "${text}"`);
|
||||
return text;
|
||||
}
|
||||
|
||||
if (keyPath === 'languages' || keyPath.startsWith('languages.')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options = {};
|
||||
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, '<ph>$1</ph>');
|
||||
options.tagHandling = 'xml';
|
||||
options.ignoreTags = ['ph'];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Translating "${text}" from '${masterLanguageCode}' to '${targetLanguage}'...`);
|
||||
|
||||
const deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
|
||||
|
||||
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
|
||||
|
||||
// Delay to avoid rate-limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
let translatedText = result.text;
|
||||
|
||||
if (hasPlaceholders) {
|
||||
// Unwrap the placeholders. The translator might add spaces around tags.
|
||||
translatedText = translatedText.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, '$1');
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, err.message || err);
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder objects to match eng (master lang) key order
|
||||
function reorderObject(obj, masterObj) {
|
||||
// If obj is not an object, return as is
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
|
||||
const newObj = {};
|
||||
for (const key in masterObj) {
|
||||
if (Object.hasOwn(masterObj, key)) {
|
||||
if (Object.hasOwn(obj, key)) {
|
||||
newObj[key] = reorderObject(obj[key], masterObj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
|
||||
// --- Recursive key processor ---
|
||||
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
|
||||
let changesMade = false;
|
||||
let meaningfulChanges = 0; // Only count meaningful changes
|
||||
|
||||
// First pass: Add/update keys from master to target
|
||||
for (const key in masterObj) {
|
||||
if (Object.hasOwn(masterObj, key)) {
|
||||
const currentPathPartsNext = [...currentPathParts, key];
|
||||
const currentKeyPath = currentPathPartsNext.join('.');
|
||||
|
||||
const masterValue = masterObj[key];
|
||||
|
||||
// Special handling for "languages" key - always copy the entire object from master
|
||||
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
|
||||
if (!checkOnly && !doEnforceOrder) {
|
||||
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
|
||||
}
|
||||
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
|
||||
changesMade = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
|
||||
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Creating new object structures is meaningful
|
||||
// In check mode, create a temporary object for processing
|
||||
targetObj[key] = {};
|
||||
} else {
|
||||
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
targetObj[key] = {};
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (targetObj[key] && typeof targetObj[key] === 'object') {
|
||||
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
|
||||
doEnforceOrder, doCleanupOnly);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
meaningfulChanges += result;
|
||||
changesMade = true;
|
||||
} else if (result) {
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else if (!doCleanupOnly) {
|
||||
// Only handle non‑object values
|
||||
if (typeof masterValue === 'string') {
|
||||
const existingTargetValue = targetObj[key];
|
||||
const isMissing = !Object.hasOwn(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
|
||||
|
||||
if (isMissing) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Translation is meaningful
|
||||
} else if (doEnforceOrder) {
|
||||
// Skip translations for new keys
|
||||
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
} else {
|
||||
// Sync translation
|
||||
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
|
||||
if (result === "") {
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
targetObj[key] = result;
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!Object.hasOwn(targetObj, key)) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Copying non-string values is meaningful
|
||||
} else {
|
||||
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
|
||||
targetObj[key] = masterValue;
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Remove obsolete keys that exist in target but not in master
|
||||
const keysToRemove = [];
|
||||
for (const key in targetObj) {
|
||||
if (Object.hasOwn(targetObj, key)) {
|
||||
if (!Object.hasOwn(masterObj, key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
const currentKeyPath = [...currentPathParts, key].join('.');
|
||||
if (checkOnly) {
|
||||
console.log(`Would remove obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Removing obsolete keys is meaningful
|
||||
} else {
|
||||
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
|
||||
delete targetObj[key];
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
return checkOnly ? meaningfulChanges : changesMade;
|
||||
}
|
||||
|
||||
// --- Main synchronization ---
|
||||
async function syncAllTranslations() {
|
||||
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const isEnforceOrder = enforceOrder && !cleanupOnly;
|
||||
const isCleanup = cleanupOnly;
|
||||
|
||||
if (checkOnly) {
|
||||
console.log("--- Checking for translation changes (no translations will be performed) ---");
|
||||
} else if (isCleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else if (isEnforceOrder) {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
} else if (isSync) {
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
}
|
||||
|
||||
if (!await fs.pathExists(masterLocaleFile)) {
|
||||
console.error(`Master locale file not found: ${masterLocaleFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const masterContent = await fs.readJson(masterLocaleFile);
|
||||
console.log(`Loaded master translations from ${masterLocaleFile}`);
|
||||
|
||||
let meaningfulChanges = 0;
|
||||
let hasMeaningfulChanges = false;
|
||||
|
||||
for (const targetFile of targetLocaleFiles) {
|
||||
const targetLangCode = path.basename(targetFile, '.json');
|
||||
let targetContent = {};
|
||||
let originalTargetContent = null;
|
||||
const fileExisted = await fs.pathExists(targetFile);
|
||||
|
||||
if (fileExisted) {
|
||||
try {
|
||||
originalTargetContent = await fs.readJson(targetFile);
|
||||
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
|
||||
targetContent = {};
|
||||
originalTargetContent = null;
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) continue;
|
||||
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
|
||||
}
|
||||
|
||||
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
|
||||
|
||||
let reorderedContent = targetContent;
|
||||
let orderChanged = false;
|
||||
if (isEnforceOrder && originalTargetContent && !checkOnly) {
|
||||
reorderedContent = reorderObject(targetContent, masterContent);
|
||||
// Compare the reordered content with the original to see if order changed
|
||||
const origStr = JSON.stringify(originalTargetContent);
|
||||
const reorderedStr = JSON.stringify(reorderedContent);
|
||||
if (origStr !== reorderedStr) {
|
||||
orderChanged = true;
|
||||
console.log(`- Reordered keys in ${targetLangCode}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
let wouldChange = false;
|
||||
if (typeof result === 'number' && result > 0) {
|
||||
meaningfulChanges += result;
|
||||
wouldChange = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
}
|
||||
if (!fileExisted && !isCleanup) {
|
||||
wouldChange = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
}
|
||||
if (wouldChange) {
|
||||
hasMeaningfulChanges = true;
|
||||
}
|
||||
} else {
|
||||
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
|
||||
if (needsWrite) {
|
||||
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
|
||||
try {
|
||||
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
|
||||
if (!isCleanup && !isEnforceOrder) {
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${targetFile}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`No changes needed for ${targetFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
if (hasMeaningfulChanges) {
|
||||
console.log(`\n⚠️ Found ${meaningfulChanges} meaningful translation changes needed. -- update via "make sync-translations"`);
|
||||
return 1; // Exit code 1 for meaningful changes needed
|
||||
} else {
|
||||
console.log('\n✅ No meaningful translation changes needed.');
|
||||
return 0; // Exit code 0 for no meaningful changes
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) {
|
||||
console.log('\n✅ Cleanup complete (obsolete keys removed).');
|
||||
} else if (isEnforceOrder) {
|
||||
console.log('\n✅ Enforce order complete.');
|
||||
} else {
|
||||
console.log('\n✅ Translation synchronization complete (via DeepL).');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
syncAllTranslations()
|
||||
.then(exitCode => {
|
||||
process.exit(exitCode);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("\n❌ An error occurred during translation synchronization:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<script>
|
||||
import { onMounted } from "vue";
|
||||
import { mutations } from "@/store/index.ts"; // Import your store's mutations
|
||||
import { scheduleFullIcons } from "@/utils/loadFullIcons.js";
|
||||
import { mutations } from "@/store"; // Import your store's mutations
|
||||
import { scheduleFullIcons } from "@/utils/loadFullIcons";
|
||||
|
||||
mutations.setLoading("main-app", true);
|
||||
export default {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
import { invalidateDirMetadataItem, markDirMetadataItemChanged } from "@/utils/cache/metadataCache.ts";
|
||||
import { getApiPath, getParentDir } from "@/utils/url.js";
|
||||
import { invalidateDirMetadataCache, invalidateDirMetadataItem } from "@/utils/cache/metadataCache.ts";
|
||||
import { fetchURL } from "./utils.ts";
|
||||
|
||||
/**
|
||||
@@ -35,7 +35,7 @@ export async function createArchive(opts) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
markDirMetadataItemChanged({ source: toSource || fromSource }, destination);
|
||||
invalidateDirMetadataCache({ source: toSource || fromSource, path: getParentDir(destination) });
|
||||
if (deleteAfter) {
|
||||
for (const p of paths) {
|
||||
invalidateDirMetadataItem({ source: fromSource }, p);
|
||||
@@ -81,7 +81,7 @@ export async function unarchive(opts) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!preview) {
|
||||
invalidateDirMetadataItem({ source: toSource || fromSource }, destination);
|
||||
invalidateDirMetadataCache({ source: toSource || fromSource, path: destination });
|
||||
if (deleteAfter) {
|
||||
invalidateDirMetadataItem({ source: fromSource }, path);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
// - /api/tools/ -> tools.js
|
||||
// - /api/office/ -> office.js
|
||||
// - /api/wopi/ -> wopi.js
|
||||
// - /api/media/ -> media.ts
|
||||
// - /api/media/ -> media.js
|
||||
// - /public/api/* -> public functions in respective files (e.g., resourcesApi.fetchFilesPublic)
|
||||
|
||||
import * as accessApi from "./access.js";
|
||||
import * as authApi from "./auth.js";
|
||||
import * as mediaApi from "./media.ts";
|
||||
import * as mediaApi from "./media.js";
|
||||
import * as officeApi from "./office.js";
|
||||
import * as resourcesApi from "./resources.js";
|
||||
import * as settingsApi from "./settings.js";
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { state } from "@/store/index.ts";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { adjustedData, fetchURL } from "./utils.ts";
|
||||
import {
|
||||
getDirMetadataEntry,
|
||||
setCachedDirMetadata,
|
||||
setDirMetadataEntryPromise,
|
||||
stemOf,
|
||||
takePendingDirChanges,
|
||||
} from "@/utils/cache/metadataCache.ts";
|
||||
|
||||
// GET /api/media/subtitles or /public/api/media/subtitles
|
||||
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
|
||||
try {
|
||||
const hash = state.shareInfo?.hash || null
|
||||
const baseParams = {
|
||||
path,
|
||||
name: subtitleName,
|
||||
...(embedded && { embedded: 'true' }),
|
||||
}
|
||||
|
||||
let res
|
||||
if (hash) {
|
||||
const apiPath = getPublicApiPath('media/subtitles', {
|
||||
hash,
|
||||
...baseParams,
|
||||
})
|
||||
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
|
||||
res = await fetchURL(apiPath, {
|
||||
headers: { 'X-SHARE-PASSWORD': encodeURIComponent(sharePassword) },
|
||||
})
|
||||
} else {
|
||||
const apiPath = getApiPath('media/subtitles', {
|
||||
source,
|
||||
...baseParams,
|
||||
})
|
||||
res = await fetchURL(apiPath)
|
||||
}
|
||||
|
||||
const content = await res.text()
|
||||
return content
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/media/lyrics
|
||||
export async function getLyrics(source, path) {
|
||||
const apiPath = getApiPath('media/lyrics', {
|
||||
source: source,
|
||||
path: path,
|
||||
});
|
||||
const res = await fetchURL(apiPath);
|
||||
const data = await res.json();
|
||||
return { lyrics: data.lyrics || "", format: data.format || "" };
|
||||
}
|
||||
|
||||
async function publicResponseError(response) {
|
||||
const error = new Error(response.statusText);
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (data?.message) {
|
||||
error.message = data.message;
|
||||
}
|
||||
} catch (_e) {
|
||||
// keep the status text
|
||||
}
|
||||
/** @type {any} */ (error).status = response.status;
|
||||
return error;
|
||||
}
|
||||
|
||||
// GET /public/api/media/lyrics
|
||||
export async function getLyricsPublic(path, hash, password = "") {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/lyrics", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await publicResponseError(response);
|
||||
}
|
||||
const data = await response.json();
|
||||
return { lyrics: data.lyrics || "", format: data.format || "" };
|
||||
}
|
||||
|
||||
// GET /api/media/metadata — directory or file with metadata; optional albumArt for embedded cover extraction.
|
||||
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
|
||||
/** @returns {Promise<object>} resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadata(source, path, albumArt = false) {
|
||||
const apiPath = getApiPath("media/metadata", {
|
||||
source,
|
||||
path,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
});
|
||||
const res = await fetchURL(apiPath);
|
||||
const data = await res.json();
|
||||
return adjustedData(data);
|
||||
}
|
||||
|
||||
// GET /public/api/media/metadata
|
||||
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
|
||||
/** @returns {Promise<object>} resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "", albumArt = false) {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/metadata", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await publicResponseError(response);
|
||||
}
|
||||
const data = await response.json();
|
||||
return adjustedData(data);
|
||||
}
|
||||
|
||||
async function fetchWholeDirectoryMetadata({ isShare, source, hash, path, albumArt }, password) {
|
||||
const payload = isShare
|
||||
? await fetchDirectoryMediaMetadataPublic(path, hash, password, albumArt)
|
||||
: await fetchDirectoryMediaMetadata(source, path, albumArt);
|
||||
return new Map(
|
||||
(payload?.items || []).filter((i) => i.metadata).map((i) => [i.name, i.metadata])
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchItemMetadata({ isShare, source, hash, path, albumArt }, password, name) {
|
||||
const base = !path || path === "/" ? "" : path.replace(/\/+$/, "");
|
||||
const itemPath = `${base}/${name}`;
|
||||
const payload = isShare
|
||||
? await fetchDirectoryMediaMetadataPublic(itemPath, hash, password, albumArt)
|
||||
: await fetchDirectoryMediaMetadata(source, itemPath, albumArt);
|
||||
return payload && payload.type !== "directory" && payload.metadata ? payload.metadata : null;
|
||||
}
|
||||
|
||||
// Applies the per-item changes recorded since the folder was cached, fetching only the changed items.
|
||||
function resolvePendingChanges(entry, { removed, dirty, stems }, cacheOpts, password) {
|
||||
const base = entry.promise;
|
||||
const promise = (async () => {
|
||||
const map = new Map(await base);
|
||||
for (const name of removed) {
|
||||
map.delete(name);
|
||||
}
|
||||
const toRefresh = new Set(dirty);
|
||||
if (stems.size) {
|
||||
for (const name of map.keys()) {
|
||||
if (stems.has(stemOf(name))) {
|
||||
toRefresh.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// returns the names that failed, others are applied
|
||||
const refresh = async (names) => {
|
||||
const failed = [];
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
const metadata = await fetchItemMetadata(cacheOpts, password, name);
|
||||
if (metadata) {
|
||||
map.set(name, metadata);
|
||||
} else {
|
||||
map.delete(name);
|
||||
}
|
||||
} catch (_e) {
|
||||
failed.push(name);
|
||||
}
|
||||
})
|
||||
);
|
||||
return failed;
|
||||
};
|
||||
const failed = await refresh([...toRefresh]);
|
||||
if (failed.length && (await refresh(failed)).length) {
|
||||
return fetchWholeDirectoryMetadata(cacheOpts, password);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
setDirMetadataEntryPromise(entry, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches media metadata, and caches it by directory, shared for all the callers to avoid unnecessary requests.
|
||||
* Items changed (deleted, uploaded, moved) are patched individually instead of refetch the whole dir
|
||||
* @param {string} path - directory path
|
||||
* @param {{isShare?: boolean, source?: string, hash?: string, password?: string, albumArt?: boolean}} opts
|
||||
* @returns {Promise<Map<string, object>>}
|
||||
*/
|
||||
export async function getDirectoryMetadataMap(path, opts = {}) {
|
||||
const { isShare = false, source, hash, password = "", albumArt = false } = opts;
|
||||
const cacheOpts = { isShare, source, hash, path, albumArt };
|
||||
const entry = albumArt ? null : getDirMetadataEntry(cacheOpts);
|
||||
if (entry) {
|
||||
const pending = takePendingDirChanges(entry);
|
||||
return pending ? resolvePendingChanges(entry, pending, cacheOpts, password) : entry.promise;
|
||||
}
|
||||
const promise = fetchWholeDirectoryMetadata(cacheOpts, password);
|
||||
if (!albumArt) {
|
||||
setCachedDirMetadata(cacheOpts, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
// GET /api/media/stream — audio/video bytes via viewToken (range-based, not download-metered).
|
||||
export function getStreamURL(source, path, viewToken) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
if (!viewToken) {
|
||||
throw new Error('view token required')
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
source: source,
|
||||
file: path,
|
||||
viewToken: viewToken,
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
const apiPath = getApiPath('media/stream', params)
|
||||
return window.origin + apiPath
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error getting stream URL')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/api/media/stream
|
||||
export function getStreamURLPublic(share, files, viewToken) {
|
||||
if (!viewToken) {
|
||||
throw new Error('view token required')
|
||||
}
|
||||
const fileArray = Array.isArray(files) ? files : [files]
|
||||
const params = {
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
viewToken: viewToken,
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
const apiPath = getPublicApiPath('media/stream', params)
|
||||
return window.origin + apiPath
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, state } from "@/store/index.ts";
|
||||
import type { MediaMetadata, SubtitleTrack } from "@/store/types.ts";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { adjustedData, fetchURL, type ListingResponse } from "./utils.ts";
|
||||
import { cachedDirectory, cachedItem, type DirMetadataMap, type MetadataScope } from "@/utils/cache/metadataCache.ts";
|
||||
|
||||
/** GET /api/media/metadata: a directory with the metadata of its items, or a single file */
|
||||
export type MediaResource = ListingResponse & {
|
||||
metadata?: MediaMetadata;
|
||||
subtitles?: SubtitleTrack[];
|
||||
hasPreview?: boolean;
|
||||
};
|
||||
|
||||
export interface Lyrics {
|
||||
lyrics: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : "";
|
||||
}
|
||||
|
||||
// Inside a share the requests go to the public API, otherwise to the source.
|
||||
// Decided here so callers don't branch, and it is the scope of what gets cached.
|
||||
function mediaScope(source?: string): MetadataScope {
|
||||
const hash = getters.shareHash();
|
||||
return hash ? { isShare: true, hash } : { source };
|
||||
}
|
||||
|
||||
// GET /api/media/{endpoint} or /public/api/media/{endpoint}
|
||||
function fetchMedia(endpoint: string, scope: MetadataScope, params: Record<string, string>): Promise<Response> {
|
||||
if (scope.isShare) {
|
||||
const password = localStorage.getItem(`sharepass:${scope.hash}`) || "";
|
||||
return fetchURL(getPublicApiPath(`media/${endpoint}`, { hash: scope.hash, ...params }), {
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password) },
|
||||
});
|
||||
}
|
||||
return fetchURL(getApiPath(`media/${endpoint}`, { source: scope.source, ...params }));
|
||||
}
|
||||
|
||||
export function getSubtitleContent(source: string, path: string, subtitleName: string, embedded = false): Promise<string> {
|
||||
const scope = mediaScope(source);
|
||||
return cachedItem(scope, path, `subtitle:${subtitleName}:${embedded}`, async () => {
|
||||
try {
|
||||
const res = await fetchMedia("subtitles", scope, {
|
||||
path,
|
||||
name: subtitleName,
|
||||
...(embedded && { embedded: "true" }),
|
||||
});
|
||||
return await res.text();
|
||||
} catch (err) {
|
||||
notify.showError(errorMessage(err) || `Error fetching subtitle ${subtitleName}`);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getLyrics(source: string, path: string): Promise<Lyrics> {
|
||||
const scope = mediaScope(source);
|
||||
return cachedItem(scope, path, "lyrics", async () => {
|
||||
const res = await fetchMedia("lyrics", scope, { path });
|
||||
const data = await res.json();
|
||||
return { lyrics: data.lyrics || "", format: data.format || "" };
|
||||
});
|
||||
}
|
||||
|
||||
// albumArt adds the embedded cover of audio files.
|
||||
async function fetchMetadata(scope: MetadataScope, path: string, albumArt = false): Promise<MediaResource> {
|
||||
const res = await fetchMedia("metadata", scope, { path, ...(albumArt && { albumArt: "true" }) });
|
||||
return adjustedData(await res.json());
|
||||
}
|
||||
|
||||
/** Metadata of a single file */
|
||||
export function getItemMediaMetadata(
|
||||
path: string,
|
||||
{ source, albumArt = false }: { source?: string; albumArt?: boolean } = {},
|
||||
): Promise<MediaResource> {
|
||||
const scope = mediaScope(source);
|
||||
const fetcher = () => fetchMetadata(scope, path, albumArt);
|
||||
return albumArt ? fetcher() : cachedItem(scope, path, "metadata", fetcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata of the items of a directory by name, cached by dir and shared for all callers.
|
||||
* Items changed are patched individually instead of refetching the whole dir again.
|
||||
*/
|
||||
export function getDirectoryMetadataMap(path: string, { source }: { source?: string } = {}): Promise<DirMetadataMap> {
|
||||
const scope = mediaScope(source);
|
||||
const base = !path || path === "/" ? "" : path.replace(/\/+$/, "");
|
||||
return cachedDirectory(
|
||||
scope,
|
||||
path,
|
||||
async () => {
|
||||
const payload = await fetchMetadata(scope, path);
|
||||
const withMetadata = (payload.items || []).filter((i) => i.metadata);
|
||||
return new Map(withMetadata.map((i) => [i.name, i.metadata as object]));
|
||||
},
|
||||
async (name) => {
|
||||
const payload = await fetchMetadata(scope, `${base}/${name}`);
|
||||
return payload.type !== "directory" && payload.metadata ? payload.metadata : null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/media/stream or /public/api/media/stream — audio/video bytes via viewToken (range-based, not download-metered).
|
||||
export function getStreamURL(source: string, path: string, viewToken: string): string {
|
||||
const scope = mediaScope(source);
|
||||
if (!scope.isShare && !source) {
|
||||
throw new Error("no source provided");
|
||||
}
|
||||
if (!viewToken) {
|
||||
throw new Error("view token required");
|
||||
}
|
||||
const params = { file: path, viewToken: viewToken, sessionId: state.sessionId };
|
||||
const apiPath = scope.isShare
|
||||
? getPublicApiPath("media/stream", { hash: scope.hash, ...params })
|
||||
: getApiPath("media/stream", { source, ...params });
|
||||
return window.origin + apiPath;
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import { notify } from "@/notify/index.ts"
|
||||
import { getters, mutations, state } from "@/store/index.ts"
|
||||
import { globalVars } from "@/utils/constants.ts"
|
||||
import { globalVars } from "@/utils/constants.js"
|
||||
import { downloadManager } from "@/utils/downloadManager.ts"
|
||||
import {
|
||||
notifyDownloadComplete,
|
||||
notifyDownloadError,
|
||||
} from "@/utils/appNotifications.js"
|
||||
import { renew } from "@/utils/auth.js"
|
||||
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
|
||||
import { getApiPath, getPublicApiPath, getParentDir } from '@/utils/url.js'
|
||||
import { adjustedData, fetchURL } from "./utils.ts"
|
||||
import { rememberViewToken } from "./viewToken.js"
|
||||
import { isMediaFile } from "@/utils/mediaFile.js"
|
||||
import { getObjectProperty } from "@/utils/object.js"
|
||||
import { getStreamURL } from "./media.ts"
|
||||
import { getStreamURL, getStreamURLPublic } from "./media.js"
|
||||
import {
|
||||
invalidateDirMetadataCache,
|
||||
invalidateDirMetadataItem,
|
||||
markDirMetadataItemChanged,
|
||||
removeDirMetadataItem
|
||||
@@ -34,7 +35,7 @@ function parseMockDataCount(value, defaultCount) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultCount;
|
||||
}
|
||||
const n = Math.trunc(Number(value));
|
||||
const n = parseInt(String(value), 10);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return defaultCount;
|
||||
}
|
||||
@@ -1025,7 +1026,7 @@ export async function moveCopy(
|
||||
removeDirMetadataItem({ source: item.fromSource }, item.from)
|
||||
}
|
||||
if (rename) {
|
||||
invalidateDirMetadataItem({ source: item.toSource }, item.to)
|
||||
invalidateDirMetadataCache({ source: item.toSource, path: getParentDir(item.to) })
|
||||
} else {
|
||||
markDirMetadataItemChanged({ source: item.toSource }, item.isDir ? `${item.to}/` : item.to)
|
||||
}
|
||||
@@ -1146,6 +1147,9 @@ export function getViewURL(source, path, viewToken, shareInfo = null, allowDownl
|
||||
|
||||
if (viewToken) {
|
||||
if (isMediaFile(typeHint)) {
|
||||
if (shareInfo) {
|
||||
return getStreamURLPublic(shareInfo, [path], viewToken)
|
||||
}
|
||||
return getStreamURL(source, path, viewToken)
|
||||
}
|
||||
if (shareInfo) {
|
||||
@@ -1661,7 +1665,7 @@ export async function moveCopyPublic(
|
||||
removeDirMetadataItem({ isShare: true, hash }, item.from)
|
||||
}
|
||||
if (rename) {
|
||||
invalidateDirMetadataItem({ isShare: true, hash }, item.to)
|
||||
invalidateDirMetadataCache({ isShare: true, hash, path: getParentDir(item.to) })
|
||||
} else {
|
||||
markDirMetadataItemChanged({ isShare: true, hash }, item.isDir ? `${item.to}/` : item.to)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("@/store", () => ({
|
||||
vi.mock("@/api/media", () => ({
|
||||
getStreamURL: (_source, path, viewToken) =>
|
||||
`http://localhost/api/media/stream?file=${encodeURIComponent(path)}&viewToken=${viewToken}`,
|
||||
getStreamURLPublic: () => "http://localhost/public/stream",
|
||||
}));
|
||||
|
||||
import { getViewURL, getOpenFileURL } from "@/api/resources.js";
|
||||
|
||||
@@ -51,7 +51,7 @@ function readCacheForScope(scope) {
|
||||
if (!parsed?.viewToken || !parsed?.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return { viewToken: parsed.viewToken, expiresAt: parsed.expiresAt };
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
close: multistate === 'close',
|
||||
}"
|
||||
>
|
||||
<title id="toggle-navbar-title">{{ t('general.close') }}</title>
|
||||
<title id="toggle-navbar-title">{{ $t('general.close') }}</title>
|
||||
<path class="line top" d="M 30,33 H 70" />
|
||||
<path class="line middle" d="M 30,50 H 70" />
|
||||
<path class="line bottom" d="M 30,67 H 70" />
|
||||
@@ -56,9 +56,8 @@ export function actionIconClass(icon: string, customIconClass?: string): string
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getters, mutations } from "@/store/index.ts";
|
||||
import type { Prompt } from "@/store/types.ts";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { getters, mutations } from "@/store";
|
||||
import type { Prompt } from "@/store/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -83,8 +82,6 @@ const emit = defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const multistate = computed(() => getters.multibuttonState());
|
||||
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
|
||||
|
||||
|
||||
@@ -44,12 +44,6 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"button-clicked": (_value) => true,
|
||||
"remove-button-clicked": (_value) => true,
|
||||
disableAll: () => true,
|
||||
enableAll: () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeButton: null, // Initially no button is active
|
||||
@@ -83,11 +77,12 @@ export default {
|
||||
this.$emit("remove-button-clicked", value);
|
||||
} else {
|
||||
// Emit remove-button-clicked for all other indexes
|
||||
for (const [idx, button] of this.buttons.entries()) {
|
||||
this.buttons.forEach((button, idx) => {
|
||||
if (idx !== index) {
|
||||
this.$emit("remove-button-clicked", button.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.activeButton = index;
|
||||
this.$emit("button-clicked", value);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,7 @@ vi.mock("@/store", () => ({
|
||||
isSplitViewActive: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/i18n/index.ts", () => ({
|
||||
default: { global: { t: (key: string) => key } },
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<p v-if="newFileTemplates.length === 0 && isLoggedIn" class="context-menu-empty-message">
|
||||
{{ t('prompts.noTemplate') }}
|
||||
{{ $t('prompts.noTemplate') }}
|
||||
</p>
|
||||
<button
|
||||
v-for="(item, index) in newFileTemplateItems"
|
||||
@@ -39,12 +39,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="action no-select"
|
||||
:aria-label="t('files.emptyFile')"
|
||||
:title="t('files.emptyFile')"
|
||||
:aria-label="$t('files.emptyFile')"
|
||||
:title="$t('files.emptyFile')"
|
||||
@click="createEmptyFile"
|
||||
>
|
||||
<i :class="actionIconClass('insert_drive_file')">insert_drive_file</i>
|
||||
<span>{{ t('files.emptyFile') }}</span>
|
||||
<span>{{ $t('files.emptyFile') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
@mouseleave="hideInteractiveTooltip()"
|
||||
@mouseenter="showHoverTooltip(t('buttons.selectedCount'), $event)"
|
||||
@mouseenter="showHoverTooltip($t('buttons.selectedCount'), $event)"
|
||||
class="button selected-count-header"
|
||||
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
|
||||
>
|
||||
@@ -112,21 +112,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { resourcesApi, shareApi, usersApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { resourcesApi, shareApi, usersApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store/index.js";
|
||||
import type { FileListItem, ReqObject } from "@/store/types.ts";
|
||||
import { hideInteractiveTooltip, showHoverTooltip } from "@/utils/tooltipHelp.js";
|
||||
import { goToItem, getParentDir, removeLastDir } from "@/utils/url.js";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import downloadFiles from "@/utils/download.js";
|
||||
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare.js";
|
||||
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype.js";
|
||||
import { isMediaRequest } from "@/utils/mediaFile.js";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import downloadFiles from "@/utils/download";
|
||||
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare";
|
||||
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype";
|
||||
import { isMediaRequest } from "@/utils/mediaFile";
|
||||
import { actionIconClass } from "@/components/Action.vue";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
import { popEnter, popLeave } from "@/utils/transitions";
|
||||
|
||||
type MenuItem = FileListItem & { isDir?: boolean; from?: string };
|
||||
|
||||
@@ -495,12 +495,8 @@ function itemsForPrompt(): MenuItem[] {
|
||||
}
|
||||
|
||||
function showInfoPrompt() {
|
||||
const item =
|
||||
isPreview.value && !props.items?.length && (showOverflow.value || !firstSelected.value)
|
||||
? state.req
|
||||
: firstSelected.value;
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt({ name: "info", props: { item } });
|
||||
mutations.showPrompt({ name: "info", props: { item: firstSelected.value } });
|
||||
}
|
||||
|
||||
function navToItem() {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
createLightningStrike,
|
||||
prefersReducedMotion,
|
||||
type LightningStrike,
|
||||
} from "@/utils/lightning-bolt.ts";
|
||||
} from "@/utils/lightning-bolt";
|
||||
|
||||
type Bolt = LightningStrike & { id: number };
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
export default {
|
||||
name: "load-failed",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@keydown.esc.prevent="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
<div class="menu-option-list-scroll" :style="scrollStyle">
|
||||
<div ref="scroll" class="menu-option-list-scroll" :style="scrollStyle">
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="optionKey(option.value)"
|
||||
@@ -24,7 +24,7 @@
|
||||
}"
|
||||
:aria-selected="isSelected(option.value) ? 'true' : 'false'"
|
||||
:disabled="option.disabled"
|
||||
@click="$emit('option-select', option)"
|
||||
@click="$emit('select', option)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
@@ -69,11 +69,9 @@ export default {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"option-select": (_option) => true,
|
||||
"update:searchQuery": (_query) => true,
|
||||
close: () => true,
|
||||
},
|
||||
|
||||
emits: ["select", "update:searchQuery", "close"],
|
||||
|
||||
computed: {
|
||||
resolvedSearchPlaceholder() {
|
||||
return this.searchPlaceholder || this.$t("general.search");
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
export default {
|
||||
name: "notifications",
|
||||
|
||||
@@ -47,15 +47,12 @@ export default {
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
valBackground: {
|
||||
type: [Number, String],
|
||||
"val-background": {
|
||||
default: null,
|
||||
},
|
||||
valText: {
|
||||
type: [Number, String],
|
||||
"val-text": {
|
||||
default: null,
|
||||
},
|
||||
unit: {
|
||||
@@ -63,24 +60,23 @@ export default {
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
// either a number (pixel width/height) or 'tiny', 'small',
|
||||
// 'medium', 'large', 'huge', 'massive' for common sizes
|
||||
default: "big",
|
||||
},
|
||||
bgColor: {
|
||||
"bg-color": {
|
||||
type: String,
|
||||
default: "#eee",
|
||||
},
|
||||
barColor: {
|
||||
"bar-color": {
|
||||
type: String,
|
||||
default: "var(--primaryColor)", // match .blue color to Material Design's 'Blue 500' color
|
||||
},
|
||||
barTransition: {
|
||||
"bar-transition": {
|
||||
type: String,
|
||||
default: "all 0.5s ease",
|
||||
},
|
||||
barBorderRadius: {
|
||||
"bar-border-radius": {
|
||||
type: Number,
|
||||
default: 8, // ~0.5em at default font size
|
||||
},
|
||||
@@ -92,19 +88,19 @@ export default {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
textAlign: {
|
||||
"text-align": {
|
||||
type: String,
|
||||
default: "center", // 'left', 'right'
|
||||
},
|
||||
textPosition: {
|
||||
"text-position": {
|
||||
type: String,
|
||||
default: "inside", // 'bottom', 'top', 'middle', 'inside'
|
||||
},
|
||||
fontSize: {
|
||||
"font-size": {
|
||||
type: Number,
|
||||
default: 13,
|
||||
},
|
||||
textFgColor: {
|
||||
"text-fg-color": {
|
||||
type: String,
|
||||
default: "#000",
|
||||
},
|
||||
@@ -112,7 +108,7 @@ export default {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
helpText: {
|
||||
"help-text": {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
@@ -137,7 +133,7 @@ export default {
|
||||
displayed_text() {
|
||||
// Use valText if provided (for hybrid mode text override), otherwise use val
|
||||
const displayVal = this.valText !== null ? this.valText : this.val;
|
||||
|
||||
|
||||
if (!isNumber(displayVal)) return displayVal;
|
||||
|
||||
// Cap % like the bar fill so nested-mount indexed size > partition total
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- Search input section -->
|
||||
<div class="search-input-container">
|
||||
<!-- Close button visible when search is active -->
|
||||
<button v-if="active" type="button" class="action" @click="close" :aria-label="t('general.close')"
|
||||
:title="t('general.close')">
|
||||
<button v-if="active" type="button" class="action" @click="close" :aria-label="$t('general.close')"
|
||||
:title="$t('general.close')">
|
||||
<i class="material-symbols">close</i>
|
||||
</button>
|
||||
<!-- Search icon when search is not active -->
|
||||
@@ -12,13 +12,13 @@
|
||||
<!-- Input field for search -->
|
||||
<input id="search-input" type="text"
|
||||
@keyup.exact="keyup" @input="onInput" ref="input" :autofocus="active" v-model.trim="value"
|
||||
aria-label="search input" :placeholder="t('general.search', { suffix: '...' })" />
|
||||
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
|
||||
<div v-if="eventTheme === 'halloween'" class="halloween-eyes" aria-hidden="true">
|
||||
<div class="eye"><div class="pupil"></div></div>
|
||||
<div class="eye right"><div class="pupil"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="active" id="results">
|
||||
<div v-show="active" id="results" ref="result">
|
||||
<div class="inputWrapper">
|
||||
<ExpandDropdown
|
||||
v-if="multipleSources"
|
||||
@@ -27,13 +27,13 @@
|
||||
transparent
|
||||
:options="searchSourceOptions"
|
||||
all-value="__all__"
|
||||
:all-selected-label="t('general.all')"
|
||||
:all-selected-label="$t('general.all')"
|
||||
aria-label="search sources dropdown"
|
||||
@update:model-value="updateSource"
|
||||
/>
|
||||
|
||||
<!-- Formatted display of selected value -->
|
||||
<div class="searchContext">{{ t("search.searchContext", { context: getContext }) }}</div>
|
||||
<div class="searchContext">{{ $t("search.searchContext", { context: getContext }) }}</div>
|
||||
</div>
|
||||
|
||||
<div id="result-list" ref="resultListEl">
|
||||
@@ -48,8 +48,8 @@
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="showPreviewImages"
|
||||
:name="t('search.showPreviewImages')"
|
||||
:description="t('search.showPreviewImagesDescription')"
|
||||
:name="$t('search.showPreviewImages')"
|
||||
:description="$t('search.showPreviewImagesDescription')"
|
||||
/>
|
||||
</template>
|
||||
</SearchFilters>
|
||||
@@ -90,6 +90,7 @@
|
||||
:thumbnail-url="showPreviewImages ? getThumbnailUrl(s) : ''"
|
||||
/>
|
||||
<span class="text-container">
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ basePath(s.path, s.type === "directory") }}{{ baseName(s.path) }}
|
||||
</span>
|
||||
<div class="filesize">{{ humanSize(s.size) }}</div>
|
||||
@@ -104,7 +105,7 @@
|
||||
class="button open-advanced-search-button"
|
||||
@click.stop.prevent="openInAdvancedSearch"
|
||||
>
|
||||
{{ t("search.openInAdvancedSearch") }}
|
||||
{{ $t("search.openInAdvancedSearch") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,26 +113,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch, type Ref } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { useListingContextMenu } from "@/composables/useListingContextMenu.ts";
|
||||
import { useSearchFilters } from "@/composables/useSearchFilters.ts";
|
||||
import { useSearchRequest } from "@/composables/useSearchRequest.ts";
|
||||
import { resourcesApi, toolsApi } from "@/api/index.js";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useListingContextMenu } from "@/composables/useListingContextMenu";
|
||||
import { useSearchFilters } from "@/composables/useSearchFilters";
|
||||
import { useSearchRequest } from "@/composables/useSearchRequest";
|
||||
import { resourcesApi, toolsApi } from "@/api";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import SearchFilters from "@/components/tools/SearchFilters.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { router } from "@/router/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import type { FileListItem } from "@/store/types.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { globalVars } from "@/utils/constants.ts";
|
||||
import { router } from "@/router";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import type { FileListItem } from "@/store/types";
|
||||
import { url } from "@/utils/";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { debounce } from "@/utils/debounce.js";
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
|
||||
import { shouldIgnoreOutsideTap } from "@/utils/tooltipHelp.js";
|
||||
|
||||
@@ -149,8 +150,8 @@ interface SearchResult {
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const resultListEl = useTemplateRef<HTMLElement>("resultListEl");
|
||||
const input = useTemplateRef<HTMLInputElement>("input");
|
||||
const resultListEl = ref<HTMLElement | null>(null);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const noneMessage = ref(t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }));
|
||||
const showPreviewImages = ref(false);
|
||||
const advancedOptionsExpanded = ref(false);
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
|
||||
import { getters, state } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { getters, state } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "Shelf",
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
max="24"
|
||||
step="1"
|
||||
/>
|
||||
<!-- i18n-ignore -->
|
||||
<span class="size-label">{{ editorFontSize }}px</span>
|
||||
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,8 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
|
||||
export default {
|
||||
name: "StatusBar",
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getIconClass } from "@/utils/material-symbols.js";
|
||||
import { getIconClass } from "@/utils/material-symbols";
|
||||
|
||||
export default {
|
||||
name: "Toast",
|
||||
props: {
|
||||
toasts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { state } from "@/store/index.ts";
|
||||
import { state } from "@/store";
|
||||
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
|
||||
|
||||
const margin = 15;
|
||||
const adjustedX = ref(0);
|
||||
const adjustedY = ref(0);
|
||||
const tooltipRef = useTemplateRef<HTMLElement>("tooltipRef");
|
||||
const tooltipRef = ref<HTMLElement | null>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const tooltip = computed(() => state.tooltip);
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
<template>
|
||||
<div class="audio-side-panel">
|
||||
<div class="panel-tabs">
|
||||
<SlidingTabs v-model="activeTab" :tabs="panelTabs" />
|
||||
<div class="tab-container">
|
||||
<input type="radio" id="tab-queue" v-model="activeTab" value="queue" hidden />
|
||||
<label for="tab-queue" class="tab-btn" :class="{ active: activeTab === 'queue' }" :title="$t('player.QueuePlayback')">
|
||||
<i class="material-symbols">queue_music</i>
|
||||
<span>{{ $t('player.queue') }}</span>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<span v-if="queueCount > 0">({{ queueCount }})</span>
|
||||
</label>
|
||||
<input type="radio" id="tab-lyrics" v-model="activeTab" value="lyrics" hidden />
|
||||
<label for="tab-lyrics" class="tab-btn" :class="{ active: activeTab === 'lyrics' }" :title="$t('player.lyrics')">
|
||||
<i class="material-symbols">lyrics</i>
|
||||
<span>{{ $t('player.lyrics') }}</span>
|
||||
</label>
|
||||
<input type="radio" id="tab-visualizer" v-model="activeTab" value="visualizer" hidden />
|
||||
<label for="tab-visualizer" class="tab-btn" :class="{ active: activeTab === 'visualizer' }" :title="$t('player.visualizer.title')">
|
||||
<i class="material-symbols">equalizer</i>
|
||||
<span>{{ $t('player.visualizer.title') }}</span>
|
||||
</label>
|
||||
<div class="tab-indicator" :style="indicatorStyle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
@@ -18,7 +37,7 @@
|
||||
position-mode="absolute"
|
||||
:edge-offset="{ top: '0.5em', right: '0.5em' }"
|
||||
:auto-hide="false"
|
||||
@press="lyricsScrollLocked = !lyricsScrollLocked"
|
||||
@click="lyricsScrollLocked = !lyricsScrollLocked"
|
||||
:label="lyricsLockToggleLabel"
|
||||
/>
|
||||
<!-- Scrollable area -->
|
||||
@@ -61,7 +80,7 @@
|
||||
position-mode="absolute"
|
||||
:edge-offset="{ top: '0.5em', right: '0.5em' }"
|
||||
:auto-hide="false"
|
||||
@press="showVisualizerSettings"
|
||||
@click="showVisualizerSettings"
|
||||
:label="visualizerSettingsLabel"
|
||||
/>
|
||||
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
|
||||
@@ -71,10 +90,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
|
||||
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
|
||||
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
|
||||
import { mutations, state } from "@/store/index.ts";
|
||||
import { mutations, state } from "@/store";
|
||||
import { visualizerConfig } from "@/utils/visualizerConfig.js";
|
||||
|
||||
const LAST_TAB_KEY = 'plyrSidePanelActiveTab';
|
||||
@@ -101,7 +119,7 @@ const FREQ_LABELS = [
|
||||
|
||||
export default {
|
||||
name: "AudioPanel",
|
||||
components: { SlidingTabs, PlaybackQueue, FloatingActionButton },
|
||||
components: { PlaybackQueue, FloatingActionButton },
|
||||
props: {
|
||||
lyrics: { type: Array, default: () => [] },
|
||||
lyricsMeta: { type: String, default: '' },
|
||||
@@ -111,10 +129,7 @@ export default {
|
||||
audioContext: { type: Object, default: null },
|
||||
audioSource: { type: Object, default: null }, // MediaElementAudioSourceNode, see https://developer.mozilla.org/en-US/docs/Web/API/MediaElementAudioSourceNode
|
||||
},
|
||||
emits: {
|
||||
seek: (timestamp) => typeof timestamp === "number",
|
||||
"needs-audio-graph": () => true,
|
||||
},
|
||||
emits: ["seek", "needs-audio-graph"],
|
||||
data() {
|
||||
return {
|
||||
activeTab: (() => {
|
||||
@@ -122,7 +137,6 @@ export default {
|
||||
return stored && ['queue', 'lyrics', 'visualizer'].includes(stored) ? stored : 'queue';
|
||||
})(),
|
||||
lyricsScrollLocked: false,
|
||||
resizeObserver: null,
|
||||
// Visualizer state
|
||||
visualizerAnalyserLeft: null,
|
||||
visualizerAnalyserRight: null,
|
||||
@@ -169,18 +183,12 @@ export default {
|
||||
visualizerSettingsLabel() {
|
||||
return this.$t('player.visualizer.settings');
|
||||
},
|
||||
panelTabs() {
|
||||
return [
|
||||
{
|
||||
id: 'queue',
|
||||
icon: 'queue_music',
|
||||
label: this.$t('player.queue'),
|
||||
title: this.$t('player.QueuePlayback'),
|
||||
suffix: this.queueCount > 0 ? `(${this.queueCount})` : undefined,
|
||||
},
|
||||
{ id: 'lyrics', icon: 'lyrics', label: this.$t('player.lyrics') },
|
||||
{ id: 'visualizer', icon: 'equalizer', label: this.$t('player.visualizer.title') },
|
||||
];
|
||||
// tabs in the panel
|
||||
indicatorStyle() {
|
||||
const tabCount = 3;
|
||||
const width = 100 / tabCount;
|
||||
const tabIndex = ['queue', 'lyrics', 'visualizer'].indexOf(this.activeTab);
|
||||
return { width: `${width}%`, left: `${tabIndex * width}%` };
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -248,8 +256,19 @@ export default {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
});
|
||||
window.addEventListener('resize', this.onWindowResize);
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.windowResizeHandler = () => {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
};
|
||||
window.addEventListener('resize', this.windowResizeHandler);
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (this.activeTab !== 'visualizer') return;
|
||||
if (document.hidden) {
|
||||
this.stopVisualizer();
|
||||
} else {
|
||||
this.startVisualizer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
|
||||
await this.$nextTick();
|
||||
const container = this.$el?.querySelector('.tab-visualizer');
|
||||
if (container) this.resizeObserver.observe(container);
|
||||
@@ -257,23 +276,12 @@ export default {
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
this.resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.removeEventListener('resize', this.windowResizeHandler);
|
||||
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
|
||||
this.stopVisualizer();
|
||||
this.fullCleanup();
|
||||
},
|
||||
methods: {
|
||||
onWindowResize() {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
},
|
||||
onVisibilityChange() {
|
||||
if (this.activeTab !== 'visualizer') return;
|
||||
if (document.hidden) {
|
||||
this.stopVisualizer();
|
||||
} else {
|
||||
this.startVisualizer();
|
||||
}
|
||||
},
|
||||
scrollToActiveLine() {
|
||||
if (this.lyricsScrollLocked) return;
|
||||
const el = this.$refs.lyricsScrollable;
|
||||
@@ -780,6 +788,62 @@ export default {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
/* Container for the sliding indicator */
|
||||
.tab-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3em;
|
||||
padding: 0.5em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--textSecondary);
|
||||
border-radius: var(--borderRadius);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease, transform 0.2s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn > i,
|
||||
.tab-btn > span + span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn > span:first-of-type {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
left: 0;
|
||||
background: var(--primaryColor);
|
||||
border-radius: var(--borderRadius);
|
||||
z-index: 0;
|
||||
transition: left 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -910,6 +974,11 @@ export default {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.active) {
|
||||
color: var(--primaryColor);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.tab-visualizer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { url } from "@/utils";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
|
||||
|
||||
export default {
|
||||
name: "breadcrumbs",
|
||||
@@ -48,12 +48,7 @@ export default {
|
||||
dragOverItem: null, // Track which breadcrumb is being dragged over
|
||||
};
|
||||
},
|
||||
props: {
|
||||
noLink: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
props: ["noLink"],
|
||||
mounted() {
|
||||
this.updatePaths();
|
||||
window.addEventListener('dragend', this.clearDragState);
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
ref="extraMenuTrigger"
|
||||
type="button"
|
||||
class="editor-toolbar-btn"
|
||||
:title="t('editor.md.moreOptions')"
|
||||
:aria-label="t('editor.md.moreOptions')"
|
||||
:title="$t('editor.md.moreOptions')"
|
||||
:aria-label="$t('editor.md.moreOptions')"
|
||||
@mousedown.prevent
|
||||
@click="toggleMenu('extra')"
|
||||
>
|
||||
@@ -134,16 +134,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, useTemplateRef, watch } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import ace from "ace-builds";
|
||||
import type { Ace } from "ace-builds";
|
||||
import { mutations, state, getters } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { mutations, state, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { removeLastDir } from "@/utils/url.js";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
interface AnchorRange {
|
||||
start: Ace.Anchor;
|
||||
@@ -221,8 +221,8 @@ const lastColors = reactive(new Map<string, string>([
|
||||
const colorInputRefs = new Map<string, HTMLInputElement>();
|
||||
const iconMenuTriggerEls = new Map<"align" | "clipboard", HTMLElement>();
|
||||
const iconMenuEls = new Map<"align" | "clipboard", HTMLElement>();
|
||||
const extraMenuTrigger = useTemplateRef<HTMLElement>("extraMenuTrigger");
|
||||
const extraMenu = useTemplateRef<HTMLElement>("extraMenu");
|
||||
const extraMenuTrigger = ref<HTMLElement | null>(null);
|
||||
const extraMenu = ref<HTMLElement | null>(null);
|
||||
|
||||
const toolbarButtons = computed((): ToolbarButton[] => {
|
||||
// alwaysAvailable are for buttons that will be available for all filetypes, not just markdown.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
v-if="thumbnailSrc && !fullImageLoaded && !isTiff"
|
||||
:src="thumbnailSrc"
|
||||
class="image-ex-img"
|
||||
ref="thumbnail"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -37,13 +38,13 @@
|
||||
|
||||
<script>
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from '@/store';
|
||||
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype.js";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
|
||||
import throttle from "@/utils/throttle.js";
|
||||
import throttle from "@/utils/throttle";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -55,14 +56,8 @@ export default {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"navigate-previous": () => true,
|
||||
"navigate-next": () => true,
|
||||
"close-preview": () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
thumbBlobKey: null, // only read in lifecycle/sync code, never during render
|
||||
moveDisabledTime: 200,
|
||||
classList: [],
|
||||
zoomStep: 0.25,
|
||||
@@ -218,6 +213,9 @@ export default {
|
||||
return this.edgeDy >= this.edgeCommitY && ay >= ax;
|
||||
},
|
||||
},
|
||||
beforeCreate() {
|
||||
this.thumbBlobKey = null;
|
||||
},
|
||||
created() {
|
||||
this.syncThumbnailBlob();
|
||||
},
|
||||
@@ -527,7 +525,7 @@ export default {
|
||||
this.showDismissHint = false;
|
||||
} else {
|
||||
this.dragOffsetX = 0;
|
||||
const downward = Math.max(this.edgeDy, 0);
|
||||
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
|
||||
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
|
||||
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
|
||||
this.showNavHint = false;
|
||||
@@ -551,7 +549,7 @@ export default {
|
||||
this.showDismissHint = false;
|
||||
} else {
|
||||
this.dragOffsetX = 0;
|
||||
const downward = Math.max(this.edgeDy, 0);
|
||||
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
|
||||
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
|
||||
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
|
||||
this.showNavHint = false;
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
|
||||
<div v-if="!hidePathChrome && showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
|
||||
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
{{ t("prompts.destinationSource") }}
|
||||
{{ $t("prompts.destinationSource") }}
|
||||
</label>
|
||||
<ExpandDropdown
|
||||
input-id="destinationSource"
|
||||
v-model="currentSource"
|
||||
:options="sourceOptions"
|
||||
:aria-label="t('prompts.destinationSource')"
|
||||
:aria-label="$t('prompts.destinationSource')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sortable Column Header (opt-in via sortable prop, e.g. destination pickers) -->
|
||||
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.sticky" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
|
||||
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.pinned" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
|
||||
<!-- Current Path Display -->
|
||||
<div v-if="!hidePathChrome && !fileList" aria-label="filelist-path" class="searchContext button clickable">
|
||||
{{ t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
|
||||
{{ $t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
|
||||
</div>
|
||||
<slot name="sticky" />
|
||||
<ListingHeader v-if="sortable && !loading" :use-picker-sorting="!fileList" :use-navigation-sorting="!!fileList" />
|
||||
@@ -68,27 +68,26 @@
|
||||
</div>
|
||||
<h2 v-if="filterQuery && !visibleItems.some((item) => item.name !== '..')" class="no-results">
|
||||
<i class="material-symbols-outlined">search_off</i>
|
||||
<span>{{ t("tools.advancedSearch.noResults") }}</span>
|
||||
<span>{{ $t("tools.advancedSearch.noResults") }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
|
||||
import { state, mutations, getters } from "@/store/index.ts";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import type { FileListItem, MediaMetadata } from "@/store/types.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import ListingItem from "@/components/files/ListingItem.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
|
||||
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
|
||||
type ItemInfo = Omit<FileListItem, "metadata"> & { metadata?: MediaMetadata; isDir?: boolean; hasDuration?: boolean };
|
||||
|
||||
export interface ListEntry {
|
||||
name: string;
|
||||
@@ -99,7 +98,7 @@ export interface ListEntry {
|
||||
size?: number;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
metadata?: MediaMetadata;
|
||||
metadata?: unknown;
|
||||
isDirectory?: boolean;
|
||||
originalItem?: ItemInfo;
|
||||
}
|
||||
@@ -162,14 +161,8 @@ const emit = defineEmits<{
|
||||
"update:filterQuery": [value: string];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
sticky?(): unknown;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const root = useTemplateRef<HTMLElement>("root");
|
||||
const list = useTemplateRef<HTMLElement>("list");
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
|
||||
const fallbackModified = new Date().toISOString();
|
||||
const initialSource = props.browseSource || state.req.source;
|
||||
@@ -337,8 +330,7 @@ onMounted(async () => {
|
||||
if (currentSource.value !== state.req.source || props.browsePath) {
|
||||
void withLoading(() => resourcesApi.fetchFiles(sourceToUse, pathToUse).then(fillOptions));
|
||||
} else {
|
||||
const pending = fillOptions(initialReq);
|
||||
if (pending) void withLoading(() => pending);
|
||||
fillOptions(initialReq);
|
||||
}
|
||||
}
|
||||
attachScrollListener();
|
||||
@@ -380,7 +372,7 @@ async function withLoading(operation: () => Promise<unknown>) {
|
||||
} finally {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 200 - elapsed);
|
||||
await new Promise((resolve) => { setTimeout(resolve, remaining); });
|
||||
await new Promise((resolve) => setTimeout(resolve, remaining));
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -425,22 +417,7 @@ function resetToShare(newHash: string) {
|
||||
void withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(fillOptions));
|
||||
}
|
||||
|
||||
function fillOptions(req: { source?: string; type?: string; items?: FileListItem[] }): Promise<unknown> | undefined {
|
||||
// if the path points to a file browse its parent and highlight the item
|
||||
if (req.type && req.type !== "directory" && path.value !== "/") {
|
||||
const filePath = path.value;
|
||||
const parent = `${url.removeLastDir(filePath)}/`;
|
||||
path.value = parent
|
||||
const showParent = (parentReq: Parameters<typeof fillOptions>[0]) => {
|
||||
const pending = fillOptions(parentReq);
|
||||
selectEntry(filePath);
|
||||
return pending;
|
||||
};
|
||||
if (!props.browseSource && (props.browseShare || getters.isShare())) {
|
||||
return resourcesApi.fetchFilesPublic(parent, props.browseShare || getters.shareHash()).then(showParent);
|
||||
}
|
||||
return resourcesApi.fetchFiles(source.value ?? currentSource.value, parent).then(showParent);
|
||||
}
|
||||
function fillOptions(req: { source?: string; items?: FileListItem[] }) {
|
||||
// Sets the current path and resets the current items.
|
||||
// Use path (the path we're browsing) instead of req.path (which may be relative)
|
||||
current.value = path.value;
|
||||
@@ -485,16 +462,6 @@ function fillOptions(req: { source?: string; type?: string; items?: FileListItem
|
||||
type: "directory",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function selectEntry(itemPath: string) {
|
||||
const entry = items.value.find((item) => item.path === itemPath);
|
||||
if (!entry || entry.type === "directory") return;
|
||||
selected.value = entry.path;
|
||||
selectedSource.value = entry.source;
|
||||
selectedType.value = entry.type;
|
||||
emit("update:selected", { path: entry.path, source: entry.source, type: entry.type, isValid: true });
|
||||
}
|
||||
|
||||
function sortEntries(entries: ListEntry[]) {
|
||||
@@ -658,9 +625,8 @@ function isContextItem(item: ListEntry) {
|
||||
}
|
||||
|
||||
function attachScrollListener() {
|
||||
const prompt = root.value?.closest<HTMLElement>(".floating-window > .card-content");
|
||||
if (!prompt || !root.value) return;
|
||||
const el = findScrollParents(root.value).find((node) => node !== prompt && prompt.contains(node)) ?? prompt;
|
||||
const el = root.value?.closest<HTMLElement>(".floating-window > .card-content");
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
scrollContainer = el;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="tree-error">
|
||||
<i class="material-symbols">error</i>
|
||||
<span>{{ t("prompts.error") }}</span>
|
||||
<span>{{ $t("prompts.error") }}</span>
|
||||
</div>
|
||||
<div v-if="rootNodes.length" ref="listEl" class="tree-list">
|
||||
<div data-virtual-group="rows" :style="virtual.group('rows').style">
|
||||
@@ -60,7 +60,7 @@
|
||||
</a>
|
||||
<div v-else-if="row.kind === 'empty'" class="tree-empty-folder">
|
||||
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
|
||||
<span>{{ t('files.lonely') }}</span>
|
||||
<span>{{ $t('files.lonely') }}</span>
|
||||
</div>
|
||||
<div v-else-if="row.kind === 'loading'" class="tree-loading">
|
||||
<LoadingSpinner size="small" mode="placeholder" />
|
||||
@@ -74,24 +74,24 @@
|
||||
<!-- Empty state -->
|
||||
<div v-if="rootNodes.length === 0 && !loading && !error" class="tree-empty">
|
||||
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
|
||||
{{ t('files.lonely') }}
|
||||
{{ $t('files.lonely') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { resourcesApi } from '@/api';
|
||||
import Icon from '@/components/files/Icon.vue';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner.vue';
|
||||
import i18n from '@/i18n/index.ts';
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { state, getters, mutations } from '@/store';
|
||||
import { eventBus } from '@/store/eventBus';
|
||||
import type { FileListItem } from '@/store/types.ts';
|
||||
import { buildItemUrl, goToItem, joinPath } from "@/utils/url.js";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
import { buildItemUrl, goToItem, joinPath } from '@/utils/url';
|
||||
import { goToItemNotificationButton } from '@/utils/notificationActions';
|
||||
import { notify } from '@/notify';
|
||||
import { notifyMoveCopyFailure } from '@/utils/appNotifications';
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { sortedItems } from '@/utils/sort.ts';
|
||||
import { findScrollParents, useListingVirtualization } from '@/composables/useListingVirtualization.ts';
|
||||
@@ -162,8 +162,8 @@ const props = withDefaults(
|
||||
|
||||
const t = i18n.global.t.bind(i18n.global);
|
||||
|
||||
const treeEl = useTemplateRef<HTMLElement>("treeEl");
|
||||
const listEl = useTemplateRef<HTMLElement>("listEl");
|
||||
const treeEl = ref<HTMLElement | null>(null);
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
const rootNodes = ref<TreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
:fbdata="threeDFbdata"
|
||||
:is-thumbnail="true"
|
||||
:add-load-delay="true"
|
||||
@load-error="handle3DError" />
|
||||
@error="handle3DError" />
|
||||
<TextPreview v-else-if="shouldUseTextPreview"
|
||||
:url="textPreviewUrl"
|
||||
:language="textPreviewLanguage"
|
||||
:modified="modified"
|
||||
@load-error="handleTextPreviewError" />
|
||||
@error="handleTextPreviewError" />
|
||||
</span>
|
||||
<!-- Regular material icon (no preview) -->
|
||||
<span v-else ref="tileEl" class="image-preview" @mouseenter="hasTextPreview && handleMouseEnter()" @mouseleave="hasTextPreview && handleMouseLeave()">
|
||||
@@ -40,11 +40,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, useTemplateRef, watch, type Ref } from "vue";
|
||||
import { fetchPreviewImage } from "@/api/resources.js";
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, watch, type Ref } from "vue";
|
||||
import { fetchPreviewImage } from "@/api/resources";
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { getTypeInfo } from "@/utils/mimetype.js";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import {
|
||||
acquireThumbnailBlob,
|
||||
getThumbnailBlobKey,
|
||||
@@ -234,7 +234,7 @@ const textPreviewLanguage = computed(() => languageFromPath(props.filename));
|
||||
|
||||
// Measured tile width; only observed for text-previewable files so image tiles pay nothing.
|
||||
const tileWidth = ref(0);
|
||||
const tileEl = useTemplateRef<HTMLElement>("tileEl");
|
||||
const tileEl = ref<HTMLElement | null>(null);
|
||||
let tileResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function observeTileWidth(el: HTMLElement | null): void {
|
||||
@@ -319,7 +319,7 @@ function clearPopupHoverTimeout(): void {
|
||||
|
||||
function cancelPreloadRequests(): void {
|
||||
clearPopupHoverTimeout();
|
||||
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
|
||||
previewTimeouts.forEach(clearTimeout);
|
||||
previewTimeouts = [];
|
||||
for (const controller of preloadAbortControllers) {
|
||||
controller.abort();
|
||||
@@ -483,7 +483,7 @@ function showPopupPreview(): void {
|
||||
|
||||
const updateThumbnail = () => {
|
||||
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== props.path) {
|
||||
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
|
||||
previewTimeouts.forEach(clearTimeout);
|
||||
previewTimeouts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state } from "@/store/index.ts";
|
||||
import { fromNow } from "@/utils/moment.ts";
|
||||
import { state } from "@/store";
|
||||
import { fromNow } from "@/utils/moment";
|
||||
import SettingsTable from "@/components/settings/Table.vue";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:aria-label="open ? $t('general.cancel') : $t('general.search')"
|
||||
:title="open ? $t('general.cancel') : $t('general.search')"
|
||||
>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<i class="material-symbols">{{ open ? "close" : "search" }}</i>
|
||||
</button>
|
||||
<input
|
||||
@@ -36,9 +37,7 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"update:modelValue": (value) => typeof value === "string",
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
|
||||
export default {
|
||||
name: "ListingHeader",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
class="material-symbols overlay-icon overlay-icon--bottom clickable"
|
||||
aria-label="Download"
|
||||
@click.stop.prevent="downloadFile"
|
||||
>file_download</button>
|
||||
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
@@ -93,23 +93,23 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, provide, shallowRef } from "vue";
|
||||
import { globalVars } from "@/utils/constants.ts";
|
||||
import downloadFiles from "@/utils/download.js";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { getKindKey } from "@/utils/mimetype.js";
|
||||
import { isTextPreviewableType } from "@/utils/textPreview.ts";
|
||||
import { computed, shallowRef } from "vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import downloadFiles from "@/utils/download";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getKindKey } from "@/utils/mimetype";
|
||||
import { isTextPreviewableType } from "@/utils/textPreview";
|
||||
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { formatDuration } from "@/utils/files.js";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { checkConflict } from "@/utils/upload.ts";
|
||||
import { state, getters, mutations } from "@/store/index.ts"; // Import your custom store
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { checkConflict } from "@/utils/upload";
|
||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||
import { url } from "@/utils";
|
||||
import { formatTimestamp, fromNow } from "@/utils/moment";
|
||||
import { notify } from "@/notify";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
|
||||
const selectedIndexSet = computed(() => {
|
||||
@@ -204,29 +204,25 @@ export default {
|
||||
localSelected: false,
|
||||
};
|
||||
},
|
||||
emits: {
|
||||
"item-select": (payload) => payload != null && "index" in payload,
|
||||
"select-range": (payload) => payload != null && "startIndex" in payload && "endIndex" in payload,
|
||||
beforeCreate() {
|
||||
// runs before provide(), non-reactive holder on purpose
|
||||
this.viewportRef = shallowRef(false);
|
||||
},
|
||||
setup() {
|
||||
// The Icons inside this item inject it with Icon.vue
|
||||
// we're using setup for now temporarily as is more easy than convert the whole component to script setup.
|
||||
const viewportRef = shallowRef(false);
|
||||
provide("inView", viewportRef);
|
||||
return {
|
||||
setInView(visible) {
|
||||
viewportRef.value = visible;
|
||||
},
|
||||
touches: 0,
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
isLongPress: false,
|
||||
isSwipe: false,
|
||||
contextTimeout: null,
|
||||
observedEl: null,
|
||||
observer: null,
|
||||
isUnmounted: false,
|
||||
};
|
||||
provide() {
|
||||
// the Icons inside this item inject it, see Icon.vue
|
||||
return { inView: this.viewportRef };
|
||||
},
|
||||
created() {
|
||||
// non-reactive on purpose, they're never read by the template
|
||||
this.touches = 0;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.isLongPress = false;
|
||||
this.isSwipe = false;
|
||||
this.contextTimeout = null;
|
||||
this.observedEl = null;
|
||||
this.observer = null;
|
||||
this.isUnmounted = false;
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
@@ -495,7 +491,7 @@ export default {
|
||||
if (this.$el && this.$el instanceof Element) {
|
||||
this.observedEl = this.$el;
|
||||
viewportCallbacks.set(this.$el, (visible) => {
|
||||
this.setInView(visible);
|
||||
this.viewportRef.value = visible;
|
||||
if (visible) {
|
||||
this.isThumbnailInView = true;
|
||||
}
|
||||
@@ -520,7 +516,7 @@ export default {
|
||||
downloadFiles(state.selected);
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
this.$emit('item-select', { index: this.index, selected: true });
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
},
|
||||
/** @param {TouchEvent} event */
|
||||
@@ -618,7 +614,7 @@ export default {
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
if (!this.localSelected) {
|
||||
this.$emit('item-select', { index: this.index, selected: true });
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
@@ -691,7 +687,11 @@ export default {
|
||||
const destinationDir = this.path;
|
||||
|
||||
// If destination dir is the same as or contains the source path, skip
|
||||
return !(fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`));
|
||||
if (fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// If all items were filtered out, silently skip the operation
|
||||
@@ -805,7 +805,7 @@ export default {
|
||||
mutations.resetSelected();
|
||||
mutations.addSelected(this.index);
|
||||
} else {
|
||||
this.$emit('item-select', { index: this.index, selected: true });
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,7 +899,7 @@ export default {
|
||||
// Local selection handling - emit events instead of updating global state
|
||||
if (event.shiftKey) {
|
||||
// Shift-click: select range (emit event for parent to handle)
|
||||
this.$emit('select-range', {
|
||||
this.$emit('selectRange', {
|
||||
startIndex: state.lastSelectedIndex !== null ? state.lastSelectedIndex : this.index,
|
||||
endIndex: this.index
|
||||
});
|
||||
@@ -908,7 +908,7 @@ export default {
|
||||
|
||||
// Always toggle - parent component will handle the toggle logic
|
||||
// Just emit the index, parent will check current state and toggle
|
||||
this.$emit('item-select', { index: this.index });
|
||||
this.$emit('select', { index: this.index });
|
||||
}
|
||||
},
|
||||
open() {
|
||||
|
||||
@@ -20,19 +20,18 @@
|
||||
v-if="showSplitViewToggle"
|
||||
icon="vertical_split"
|
||||
:label="splitViewActionLabel"
|
||||
@press="toggleSplitView"
|
||||
@click="toggleSplitView"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from "vue";
|
||||
import type { Ace } from "ace-builds";
|
||||
import i18n from "@/i18n/index.ts";
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import Scrollbar from "@/components/files/Scrollbar.vue";
|
||||
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
|
||||
import { createScrollSyncGuard } from "@/utils/markdownScrollSync.ts";
|
||||
import { createScrollSyncGuard } from "@/utils/markdownScrollSync";
|
||||
|
||||
const MD_SPLIT_PERCENT_KEY = "mdSplitPercent";
|
||||
const LIVE_CONTENT_INTERVAL_MS = 185; // Throttle preview updates to avoid performance issues
|
||||
@@ -63,9 +62,7 @@ export default {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
resize: (percent: number) => typeof percent === "number",
|
||||
},
|
||||
emits: ["resize"],
|
||||
data: () => ({
|
||||
liveMarkdownContent: "", // editor current buffer
|
||||
liveContentTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
@@ -115,7 +112,7 @@ export default {
|
||||
return getters.showSplitViewToggle();
|
||||
},
|
||||
splitViewActionLabel() {
|
||||
return getters.isSplitViewActive() ? i18n.global.t("editor.exitSplitView") : i18n.global.t("editor.splitView");
|
||||
return getters.isSplitViewActive() ? this.$t("editor.exitSplitView") : this.$t("editor.splitView");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations, state } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { mutations, state } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
function normalizeContextId() {
|
||||
return `path-picker-btn-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
@@ -67,11 +67,7 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
emits: {
|
||||
"update:path": (path) => typeof path === "string",
|
||||
"update:source": (source) => typeof source === "string",
|
||||
navigate: () => true,
|
||||
},
|
||||
emits: ["update:path", "update:source", "navigate"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
:key="sourceInfo.path"
|
||||
:url="sourceInfo.textUrl"
|
||||
:modified="sourceInfo.modified"
|
||||
@load-error="onTextError"
|
||||
@error="onTextError"
|
||||
/>
|
||||
</div>
|
||||
<img v-else-if="sourceInfo?.url" :src="sourceInfo.url" alt="Popup img" @load="onImageLoad" />
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<script>
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { setImageLoaded } from "@/utils/cache/imageCache.ts";
|
||||
|
||||
export default {
|
||||
@@ -37,11 +37,14 @@ export default {
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
},
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
rafId: null,
|
||||
};
|
||||
},
|
||||
beforeCreate() {
|
||||
// Plain fields on purpose cuz nothing in the template reads them
|
||||
this.cursorX = 0;
|
||||
this.cursorY = 0;
|
||||
this.rafId = null;
|
||||
},
|
||||
watch: {
|
||||
async sourceInfo(newVal) {
|
||||
if (newVal) {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, mutations, getters } from "@/store/index.ts";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { isHtmlMimeType } from "@/utils/mimetype.js";
|
||||
|
||||
@@ -63,11 +63,12 @@ export default {
|
||||
canScroll: false,
|
||||
resizeObserver: null,
|
||||
mutationObserver: null,
|
||||
wrapperObserver: null,
|
||||
wrapperRect: null,
|
||||
readyTimeout: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.wrapperRect = null;
|
||||
this.readyTimeout = null;
|
||||
},
|
||||
computed: {
|
||||
isScrollable() {
|
||||
return getters.isScrollable();
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters } from "@/store/index.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { getTypeInfo } from "@/utils/mimetype.js";
|
||||
import { state, getters } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
|
||||
export default {
|
||||
name: "ShareInfo",
|
||||
|
||||
@@ -1,96 +1,96 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
getters: { isDarkMode: () => false },
|
||||
state: { sessionId: "test-session" },
|
||||
}));
|
||||
|
||||
import TextPreview from "./TextPreview.vue";
|
||||
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
|
||||
|
||||
function payload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: "",
|
||||
kind: "plain",
|
||||
format: "text",
|
||||
truncated: false,
|
||||
size: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFetch(data: unknown) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
|
||||
);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
// Wait for the fetch promise chain and queued limiter to settle.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 0); });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({ render: () => h(TextPreview, props) });
|
||||
app.mount(el);
|
||||
return { el, app };
|
||||
}
|
||||
|
||||
describe("TextPreview rendering", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("does not render a truncation fade for a truncated payload", async () => {
|
||||
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
|
||||
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
|
||||
await flush();
|
||||
|
||||
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
|
||||
expect(el.querySelector(".text-preview-fade")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("uses the language prop to pick the CSV delimiter", async () => {
|
||||
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
|
||||
const { el, app } = mount({
|
||||
url: "/api/resources/preview/text?path=%2Fdata.tsv",
|
||||
modified: "1",
|
||||
language: "tsv",
|
||||
});
|
||||
await flush();
|
||||
|
||||
const cells = el.querySelectorAll(".text-preview-table thead th");
|
||||
expect(cells).toHaveLength(2);
|
||||
expect(cells[0]?.textContent).toBe("a");
|
||||
expect(cells[1]?.textContent).toBe("b");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("reuses the rendered HTML cache for the same url and modified time", async () => {
|
||||
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
|
||||
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
|
||||
|
||||
const first = mount(props);
|
||||
await flush();
|
||||
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
first.app.unmount();
|
||||
|
||||
// A second mount (as happens when the popup reopens) must not re-fetch.
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
fetchMock.mockClear();
|
||||
const second = mount(props);
|
||||
await flush();
|
||||
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
second.app.unmount();
|
||||
});
|
||||
});
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
getters: { isDarkMode: () => false },
|
||||
state: { sessionId: "test-session" },
|
||||
}));
|
||||
|
||||
import TextPreview from "./TextPreview.vue";
|
||||
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
|
||||
|
||||
function payload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: "",
|
||||
kind: "plain",
|
||||
format: "text",
|
||||
truncated: false,
|
||||
size: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFetch(data: unknown) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
|
||||
);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
// Wait for the fetch promise chain and queued limiter to settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({ render: () => h(TextPreview, props) });
|
||||
app.mount(el);
|
||||
return { el, app };
|
||||
}
|
||||
|
||||
describe("TextPreview rendering", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("does not render a truncation fade for a truncated payload", async () => {
|
||||
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
|
||||
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
|
||||
await flush();
|
||||
|
||||
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
|
||||
expect(el.querySelector(".text-preview-fade")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("uses the language prop to pick the CSV delimiter", async () => {
|
||||
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
|
||||
const { el, app } = mount({
|
||||
url: "/api/resources/preview/text?path=%2Fdata.tsv",
|
||||
modified: "1",
|
||||
language: "tsv",
|
||||
});
|
||||
await flush();
|
||||
|
||||
const cells = el.querySelectorAll(".text-preview-table thead th");
|
||||
expect(cells).toHaveLength(2);
|
||||
expect(cells[0]?.textContent).toBe("a");
|
||||
expect(cells[1]?.textContent).toBe("b");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("reuses the rendered HTML cache for the same url and modified time", async () => {
|
||||
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
|
||||
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
|
||||
|
||||
const first = mount(props);
|
||||
await flush();
|
||||
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
first.app.unmount();
|
||||
|
||||
// A second mount (as happens when the popup reopens) must not re-fetch.
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
fetchMock.mockClear();
|
||||
const second = mount(props);
|
||||
await flush();
|
||||
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
second.app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
|
||||
<div ref="root" class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
|
||||
<span v-if="status === 'loading'" class="material-symbols text-preview-placeholder">{{ PLACEHOLDER_ICON }}</span>
|
||||
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap">
|
||||
<table class="text-preview-table">
|
||||
@@ -15,9 +15,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- biome-ignore lint/nursery/noVueVHtml: renderedHtml is sanitized -->
|
||||
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" v-html="renderedHtml"></div>
|
||||
<!-- biome-ignore lint/nursery/noVueVHtml: highlighted is sanitized -->
|
||||
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" v-html="highlighted"></pre>
|
||||
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain">{{ payload.content }}</pre>
|
||||
</div>
|
||||
@@ -25,7 +23,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { getters } from "@/store/index.ts";
|
||||
import { getters } from "@/store";
|
||||
import { applyHighlightTheme, loadHljs } from "@/utils/highlight.ts";
|
||||
import { parseDelimited } from "@/utils/delimited.ts";
|
||||
import {
|
||||
@@ -51,7 +49,7 @@ const props = defineProps<{
|
||||
language?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<(e: "load-error") => void>();
|
||||
const emit = defineEmits<(e: "error") => void>();
|
||||
|
||||
// Only plain source files are syntax highlighted. Markdown and HTML are rendered instead.
|
||||
const HIGHLIGHT_KINDS = new Set(["code", "css"]);
|
||||
@@ -182,7 +180,7 @@ watch(
|
||||
} catch {
|
||||
if (current !== requestId) return;
|
||||
status.value = "error";
|
||||
emit("load-error");
|
||||
emit("error");
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<!-- Previous button -->
|
||||
<button
|
||||
v-if="enabled && hasPrevious"
|
||||
ref="prevButton"
|
||||
type="button"
|
||||
:tabindex="showNav ? 0 : -1"
|
||||
@click.prevent="handlePrevClick"
|
||||
@@ -43,13 +44,14 @@
|
||||
:title="$t('general.previous')"
|
||||
>
|
||||
<i class="material-symbols">
|
||||
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }}
|
||||
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</i>
|
||||
</button>
|
||||
|
||||
<!-- Next button -->
|
||||
<button
|
||||
v-if="enabled && hasNext"
|
||||
ref="nextButton"
|
||||
type="button"
|
||||
:tabindex="showNav ? 0 : -1"
|
||||
@click.prevent="handleNextClick"
|
||||
@@ -68,13 +70,14 @@
|
||||
:title="$t('general.next')"
|
||||
>
|
||||
<i class="material-symbols">
|
||||
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }}
|
||||
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</i>
|
||||
</button>
|
||||
|
||||
<!-- Close preview (same control as swipe-down / back) -->
|
||||
<button
|
||||
v-if="enabled && showPreviewCloseButton"
|
||||
ref="closeButton"
|
||||
type="button"
|
||||
:tabindex="showCloseNavChrome ? 0 : -1"
|
||||
@click.prevent="handleClosePreviewClick"
|
||||
@@ -96,11 +99,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
import throttle from "@/utils/throttle.js";
|
||||
import { previewViews } from "@/utils/constants.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import throttle from "@/utils/throttle";
|
||||
import { previewViews } from "@/utils/constants";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { navigatePlaybackQueue } from "@/utils/playbackQueue.js";
|
||||
import {
|
||||
isInLeftNavHoverZone,
|
||||
@@ -124,7 +127,6 @@ export default {
|
||||
threshold: 0, // Will be calculated as 10em in pixels
|
||||
atFullExtent: false,
|
||||
triggered: false,
|
||||
wasDrag: false,
|
||||
},
|
||||
// State tracking
|
||||
navigationTimeout: null,
|
||||
@@ -218,12 +220,6 @@ export default {
|
||||
nextRaw() {
|
||||
return state.navigation.nextRaw;
|
||||
},
|
||||
navigationRequestKey() {
|
||||
return state.req ? `${state.req.source ?? ""}\u0000${state.req.path ?? ""}` : "";
|
||||
},
|
||||
navigationParentItems() {
|
||||
return state.req?.parentDirItems;
|
||||
},
|
||||
currentView() {
|
||||
const view = getters.currentView();
|
||||
return view;
|
||||
@@ -285,6 +281,18 @@ export default {
|
||||
await this.setupNavigationForCurrentItem();
|
||||
}
|
||||
},
|
||||
'state.req': {
|
||||
async handler() {
|
||||
this.updateNavigationEnabled();
|
||||
// Auto-setup navigation when request changes and we're enabled
|
||||
if (this.enabled) {
|
||||
await this.$nextTick();
|
||||
await this.setupNavigationForCurrentItem();
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: false
|
||||
},
|
||||
async enabled(newEnabled) {
|
||||
if (newEnabled && state.req) {
|
||||
await this.$nextTick();
|
||||
@@ -301,21 +309,18 @@ export default {
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
navigationRequestKey() {
|
||||
if (this.enabled && state.req) {
|
||||
void this.setupNavigationForCurrentItem();
|
||||
}
|
||||
},
|
||||
navigationParentItems() {
|
||||
if (this.enabled && state.req) {
|
||||
void this.setupNavigationForCurrentItem();
|
||||
}
|
||||
},
|
||||
navigationSortOverride() {
|
||||
if (this.enabled && state.req) {
|
||||
void this.setupNavigationForCurrentItem();
|
||||
}
|
||||
},
|
||||
// Watch for when navigation links are set up
|
||||
'state.navigation.previousLink'() {
|
||||
this.showInitialNavigation();
|
||||
},
|
||||
'state.navigation.nextLink'() {
|
||||
this.showInitialNavigation();
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
window.addEventListener("keydown", this.keyEvent);
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
<template>
|
||||
<div class="card-content" :class="{ 'embedded-content': embedded }">
|
||||
<div class="card-content">
|
||||
<div v-if="loading" class="loading-spinner-wrapper">
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!pathExists && baseEntries.length > 0 && !isEditingPath" class="warning-banner">
|
||||
<!-- Warning banner for missing path -->
|
||||
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
|
||||
<i class="material-symbols-outlined">warning</i>
|
||||
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
|
||||
<button type="button" class="button button--flat button--blue" @click="startPathReassignment">
|
||||
{{ $t("messages.reassignPath") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isEditingPath" :class="{ 'embedded-list': embedded }">
|
||||
<file-list
|
||||
@update:selected="updateTempPath"
|
||||
:browseSource="sourceName"
|
||||
:browsePath="embedded ? currentPath : null"
|
||||
:hideDestinationSource="true"
|
||||
:showFiles="true"
|
||||
></file-list>
|
||||
|
||||
<div v-if="isEditingPath">
|
||||
<file-list @update:selected="updateTempPath" :browseSource="sourceName" :showFiles="true"></file-list>
|
||||
</div>
|
||||
<div v-else>
|
||||
<PathPickerButton
|
||||
v-if="!embedded"
|
||||
class="path-picker"
|
||||
v-model:path="currentPath"
|
||||
v-model:source="currentSource"
|
||||
@@ -32,18 +27,20 @@
|
||||
:placeholder="$t('sidebar.chooseSource')"
|
||||
@navigate="onPathPickerNavigate"
|
||||
/>
|
||||
<div v-if="!embedded" class="settings-items">
|
||||
<div class="settings-items">
|
||||
<ActivityViewerButton class="item" :href="activityViewerHref" />
|
||||
</div>
|
||||
<!-- Default behavior banner -->
|
||||
<div class="card item">
|
||||
<div class="card-content banner-content">
|
||||
<i class="material-symbols-outlined">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i>
|
||||
<i class="material-symbols-outlined">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
|
||||
}}
|
||||
<HelpTooltipIcon :text="$t('access.defaultBehaviorDescription')" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Add Form -->
|
||||
<div class="form-flex-group">
|
||||
<ExpandDropdown
|
||||
v-model="addType"
|
||||
@@ -66,15 +63,15 @@
|
||||
:icon="addType === 'group' ? 'group_add' : 'person_add'"
|
||||
class="flat-left form-compact"
|
||||
:exclude="existingNames"
|
||||
@selected="addEntries"
|
||||
@select="addEntries"
|
||||
/>
|
||||
<button v-else type="button" class="button form-button flat-left form-compact" @click="addEntries([])">
|
||||
<i class="material-symbols-outlined">add</i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Cascade Delete Toggle -->
|
||||
<div v-if="entries.length > 0" class="cascade-toggle-section settings-items">
|
||||
<ToggleSwitch class="item" v-model="cascadeDelete"
|
||||
<div v-if="entries.length > 0" class="cascade-toggle-section">
|
||||
<ToggleSwitch v-model="cascadeDelete"
|
||||
:name="$t('access.cascadeDelete')"
|
||||
:description="$t('access.cascadeDeleteDescription')" />
|
||||
</div>
|
||||
@@ -94,7 +91,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="card-actions" :class="{ 'embedded-actions': embedded }">
|
||||
<div class="card-actions">
|
||||
<template v-if="isEditingPath">
|
||||
<button type="button" class="button button--flat button--grey" @click="cancelPathChange" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
|
||||
{{ $t("general.cancel") }}
|
||||
@@ -104,7 +101,7 @@
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="!embedded" type="button" class="button button--flat button--grey" @click="cancelChanges" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
|
||||
<button type="button" class="button button--flat button--grey" @click="cancelChanges" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
|
||||
{{ $t("general.cancel") }}
|
||||
</button>
|
||||
<button type="button" class="button button--flat" :disabled="!dirty || saving" @click="saveChanges" :aria-label="$t('general.save')" :title="$t('general.save')">
|
||||
@@ -115,9 +112,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { mutations } from "@/store/index.ts";
|
||||
import { accessApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify";
|
||||
import { mutations } from "@/store";
|
||||
import { accessApi } from "@/api";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import FileList from "../files/FileList.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
@@ -127,8 +124,8 @@ import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
|
||||
import SettingsTable from "@/components/settings/Table.vue";
|
||||
import EntityPickerButton from "@/components/settings/EntityPickerButton.vue";
|
||||
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { activityViewerPresets } from "@/utils/activityViewerLink";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "access",
|
||||
@@ -146,8 +143,7 @@ export default {
|
||||
props: {
|
||||
promptId: { type: [String, Number], default: null },
|
||||
sourceName: { type: String, required: true },
|
||||
path: { type: String, default: "/" },
|
||||
embedded: { type: Boolean, default: false }
|
||||
path: { type: String, required: true, default: "/" }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -249,10 +245,6 @@ export default {
|
||||
entry,
|
||||
}));
|
||||
},
|
||||
parentPath() {
|
||||
const trimmed = this.currentPath.replace(/\/+$/, "");
|
||||
return trimmed.slice(0, trimmed.lastIndexOf("/")) || "/";
|
||||
},
|
||||
activityViewerHref() {
|
||||
return activityViewerPresets.access(this.currentSource, this.currentPath);
|
||||
},
|
||||
@@ -400,9 +392,8 @@ export default {
|
||||
async saveChanges() {
|
||||
if (this.saving) return;
|
||||
this.saving = true;
|
||||
let failed = false;
|
||||
try {
|
||||
for (const op of this.pendingDeletes.slice()) {
|
||||
for (const op of [...this.pendingDeletes]) {
|
||||
await accessApi.del(this.currentSource, this.currentPath, {
|
||||
allow: op.allow,
|
||||
ruleCategory: op.ruleCategory,
|
||||
@@ -411,7 +402,7 @@ export default {
|
||||
});
|
||||
this.pendingDeletes.splice(this.pendingDeletes.indexOf(op), 1);
|
||||
}
|
||||
for (const op of this.pendingAdds.slice()) {
|
||||
for (const op of [...this.pendingAdds]) {
|
||||
await accessApi.add(this.currentSource, this.currentPath, {
|
||||
allow: op.allow,
|
||||
ruleCategory: op.ruleCategory,
|
||||
@@ -420,7 +411,6 @@ export default {
|
||||
this.pendingAdds.splice(this.pendingAdds.indexOf(op), 1);
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
notify.showError(e);
|
||||
console.error(e);
|
||||
}
|
||||
@@ -428,10 +418,7 @@ export default {
|
||||
// Emit event to refresh access rules list
|
||||
eventBus.emit('accessRulesChanged');
|
||||
this.saving = false;
|
||||
if (!failed && this.embedded) {
|
||||
notify.showSuccessToast(this.$t("settings.settingsUpdated"));
|
||||
}
|
||||
if (!this.dirty && !this.embedded) {
|
||||
if (!this.dirty) {
|
||||
mutations.closeTopPrompt(this.promptId ?? undefined);
|
||||
}
|
||||
},
|
||||
@@ -480,26 +467,6 @@ export default {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.embedded-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.embedded-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.embedded-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.loading-spinner-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
>
|
||||
<span class="permission-name">{{ permission }}</span>
|
||||
<span class="permission-status" :class="{ enabled: isEnabled, disabled: !isEnabled }">
|
||||
{{ isEnabled ? '✓' : '✗' }}
|
||||
{{ isEnabled ? '✓' : '✗' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,11 +72,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store/index.ts";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { authApi } from "@/api/index.js";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import { mutations } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { authApi } from "@/api";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
|
||||
export default {
|
||||
name: "ActionApi",
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
|
||||
<script>
|
||||
import SettingsTable from "@/components/settings/Table.vue";
|
||||
import { formatTimestamp } from "@/utils/moment.ts";
|
||||
import { formatTimestamp } from "@/utils/moment";
|
||||
import {
|
||||
buildActivityDetailRows,
|
||||
activityEventLabel,
|
||||
activityRowPath,
|
||||
activityRowSource,
|
||||
} from "@/utils/activityDetails.js";
|
||||
} from "@/utils/activityDetails";
|
||||
|
||||
export default {
|
||||
name: "ActivityEventDetails",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Editor
|
||||
:viewer-mode="true"
|
||||
:content="previewContent"
|
||||
editor-mode="json"
|
||||
:editor-mode="'json'"
|
||||
:read-only="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
<script>
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getAnalyticsPreview } from "@/api/settings.js";
|
||||
import { notify } from "@/notify";
|
||||
import { getAnalyticsPreview } from "@/api/settings";
|
||||
|
||||
export default {
|
||||
name: "AnalyticsDiagnostic",
|
||||
|
||||
@@ -142,10 +142,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, mutations, getters } from "@/store/index.ts";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import FileList from "@/components/files/FileList.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters } from "@/store/index.ts";
|
||||
import { getters } from "@/store";
|
||||
|
||||
export default {
|
||||
name: "closeWithActiveUploads",
|
||||
@@ -41,18 +41,18 @@ export default {
|
||||
},
|
||||
created() {
|
||||
// Listen for ESC key to treat it as "Stay on Prompt"
|
||||
this.escKeyHandler = (event) => {
|
||||
if (event.keyCode === 27 && getters.currentPromptName() === "CloseWithActiveUploads") {
|
||||
event.stopImmediatePropagation();
|
||||
this.stayOnPrompt();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", this.escKeyHandler, true);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("keydown", this.escKeyHandler, true);
|
||||
},
|
||||
methods: {
|
||||
escKeyHandler(event) {
|
||||
if (event.keyCode === 27 && getters.currentPromptName() === "CloseWithActiveUploads") {
|
||||
event.stopImmediatePropagation();
|
||||
this.stayOnPrompt();
|
||||
}
|
||||
},
|
||||
stayOnPrompt() {
|
||||
if (this.currentPrompt?.cancel) {
|
||||
this.currentPrompt.cancel();
|
||||
|
||||
@@ -4,21 +4,22 @@
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="showFull"
|
||||
@update:model-value="fetchConfig"
|
||||
@update:modelValue="fetchConfig"
|
||||
:name="$t('settings.configViewerShowFull')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="showComments"
|
||||
@update:model-value="fetchConfig"
|
||||
@update:modelValue="fetchConfig"
|
||||
:name="$t('settings.configViewerShowComments')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<Editor
|
||||
:viewer-mode="true"
|
||||
:content="configContent"
|
||||
editor-mode="yaml"
|
||||
:editor-mode="'yaml'"
|
||||
:read-only="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -27,7 +28,7 @@
|
||||
|
||||
<script>
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { config } from "@/api/settings.js";
|
||||
import { config } from "@/api/settings";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store/index.ts";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
import { mutations } from "@/store";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
|
||||
|
||||
export default {
|
||||
name: "copy-paste-confirm",
|
||||
|
||||
@@ -62,12 +62,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store/index.ts";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { authApi } from "@/api/index.js";
|
||||
import { mutations } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { authApi } from "@/api";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters, mutations } from "@/store/index.ts";
|
||||
import { getters, mutations } from "@/store";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
|
||||
|
||||
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user