mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-13 12:41:59 -05:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd07f4ac1d | ||
|
|
da548cb7b3 | ||
|
|
8cbca1ab3a | ||
|
|
f547d0de32 | ||
|
|
e517ad2777 | ||
|
|
74f2951688 | ||
|
|
943a915cf3 | ||
|
|
efe5379f3f | ||
|
|
0d2451e009 | ||
|
|
20af71da61 | ||
|
|
324a9c3802 | ||
|
|
7f88f829ef | ||
|
|
fafb755576 | ||
|
|
3e05196256 | ||
|
|
4db06c9ab1 | ||
|
|
0d30433739 | ||
|
|
b45e3498b8 | ||
|
|
972ba638df |
No files matched your search
+31
-18
@@ -1,20 +1,11 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# 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).
|
||||
# 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).
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
|
||||
FROM golang:bookworm AS base
|
||||
ARG VERSION
|
||||
@@ -38,14 +29,36 @@ COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM ${FFMPEG_GPU_IMAGE} AS runtime
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin"
|
||||
|
||||
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_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,utility,video,graphics
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,video,utility,graphics
|
||||
|
||||
RUN useradd -m -u 1000 -s /usr/sbin/nologin filebrowser
|
||||
USER filebrowser
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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"
|
||||
+11
-11
@@ -5,7 +5,6 @@ go 1.27.1
|
||||
require (
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
@@ -22,21 +21,21 @@ 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.2.2
|
||||
github.com/gtsteffaniak/go-media v1.2.1
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.48.0
|
||||
golang.org/x/image v0.47.0
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/net v0.61.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sync v0.25.0
|
||||
golang.org/x/sys v0.50.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/text v0.44.0
|
||||
golang.org/x/text v0.43.0
|
||||
golang.org/x/time v0.16.0
|
||||
golang.org/x/tools v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -45,7 +44,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/Eyevinn/mp4ff v0.62.1 // indirect
|
||||
github.com/Eyevinn/mp4ff v0.61.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/abema/go-mp4 v1.7.4 // indirect
|
||||
github.com/at-wat/ebml-go v0.19.4 // indirect
|
||||
@@ -55,10 +54,10 @@ require (
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.7 // indirect
|
||||
github.com/gen2brain/h265 v0.2.5 // indirect
|
||||
github.com/gen2brain/jxl v0.2.1 // indirect
|
||||
github.com/gen2brain/vpx v0.2.2 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.6 // indirect
|
||||
github.com/gen2brain/h265 v0.2.4 // indirect
|
||||
github.com/gen2brain/jxl v0.2.0 // indirect
|
||||
github.com/gen2brain/vpx v0.2.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||
@@ -81,6 +80,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0 // indirect
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
|
||||
+24
-22
@@ -2,8 +2,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Eyevinn/mp4ff v0.62.1 h1:EMuNIrmTCm5acOCVENca5Fzk+Mdek/l4bQZdk2EfReo=
|
||||
github.com/Eyevinn/mp4ff v0.62.1/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
|
||||
github.com/Eyevinn/mp4ff v0.61.0 h1:uX3GBauEouxuGepRbv15JB7X2RyVyE2EZEfLpgimL6o=
|
||||
github.com/Eyevinn/mp4ff v0.61.0/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
@@ -25,8 +25,6 @@ github.com/at-wat/ebml-go v0.19.4/go.mod h1:wu3fQj+DWo6SOOl9c4yDi+gFs9Oa96Z2B5qx
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
@@ -64,14 +62,14 @@ github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDm
|
||||
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||
github.com/gen2brain/folio v0.2.8 h1:9iDI7h+zt4mjwEnzm1x8Pl9BrWqLwUwB74X/fnc1PZs=
|
||||
github.com/gen2brain/folio v0.2.8/go.mod h1:4/PcXfaXIYmW1nwQ4bJeE+aG5ybG58Aw9xrwODXaXII=
|
||||
github.com/gen2brain/gav1d v0.2.7 h1:ehu3jWt+0/46Is5llW/ZyX2OA3hTBJNukKnebzuk8KU=
|
||||
github.com/gen2brain/gav1d v0.2.7/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
github.com/gen2brain/h265 v0.2.5 h1:D3qCPx5GU+gHpinvLJtYMLSmQy64RGOm4fzMNGQcA1g=
|
||||
github.com/gen2brain/h265 v0.2.5/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
|
||||
github.com/gen2brain/jxl v0.2.1 h1:74+8uw2RxUHGDVrDp+727PZ2hvAd5/psyV/mpsOQea0=
|
||||
github.com/gen2brain/jxl v0.2.1/go.mod h1:d7VMurg9ek62xfuyZyVAumFcuaQf9U8NlW57j/k5MQI=
|
||||
github.com/gen2brain/vpx v0.2.2 h1:kRCIx6Y2LsTe8hercAfhER2QbeHYnBOetovy4pkC4V0=
|
||||
github.com/gen2brain/vpx v0.2.2/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/gen2brain/gav1d v0.2.6 h1:7TSkrWTdE22k5Ul7vYZML7Nu80gNW6OovV7FSCITSE0=
|
||||
github.com/gen2brain/gav1d v0.2.6/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
github.com/gen2brain/h265 v0.2.4 h1:JUSbGkRAYZpd7ucCL192yx4ViEo75ZO6OEdm1TQt9qM=
|
||||
github.com/gen2brain/h265 v0.2.4/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
|
||||
github.com/gen2brain/jxl v0.2.0 h1:cXSQnfbcC0JGFzaI344VzPMbe7mh6rYc0tjPcBUsE4U=
|
||||
github.com/gen2brain/jxl v0.2.0/go.mod h1:VjJHRai/8I8Es01mULqyiOihRBOjJr1Zo+NoN/MyIwo=
|
||||
github.com/gen2brain/vpx v0.2.1 h1:UD6tibcs/zlaIOKNFXeVyZuHBEaW9GYfxKSxvAN9cc8=
|
||||
github.com/gen2brain/vpx v0.2.1/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@@ -159,14 +157,18 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0 h1:CILp67bcOtiDXvnrQubnH9ctub0OSKnUNA2JJuoIP+c=
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0/go.mod h1:C/dIrjfdUcHgdyChr2YXwLY3pIVjENI9npSd98PU4lE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmmq6v5tnA=
|
||||
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.2.2 h1:CgdXyqiZGa81vZUGL1AXGxQe+8owkUkX6roNACGQJ0w=
|
||||
github.com/gtsteffaniak/go-media v1.2.2/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
|
||||
github.com/gtsteffaniak/go-media v1.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
|
||||
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-media v1.2.1 h1:SjrPqgQTHnenAFuVh8fTRxk8+qXFEaCxiADx3zkM1OI=
|
||||
github.com/gtsteffaniak/go-media v1.2.1/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
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=
|
||||
@@ -256,8 +258,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.48.0 h1:1AvuPcuoOTJpA7s4VZ5umrb90T3So0rkB/bSGmxkxik=
|
||||
golang.org/x/image v0.48.0/go.mod h1:mmtg64iqJKGqEgy+r13dfybl85tmxUd2lMnmaiSdWYY=
|
||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
@@ -277,8 +279,8 @@ golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -290,8 +292,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
|
||||
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
@@ -301,8 +303,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -14,13 +14,13 @@ func TestUserUpdateChangesSkipsSensitiveFields(t *testing.T) {
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "alice",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: true,
|
||||
},
|
||||
},
|
||||
TOTPSecret: "secret-value",
|
||||
}
|
||||
after := *before
|
||||
after.DarkMode = users.DarkModeLight
|
||||
after.DarkMode = false
|
||||
after.TOTPSecret = "new-secret"
|
||||
|
||||
changes := UserUpdateChanges(before, &after, []string{"totpSecret", "darkMode"}, false)
|
||||
@@ -36,7 +36,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "akadmin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: true,
|
||||
StickySidebar: true,
|
||||
Locale: "en",
|
||||
SingleClick: false,
|
||||
@@ -47,7 +47,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
},
|
||||
}
|
||||
after := *before
|
||||
after.DarkMode = users.DarkModeLight
|
||||
after.DarkMode = false
|
||||
after.Sorting = users.Sorting{By: "name", Asc: true}
|
||||
|
||||
which := []string{
|
||||
@@ -60,7 +60,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("expected 3 changes, got %d: %#v", len(changes), changes)
|
||||
}
|
||||
if changes[0].Field != "darkMode" || changes[0].From != "dark" || changes[0].To != "light" {
|
||||
if changes[0].Field != "darkMode" || changes[0].From != "true" || changes[0].To != "false" {
|
||||
t.Fatalf("unexpected darkMode change: %#v", changes[0])
|
||||
}
|
||||
if changes[1].Field != "sorting.asc" || changes[1].From != "false" || changes[1].To != "true" {
|
||||
|
||||
@@ -25,8 +25,8 @@ func TestFinishUserLoad_legacyProfileRowPreservesAbsentSettingsFields(t *testing
|
||||
if !loaded.ShowFirstLogin {
|
||||
t.Fatal("expected top-level showFirstLogin to be preserved when settings omits it")
|
||||
}
|
||||
if !loaded.DarkMode.IsDark() {
|
||||
t.Fatalf("expected profile darkMode=true, got %q", loaded.DarkMode)
|
||||
if !loaded.DarkMode {
|
||||
t.Fatal("expected profile darkMode=true")
|
||||
}
|
||||
if len(loaded.PasskeyCredentials) != 0 {
|
||||
t.Fatalf("expected no passkeys when settings omits passkeyCredentials, got %#v", loaded.PasskeyCredentials)
|
||||
@@ -95,7 +95,7 @@ func TestUserDataRoundTrip_profileSettingsOverlay(t *testing.T) {
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
ShowFirstLogin: true,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: true,
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "Home", Target: "/files"},
|
||||
},
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DarkMode is the user's theme preference: "auto" (follow the client's
|
||||
// system preference), "dark", or "light". It unmarshals from legacy JSON
|
||||
// booleans for backward compatibility with existing stored users.
|
||||
type DarkMode string
|
||||
|
||||
const (
|
||||
DarkModeAuto DarkMode = "auto"
|
||||
DarkModeDark DarkMode = "dark"
|
||||
DarkModeLight DarkMode = "light"
|
||||
)
|
||||
|
||||
// UnmarshalJSON accepts legacy boolean values (true -> dark, false -> light),
|
||||
// the tri-state strings ("auto", "dark", "light"), and null (unset).
|
||||
func (d *DarkMode) UnmarshalJSON(data []byte) error {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
*d = ""
|
||||
case bool:
|
||||
if val {
|
||||
*d = DarkModeDark
|
||||
} else {
|
||||
*d = DarkModeLight
|
||||
}
|
||||
case string:
|
||||
switch DarkMode(strings.ToLower(val)) {
|
||||
case "":
|
||||
*d = ""
|
||||
case "true":
|
||||
// Some YAML decoders cast scalar booleans to strings.
|
||||
*d = DarkModeDark
|
||||
case "false":
|
||||
*d = DarkModeLight
|
||||
case DarkModeAuto:
|
||||
*d = DarkModeAuto
|
||||
case DarkModeDark:
|
||||
*d = DarkModeDark
|
||||
case DarkModeLight:
|
||||
*d = DarkModeLight
|
||||
default:
|
||||
return fmt.Errorf("invalid darkMode value %q: must be \"auto\", \"dark\" or \"light\"", val)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid darkMode value: must be a boolean or one of \"auto\", \"dark\", \"light\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDark reports the effective boolean value for server-side rendering
|
||||
// contexts. "auto" cannot be resolved server-side; callers that can know the
|
||||
// client preference should handle DarkModeAuto explicitly.
|
||||
func (d DarkMode) IsDark() bool {
|
||||
return d == DarkModeDark
|
||||
}
|
||||
|
||||
// Or returns d unless it is unset (""), in which case it returns def.
|
||||
func (d DarkMode) Or(def DarkMode) DarkMode {
|
||||
if d == "" {
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -308,7 +308,7 @@ type NonAdminEditable struct {
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode DarkMode `json:"darkMode"` // theme preference: "auto" (follow system), "dark", or "light"
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
|
||||
@@ -80,11 +80,6 @@ var heifImageExts = map[string]bool{
|
||||
|
||||
const heicDecodeMaxEdge = 16384
|
||||
|
||||
// stillImageMaxSourcePixels caps the original image area (width×height) passed to
|
||||
// go-media for HEIC, JPEG XL, and camera RAW decode. JPEG XL honors it today
|
||||
// (~50 MiB of 8-bit RGB at 4096×4096); other still formats ignore the field.
|
||||
const stillImageMaxSourcePixels = 4096 * 4096
|
||||
|
||||
// cameraRawExts match go-media rawpreview formats (also iteminfo raw extensions).
|
||||
var cameraRawExts = map[string]bool{
|
||||
".cr2": true, ".cr3": true, ".nef": true, ".nrw": true, ".arw": true, ".srf": true, ".sr2": true,
|
||||
@@ -633,11 +628,10 @@ func stillImageJPEG(path string, maxW, maxH, quality int, op string, supported f
|
||||
quality = 85
|
||||
}
|
||||
res, err := media.Thumbnail(f, size, media.ThumbnailOptions{
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
MaxSourcePixels: stillImageMaxSourcePixels,
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
})
|
||||
if err != nil {
|
||||
logFail(err)
|
||||
|
||||
@@ -2,13 +2,11 @@ package gomedia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -68,24 +66,3 @@ func TestJXLJPEGMissingFileReturnsFalse(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
require.Empty(t, data)
|
||||
}
|
||||
|
||||
// JPEG XL above stillImageMaxSourcePixels must fail with go-media's ErrUnsupported
|
||||
// and return ok=false (no ffmpeg fallback).
|
||||
func TestJXLJPEGRejectsOversizedSource(t *testing.T) {
|
||||
withGate(t, false)
|
||||
f, err := os.Open(fixtureJXL)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = media.Thumbnail(f, info.Size(), media.ThumbnailOptions{
|
||||
MaxWidth: 256,
|
||||
MaxHeight: 256,
|
||||
Format: "jpeg",
|
||||
Quality: 85,
|
||||
MaxSourcePixels: 1,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, media.ErrUnsupported), "oversized JXL must surface ErrUnsupported, got %v", err)
|
||||
}
|
||||
@@ -59,8 +59,8 @@ func runShadow[T any](op, path string, goLatency time.Duration, fast T, legacy L
|
||||
ffStart := time.Now()
|
||||
legacyVal, ffOK := legacy()
|
||||
ffLatency := time.Since(ffStart)
|
||||
shadowLogf("go-media shadow %s for %s: go-media=%.2fms legacy(ffmpeg/dhowden)=%.2fms diff(go-legacy)=%+.2fms",
|
||||
op, path, ms(goLatency), ms(ffLatency), ms(goLatency)-ms(ffLatency))
|
||||
shadowLogf("go-media shadow %s for %s: go-media=%.2fms legacy(ffmpeg/dhowden)=%.2fms diff(legacy-go)=%+.2fms legacy_ok=%t",
|
||||
op, path, ms(goLatency), ms(ffLatency), ms(ffLatency)-ms(goLatency), ffOK)
|
||||
|
||||
if !ffOK {
|
||||
// go-media read the file but the legacy path failed. That is an outcome
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
@@ -123,21 +122,23 @@ func TestDefaultBackgroundColor(t *testing.T) {
|
||||
saved := settings.Config
|
||||
defer func() { settings.Config = saved }()
|
||||
|
||||
dark := true
|
||||
light := false
|
||||
|
||||
settings.Config.Frontend.Styling.DarkBackground = "#111111"
|
||||
settings.Config.Frontend.Styling.LightBackground = "#ffffff"
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = users.DarkModeDark
|
||||
settings.Config.UserDefaults.UI.DarkMode = &dark
|
||||
if got := settings.DefaultBackgroundColor(); got != "#111111" {
|
||||
t.Fatalf("dark branch: got %q, want %q", got, "#111111")
|
||||
}
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = users.DarkModeLight
|
||||
settings.Config.UserDefaults.UI.DarkMode = &light
|
||||
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
|
||||
t.Fatalf("light branch (dark disabled): got %q, want %q", got, "#ffffff")
|
||||
}
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = ""
|
||||
settings.Config.UserDefaults.UI.DarkMode = nil
|
||||
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
|
||||
t.Fatalf("light branch (unset): got %q, want %q", got, "#ffffff")
|
||||
}
|
||||
|
||||
@@ -14,21 +14,14 @@ const (
|
||||
|
||||
var (
|
||||
mediaSettingsMu sync.RWMutex
|
||||
mediaSettingsDefault settings.FullAdminMedia
|
||||
mediaSettingsDefault settings.MediaSettings
|
||||
)
|
||||
|
||||
// saveMediaSettings is the only write path for media settings. It clears derived
|
||||
// fields (EnabledProfiles, ImagePreview) so read-time values are never persisted.
|
||||
func saveMediaSettings(key string, ms settings.FullAdminMedia) error {
|
||||
settings.ClearMediaDerived(&ms)
|
||||
return sqlDb.SaveSetting(key, ms)
|
||||
}
|
||||
|
||||
// InitMediaSettings loads persisted runtime media settings from SQLite and seeds from config when missing.
|
||||
func InitMediaSettings() error {
|
||||
if _, err := sqlDb.GetSetting(mediaSettingsDefaultKey); err != nil {
|
||||
seed := settings.SanitizeMediaSettings(settings.CurrentMediaSettings())
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, seed); saveErr != nil {
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, seed); saveErr != nil {
|
||||
return fmt.Errorf("seed media settings: %w", saveErr)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +43,7 @@ func InitMediaSettings() error {
|
||||
// sanitize, so clamps (e.g. the readRate floor) must run again.
|
||||
overlaid = settings.SanitizeMediaSettings(overlaid)
|
||||
if !settings.MediaSettingsEqual(loaded, overlaid) {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, overlaid); saveErr != nil {
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, overlaid); saveErr != nil {
|
||||
return fmt.Errorf("save config-synced media settings: %w", saveErr)
|
||||
}
|
||||
loaded = overlaid
|
||||
@@ -64,22 +57,22 @@ func InitMediaSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMediaSettingsSetting(key string) (settings.FullAdminMedia, error) {
|
||||
func loadMediaSettingsSetting(key string) (settings.MediaSettings, error) {
|
||||
raw, err := sqlDb.GetSetting(key)
|
||||
if err != nil {
|
||||
return settings.FullAdminMedia{}, err
|
||||
return settings.MediaSettings{}, err
|
||||
}
|
||||
var ms settings.FullAdminMedia
|
||||
var ms settings.MediaSettings
|
||||
if err := json.Unmarshal(raw, &ms); err != nil {
|
||||
return settings.FullAdminMedia{}, fmt.Errorf("parse %s: %w", key, err)
|
||||
return settings.MediaSettings{}, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// GetMediaSettings returns the stored runtime media settings. The value is deep-copied
|
||||
// GetMediaSettings returns the runtime media settings. The value is deep-copied
|
||||
// so callers cannot mutate the stored settings (or the live config they are
|
||||
// applied to) through the conversion maps.
|
||||
func GetMediaSettings() settings.FullAdminMedia {
|
||||
func GetMediaSettings() settings.MediaSettings {
|
||||
mediaSettingsMu.RLock()
|
||||
defer mediaSettingsMu.RUnlock()
|
||||
return settings.CloneMediaSettings(mediaSettingsDefault)
|
||||
@@ -97,11 +90,8 @@ func PatchMediaSettings(patchJSON []byte) error {
|
||||
return mergeErr
|
||||
}
|
||||
merged = settings.SanitizeMediaSettings(merged)
|
||||
// A patch may echo derived fields back (e.g. imagePreview from the admin UI).
|
||||
// Clear them before the value reaches memory or the database.
|
||||
settings.ClearMediaDerived(&merged)
|
||||
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, merged); saveErr != nil {
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, merged); saveErr != nil {
|
||||
mediaSettingsMu.Unlock()
|
||||
return fmt.Errorf("save media settings: %w", saveErr)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -35,56 +34,6 @@ func TestGetMediaSettingsReturnsIndependentCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Derived read-time values must never reach the database, even when a caller
|
||||
// passes a struct that has them populated.
|
||||
func TestSaveMediaSettingsNeverPersistsDerivedFields(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
ms := GetMediaSettings()
|
||||
ms.Transcode.EnabledProfiles = []string{"quality"}
|
||||
ms.ImagePreview = map[settings.ImagePreviewType]bool{settings.JPEGImagePreview: true}
|
||||
if err := saveMediaSettings(mediaSettingsDefaultKey, ms); err != nil {
|
||||
t.Fatalf("saveMediaSettings: %v", err)
|
||||
}
|
||||
|
||||
raw, err := sqlDb.GetSetting(mediaSettingsDefaultKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting: %v", err)
|
||||
}
|
||||
var stored map[string]any
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("decode stored settings: %v", err)
|
||||
}
|
||||
transcode, ok := stored["transcode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("stored transcode = %T, want object", stored["transcode"])
|
||||
}
|
||||
if _, ok := transcode["enabledProfiles"]; ok {
|
||||
t.Fatal("enabledProfiles (derived) was persisted")
|
||||
}
|
||||
if _, ok := stored["imagePreview"]; ok {
|
||||
t.Fatal("imagePreview (derived) was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// The admin UI echoes its whole payload back on PATCH, derived fields included.
|
||||
// Those must be dropped before the value reaches memory, not only before the database.
|
||||
func TestPatchMediaSettingsDropsEchoedDerivedFields(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
patch := []byte(`{"imagePreview":{"jpeg":true},"transcode":{"enabledProfiles":["quality"]}}`)
|
||||
if err := PatchMediaSettings(patch); err != nil {
|
||||
t.Fatalf("PatchMediaSettings: %v", err)
|
||||
}
|
||||
got := GetMediaSettings()
|
||||
if got.ImagePreview != nil {
|
||||
t.Fatal("echoed imagePreview stayed in memory")
|
||||
}
|
||||
if got.Transcode.EnabledProfiles != nil {
|
||||
t.Fatal("echoed enabledProfiles stayed in memory")
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh install seeds the stored media settings from config.
|
||||
func TestMediaSettingsSeedFromConfigOnFirstInit(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestStateInit_jwtYAML_organizedDefaultsOnNewUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.DarkMode.IsDark() || !loaded.Preview.Image {
|
||||
if !loaded.DarkMode || !loaded.Preview.Image {
|
||||
t.Fatalf("profile fields: darkMode=%v preview.image=%v", loaded.DarkMode, loaded.Preview.Image)
|
||||
}
|
||||
if loaded.BackendScopes[0].Permissions.Modify != access.Modify {
|
||||
|
||||
@@ -1098,14 +1098,14 @@ func TestManagerSuspendsProducerWhenAllClientsPaused(t *testing.T) {
|
||||
in := testInput(t, "pauser", "/v.mp4")
|
||||
sess := mustStart(t, mgr, in)
|
||||
waitForStarts(t, engine, 1)
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
if snap := snapshotProducer(t, mgr, sess.ID); !snap.exists || !snap.suspended {
|
||||
t.Fatal("a suspended producer must stay registered and marked suspended")
|
||||
}
|
||||
@@ -1185,13 +1185,13 @@ func TestManagerResumesProducerOnUnpauseHeartbeat(t *testing.T) {
|
||||
pollUntil(t, 2*time.Second, func() bool {
|
||||
return snapshotProducer(t, mgr, sess.ID).encodeHead >= 1
|
||||
}, "encode head to advance past segment 0")
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1216,13 +1216,13 @@ func TestManagerResumesProducerOnSegmentRequest(t *testing.T) {
|
||||
in := testInput(t, "pauser", "/v.mp4")
|
||||
sess := mustStart(t, mgr, in)
|
||||
waitForStarts(t, engine, 1)
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
|
||||
// The suspended job reads as !Running, so a request beyond its head takes
|
||||
// the restart path: the relaunched job produces the requested segment.
|
||||
@@ -1246,13 +1246,13 @@ func TestManagerStopReleasesSuspendedProducer(t *testing.T) {
|
||||
in := testInput(t, "pauser", "/v.mp4")
|
||||
sess := mustStart(t, mgr, in)
|
||||
waitForStarts(t, engine, 1)
|
||||
job := engine.LastJob()
|
||||
|
||||
if err := mgr.Heartbeat(in.Username, sess.ID, HeartbeatRequest{ClientID: "c1", Paused: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForProducerVODJobAttached(t, mgr, sess.ID)
|
||||
suspendPausedProducers(t, mgr, time.Now().Add(time.Hour))
|
||||
waitForProducerParked(t, mgr, sess.ID)
|
||||
pollUntil(t, 2*time.Second, func() bool { return !job.Running() }, "suspended job to stop")
|
||||
|
||||
if err := mgr.Stop(in.Username, sess.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1308,29 +1308,6 @@ func mustStart(t *testing.T, mgr *Manager, in StartInput) SessionInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// waitForProducerVODJobAttached waits until the session's producer has the VOD
|
||||
// job wired up. StartCount can advance before runProducerJob assigns p.job.
|
||||
func waitForProducerVODJobAttached(t *testing.T, mgr *Manager, sessionID string) {
|
||||
t.Helper()
|
||||
pollUntil(t, 5*time.Second, func() bool {
|
||||
mgr.mu.Lock()
|
||||
p := mgr.producers[sessionID]
|
||||
ok := p != nil && p.job != nil
|
||||
mgr.mu.Unlock()
|
||||
return ok
|
||||
}, "producer VOD job attached")
|
||||
}
|
||||
|
||||
// waitForProducerParked waits until suspendPausedProducers has parked the
|
||||
// producer (suspended flag), instead of polling a stale *fakeJob snapshot.
|
||||
func waitForProducerParked(t *testing.T, mgr *Manager, sessionID string) {
|
||||
t.Helper()
|
||||
pollUntil(t, 5*time.Second, func() bool {
|
||||
snap := snapshotProducer(t, mgr, sessionID)
|
||||
return snap.exists && snap.suspended
|
||||
}, "suspended producer to park")
|
||||
}
|
||||
|
||||
// pollUntil fails the test when cond is not met within timeout.
|
||||
func pollUntil(t *testing.T, timeout time.Duration, cond func() bool, msg string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
@@ -36,6 +39,9 @@ const (
|
||||
// duplicateResultsCache caches duplicate search results for 15 seconds
|
||||
var duplicateResultsCache = cache.NewCache[duplicateResponse](15 * time.Second)
|
||||
|
||||
// checksumCache caches file checksums for 1 hour, keyed by source/path/modtime
|
||||
var checksumCache = cache.NewCache[string](1 * time.Hour)
|
||||
|
||||
type duplicateGroup struct {
|
||||
Size int64 `json:"size"`
|
||||
Count int `json:"count"`
|
||||
@@ -121,12 +127,10 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
|
||||
// identical content but different filenames)
|
||||
//
|
||||
// Performance Optimizations:
|
||||
// - Checksums are cached for 1 hour (keyed by path/size/modtime) to speed up subsequent requests
|
||||
// The checksum budget counts only cache misses (real disk I/O); cached digests are free,
|
||||
// so a re-run proceeds past the previously-examined files instead of stalling at the limit.
|
||||
// - Files are processed in batches to balance memory usage vs SQL query count
|
||||
// - Resource limits prevent timeouts: max 2 minutes, max 10K checksum operations
|
||||
// - Large fuzzy groups are skipped to avoid expensive checksum operations on false positives
|
||||
// - Checksums are cached for 1 hour (keyed by path/size/modtime) to speed up subsequent requests
|
||||
// - Files are processed in batches to balance memory usage vs SQL query count
|
||||
// - Resource limits prevent timeouts: max 2 minutes, max 10K checksum operations
|
||||
// - Large fuzzy groups are skipped to avoid expensive checksum operations on false positives
|
||||
//
|
||||
// Response includes incomplete flag if processing was stopped early due to resource limits.
|
||||
//
|
||||
@@ -166,10 +170,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
|
||||
// Safety check: Reject duplicate search during active indexing to prevent resource contention
|
||||
// This protects against CPU/memory spikes and ensures indexing performance is not degraded
|
||||
// Use GetStatus() (computed from active scanners and persisted lastScanned) rather than
|
||||
// the raw Status field, which is only set to "ready" by PostScan and would otherwise
|
||||
// block searches indefinitely after a restart until the first scheduled scan runs.
|
||||
if index.GetStatus() == indexing.INDEXING {
|
||||
if string(index.Status) == "indexing" {
|
||||
return http.StatusServiceUnavailable, fmt.Errorf("duplicate search is not available while indexing is in progress - please try again when indexing completes")
|
||||
}
|
||||
|
||||
@@ -237,13 +238,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
startTime: time.Now(),
|
||||
uniqueChecksums: make(map[string]bool),
|
||||
}
|
||||
duplicateGroups := findDuplicatesInIndex(r.Context(), index, opts, stats)
|
||||
|
||||
// The client went away — do not cache or record a truncated result. A cached
|
||||
// partial response would poison the 15s duplicateResultsCache for the next user.
|
||||
if err := r.Context().Err(); err != nil {
|
||||
return http.StatusRequestTimeout, err
|
||||
}
|
||||
duplicateGroups := findDuplicatesInIndex(index, opts, stats)
|
||||
|
||||
// Log resource usage for monitoring
|
||||
uniqueChecksumCount := len(stats.uniqueChecksums)
|
||||
@@ -259,7 +254,8 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
Reason: stats.stopReason,
|
||||
}
|
||||
|
||||
cacheDuplicateResponse(r.Context(), cacheKey, response)
|
||||
// Cache the results before returning (even partial results)
|
||||
duplicateResultsCache.Set(cacheKey, response)
|
||||
|
||||
if stats.stopped {
|
||||
w.Header().Set("X-Search-Incomplete", "true")
|
||||
@@ -271,7 +267,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
}
|
||||
|
||||
// findDuplicatesInIndex finds duplicates using the shared IndexDB with resource limits
|
||||
func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *duplicatesOptions, stats *duplicateProcessingStats) []duplicateGroup {
|
||||
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats *duplicateProcessingStats) []duplicateGroup {
|
||||
// Get the shared IndexDB
|
||||
indexDB := indexing.GetIndexDB()
|
||||
|
||||
@@ -294,11 +290,6 @@ func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *dup
|
||||
|
||||
// Process sizes in batches
|
||||
for batchStart := 0; batchStart < len(sizes); batchStart += bulkSizeQueryLimit {
|
||||
if ctx.Err() != nil {
|
||||
stats.stopped = true
|
||||
stats.stopReason = ctx.Err().Error()
|
||||
break
|
||||
}
|
||||
// Check resource limits before processing each batch
|
||||
if shouldStop, reason := stats.shouldStop(); shouldStop {
|
||||
stats.stopped = true
|
||||
@@ -336,11 +327,6 @@ func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *dup
|
||||
// Memory optimization: Delete entries from filesBySize immediately after processing
|
||||
// to allow GC to reclaim memory as we go, rather than holding all data until batch completes
|
||||
for _, size := range sizeBatch {
|
||||
if ctx.Err() != nil {
|
||||
stats.stopped = true
|
||||
stats.stopReason = ctx.Err().Error()
|
||||
break
|
||||
}
|
||||
files, ok := filesBySize[size]
|
||||
if !ok || len(files) < 2 {
|
||||
// Clear entry even if not processing to free memory
|
||||
@@ -359,8 +345,8 @@ func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *dup
|
||||
continue
|
||||
}
|
||||
|
||||
// Group files by MIME (+ extension when MIME is ambiguous) within this size bucket.
|
||||
filesByType := groupFilesByTypeBucket(files)
|
||||
// Group files by MIME type in memory (already ordered by type from SQL)
|
||||
filesByType := groupFilesByType(files)
|
||||
|
||||
// Process each MIME type group
|
||||
for _, typeFiles := range filesByType {
|
||||
@@ -378,13 +364,29 @@ func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *dup
|
||||
|
||||
// Use fuzzy filename matching to group files within this MIME type
|
||||
// This reduces checksum operations by grouping similar filenames
|
||||
filenameGroups := groupFilesByFilename(ctx, typeFiles, size, stats)
|
||||
filenameGroups := groupFilesByFilename(typeFiles, size)
|
||||
|
||||
totalFilesInGroups := 0
|
||||
for _, g := range filenameGroups {
|
||||
if len(g) >= 2 {
|
||||
totalFilesInGroups += len(g)
|
||||
}
|
||||
}
|
||||
|
||||
// Process each fuzzy filename group
|
||||
for _, fileGroup := range filenameGroups {
|
||||
if len(fileGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip checksumming if fuzzy group is too large - groups with many files
|
||||
// are unlikely to be true duplicates (fuzzy matching is too permissive)
|
||||
// This optimization prevents expensive checksum operations on false positives
|
||||
if len(fileGroup) > maxFuzzyGroupSize {
|
||||
logger.Debugf("[Duplicates] Skipping checksum for fuzzy group with %d files (exceeds limit of %d)", len(fileGroup), maxFuzzyGroupSize)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stop if we've hit the group limit
|
||||
if len(groupsWithChecksums) >= maxGroups {
|
||||
break
|
||||
@@ -401,7 +403,7 @@ func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *dup
|
||||
// Verify with checksums using 3-pass progressive verification
|
||||
// At this point, files match on: size + MIME type + fuzzy filename similarity (50%+)
|
||||
// Large fuzzy groups (>10 files) are skipped above to avoid expensive false positives
|
||||
verifiedGroups := groupFilesByChecksum(ctx, fileGroup, index, size, stats)
|
||||
verifiedGroups := groupFilesByChecksum(fileGroup, index, size, stats)
|
||||
|
||||
// Create SearchResult objects and track checksums for merging
|
||||
for _, checksumGroup := range verifiedGroups {
|
||||
@@ -481,15 +483,100 @@ func findDuplicatesInIndex(ctx context.Context, index *indexing.Index, opts *dup
|
||||
})
|
||||
}
|
||||
|
||||
// Groups are already sorted by size (largest to smallest) from SQL query
|
||||
return duplicateGroups
|
||||
}
|
||||
|
||||
// cacheDuplicateResponse stores a duplicate search response when the client context is still active.
|
||||
func cacheDuplicateResponse(ctx context.Context, cacheKey string, response duplicateResponse) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
// mergeGroupsByChecksum merges groups that share any checksum values
|
||||
// This catches cases where files have identical content but were grouped separately
|
||||
func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGroupWithChecksums {
|
||||
if len(groups) <= 1 {
|
||||
return groups
|
||||
}
|
||||
duplicateResultsCache.Set(cacheKey, response)
|
||||
|
||||
// Build a map: checksum -> list of group indices that contain this checksum
|
||||
checksumToGroups := make(map[string][]int)
|
||||
for i, group := range groups {
|
||||
if group.Checksum != "" {
|
||||
checksumToGroups[group.Checksum] = append(checksumToGroups[group.Checksum], i)
|
||||
}
|
||||
}
|
||||
|
||||
// Use union-find to merge groups that share checksums
|
||||
parent := make([]int, len(groups))
|
||||
for i := range parent {
|
||||
parent[i] = i
|
||||
}
|
||||
|
||||
var find func(int) int
|
||||
find = func(x int) int {
|
||||
if parent[x] != x {
|
||||
parent[x] = find(parent[x]) // Path compression
|
||||
}
|
||||
return parent[x]
|
||||
}
|
||||
|
||||
union := func(x, y int) {
|
||||
px, py := find(x), find(y)
|
||||
if px != py {
|
||||
parent[px] = py
|
||||
}
|
||||
}
|
||||
|
||||
// Merge groups that share any checksum
|
||||
for _, groupIndices := range checksumToGroups {
|
||||
if len(groupIndices) < 2 {
|
||||
continue
|
||||
}
|
||||
// Merge all groups that share this checksum
|
||||
for i := 1; i < len(groupIndices); i++ {
|
||||
union(groupIndices[0], groupIndices[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Group indices by their root parent
|
||||
groupsByRoot := make(map[int][]int)
|
||||
for i := range groups {
|
||||
root := find(i)
|
||||
groupsByRoot[root] = append(groupsByRoot[root], i)
|
||||
}
|
||||
|
||||
// Merge groups that belong to the same root
|
||||
merged := make([]duplicateGroupWithChecksums, 0, len(groupsByRoot))
|
||||
for _, indices := range groupsByRoot {
|
||||
if len(indices) == 1 {
|
||||
// No merging needed, just add the group
|
||||
merged = append(merged, groups[indices[0]])
|
||||
continue
|
||||
}
|
||||
|
||||
// Merge multiple groups
|
||||
combinedFiles := make([]*indexing.SearchResult, 0)
|
||||
var combinedSize int64
|
||||
var combinedChecksum string
|
||||
|
||||
for _, idx := range indices {
|
||||
group := groups[idx]
|
||||
combinedFiles = append(combinedFiles, group.Files...)
|
||||
if combinedChecksum == "" {
|
||||
combinedChecksum = group.Checksum
|
||||
}
|
||||
if combinedSize == 0 {
|
||||
combinedSize = group.Size
|
||||
}
|
||||
}
|
||||
|
||||
// Only include merged groups with 2+ files
|
||||
if len(combinedFiles) >= 2 {
|
||||
merged = append(merged, duplicateGroupWithChecksums{
|
||||
Size: combinedSize,
|
||||
Files: combinedFiles,
|
||||
Checksum: combinedChecksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func prepDuplicatesOptions(r *http.Request, d *Context) (*duplicatesOptions, error) {
|
||||
@@ -537,6 +624,349 @@ func prepDuplicatesOptions(r *http.Request, d *Context) (*duplicatesOptions, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// groupFilesByChecksum groups files by partial checksum and returns groups with their checksums
|
||||
func groupFilesByChecksum(files []*iteminfo.FileInfo, index *indexing.Index, fileSize int64, stats *duplicateProcessingStats) []checksumGroup {
|
||||
if len(files) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PASS 1: Header checksums only (first 8KB)
|
||||
// This is the fastest way to eliminate files that are definitely different
|
||||
// ONLY checksum files that already matched on filename+size
|
||||
headerGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
|
||||
for _, file := range files {
|
||||
// Check if we've hit the checksum operation limit
|
||||
if stats.checksumOperations >= maxChecksumOperations {
|
||||
logger.Warningf("[Duplicates] Reached checksum operation limit (%d) in header pass", maxChecksumOperations)
|
||||
break
|
||||
}
|
||||
|
||||
// Verify size matches expected size (should match as we queried by size)
|
||||
if file.Size != fileSize {
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct filesystem path for checksum computation
|
||||
// index.Path is the absolute filesystem root, file.Path is index-relative
|
||||
filePath := filepath.Join(index.Path, file.Path)
|
||||
headerChecksum, err := computeHeaderChecksum(index.Path, filePath, fileSize, file.ModTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats.checksumOperations++
|
||||
stats.uniqueChecksums[headerChecksum] = true
|
||||
headerGroups[headerChecksum] = append(headerGroups[headerChecksum], file)
|
||||
}
|
||||
|
||||
// Filter to only groups with 2+ files (candidates for duplicates)
|
||||
headerCandidates := make([][]*iteminfo.FileInfo, 0)
|
||||
filesAfterHeaderPass := 0
|
||||
for _, group := range headerGroups {
|
||||
if len(group) >= 2 {
|
||||
headerCandidates = append(headerCandidates, group)
|
||||
filesAfterHeaderPass += len(group)
|
||||
}
|
||||
}
|
||||
|
||||
if len(headerCandidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PASS 2: Middle checksums (header + middle) for header-matched groups
|
||||
// Only process files that matched on header to save disk I/O
|
||||
middleGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
|
||||
for _, headerGroup := range headerCandidates {
|
||||
if len(headerGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, file := range headerGroup {
|
||||
// Check if we've hit the checksum operation limit
|
||||
if stats.checksumOperations >= maxChecksumOperations {
|
||||
logger.Warningf("[Duplicates] Reached checksum operation limit (%d) in middle pass", maxChecksumOperations)
|
||||
break
|
||||
}
|
||||
|
||||
filePath := filepath.Join(index.Path, file.Path)
|
||||
middleChecksum, err := computeMiddleChecksum(index.Path, filePath, fileSize, file.ModTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats.checksumOperations++
|
||||
stats.uniqueChecksums[middleChecksum] = true
|
||||
middleGroups[middleChecksum] = append(middleGroups[middleChecksum], file)
|
||||
}
|
||||
}
|
||||
|
||||
// Final verification: Use middle checksum as final (header + middle is sufficient)
|
||||
// Files that match on header + middle but differ at end are extremely rare
|
||||
// This 2-pass system catches 99.9%+ of differences while being faster
|
||||
groups := make([]checksumGroup, 0, len(middleGroups))
|
||||
for checksum, group := range middleGroups {
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, checksumGroup{
|
||||
Files: group,
|
||||
Checksum: checksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// computeHeaderChecksum calculates MD5 hash of only the first 8KB of a file
|
||||
// This is the fastest initial pass to eliminate non-matching files
|
||||
func computeHeaderChecksum(sourcePath, filePath string, size int64, modTime time.Time) (string, error) {
|
||||
// Generate cache key for header-only checksum
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%d:header", sourcePath, filePath, size, modTime.Unix())
|
||||
|
||||
// Check cache first
|
||||
if cachedChecksum, ok := checksumCache.Get(cacheKey); ok {
|
||||
return cachedChecksum, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
buf := make([]byte, 8192) // 8KB buffer
|
||||
|
||||
// Read only first 8KB (or entire file if smaller)
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
|
||||
checksum := fmt.Sprintf("%x", hash.Sum(nil))
|
||||
|
||||
// Cache the result
|
||||
checksumCache.Set(cacheKey, checksum)
|
||||
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
// computeMiddleChecksum calculates MD5 hash of header + middle portion
|
||||
// Only called when header checksums match (progressive verification)
|
||||
func computeMiddleChecksum(sourcePath, filePath string, size int64, modTime time.Time) (string, error) {
|
||||
// Generate cache key for header+middle checksum
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%d:middle", sourcePath, filePath, size, modTime.Unix())
|
||||
|
||||
// Check cache first
|
||||
if cachedChecksum, ok := checksumCache.Get(cacheKey); ok {
|
||||
return cachedChecksum, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
buf := make([]byte, 8192) // 8KB buffer
|
||||
|
||||
// Read first 8KB
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
|
||||
// For larger files, also read middle 8KB
|
||||
if size > 16384 { // 16KB
|
||||
middleOffset := size / 2
|
||||
if _, err := file.Seek(middleOffset, 0); err == nil {
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
checksum := fmt.Sprintf("%x", hash.Sum(nil))
|
||||
|
||||
// Cache the result
|
||||
checksumCache.Set(cacheKey, checksum)
|
||||
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
// groupFilesByFilename groups files by fuzzy filename matching
|
||||
// Works with iteminfo.FileInfo from the main IndexDB
|
||||
// Normalizes filenames on-the-fly for comparison
|
||||
// NOTE: Files passed in should already be filtered by MIME type at the SQL level,
|
||||
// so no need to check extensions here (extension checking is redundant with MIME type filtering)
|
||||
func groupFilesByFilename(files []*iteminfo.FileInfo, expectedSize int64) [][]*iteminfo.FileInfo {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := [][]*iteminfo.FileInfo{}
|
||||
used := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(files); i++ {
|
||||
if used[i] || files[i].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
group := []*iteminfo.FileInfo{files[i]}
|
||||
used[i] = true
|
||||
|
||||
// Normalize filename for fuzzy matching
|
||||
filename1 := normalizeFilename(files[i].Name)
|
||||
|
||||
for j := i + 1; j < len(files); j++ {
|
||||
if used[j] || files[j].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check size (should match, but safe to check)
|
||||
if files[j].Size != expectedSize {
|
||||
continue
|
||||
}
|
||||
|
||||
filename2 := normalizeFilename(files[j].Name)
|
||||
|
||||
// Check if filenames are similar enough using levenshtein distance
|
||||
if filenamesSimilar(filename1, filename2) {
|
||||
group = append(group, files[j])
|
||||
used[j] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// normalizeFilename converts filename to lowercase and removes special characters
|
||||
// to make fuzzy matching more effective
|
||||
func normalizeFilename(filename string) string {
|
||||
// Convert to lowercase
|
||||
filename = strings.ToLower(filename)
|
||||
|
||||
// Remove file extension for comparison
|
||||
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
||||
filename = filename[:idx]
|
||||
}
|
||||
|
||||
// Remove non-alphanumeric characters except spaces and hyphens
|
||||
var result strings.Builder
|
||||
for _, r := range filename {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// filenamesSimilar checks if two normalized filenames are similar enough to be considered duplicates
|
||||
func filenamesSimilar(name1, name2 string) bool {
|
||||
// Exact match
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty names don't match
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
distance := levenshteinDistance(name1, name2)
|
||||
maxLen := max(len(name1), len(name2))
|
||||
|
||||
// Require at least 50% similarity (lowered to catch more duplicates with username prefixes)
|
||||
// Examples: "video.mp4" vs "username_video.mp4" should match
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= 0.5
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the edit distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
}
|
||||
|
||||
// Initialize first row and column
|
||||
for i := 0; i <= len(s1); i++ {
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// min returns the minimum of three integers
|
||||
func min(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// groupFilesByType groups files by MIME type
|
||||
// Assumes files are already sorted by type (from SQL ORDER BY)
|
||||
func groupFilesByType(files []*iteminfo.FileInfo) map[string][]*iteminfo.FileInfo {
|
||||
grouped := make(map[string][]*iteminfo.FileInfo)
|
||||
for _, file := range files {
|
||||
grouped[file.Type] = append(grouped[file.Type], file)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// filterFilesByPermission filters files to only include those the user is permitted to access
|
||||
// This is called early in the duplicate search process to avoid processing files the user can't see
|
||||
func filterFilesByPermission(files []*iteminfo.FileInfo, index *indexing.Index, username string) []*iteminfo.FileInfo {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
type duplicateCorpus struct {
|
||||
dir string
|
||||
files []*iteminfo.FileInfo
|
||||
}
|
||||
|
||||
func buildDuplicateCorpus(b *testing.B) *duplicateCorpus {
|
||||
b.Helper()
|
||||
dir := b.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
write := func(name string, payload []byte, mutateTail bool) {
|
||||
if mutateTail && len(payload) > 32 {
|
||||
dup := append([]byte(nil), payload...)
|
||||
dup[len(dup)-1] ^= 0xff
|
||||
payload = dup
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), payload, 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
jpegHeader := []byte{0xff, 0xd8, 0xff, 0xe0}
|
||||
pngHeader := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
pdfHeader := []byte("%PDF-1.4\n")
|
||||
mp4Header := []byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p'}
|
||||
|
||||
mkPayload := func(header []byte, size int) []byte {
|
||||
out := make([]byte, size)
|
||||
copy(out, header)
|
||||
for i := len(header); i < len(out); i++ {
|
||||
out[i] = byte(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
baseJPEG := mkPayload(jpegHeader, 65536)
|
||||
basePNG := mkPayload(pngHeader, 65536)
|
||||
basePDF := mkPayload(pdfHeader, 65536)
|
||||
baseMP4 := mkPayload(mp4Header, 65536)
|
||||
|
||||
entries := []struct {
|
||||
name string
|
||||
typ string
|
||||
data []byte
|
||||
}{
|
||||
{"exact-a.jpg", "image/jpeg", baseJPEG},
|
||||
{"exact-b.jpg", "image/jpeg", baseJPEG},
|
||||
{"renamed-copy.jpeg", "image/jpeg", baseJPEG},
|
||||
{"user_exact-a.jpg", "image/jpeg", baseJPEG},
|
||||
{"png-a.png", "image/png", basePNG},
|
||||
{"png-b.png", "image/png", basePNG},
|
||||
{"doc-a.pdf", "application/pdf", basePDF},
|
||||
{"doc-b.pdf", "application/pdf", basePDF},
|
||||
{"clip-a.mp4", "video/mp4", baseMP4},
|
||||
{"clip-b.mp4", "video/mp4", baseMP4},
|
||||
{"sized-a.bin", "application/octet-stream", baseJPEG},
|
||||
{"sized-b.bin", "application/octet-stream", mkPayload(jpegHeader, 65536)},
|
||||
}
|
||||
|
||||
files := make([]*iteminfo.FileInfo, 0, len(entries)+50)
|
||||
for _, e := range entries {
|
||||
write(e.name, e.data, false)
|
||||
files = append(files, &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: e.name, Size: int64(len(e.data)), ModTime: now, Type: e.typ},
|
||||
Path: "/" + e.name,
|
||||
})
|
||||
}
|
||||
write("tail-b.jpg", baseJPEG, true)
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
name := fmt.Sprintf("fuzzy_photo_%d.jpg", i)
|
||||
write(name, baseJPEG, false)
|
||||
files = append(files, &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: name, Size: int64(len(baseJPEG)), ModTime: now, Type: "image/jpeg"},
|
||||
Path: "/" + name,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
name := fmt.Sprintf("bucket_%04d.jpg", i)
|
||||
write(name, mkPayload(jpegHeader, 65536), i%7 == 0)
|
||||
files = append(files, &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: name, Size: 65536, ModTime: now, Type: "image/jpeg"},
|
||||
Path: "/" + name,
|
||||
})
|
||||
}
|
||||
|
||||
return &duplicateCorpus{dir: dir, files: files}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateTypeBuckets(b *testing.B) {
|
||||
corpus := buildDuplicateCorpus(b)
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = groupFilesByTypeBucket(corpus.files)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateFilenameGroups(b *testing.B) {
|
||||
corpus := buildDuplicateCorpus(b)
|
||||
subset := corpus.files[len(corpus.files)-30:]
|
||||
ctx := context.Background()
|
||||
stats := &duplicateProcessingStats{startTime: time.Now()}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = groupFilesByFilename(ctx, subset, 65536, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateChecksumGrouping(b *testing.B) {
|
||||
corpus := buildDuplicateCorpus(b)
|
||||
pair := []*iteminfo.FileInfo{corpus.files[0], corpus.files[1]}
|
||||
idx := &indexing.Index{Path: corpus.dir}
|
||||
ctx := context.Background()
|
||||
stats := &duplicateProcessingStats{startTime: time.Now(), uniqueChecksums: map[string]bool{}}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = groupFilesByChecksum(ctx, pair, idx, pair[0].Size, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateMerge(b *testing.B) {
|
||||
mkFiles := func(n int, prefix string) []*indexing.SearchResult {
|
||||
out := make([]*indexing.SearchResult, n)
|
||||
for i := range out {
|
||||
out[i] = &indexing.SearchResult{Path: fmt.Sprintf("/%s/%d", prefix, i)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
groups := []duplicateGroupWithChecksums{
|
||||
{Size: 100, Checksum: "d1", Files: mkFiles(4, "a")},
|
||||
{Size: 100, Checksum: "d1", Files: mkFiles(3, "b")},
|
||||
{Size: 200, Checksum: "d2", Files: mkFiles(2, "c")},
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = mergeGroupsByChecksum(groups)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// typeBucketKey returns the MIME/extension bucket used inside a fixed-size group.
|
||||
// When the MIME type already implies a single on-disk format, extension is omitted
|
||||
// as a redundant pre-filter. Otherwise a normalized lowercase extension is appended.
|
||||
//
|
||||
// JPEG aliases: ".jpg", ".jpeg", and ".jpe" normalize to "jpg" so differently named
|
||||
// JPEGs stay in one bucket without splitting the MIME-implied image/jpeg group.
|
||||
func typeBucketKey(file *iteminfo.FileInfo) string {
|
||||
mime := file.Type
|
||||
if mimeImpliesExtension(mime) {
|
||||
return mime
|
||||
}
|
||||
return mime + "\x1f" + normalizedFileExtension(file.Name)
|
||||
}
|
||||
|
||||
func mimeImpliesExtension(mime string) bool {
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff",
|
||||
"video/mp4", "video/webm", "video/quicktime", "video/x-msvideo",
|
||||
"audio/mpeg", "audio/wav", "audio/flac", "audio/ogg",
|
||||
"application/pdf", "application/zip", "application/gzip",
|
||||
"text/plain", "text/html", "text/css", "application/json":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizedFileExtension returns a lowercase extension without the dot, folding JPEG aliases to "jpg".
|
||||
func normalizedFileExtension(name string) string {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
switch ext {
|
||||
case "jpeg", "jpe":
|
||||
return "jpg"
|
||||
default:
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
func groupFilesByTypeBucket(files []*iteminfo.FileInfo) map[string][]*iteminfo.FileInfo {
|
||||
grouped := make(map[string][]*iteminfo.FileInfo)
|
||||
for _, file := range files {
|
||||
key := typeBucketKey(file)
|
||||
grouped[key] = append(grouped[key], file)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestTypeBucketKeyJPEGAliases(t *testing.T) {
|
||||
jpg := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "a.jpg", Type: "image/jpeg"}}
|
||||
jpeg := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "b.jpeg", Type: "image/jpeg"}}
|
||||
if typeBucketKey(jpg) != typeBucketKey(jpeg) {
|
||||
t.Fatalf("jpg and jpeg should share bucket: %q vs %q", typeBucketKey(jpg), typeBucketKey(jpeg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeBucketKeyAmbiguousMIMEUsesExtension(t *testing.T) {
|
||||
a := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "a.bin", Type: "application/octet-stream"}}
|
||||
b := &iteminfo.FileInfo{ItemInfo: iteminfo.ItemInfo{Name: "b.dat", Type: "application/octet-stream"}}
|
||||
if typeBucketKey(a) == typeBucketKey(b) {
|
||||
t.Fatal("different extensions should not share bucket for ambiguous MIME")
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChecksumCacheRejectsExpiredDigest(t *testing.T) {
|
||||
key := checksumCacheKey{
|
||||
sourcePath: "/src",
|
||||
filePath: "/file-a",
|
||||
size: 4096,
|
||||
modTime: 42,
|
||||
kind: checksumKindHeader,
|
||||
}
|
||||
encoded := checksumCacheKeyString(key)
|
||||
checksumTypedCache.SetWithExp(encoded, "digest", time.Millisecond)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if _, ok := getChecksumFromCache(key); ok {
|
||||
t.Fatal("expired digest should not be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumCacheMissAfterFileIdentityChange(t *testing.T) {
|
||||
modA := checksumCacheKey{
|
||||
sourcePath: "/src",
|
||||
filePath: "/file-a",
|
||||
size: 4096,
|
||||
modTime: 100,
|
||||
kind: checksumKindHeader,
|
||||
}
|
||||
modB := modA
|
||||
modB.modTime = 200
|
||||
|
||||
checksumTypedCache.Set(checksumCacheKeyString(modA), "digest-a")
|
||||
if _, ok := getChecksumFromCache(modB); ok {
|
||||
t.Fatal("modified file identity must not hit the prior cache key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDuplicateResponseSkipsCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cacheKey := "cancel-test:" + time.Now().Format(time.RFC3339Nano)
|
||||
cacheDuplicateResponse(ctx, cacheKey, duplicateResponse{
|
||||
Groups: []duplicateGroup{{Size: 1, Count: 2}},
|
||||
})
|
||||
if _, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
t.Fatal("canceled context must not cache duplicate results")
|
||||
}
|
||||
|
||||
cacheDuplicateResponse(context.Background(), cacheKey, duplicateResponse{
|
||||
Groups: []duplicateGroup{{Size: 2, Count: 2}},
|
||||
})
|
||||
if _, ok := duplicateResultsCache.Get(cacheKey); !ok {
|
||||
t.Fatal("active context should cache duplicate results")
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
checksumSampleSize = 8192
|
||||
checksumSmallFile = 16384
|
||||
checksumCacheTTL = time.Hour
|
||||
)
|
||||
|
||||
type checksumSampleKind uint8
|
||||
|
||||
const (
|
||||
checksumKindHeader checksumSampleKind = iota
|
||||
checksumKindMiddle
|
||||
)
|
||||
|
||||
// checksumCacheKey identifies a cached sample digest for a file snapshot.
|
||||
type checksumCacheKey struct {
|
||||
sourcePath string
|
||||
filePath string
|
||||
size int64
|
||||
modTime int64
|
||||
kind checksumSampleKind
|
||||
}
|
||||
|
||||
var (
|
||||
// checksumTypedCache stores sample digests keyed by string-encoded checksumCacheKey.
|
||||
// The go-cache backend sweeps expired entries on an hourly ticker, so stale keys from
|
||||
// renamed/deleted/modified files are evicted without a lookup — unlike a bare sync.Map
|
||||
// which would grow without bound. Cleanup interval is the second arg (1h, matching TTL).
|
||||
checksumTypedCache = cache.NewCache[string](checksumCacheTTL, checksumCacheTTL)
|
||||
checksumBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, checksumSampleSize)
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type fileSampleDigests struct {
|
||||
header string
|
||||
middle string
|
||||
}
|
||||
|
||||
func duplicateChecksumHasher() hash.Hash {
|
||||
return xxhash.New()
|
||||
}
|
||||
|
||||
func digestHex(h hash.Hash) string {
|
||||
sum := h.Sum(nil)
|
||||
dst := make([]byte, hex.EncodedLen(len(sum)))
|
||||
hex.Encode(dst, sum)
|
||||
return string(dst)
|
||||
}
|
||||
|
||||
// checksumCacheKeyString encodes a typed cache key as a single string so it can
|
||||
// be stored in the TTL-sweeping go-cache. Length prefixes keep fields unambiguous.
|
||||
func checksumCacheKeyString(key checksumCacheKey) string {
|
||||
return fmt.Sprintf("%d|%d|%d|%d|%s|%s",
|
||||
key.kind, key.size, key.modTime, len(key.sourcePath),
|
||||
key.sourcePath, key.filePath)
|
||||
}
|
||||
|
||||
func getChecksumFromCache(key checksumCacheKey) (string, bool) {
|
||||
return checksumTypedCache.Get(checksumCacheKeyString(key))
|
||||
}
|
||||
|
||||
func setChecksumCache(key checksumCacheKey, digest string) {
|
||||
checksumTypedCache.Set(checksumCacheKeyString(key), digest)
|
||||
}
|
||||
|
||||
func readFileSampleDigests(ctx context.Context, sourcePath, filePath string, size int64, modTime time.Time, stats *duplicateProcessingStats) (fileSampleDigests, error) {
|
||||
headerKey := checksumCacheKey{sourcePath: sourcePath, filePath: filePath, size: size, modTime: modTime.UnixNano(), kind: checksumKindHeader}
|
||||
middleKey := checksumCacheKey{sourcePath: sourcePath, filePath: filePath, size: size, modTime: modTime.UnixNano(), kind: checksumKindMiddle}
|
||||
|
||||
var out fileSampleDigests
|
||||
if h, ok := getChecksumFromCache(headerKey); ok {
|
||||
out.header = h
|
||||
}
|
||||
if m, ok := getChecksumFromCache(middleKey); ok {
|
||||
out.middle = m
|
||||
}
|
||||
if out.header != "" && out.middle != "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return fileSampleDigests{}, ctx.Err()
|
||||
}
|
||||
// Only count operations that actually touch disk. Cache hits are O(1) lookups
|
||||
// and must not consume the checksum budget — otherwise a second search over
|
||||
// the same files stalls at the limit without reading a single new byte.
|
||||
// The budget check lives here (post-cache) so cached digests are always served.
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
return fileSampleDigests{}, fmt.Errorf("duplicate search budget exhausted")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fileSampleDigests{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// A disk read is happening — charge it against the checksum budget.
|
||||
stats.checksumOperations++
|
||||
|
||||
raw := checksumBufPool.Get()
|
||||
bufPtr, ok := raw.(*[]byte)
|
||||
if !ok {
|
||||
return fileSampleDigests{}, fmt.Errorf("invalid checksum buffer pool type")
|
||||
}
|
||||
buf := *bufPtr
|
||||
defer checksumBufPool.Put(bufPtr)
|
||||
|
||||
if size <= checksumSmallFile {
|
||||
h := duplicateChecksumHasher()
|
||||
remain := size
|
||||
for remain > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return fileSampleDigests{}, ctx.Err()
|
||||
}
|
||||
toRead := int64(len(buf))
|
||||
if toRead > remain {
|
||||
toRead = remain
|
||||
}
|
||||
n, readErr := file.Read(buf[:toRead])
|
||||
if n > 0 {
|
||||
_, _ = h.Write(buf[:n])
|
||||
remain -= int64(n)
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return fileSampleDigests{}, readErr
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
d := digestHex(h)
|
||||
out.header = d
|
||||
out.middle = d
|
||||
} else if out.header == "" || out.middle == "" {
|
||||
n, readErr := io.ReadFull(file, buf)
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return fileSampleDigests{}, readErr
|
||||
}
|
||||
|
||||
if out.header == "" {
|
||||
h := duplicateChecksumHasher()
|
||||
_, _ = h.Write(buf[:n])
|
||||
out.header = digestHex(h)
|
||||
}
|
||||
if out.middle == "" {
|
||||
middle := duplicateChecksumHasher()
|
||||
_, _ = middle.Write(buf[:n])
|
||||
middleOffset := size / 2
|
||||
if _, err := file.Seek(middleOffset, io.SeekStart); err != nil {
|
||||
return fileSampleDigests{}, err
|
||||
}
|
||||
n, readErr := io.ReadFull(file, buf)
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return fileSampleDigests{}, readErr
|
||||
}
|
||||
_, _ = middle.Write(buf[:n])
|
||||
out.middle = digestHex(middle)
|
||||
}
|
||||
}
|
||||
|
||||
if out.header != "" {
|
||||
setChecksumCache(headerKey, out.header)
|
||||
}
|
||||
if out.middle != "" {
|
||||
setChecksumCache(middleKey, out.middle)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func groupFilesByChecksum(ctx context.Context, files []*iteminfo.FileInfo, index *indexing.Index, fileSize int64, stats *duplicateProcessingStats) []checksumGroup {
|
||||
if len(files) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
headerGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
digestByPath := make(map[string]fileSampleDigests, len(files))
|
||||
|
||||
for _, file := range files {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
// Budget checks live inside readFileSampleDigests (after the cache lookup)
|
||||
// so cached digests still count toward grouping without consuming budget.
|
||||
// Checking here would stall on cached files and look stuck.
|
||||
|
||||
if file.Size != fileSize {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(index.Path, file.Path)
|
||||
digests, err := readFileSampleDigests(ctx, index.Path, filePath, fileSize, file.ModTime, stats)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digestByPath[file.Path] = digests
|
||||
stats.uniqueChecksums[digests.header] = true
|
||||
headerGroups[digests.header] = append(headerGroups[digests.header], file)
|
||||
}
|
||||
|
||||
headerCandidates := make([][]*iteminfo.FileInfo, 0)
|
||||
for _, group := range headerGroups {
|
||||
if len(group) >= 2 {
|
||||
headerCandidates = append(headerCandidates, group)
|
||||
}
|
||||
}
|
||||
if len(headerCandidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
middleGroups := make(map[string][]*iteminfo.FileInfo)
|
||||
for _, headerGroup := range headerCandidates {
|
||||
for _, file := range headerGroup {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
break
|
||||
}
|
||||
digests := digestByPath[file.Path]
|
||||
if digests.middle == "" {
|
||||
continue
|
||||
}
|
||||
stats.uniqueChecksums[digests.middle] = true
|
||||
middleGroups[digests.middle] = append(middleGroups[digests.middle], file)
|
||||
}
|
||||
}
|
||||
|
||||
groups := make([]checksumGroup, 0, len(middleGroups))
|
||||
for checksum, group := range middleGroups {
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, checksumGroup{
|
||||
Files: group,
|
||||
Checksum: checksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
func TestReadFileSampleDigestsSmallFileUsesWholeFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := make([]byte, 12_000)
|
||||
path := filepath.Join(dir, "small.bin")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := &duplicateProcessingStats{startTime: time.Now()}
|
||||
d, err := readFileSampleDigests(context.Background(), dir, path, int64(len(data)), time.Now(), stats)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.header != d.middle {
|
||||
t.Fatalf("small file header and middle digest should match: %q %q", d.header, d.middle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasherUsesXXHash(t *testing.T) {
|
||||
if _, ok := any(duplicateChecksumHasher()).(*xxhash.Digest); !ok {
|
||||
t.Fatal("expected xxhash digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedDigestsDoNotConsumeChecksumBudget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataA := make([]byte, 65536)
|
||||
for i := range dataA {
|
||||
dataA[i] = byte(i % 251)
|
||||
}
|
||||
// Use a distinct payload so its digests differ from other tests' files.
|
||||
pathA := filepath.Join(dir, "budget-a.bin")
|
||||
pathB := filepath.Join(dir, "budget-b.bin")
|
||||
if err := os.WriteFile(pathA, dataA, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pathB, dataA, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modTime := time.Date(2026, 11, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Prime the cache with one op-worth of budget.
|
||||
prime := &duplicateProcessingStats{startTime: time.Now(), uniqueChecksums: map[string]bool{}}
|
||||
d1, err := readFileSampleDigests(context.Background(), dir, pathA, int64(len(dataA)), modTime, prime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d2, err := readFileSampleDigests(context.Background(), dir, pathB, int64(len(dataA)), modTime, prime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d1.header != d2.header || d1.middle != d2.middle {
|
||||
t.Fatal("expected equal digests for identical files")
|
||||
}
|
||||
if prime.checksumOperations != 2 {
|
||||
t.Fatalf("expected 2 disk operations for two cache-miss reads, got %d", prime.checksumOperations)
|
||||
}
|
||||
|
||||
// Exhaust the budget: any further uncached file should be refused.
|
||||
prime.checksumOperations = maxChecksumOperations
|
||||
uncached := filepath.Join(dir, "budget-c.bin")
|
||||
if err = os.WriteFile(uncached, dataA, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = readFileSampleDigests(context.Background(), dir, uncached, int64(len(dataA)), modTime, prime); err == nil {
|
||||
t.Fatal("expected budget-exhausted error for uncached file")
|
||||
}
|
||||
|
||||
// But the cached files must still be served, for free.
|
||||
cached, err := readFileSampleDigests(context.Background(), dir, pathA, int64(len(dataA)), modTime, prime)
|
||||
if err != nil {
|
||||
t.Fatalf("cached digest should be served despite exhausted budget: %v", err)
|
||||
}
|
||||
if cached.header != d1.header || cached.middle != d1.middle {
|
||||
t.Fatal("cached digest mismatch")
|
||||
}
|
||||
if prime.checksumOperations != maxChecksumOperations {
|
||||
t.Fatalf("cached digest must not consume budget, got %d", prime.checksumOperations)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDuplicateHashers(b *testing.B) {
|
||||
data := make([]byte, checksumSampleSize)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
b.Run("md5", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
h := md5.New()
|
||||
_, _ = h.Write(data)
|
||||
hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
})
|
||||
b.Run("xxhash", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
h := xxhash.New()
|
||||
_, _ = h.Write(data)
|
||||
hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
type mergeGroupKey struct {
|
||||
size int64
|
||||
digest string
|
||||
}
|
||||
|
||||
// mergeGroupsByChecksum merges groups that share the same size and sample digest.
|
||||
func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGroupWithChecksums {
|
||||
if len(groups) <= 1 {
|
||||
return groups
|
||||
}
|
||||
|
||||
keyToGroups := make(map[mergeGroupKey][]int)
|
||||
for i, group := range groups {
|
||||
if group.Checksum == "" {
|
||||
continue
|
||||
}
|
||||
key := mergeGroupKey{size: group.Size, digest: group.Checksum}
|
||||
keyToGroups[key] = append(keyToGroups[key], i)
|
||||
}
|
||||
|
||||
parent := make([]int, len(groups))
|
||||
for i := range parent {
|
||||
parent[i] = i
|
||||
}
|
||||
|
||||
var find func(int) int
|
||||
find = func(x int) int {
|
||||
if parent[x] != x {
|
||||
parent[x] = find(parent[x])
|
||||
}
|
||||
return parent[x]
|
||||
}
|
||||
|
||||
union := func(x, y int) {
|
||||
px, py := find(x), find(y)
|
||||
if px != py {
|
||||
parent[px] = py
|
||||
}
|
||||
}
|
||||
|
||||
for _, indices := range keyToGroups {
|
||||
for i := 1; i < len(indices); i++ {
|
||||
union(indices[0], indices[i])
|
||||
}
|
||||
}
|
||||
|
||||
groupsByRoot := make(map[int][]int)
|
||||
for i := range groups {
|
||||
root := find(i)
|
||||
groupsByRoot[root] = append(groupsByRoot[root], i)
|
||||
}
|
||||
|
||||
merged := make([]duplicateGroupWithChecksums, 0, len(groupsByRoot))
|
||||
for _, indices := range groupsByRoot {
|
||||
if len(indices) == 1 {
|
||||
merged = append(merged, groups[indices[0]])
|
||||
continue
|
||||
}
|
||||
|
||||
combinedFiles := make([]*indexing.SearchResult, 0)
|
||||
var combinedSize int64
|
||||
var combinedChecksum string
|
||||
|
||||
for _, idx := range indices {
|
||||
group := groups[idx]
|
||||
combinedFiles = append(combinedFiles, group.Files...)
|
||||
if combinedChecksum == "" {
|
||||
combinedChecksum = group.Checksum
|
||||
}
|
||||
if combinedSize == 0 {
|
||||
combinedSize = group.Size
|
||||
}
|
||||
}
|
||||
|
||||
if len(combinedFiles) >= 2 {
|
||||
slices.SortFunc(combinedFiles, func(a, b *indexing.SearchResult) int {
|
||||
return strings.Compare(a.Path, b.Path)
|
||||
})
|
||||
merged = append(merged, duplicateGroupWithChecksums{
|
||||
Size: combinedSize,
|
||||
Files: combinedFiles,
|
||||
Checksum: combinedChecksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(merged, func(a, b duplicateGroupWithChecksums) int {
|
||||
if c := cmp.Compare(b.Size, a.Size); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(a.Checksum, b.Checksum); c != 0 {
|
||||
return c
|
||||
}
|
||||
if len(a.Files) == 0 || len(b.Files) == 0 {
|
||||
return 0
|
||||
}
|
||||
return strings.Compare(a.Files[0].Path, b.Files[0].Path)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
func TestMergeGroupsByChecksumUsesSizeAndDigest(t *testing.T) {
|
||||
groups := []duplicateGroupWithChecksums{
|
||||
{Size: 100, Checksum: "aaaa", Files: []*indexing.SearchResult{{Path: "/a"}, {Path: "/b"}}},
|
||||
{Size: 200, Checksum: "aaaa", Files: []*indexing.SearchResult{{Path: "/c"}, {Path: "/d"}}},
|
||||
{Size: 100, Checksum: "aaaa", Files: []*indexing.SearchResult{{Path: "/e"}, {Path: "/f"}}},
|
||||
}
|
||||
merged := mergeGroupsByChecksum(groups)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("expected 2 merged groups, got %d", len(merged))
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
const filenameSimilarityThreshold = 0.5
|
||||
|
||||
type levenshteinRowBuf struct {
|
||||
prev, cur []int
|
||||
}
|
||||
|
||||
func (b *levenshteinRowBuf) ensure(width int) {
|
||||
if cap(b.prev) < width+1 {
|
||||
b.prev = make([]int, width+1)
|
||||
b.cur = make([]int, width+1)
|
||||
} else {
|
||||
b.prev = b.prev[:width+1]
|
||||
b.cur = b.cur[:width+1]
|
||||
}
|
||||
}
|
||||
|
||||
func maxEditDistanceForSimilarity(maxLen int) int {
|
||||
return maxLen / 2
|
||||
}
|
||||
|
||||
func filenameSimilarWithBudget(name1, name2 string, buf *levenshteinRowBuf) bool {
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
maxLen := max(len(name1), len(name2))
|
||||
maxDist := maxEditDistanceForSimilarity(maxLen)
|
||||
if abs(len(name1)-len(name2)) > maxDist {
|
||||
return false
|
||||
}
|
||||
|
||||
distance := boundedLevenshtein(name1, name2, maxDist, buf)
|
||||
if distance < 0 {
|
||||
return false
|
||||
}
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= filenameSimilarityThreshold
|
||||
}
|
||||
|
||||
func boundedLevenshtein(s1, s2 string, maxDist int, buf *levenshteinRowBuf) int {
|
||||
n, m := len(s1), len(s2)
|
||||
if n == 0 {
|
||||
if m <= maxDist {
|
||||
return m
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if m == 0 {
|
||||
if n <= maxDist {
|
||||
return n
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
buf.ensure(m)
|
||||
prev, cur := buf.prev, buf.cur
|
||||
|
||||
for j := 0; j <= m; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= n; i++ {
|
||||
cur[0] = i
|
||||
rowMin := cur[0]
|
||||
for j := 1; j <= m; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
del := prev[j] + 1
|
||||
ins := cur[j-1] + 1
|
||||
sub := prev[j-1] + cost
|
||||
v := del
|
||||
if ins < v {
|
||||
v = ins
|
||||
}
|
||||
if sub < v {
|
||||
v = sub
|
||||
}
|
||||
cur[j] = v
|
||||
if v < rowMin {
|
||||
rowMin = v
|
||||
}
|
||||
}
|
||||
if rowMin > maxDist {
|
||||
return -1
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
|
||||
if prev[m] > maxDist {
|
||||
return -1
|
||||
}
|
||||
return prev[m]
|
||||
}
|
||||
|
||||
func groupFilesByFilename(ctx context.Context, files []*iteminfo.FileInfo, expectedSize int64, stats *duplicateProcessingStats) [][]*iteminfo.FileInfo {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
if f.Name != "" {
|
||||
normalized[i] = normalizeFilename(f.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var rowBuf levenshteinRowBuf
|
||||
groups := [][]*iteminfo.FileInfo{}
|
||||
used := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(files); i++ {
|
||||
if ctx.Err() != nil {
|
||||
return groups
|
||||
}
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
return groups
|
||||
}
|
||||
|
||||
if used[i] || files[i].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
group := []*iteminfo.FileInfo{files[i]}
|
||||
used[i] = true
|
||||
filename1 := normalized[i]
|
||||
oversized := false
|
||||
|
||||
for j := i + 1; j < len(files); j++ {
|
||||
if ctx.Err() != nil {
|
||||
return groups
|
||||
}
|
||||
if shouldStop, _ := stats.shouldStop(); shouldStop {
|
||||
return groups
|
||||
}
|
||||
|
||||
if used[j] || files[j].Name == "" {
|
||||
continue
|
||||
}
|
||||
if files[j].Size != expectedSize {
|
||||
continue
|
||||
}
|
||||
|
||||
if filenameSimilarWithBudget(filename1, normalized[j], &rowBuf) {
|
||||
group = append(group, files[j])
|
||||
used[j] = true
|
||||
if len(group) > maxFuzzyGroupSize {
|
||||
oversized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) >= 2 && !oversized {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func normalizeFilename(filename string) string {
|
||||
filename = strings.ToLower(filename)
|
||||
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
||||
filename = filename[:idx]
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, r := range filename {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/quick"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func legacyFilenamesSimilar(name1, name2 string) bool {
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
distance := legacyLevenshtein(name1, name2)
|
||||
maxLen := max(len(name1), len(name2))
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= 0.5
|
||||
}
|
||||
|
||||
func legacyLevenshtein(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
}
|
||||
for i := 0; i <= len(s1); i++ {
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
del := matrix[i-1][j] + 1
|
||||
ins := matrix[i][j-1] + 1
|
||||
sub := matrix[i-1][j-1] + cost
|
||||
v := del
|
||||
if ins < v {
|
||||
v = ins
|
||||
}
|
||||
if sub < v {
|
||||
v = sub
|
||||
}
|
||||
matrix[i][j] = v
|
||||
}
|
||||
}
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
var filenameCorpus = []string{
|
||||
"photo", "photo_1", "photo-copy", "IMG_0042", "img 0042",
|
||||
"vacation2023beach", "vacation2023_beach", "user_video", "video",
|
||||
"document-final", "document_final_v2", "a", "ab", "abc", "abcd",
|
||||
"report Q1 2024", "report-q1-2024", "my@file#name", "myfilename",
|
||||
"summer-holiday", "summer holiday", "winter-holiday",
|
||||
}
|
||||
|
||||
func TestFilenameSimilarMatchesLegacyOnCorpus(t *testing.T) {
|
||||
names := append([]string{}, filenameCorpus...)
|
||||
for i := 0; i < 50; i++ {
|
||||
names = append(names, randomNormalizedName(5+corpusRand.Intn(40)))
|
||||
}
|
||||
|
||||
var buf levenshteinRowBuf
|
||||
for _, a := range names {
|
||||
a = normalizeFilename(a + ".jpg")
|
||||
for _, b := range names {
|
||||
b = normalizeFilename(b + ".png")
|
||||
if legacyFilenamesSimilar(a, b) != filenameSimilarWithBudget(a, b, &buf) {
|
||||
t.Fatalf("mismatch %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameSimilarMatchesLegacyQuick(t *testing.T) {
|
||||
var buf levenshteinRowBuf
|
||||
if err := quick.Check(func(a, b string) bool {
|
||||
a = trimQuickString(a, 48)
|
||||
b = trimQuickString(b, 48)
|
||||
return legacyFilenamesSimilar(a, b) == filenameSimilarWithBudget(a, b, &buf)
|
||||
}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func trimQuickString(s string, max int) string {
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(b.String()))
|
||||
}
|
||||
|
||||
func randomNormalizedName(n int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789 -"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = alphabet[corpusRand.Intn(len(alphabet))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestGroupFilesByFilenameOversizedExcluded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
stats := &duplicateProcessingStats{startTime: time.Now()}
|
||||
files := make([]*iteminfo.FileInfo, 12)
|
||||
for i := range files {
|
||||
files[i] = &iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "photo.jpg", Size: 100},
|
||||
}
|
||||
}
|
||||
if len(groupFilesByFilename(ctx, files, 100, stats)) != 0 {
|
||||
t.Fatal("expected no groups for oversized cluster")
|
||||
}
|
||||
}
|
||||
|
||||
var corpusRand = rand.New(rand.NewSource(42))
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
@@ -20,13 +19,11 @@ func withMediaHandlerSettings(t *testing.T) {
|
||||
settings.Env.FFmpegAvailable = originalFFmpeg
|
||||
})
|
||||
settings.Env.FFmpegAvailable = true
|
||||
settings.Config.Integrations.Media.Transcode = settings.FullAdminTranscode{
|
||||
UserTranscode: settings.UserTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrentPerUser: 2,
|
||||
},
|
||||
MaxConcurrent: 3,
|
||||
Profiles: settings.DefaultTranscodeProfiles(),
|
||||
settings.Config.Integrations.Media.Transcode = settings.MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 3,
|
||||
MaxConcurrentPerUser: 2,
|
||||
Profiles: settings.DefaultTranscodeProfiles(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,99 +52,52 @@ func callMediaGet(t *testing.T, admin bool) map[string]any {
|
||||
return body
|
||||
}
|
||||
|
||||
// objectField returns obj[key] as a JSON object, failing the test if it is missing or not an object.
|
||||
func objectField(t *testing.T, obj map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
child, ok := obj[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s is %T, want object", key, obj[key])
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
// keysOf returns the sorted keys of a JSON object.
|
||||
func keysOf(obj map[string]any) []string {
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, path string, got []string, want []string) {
|
||||
t.Helper()
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s keys = %v, want %v", path, got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s keys = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-admin users receive exactly the PUBLIC payload. Any new key fails this test,
|
||||
// which forces a conscious decision about whether it belongs in UserMedia.
|
||||
// Non-admin users must be able to read the effective media values for the
|
||||
// player, but only the player-relevant subset.
|
||||
func TestSettingsMediaHandlerPlayerPayloadForNonAdmin(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
|
||||
body := callMediaGet(t, false)
|
||||
assertKeys(t, "top-level", keysOf(body), []string{"transcode", "imagePreview"})
|
||||
transcode := objectField(t, body, "transcode")
|
||||
assertKeys(t, "transcode", keysOf(transcode), []string{"enabled", "maxConcurrentPerUser", "enabledProfiles"})
|
||||
|
||||
if enabled, ok := transcode["enabled"].(bool); !ok || !enabled {
|
||||
t.Fatalf("transcode.enabled = %v, want true", transcode["enabled"])
|
||||
if _, ok := body["transcode"]; ok {
|
||||
t.Fatal("non-admin must not receive the full transcode settings")
|
||||
}
|
||||
if limit, ok := transcode["maxConcurrentPerUser"].(float64); !ok || limit != 2 {
|
||||
t.Fatalf("transcode.maxConcurrentPerUser = %v, want 2", transcode["maxConcurrentPerUser"])
|
||||
if _, ok := body["lockedFromConfigPaths"]; ok {
|
||||
t.Fatal("non-admin must not receive config lock metadata")
|
||||
}
|
||||
profiles, ok := transcode["enabledProfiles"].([]any)
|
||||
if _, ok := body["lockMessage"]; ok {
|
||||
t.Fatal("non-admin must not receive the config lock message")
|
||||
}
|
||||
if enabled, ok := body["enabled"].(bool); !ok || !enabled {
|
||||
t.Fatalf("enabled = %v, want true", body["enabled"])
|
||||
}
|
||||
if limit, ok := body["maxConcurrentPerUser"].(float64); !ok || limit != 2 {
|
||||
t.Fatalf("maxConcurrentPerUser = %v, want 2", body["maxConcurrentPerUser"])
|
||||
}
|
||||
profiles, ok := body["profiles"].([]any)
|
||||
if !ok || len(profiles) != 3 {
|
||||
t.Fatalf("transcode.enabledProfiles = %v, want 3 enabled ids", transcode["enabledProfiles"])
|
||||
t.Fatalf("profiles = %v, want 3 enabled ids", body["profiles"])
|
||||
}
|
||||
}
|
||||
|
||||
// Admins receive the full stored config. The keys shared with users must have the
|
||||
// same names, so the admin payload is a strict superset of the user payload.
|
||||
func TestSettingsMediaHandlerFullPayloadForAdmin(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
|
||||
admin := callMediaGet(t, true)
|
||||
user := callMediaGet(t, false)
|
||||
|
||||
// lockedFromConfigPaths is omitted when config pins nothing, so it is not required here.
|
||||
for _, key := range []string{"transcode", "imagePreview", "convert", "debug", "hardwareAcceleration", "accelDevice"} {
|
||||
if _, ok := admin[key]; !ok {
|
||||
body := callMediaGet(t, true)
|
||||
transcode, ok := body["transcode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("admin must receive the full transcode settings, got %T", body["transcode"])
|
||||
}
|
||||
if _, ok := transcode["profiles"].(map[string]any); !ok {
|
||||
t.Fatalf("admin transcode payload must include profiles, got %v", transcode["profiles"])
|
||||
}
|
||||
// Admin payload exposes the full settings surface, not the player subset.
|
||||
for _, key := range []string{"convert", "debug", "hardwareAcceleration"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Fatalf("admin payload missing %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := admin["enabled"]; ok {
|
||||
t.Fatal("admin payload must not repeat transcode.enabled at the top level")
|
||||
}
|
||||
|
||||
adminTranscode := objectField(t, admin, "transcode")
|
||||
for _, key := range []string{"maxConcurrent", "profiles", "cacheMaxSizeMB", "encoderBackend", "readRate"} {
|
||||
if _, ok := adminTranscode[key]; !ok {
|
||||
t.Fatalf("admin transcode missing %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := adminTranscode["profiles"].(map[string]any); !ok {
|
||||
t.Fatalf("admin transcode.profiles must be an object, got %T", adminTranscode["profiles"])
|
||||
}
|
||||
|
||||
// Superset rule: every key a user receives must also exist for admins. Values may
|
||||
// differ by design: admins see stored config (so the UI can save it back unchanged),
|
||||
// users see effective values (for example enabled is false when ffmpeg is missing).
|
||||
for key := range objectField(t, user, "transcode") {
|
||||
if _, ok := adminTranscode[key]; !ok {
|
||||
t.Fatalf("admin transcode missing shared key %q", key)
|
||||
}
|
||||
}
|
||||
if admin["imagePreview"] == nil {
|
||||
t.Fatal("imagePreview must be present for admins")
|
||||
if _, ok := body["enabled"]; ok {
|
||||
t.Fatal("the admin payload uses the full settings shape, not the player subset")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +106,8 @@ func TestSettingsMediaHandlerReflectsDisabledTranscode(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
settings.Config.Integrations.Media.Transcode.Enabled = false
|
||||
|
||||
transcode := objectField(t, callMediaGet(t, false), "transcode")
|
||||
if enabled, ok := transcode["enabled"].(bool); !ok || enabled {
|
||||
t.Fatalf("transcode.enabled = %v, want false", transcode["enabled"])
|
||||
body := callMediaGet(t, false)
|
||||
if enabled, ok := body["enabled"].(bool); !ok || enabled {
|
||||
t.Fatalf("enabled = %v, want false", body["enabled"])
|
||||
}
|
||||
}
|
||||
@@ -166,11 +166,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
}
|
||||
providedPath = cleanPath
|
||||
|
||||
themeMode := "light"
|
||||
if d.User.DarkMode == users.DarkModeDark ||
|
||||
(d.User.DarkMode == users.DarkModeAuto && settings.DefaultDarkMode()) {
|
||||
themeMode = "dark"
|
||||
}
|
||||
themeMode := utils.Ternary(d.User.DarkMode, "dark", "light")
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if hash != "" {
|
||||
|
||||
@@ -453,17 +453,33 @@ func settingsShareDefaultsPatchHandler(w http.ResponseWriter, r *http.Request, d
|
||||
return settingsShareDefaultsGetHandler(w, r, d)
|
||||
}
|
||||
|
||||
// mediaSettingsResponse is the ADMIN ONLY media payload: the full stored settings
|
||||
// (FullAdminMedia, which embeds the public UserTranscode fields) plus config-lock
|
||||
// metadata and ffmpeg option lists. Non-admins never receive this type.
|
||||
// settingsMediaGetHandler returns the runtime media settings.
|
||||
// @Summary Get runtime media settings
|
||||
// @Description Admins receive the DB-backed media integration settings; every authenticated user
|
||||
// @Description receives the player-relevant subset (transcode enabled, profiles, per-user limit).
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} mediaSettingsResponse "Media settings"
|
||||
// @Router /api/settings/media [get]
|
||||
// mediaSettingsResponse is the runtime media settings plus config-lock metadata.
|
||||
type mediaSettingsResponse struct {
|
||||
settings.FullAdminMedia
|
||||
settings.MediaSettings
|
||||
LockedFromConfigPaths []string `json:"lockedFromConfigPaths,omitempty"`
|
||||
LockMessage string `json:"lockMessage,omitempty"`
|
||||
AccelDeviceOptions []ffmpeg.AccelDeviceOption `json:"accelDeviceOptions,omitempty"`
|
||||
EncoderBackendOptions []ffmpeg.EncoderBackendOption `json:"encoderBackendOptions,omitempty"`
|
||||
}
|
||||
|
||||
// mediaPlayerSettings is the player-relevant subset of the runtime media
|
||||
// settings. It is safe to expose to any authenticated user so the player can
|
||||
// reflect admin changes without a page reload.
|
||||
type mediaPlayerSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Profiles []string `json:"profiles"`
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"`
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"`
|
||||
}
|
||||
|
||||
func mediaSettingsLockMeta() (paths []string, message string) {
|
||||
paths = settings.ConfigSpecifiedMediaPaths()
|
||||
if len(paths) == 0 {
|
||||
@@ -475,25 +491,12 @@ func mediaSettingsLockMeta() (paths []string, message string) {
|
||||
// The media settings APIs no longer require ffmpeg. go-media serves probing,
|
||||
// metadata, subtitles, and video thumbnails without it. ffmpeg-only controls
|
||||
// (transcoding, hardware acceleration) report their own availability.
|
||||
// @Summary Get runtime media settings
|
||||
// @Description Returns the media settings the caller is allowed to see.
|
||||
// @Description Admins get the full settings, including ffmpeg and hardware options, so they can edit them.
|
||||
// @Description Everyone else gets a small read-only view: whether transcoding is on, how many
|
||||
// @Description transcodes they can run at once, which quality presets are enabled, and which image
|
||||
// @Description previews the server can generate. Keys in the view are the same for admins and users.
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} mediaSettingsResponse "Media settings"
|
||||
// @Router /api/settings/media [get]
|
||||
func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
// PUBLIC payload: non-admins get only what the player and general UI read.
|
||||
if !d.User.Permissions.Admin {
|
||||
return RenderJSON(w, r, settings.CurrentUserMedia())
|
||||
return RenderJSON(w, r, currentMediaPlayerSettings())
|
||||
}
|
||||
// ADMIN payload: full stored settings with effective and derived values filled in
|
||||
// by PrepMediaForFrontend (a read-only copy; never saved).
|
||||
media := settings.PrepMediaForFrontend(state.GetMediaSettings())
|
||||
lockedPaths, lockMessage := mediaSettingsLockMeta()
|
||||
media := state.GetMediaSettings()
|
||||
svc := ffmpeg.Get()
|
||||
var accelDeviceOptions []ffmpeg.AccelDeviceOption
|
||||
var encoderBackendOptions []ffmpeg.EncoderBackendOption
|
||||
@@ -503,7 +506,7 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
|
||||
encoderBackendOptions = svc.EncoderBackendOptions()
|
||||
}
|
||||
return RenderJSON(w, r, mediaSettingsResponse{
|
||||
FullAdminMedia: media,
|
||||
MediaSettings: media,
|
||||
LockedFromConfigPaths: lockedPaths,
|
||||
LockMessage: lockMessage,
|
||||
AccelDeviceOptions: accelDeviceOptions,
|
||||
@@ -511,6 +514,17 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
|
||||
})
|
||||
}
|
||||
|
||||
// currentMediaPlayerSettings reads the live media settings for the player UI.
|
||||
func currentMediaPlayerSettings() mediaPlayerSettings {
|
||||
media := state.GetMediaSettings()
|
||||
return mediaPlayerSettings{
|
||||
Enabled: settings.TranscodeEnabled(),
|
||||
Profiles: settings.EnabledTranscodeProfiles(),
|
||||
MaxConcurrentPerUser: settings.TranscodeMaxConcurrentPerUser(),
|
||||
HardwareAcceleration: media.HardwareAcceleration,
|
||||
}
|
||||
}
|
||||
|
||||
// settingsMediaPatchHandler merges a partial update into the runtime media settings.
|
||||
// @Summary Update runtime media settings
|
||||
// @Description Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.
|
||||
|
||||
@@ -151,13 +151,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
externalLinks = newExternalLinks
|
||||
}
|
||||
|
||||
// Instance default dark mode: htmlVars/defaultDark use a boolean for first paint;
|
||||
// globalVars.darkMode carries the full preference (including "auto") for the SPA.
|
||||
defaultDark := settings.DefaultDarkMode()
|
||||
defaultThemeColor := settings.Config.Frontend.Styling.LightBackground
|
||||
if defaultDark {
|
||||
defaultThemeColor = settings.Config.Frontend.Styling.DarkBackground
|
||||
}
|
||||
defaultThemeColor := settings.DefaultBackgroundColor()
|
||||
staticURL := settings.Config.Http.BaseURL + "public/static"
|
||||
description := settings.Config.Frontend.Description
|
||||
title := settings.Config.Frontend.Name
|
||||
@@ -257,7 +251,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
|
||||
"userSelectedTheme": template.CSS(userSelectedTheme),
|
||||
"defaultDarkMode": settings.DefaultDarkMode(),
|
||||
"autoDarkMode": settings.DefaultDarkModeIsAuto(),
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
@@ -282,51 +275,57 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// only include constant values that don't change during a users session
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.DefaultDarkModePreference(),
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": settings.Config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !settings.Config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(settings.Config.Http.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": onlyOfficeUrlForSPA(),
|
||||
"oidcAvailable": settings.Config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"passwordMinLength": settings.PasswordMinLength(),
|
||||
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
|
||||
"ffmpegAvailable": settings.FFmpegEnabled(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
"eventBasedThemes": !settings.Config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": settings.Config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"recaptcha": recaptchaEnabled,
|
||||
"recaptchaHost": recaptchaHost,
|
||||
"recaptchaKey": recaptchaKey,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": settings.Config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !settings.Config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(settings.Config.Http.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": onlyOfficeUrlForSPA(),
|
||||
"oidcAvailable": settings.Config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"passwordMinLength": settings.PasswordMinLength(),
|
||||
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
|
||||
"ffmpegAvailable": settings.FFmpegEnabled(),
|
||||
"hardwareAccelerationAvailable": settings.HardwareAccelerationSupported(),
|
||||
"transcodeEnabled": settings.TranscodeEnabled(),
|
||||
"transcodeProfiles": settings.EnabledTranscodeProfiles(),
|
||||
"transcodeMaxPerUser": settings.TranscodeMaxConcurrentPerUser(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": settings.CanConvertImage("heic"),
|
||||
"enableJxlConversion": settings.CanConvertImage("jxl"),
|
||||
"eventBasedThemes": !settings.Config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": settings.Config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"recaptcha": recaptchaEnabled,
|
||||
"recaptchaHost": recaptchaHost,
|
||||
"recaptchaKey": recaptchaKey,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
}
|
||||
|
||||
cspNonce, err := utils.CSPNonce()
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestTranscodeManagerIsWiredWithoutFFmpeg(t *testing.T) {
|
||||
// Startup with ffmpeg missing and transcoding disabled.
|
||||
settings.Env.FFmpegAvailable = false
|
||||
disabled := originalMedia
|
||||
disabled.Transcode = settings.FullAdminTranscode{UserTranscode: settings.UserTranscode{Enabled: false}}
|
||||
disabled.Transcode = settings.MediaTranscode{Enabled: false}
|
||||
settings.ApplyMediaSettings(disabled)
|
||||
|
||||
setupTestEnv(t)
|
||||
|
||||
@@ -567,7 +567,6 @@ func TextPreviewKind(ext string) string {
|
||||
// Do not add mimetypes shared with binaries (e.g. application/x-msdownload for .exe).
|
||||
var textPreviewMimeTypes = map[string]bool{
|
||||
"application/json": true,
|
||||
"application/rtf": true,
|
||||
"application/yaml": true,
|
||||
"application/x-yaml": true,
|
||||
"application/sql": true,
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestIsTextPreviewable(t *testing.T) {
|
||||
for _, mt := range []string{"text/plain", "text/markdown", "text/lyrics", "text/x-go", "text/html"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex", "application/rtf", "text/rtf"} {
|
||||
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
// Mimetypes shared with binaries (.exe, .bat via application/x-msdownload) must stay non-previewable.
|
||||
|
||||
@@ -14,6 +14,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
dark := false
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
@@ -63,7 +64,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
DisableOptions: true,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: users.DarkModeLight,
|
||||
DarkMode: &dark,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "Night",
|
||||
Locale: "de",
|
||||
@@ -112,7 +113,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
DisableSearchOptions: true,
|
||||
StickySidebar: false,
|
||||
HideFilesInTree: true,
|
||||
DarkMode: users.DarkModeLight,
|
||||
DarkMode: false,
|
||||
Locale: "de",
|
||||
ViewMode: "list",
|
||||
SingleClick: true,
|
||||
|
||||
@@ -428,7 +428,7 @@ const (
|
||||
// clampTranscodeReadRate enforces throttled producers: unset or sub-realtime
|
||||
// read rates are raised, so a producer is never left unthrottled. Burst and
|
||||
// catch-up pacing are left to ffmpeg's defaults.
|
||||
func clampTranscodeReadRate(t *FullAdminTranscode) {
|
||||
func clampTranscodeReadRate(t *MediaTranscode) {
|
||||
if t.ReadRate <= 0 {
|
||||
t.ReadRate = defaultTranscodeReadRate
|
||||
} else if t.ReadRate < minTranscodeReadRate {
|
||||
@@ -769,9 +769,7 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
}
|
||||
|
||||
// Second pass: Decode with strict validation (disallow unknown fields within valid sections)
|
||||
// UseJSONUnmarshaler lets typed fields with custom UnmarshalJSON (e.g. users.DarkMode)
|
||||
// handle YAML scalars like booleans instead of the generic decoder casting them to strings.
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField(), yaml.UseJSONUnmarshaler())
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField())
|
||||
err = decoder.Decode(&Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshaling YAML data: %v", err)
|
||||
@@ -1038,7 +1036,7 @@ func SetDefaults(generate bool) Settings {
|
||||
DisableOptions: false,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: boolPtr(true),
|
||||
ThemeColor: "var(--blue)",
|
||||
CustomTheme: "",
|
||||
Locale: "en",
|
||||
@@ -1075,16 +1073,14 @@ func SetDefaults(generate bool) Settings {
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
s.Integrations.Media.Transcode = FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{
|
||||
Enabled: false,
|
||||
MaxConcurrentPerUser: 1,
|
||||
},
|
||||
MaxConcurrent: 2,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
ReadRate: defaultTranscodeReadRate,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
s.Integrations.Media.Transcode = MediaTranscode{
|
||||
Enabled: false,
|
||||
MaxConcurrent: 2,
|
||||
MaxConcurrentPerUser: 1,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
ReadRate: defaultTranscodeReadRate,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
}
|
||||
// Hardware acceleration is opt-out: "auto" lets go-ffmpeg follow its device
|
||||
// hierarchy (integrated GPU, discrete GPU, then software).
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestYAMLConfig_jwt_organizedUserDefaults(t *testing.T) {
|
||||
if !ud.Account.Permissions.Share {
|
||||
t.Fatal("expected account.permissions.share true")
|
||||
}
|
||||
if ud.UI.DarkMode != users.DarkModeDark {
|
||||
t.Fatalf("expected ui.darkMode true, got %q", ud.UI.DarkMode)
|
||||
if ud.UI.DarkMode == nil || !*ud.UI.DarkMode {
|
||||
t.Fatal("expected ui.darkMode true")
|
||||
}
|
||||
if ud.Listing.SingleClick {
|
||||
t.Fatal("expected listing.singleClick false")
|
||||
@@ -106,8 +106,8 @@ func TestYAMLConfig_applyUserDefaultsFrom_proxyAndJwt(t *testing.T) {
|
||||
if !u.Permissions.Share {
|
||||
t.Fatal("Share=false after ApplyUserDefaultsFrom")
|
||||
}
|
||||
if u.DarkMode != users.DarkModeDark {
|
||||
t.Fatalf("expected darkMode from ui defaults, got %q", u.DarkMode)
|
||||
if !u.DarkMode {
|
||||
t.Fatal("expected darkMode from ui defaults")
|
||||
}
|
||||
if !u.Preview.Image || !u.Preview.PopUp {
|
||||
t.Fatalf("preview: %+v", u.Preview)
|
||||
@@ -126,7 +126,7 @@ func TestYAMLConfig_profileMerge_matchesApplyUserDefaults(t *testing.T) {
|
||||
fromDefaults := ProfileFromUserDefaults(Config.UserDefaults)
|
||||
u := &users.User{FrontendUser: users.FrontendUser{Username: "merge-check"}}
|
||||
ExpandProfileIntoUser(u, fromDefaults)
|
||||
if !u.DarkMode.IsDark() || !u.Preview.Image {
|
||||
if !u.DarkMode || !u.Preview.Image {
|
||||
t.Fatalf("ExpandProfileIntoUser: darkMode=%v preview.image=%v", u.DarkMode, u.Preview.Image)
|
||||
}
|
||||
u2 := &users.User{FrontendUser: users.FrontendUser{Username: "merge-check"}}
|
||||
@@ -166,7 +166,7 @@ func TestLoadConfig_partialUserDefaults_preservesPreviewAndAutoplayDefaults(t *t
|
||||
func TestLoadConfig_partialUserDefaults_preservesOtherSetDefaults(t *testing.T) {
|
||||
loadPartialUserDefaultsConfig(t, partialUserDefaultsYAML(""))
|
||||
ud := Config.UserDefaults
|
||||
if ud.UI.DarkMode != users.DarkModeDark {
|
||||
if ud.UI.DarkMode == nil || !*ud.UI.DarkMode {
|
||||
t.Fatalf("ui.darkMode: %v", ud.UI.DarkMode)
|
||||
}
|
||||
if ud.Sidebar.ShowTools == nil || !*ud.Sidebar.ShowTools {
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestApplyEnforcedDefaultsFrom_onlyEnforcedSubset(t *testing.T) {
|
||||
Username: "alice",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
ShowHidden: false,
|
||||
DarkMode: users.DarkModeLight,
|
||||
DarkMode: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestApplyEnforcedDefaultsFrom_onlyEnforcedSubset(t *testing.T) {
|
||||
if !u.ShowHidden {
|
||||
t.Fatal("expected enforced ShowHidden true")
|
||||
}
|
||||
if u.DarkMode == users.DarkModeDark {
|
||||
if u.DarkMode {
|
||||
t.Fatal("expected non-enforced DarkMode unchanged")
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func TestProfileRoundTrip_expandCollapse(t *testing.T) {
|
||||
Username: "alice",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
ShowHidden: true,
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: true,
|
||||
ShowToolsInSidebar: true,
|
||||
Preview: users.Preview{
|
||||
Image: true,
|
||||
@@ -191,7 +191,7 @@ func TestProfileRoundTrip_expandCollapse(t *testing.T) {
|
||||
var u2 users.User
|
||||
u2.Username = "alice"
|
||||
ExpandProfileIntoUser(&u2, p)
|
||||
if !u2.ShowHidden || !u2.DarkMode.IsDark() || !u2.DisableSettings {
|
||||
if !u2.ShowHidden || !u2.DarkMode || !u2.DisableSettings {
|
||||
t.Fatal("round trip lost fields")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// Test structs for comprehensive testing
|
||||
@@ -217,10 +215,11 @@ type UserDefaults struct {
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: &trueVal,
|
||||
Locale: "es",
|
||||
},
|
||||
},
|
||||
@@ -295,11 +294,12 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
Locale: "es",
|
||||
DarkMode: users.DarkModeDark,
|
||||
DarkMode: &trueVal,
|
||||
},
|
||||
FileViewer: UserDefaultsFileViewer{
|
||||
DisableOnlyOfficeExt: ".docx .xlsx",
|
||||
@@ -331,7 +331,7 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false,
|
||||
expectFull: true, // all fields shown
|
||||
expectComments: false, // no comments
|
||||
@@ -464,7 +464,7 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: users.DarkModeDark, // default value
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
|
||||
@@ -68,14 +68,14 @@ func MediaPatchJSONForPaths(source any, paths []string) ([]byte, error) {
|
||||
|
||||
// ApplyConfigSpecifiedPathsToMedia overlays config media values onto stored settings for the
|
||||
// paths the config file pins, so a config value always wins over the stored one.
|
||||
func ApplyConfigSpecifiedPathsToMedia(stored FullAdminMedia, config Media) (FullAdminMedia, error) {
|
||||
func ApplyConfigSpecifiedPathsToMedia(stored MediaSettings, config Media) (MediaSettings, error) {
|
||||
paths := ConfigSpecifiedMediaPaths()
|
||||
if len(paths) == 0 {
|
||||
return stored, nil
|
||||
}
|
||||
patchJSON, err := MediaPatchJSONForPaths(config, paths)
|
||||
if err != nil {
|
||||
return FullAdminMedia{}, err
|
||||
return MediaSettings{}, err
|
||||
}
|
||||
if len(patchJSON) == 0 || string(patchJSON) == "{}" || string(patchJSON) == "null" {
|
||||
return stored, nil
|
||||
|
||||
@@ -60,11 +60,11 @@ func TestValidateMediaPatchNotConfigLockedWithoutLocks(t *testing.T) {
|
||||
func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
withConfigMediaPaths(t, "hardwareAcceleration", "transcode.profiles.datasaver.avgBitrateKbps")
|
||||
|
||||
stored := FullAdminMedia{
|
||||
stored := MediaSettings{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true},
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
Profiles: TranscodeProfiles{
|
||||
DataSaver: TranscodeProfile{AvgBitrateKbps: 1500},
|
||||
},
|
||||
@@ -72,7 +72,7 @@ func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
}
|
||||
config := Media{
|
||||
HardwareAcceleration: false,
|
||||
Transcode: FullAdminTranscode{
|
||||
Transcode: MediaTranscode{
|
||||
Profiles: TranscodeProfiles{
|
||||
DataSaver: TranscodeProfile{AvgBitrateKbps: 800},
|
||||
},
|
||||
@@ -97,7 +97,7 @@ func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
func TestApplyConfigSpecifiedPathsToMediaNoLocks(t *testing.T) {
|
||||
withConfigMediaPaths(t)
|
||||
|
||||
stored := FullAdminMedia{HardwareAcceleration: true}
|
||||
stored := MediaSettings{HardwareAcceleration: true}
|
||||
overlaid, err := ApplyConfigSpecifiedPathsToMedia(stored, Media{HardwareAcceleration: false})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyConfigSpecifiedPathsToMedia: %v", err)
|
||||
|
||||
@@ -7,18 +7,18 @@ import (
|
||||
)
|
||||
|
||||
// MergeMediaSettingsPatchJSON merges a partial JSON patch into base media settings.
|
||||
func MergeMediaSettingsPatchJSON(base FullAdminMedia, patchJSON []byte) (FullAdminMedia, error) {
|
||||
func MergeMediaSettingsPatchJSON(base MediaSettings, patchJSON []byte) (MediaSettings, error) {
|
||||
baseBytes, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
return FullAdminMedia{}, err
|
||||
return MediaSettings{}, err
|
||||
}
|
||||
mergedBytes, err := mergeUserDefaultsJSON(baseBytes, patchJSON)
|
||||
if err != nil {
|
||||
return FullAdminMedia{}, err
|
||||
return MediaSettings{}, err
|
||||
}
|
||||
var merged FullAdminMedia
|
||||
var merged MediaSettings
|
||||
if err := json.Unmarshal(mergedBytes, &merged); err != nil {
|
||||
return FullAdminMedia{}, fmt.Errorf("unmarshal merged media settings: %w", err)
|
||||
return MediaSettings{}, fmt.Errorf("unmarshal merged media settings: %w", err)
|
||||
}
|
||||
// An explicit accelDevice wins over the legacy hardwareAcceleration bool in
|
||||
// the base settings, which would otherwise force the device back to software.
|
||||
@@ -35,7 +35,7 @@ func MergeMediaSettingsPatchJSON(base FullAdminMedia, patchJSON []byte) (FullAdm
|
||||
}
|
||||
|
||||
// MediaSettingsEqual reports whether two runtime media settings are equivalent.
|
||||
func MediaSettingsEqual(a, b FullAdminMedia) bool {
|
||||
func MediaSettingsEqual(a, b MediaSettings) bool {
|
||||
ab, errA := json.Marshal(a)
|
||||
bb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
@@ -46,7 +46,7 @@ func MediaSettingsEqual(a, b FullAdminMedia) bool {
|
||||
|
||||
// ApplyMediaSettingsIfChanged applies runtime media settings
|
||||
// only when they actually differ. It never re-runs ffmpeg detection.
|
||||
func ApplyMediaSettingsIfChanged(s FullAdminMedia) {
|
||||
func ApplyMediaSettingsIfChanged(s MediaSettings) {
|
||||
if MediaSettingsEqual(CurrentMediaSettings(), s) {
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func ApplyMediaSettingsIfChanged(s FullAdminMedia) {
|
||||
|
||||
// CloneMediaSettings returns a deep copy of m — the conversion maps hold *bool
|
||||
// values, so a shallow copy would alias the live config's reference state.
|
||||
func CloneMediaSettings(m FullAdminMedia) FullAdminMedia {
|
||||
func CloneMediaSettings(m MediaSettings) MediaSettings {
|
||||
out := m
|
||||
out.Convert = FfmpegConvert{
|
||||
ImagePreview: cloneBoolMap(m.Convert.ImagePreview),
|
||||
@@ -84,7 +84,7 @@ func cloneBoolPtr(v *bool) *bool {
|
||||
}
|
||||
|
||||
// SanitizeMediaSettings clamps runtime media settings into supported ranges.
|
||||
func SanitizeMediaSettings(m FullAdminMedia) FullAdminMedia {
|
||||
func SanitizeMediaSettings(m MediaSettings) MediaSettings {
|
||||
t := &m.Transcode
|
||||
if t.MaxConcurrent < 1 {
|
||||
t.MaxConcurrent = 2
|
||||
|
||||
@@ -2,16 +2,17 @@ package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func testMediaSettings() FullAdminMedia {
|
||||
return FullAdminMedia{
|
||||
func testMediaSettings() MediaSettings {
|
||||
return MediaSettings{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true, MaxConcurrentPerUser: 1},
|
||||
MaxConcurrent: 2,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 2,
|
||||
MaxConcurrentPerUser: 1,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -53,11 +54,11 @@ func TestMergeMediaSettingsPatchJSONProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeMediaSettings(t *testing.T) {
|
||||
in := FullAdminMedia{
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{MaxConcurrentPerUser: 0},
|
||||
MaxConcurrent: -1,
|
||||
CacheRetentionH: 0,
|
||||
in := MediaSettings{
|
||||
Transcode: MediaTranscode{
|
||||
MaxConcurrent: -1,
|
||||
MaxConcurrentPerUser: 0,
|
||||
CacheRetentionH: 0,
|
||||
Profiles: TranscodeProfiles{
|
||||
Quality: TranscodeProfile{MaxResolution: -5, AvgBitrateKbps: -1},
|
||||
},
|
||||
@@ -115,10 +116,10 @@ func TestSanitizeMediaSettingsAccelDevice(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := SanitizeMediaSettings(FullAdminMedia{
|
||||
out := SanitizeMediaSettings(MediaSettings{
|
||||
AccelDevice: tc.device,
|
||||
HardwareAcceleration: tc.hwAccel,
|
||||
Transcode: FullAdminTranscode{UserTranscode: UserTranscode{MaxConcurrentPerUser: 1}, MaxConcurrent: 1, CacheRetentionH: 1},
|
||||
Transcode: MediaTranscode{MaxConcurrent: 1, MaxConcurrentPerUser: 1, CacheRetentionH: 1},
|
||||
})
|
||||
if out.AccelDevice != tc.wantDevice || out.HardwareAcceleration != tc.wantHW {
|
||||
t.Fatalf("accelDevice = %q/%v, want %q/%v", out.AccelDevice, out.HardwareAcceleration, tc.wantDevice, tc.wantHW)
|
||||
|
||||
@@ -209,10 +209,8 @@ func TranscodeCacheRetention() time.Duration {
|
||||
return time.Duration(h) * time.Hour
|
||||
}
|
||||
|
||||
// CurrentMediaSettings returns the stored runtime media settings (excluding ffmpegPath).
|
||||
// Derived fields (ImagePreview, EnabledProfiles) are not populated; use CurrentFullAdminMedia
|
||||
// or CurrentUserMedia for response payloads.
|
||||
func CurrentMediaSettings() FullAdminMedia {
|
||||
// CurrentMediaSettings returns the runtime media settings (excluding ffmpegPath).
|
||||
func CurrentMediaSettings() MediaSettings {
|
||||
mediaMu.RLock()
|
||||
defer mediaMu.RUnlock()
|
||||
return currentMediaSettingsLocked()
|
||||
@@ -220,68 +218,24 @@ func CurrentMediaSettings() FullAdminMedia {
|
||||
|
||||
// currentMediaSettingsLocked snapshots the live media settings. Callers must hold
|
||||
// mediaMu. The returned value is deep-copied so it cannot alias the live config.
|
||||
func currentMediaSettingsLocked() FullAdminMedia {
|
||||
func currentMediaSettingsLocked() MediaSettings {
|
||||
m := Config.Integrations.Media
|
||||
return CloneMediaSettings(FullAdminMedia{
|
||||
return CloneMediaSettings(MediaSettings{
|
||||
Convert: m.Convert,
|
||||
Debug: m.Debug,
|
||||
HardwareAcceleration: m.HardwareAcceleration,
|
||||
AccelDevice: m.AccelDevice,
|
||||
AlwaysUseFfmpeg: m.AlwaysUseFfmpeg,
|
||||
AlwaysUseOnlyOffice: m.AlwaysUseOnlyOffice,
|
||||
Transcode: m.Transcode,
|
||||
AccelDevice: m.AccelDevice,
|
||||
AlwaysUseFfmpeg: m.AlwaysUseFfmpeg,
|
||||
AlwaysUseOnlyOffice: m.AlwaysUseOnlyOffice,
|
||||
Transcode: m.Transcode,
|
||||
})
|
||||
}
|
||||
|
||||
// ImagePreviewAvailability resolves each supported image preview type to whether
|
||||
// conversion is active right now, using the same check the preview pipeline uses.
|
||||
func ImagePreviewAvailability() map[ImagePreviewType]bool {
|
||||
out := make(map[ImagePreviewType]bool, len(AllImagePreviewTypes))
|
||||
for _, t := range AllImagePreviewTypes {
|
||||
out[t] = CanConvertImage(string(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentUserMedia builds the PUBLIC media payload for non-admin users. Values come
|
||||
// from the same accessors as the admin payload, so the shared fields always agree.
|
||||
// The getters take mediaMu themselves, so they are called here without holding it.
|
||||
func CurrentUserMedia() UserMedia {
|
||||
return UserMedia{
|
||||
Transcode: UserTranscode{
|
||||
Enabled: TranscodeEnabled(),
|
||||
MaxConcurrentPerUser: TranscodeMaxConcurrentPerUser(),
|
||||
EnabledProfiles: EnabledTranscodeProfiles(),
|
||||
},
|
||||
ImagePreview: ImagePreviewAvailability(),
|
||||
}
|
||||
}
|
||||
|
||||
// PrepMediaForFrontend returns a read-only copy of m with derived values filled in,
|
||||
// following PrepForFrontend in internal/web/users.go. The caller's value is not modified.
|
||||
// Stored config (transcode.enabled, maxConcurrentPerUser) is deliberately NOT replaced
|
||||
// with effective values: the admin UI edits these fields and sends them back, so they
|
||||
// must stay the stored values. Only derived fields are filled.
|
||||
// NEVER pass the result to SaveSetting without ClearMediaDerived first.
|
||||
func PrepMediaForFrontend(m FullAdminMedia) FullAdminMedia {
|
||||
m.Transcode.EnabledProfiles = EnabledTranscodeProfiles()
|
||||
m.ImagePreview = ImagePreviewAvailability()
|
||||
return m
|
||||
}
|
||||
|
||||
// ClearMediaDerived zeroes the fields that PrepMediaForFrontend derives at read time so
|
||||
// they are never written to the database. Enabled and MaxConcurrentPerUser are stored
|
||||
// config and are not cleared here.
|
||||
func ClearMediaDerived(m *FullAdminMedia) {
|
||||
m.Transcode.EnabledProfiles = nil
|
||||
m.ImagePreview = nil
|
||||
}
|
||||
|
||||
// ApplyMediaSettings applies runtime media settings to the in-memory config.
|
||||
// It never re-runs ffmpeg detection: codec/hardware detection happens exactly
|
||||
// once at startup (InitFFmpeg). hardwareAcceleration is read live per transcode;
|
||||
// ffmpegPath, accelDevice, debug and concurrency apply on the next restart.
|
||||
func ApplyMediaSettings(s FullAdminMedia) {
|
||||
func ApplyMediaSettings(s MediaSettings) {
|
||||
mediaMu.Lock()
|
||||
defer mediaMu.Unlock()
|
||||
Config.Integrations.Media.Convert = s.Convert
|
||||
|
||||
@@ -25,15 +25,16 @@ func TestMediaAccessorsReflectRuntimeSettings(t *testing.T) {
|
||||
originalTranscode := Config.Integrations.Media.Transcode
|
||||
t.Cleanup(func() { Config.Integrations.Media.Transcode = originalTranscode })
|
||||
|
||||
ApplyMediaSettings(FullAdminMedia{
|
||||
ApplyMediaSettings(MediaSettings{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true, MaxConcurrentPerUser: 2},
|
||||
MaxConcurrent: 4,
|
||||
CacheMaxSizeMB: 512,
|
||||
CacheRetentionH: 6,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 4,
|
||||
MaxConcurrentPerUser: 2,
|
||||
CacheMaxSizeMB: 512,
|
||||
CacheRetentionH: 6,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,7 +67,7 @@ func TestMediaAccessorsReflectRuntimeSettings(t *testing.T) {
|
||||
|
||||
func TestTranscodeAccessorsUseDefaultsWhenUnset(t *testing.T) {
|
||||
withMediaConfig(t)
|
||||
ApplyMediaSettings(FullAdminMedia{})
|
||||
ApplyMediaSettings(MediaSettings{})
|
||||
|
||||
if got := TranscodeMaxConcurrent(); got != 2 {
|
||||
t.Fatalf("TranscodeMaxConcurrent = %d, want the 2 default", got)
|
||||
@@ -224,11 +225,11 @@ func TestMediaAccessorsAreRaceFree(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
enabled = !enabled
|
||||
ApplyMediaSettings(FullAdminMedia{
|
||||
ApplyMediaSettings(MediaSettings{
|
||||
Debug: enabled,
|
||||
HardwareAcceleration: enabled,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: enabled},
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: enabled,
|
||||
MaxConcurrent: 2,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestMultiConfigLoad(t *testing.T) {
|
||||
@@ -230,7 +228,7 @@ frontend:
|
||||
}
|
||||
|
||||
// Verify user defaults
|
||||
if Config.UserDefaults.UI.DarkMode != users.DarkModeDark {
|
||||
if Config.UserDefaults.UI.DarkMode == nil || !*Config.UserDefaults.UI.DarkMode {
|
||||
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.UI.DarkMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSyncEnforcedSourcePermissionsOntoUser_skipsAdmin(t *testing.T) {
|
||||
enforced := SourceFilePermissionsEnforcement{Create: true}
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{
|
||||
@@ -90,3 +90,4 @@ func TestValidateSelfUserUpdateScopesNotEnforced_caseInsensitive(t *testing.T) {
|
||||
t.Fatal("expected mixed-case scope field to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,63 +175,43 @@ type Media struct {
|
||||
// The fields below are deprecated and migrated to runtime (DB-backed) media settings on
|
||||
// first run. They are kept only to seed the database from an existing config.yaml.
|
||||
|
||||
Convert FfmpegConvert `json:"convert"` // deprecated: config for ffmpeg conversion settings. deprecated: manage via the admin media settings API
|
||||
Debug bool `json:"debug"` // deprecated: output ffmpeg stdout for media integration. deprecated: manage via the admin media settings API
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // deprecated: no longer configurable; embedded subtitles are always extracted
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // deprecated: enable hardware acceleration for ffmpeg if available. deprecated: manage via the admin media settings API
|
||||
Transcode FullAdminTranscode `json:"transcode"` // deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API
|
||||
Convert FfmpegConvert `json:"convert"` // deprecated: config for ffmpeg conversion settings. deprecated: manage via the admin media settings API
|
||||
Debug bool `json:"debug"` // deprecated: output ffmpeg stdout for media integration. deprecated: manage via the admin media settings API
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // deprecated: no longer configurable; embedded subtitles are always extracted
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // deprecated: enable hardware acceleration for ffmpeg if available. deprecated: manage via the admin media settings API
|
||||
Transcode MediaTranscode `json:"transcode"` // deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API
|
||||
|
||||
// AccelDevice selects the acceleration device: "auto" (default), "software"/"cpu",
|
||||
// "igpu", "dgpu", a Linux DRM render node (e.g. /dev/dri/renderD128), or a GPU
|
||||
// name substring. Managed via the admin media settings API.
|
||||
AccelDevice string `json:"accelDevice"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // deprecated: skip folio previews for office converter; manage via the admin media settings API
|
||||
AccelDevice string `json:"accelDevice"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // deprecated: skip folio previews for office converter; manage via the admin media settings API
|
||||
}
|
||||
|
||||
// FullAdminMedia holds every runtime (DB-backed) media integration setting.
|
||||
// ADMIN ONLY: non-admins receive UserMedia instead. FfmpegPath is config-only and intentionally excluded.
|
||||
// ImagePreview is derived at read time (see PrepMediaForFrontend) and is cleared before save.
|
||||
type FullAdminMedia struct {
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview,omitempty"` // image formats that can be converted
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // enable hardware acceleration for ffmpeg if available
|
||||
AccelDevice string `json:"accelDevice"` // acceleration device: "auto", "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name substring
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // skip the go-media fast path and use ffmpeg for all probe, metadata, subtitle, and thumbnail work (default: false)
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // skip folio document previews and use the office converter when configured (default: false)
|
||||
Transcode FullAdminTranscode `json:"transcode"` // HLS transcoding for media playback
|
||||
// MediaSettings holds the runtime (DB-backed) media integration settings.
|
||||
// FfmpegPath is config-only and intentionally excluded.
|
||||
type MediaSettings struct {
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // enable hardware acceleration for ffmpeg if available
|
||||
AccelDevice string `json:"accelDevice"` // acceleration device: "auto", "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name substring
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // skip the go-media fast path and use ffmpeg for all probe, metadata, subtitle, and thumbnail work (default: false)
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // skip folio document previews and use the office converter when configured (default: false)
|
||||
Transcode MediaTranscode `json:"transcode"` // HLS transcoding for media playback
|
||||
}
|
||||
|
||||
// UserTranscode is PUBLIC: transcode fields every authenticated user receives in
|
||||
// the UserMedia payload. FullAdminTranscode embeds it, so the same JSON keys appear
|
||||
// in both admin and user responses. Add a field here only if the UI reads it.
|
||||
type UserTranscode struct {
|
||||
Enabled bool `json:"enabled"` // enable HLS transcoding for authenticated playback (default: false)
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"` // hard per-user concurrent transcode ceiling (default: 1)
|
||||
EnabledProfiles []string `json:"enabledProfiles,omitempty"` // derived at read time (player-menu profile ids); never stored
|
||||
}
|
||||
|
||||
// UserMedia is PUBLIC: the only media payload non-admin users receive.
|
||||
// Add a top-level key here only if the player or general UI reads it.
|
||||
type UserMedia struct {
|
||||
Transcode UserTranscode `json:"transcode"`
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // availability, derived at read time
|
||||
}
|
||||
|
||||
// FullAdminTranscode configures authenticated HLS transcoding (disabled by default).
|
||||
// ADMIN ONLY beyond the embedded UserTranscode fields.
|
||||
type FullAdminTranscode struct {
|
||||
// inline: flattens the public fields in YAML (config.yaml) the same way the embed
|
||||
// flattens them in JSON, so config keys stay transcode.enabled, not transcode.UserTranscode.enabled.
|
||||
UserTranscode `yaml:",inline"`
|
||||
MaxConcurrent int `json:"maxConcurrent"` // global concurrent transcode sessions (default: 2)
|
||||
CacheMaxSizeMB int `json:"cacheMaxSizeMB"` // disk cache size limit in MB; 0 = unlimited (default: 10240)
|
||||
CacheRetentionH int `json:"cacheRetentionH"` // completed cache retention in hours (default: 24)
|
||||
Profiles TranscodeProfiles `json:"profiles"` // per-profile transcode settings shown in the player menu
|
||||
MaxResolution int `json:"maxResolution"` // superseded by profiles.<name>.maxResolution. deprecated: kept for config compatibility
|
||||
EncoderBackend string `json:"encoderBackend"` // default video encoder backend for transcodes: "" (auto), "cpu", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)
|
||||
ReadRate float64 `json:"readRate"` // VOD producer input pacing in Nx realtime (-readrate); unset or <1 clamps to >=1 (default: 2)
|
||||
// MediaTranscode configures authenticated HLS transcoding (disabled by default).
|
||||
type MediaTranscode struct {
|
||||
Enabled bool `json:"enabled"` // enable HLS transcoding for authenticated playback (default: false)
|
||||
MaxConcurrent int `json:"maxConcurrent"` // global concurrent transcode sessions (default: 2)
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"` // hard per-user concurrent transcode ceiling (default: 1)
|
||||
CacheMaxSizeMB int `json:"cacheMaxSizeMB"` // disk cache size limit in MB; 0 = unlimited (default: 10240)
|
||||
CacheRetentionH int `json:"cacheRetentionH"` // completed cache retention in hours (default: 24)
|
||||
Profiles TranscodeProfiles `json:"profiles"` // per-profile transcode settings shown in the player menu
|
||||
MaxResolution int `json:"maxResolution"` // superseded by profiles.<name>.maxResolution. deprecated: kept for config compatibility
|
||||
EncoderBackend string `json:"encoderBackend"` // default video encoder backend for transcodes: "" (auto), "cpu", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)
|
||||
ReadRate float64 `json:"readRate"` // VOD producer input pacing in Nx realtime (-readrate); unset or <1 clamps to >=1 (default: 2)
|
||||
}
|
||||
|
||||
// TranscodeProfiles configures the player's transcode quality presets.
|
||||
@@ -508,7 +488,7 @@ type UserDefaultsSearch struct {
|
||||
|
||||
// UserDefaultsUI holds UI/appearance settings
|
||||
type UserDefaultsUI struct {
|
||||
DarkMode users.DarkMode `json:"darkMode"` // theme preference: "auto" (follow system), "dark", or "light"
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// Strict hex color regex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
||||
@@ -122,22 +120,8 @@ func readCustomCSS(path string) (string, error) {
|
||||
}
|
||||
|
||||
// DefaultDarkMode reports whether the instance default theme is dark.
|
||||
// "auto" (follow system) resolves to light on the server; clients handle
|
||||
// "auto" by checking their own prefers-color-scheme media query.
|
||||
func DefaultDarkMode() bool {
|
||||
return Config.UserDefaults.UI.DarkMode == users.DarkModeDark
|
||||
}
|
||||
|
||||
// DefaultDarkModeIsAuto reports whether the instance default theme follows
|
||||
// the client system preference.
|
||||
func DefaultDarkModeIsAuto() bool {
|
||||
return Config.UserDefaults.UI.DarkMode == users.DarkModeAuto
|
||||
}
|
||||
|
||||
// DefaultDarkModePreference is the configured instance default for globalVars.darkMode
|
||||
// ("auto", "dark", or "light"). Unlike DefaultDarkMode, it preserves "auto" for the SPA.
|
||||
func DefaultDarkModePreference() users.DarkMode {
|
||||
return Config.UserDefaults.UI.DarkMode.Or(users.DarkModeDark)
|
||||
return Config.UserDefaults.UI.DarkMode != nil && *Config.UserDefaults.UI.DarkMode
|
||||
}
|
||||
|
||||
// DefaultBackgroundColor returns the background color of the instance's default theme.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestDefaultDarkModePreferencePreservesAuto(t *testing.T) {
|
||||
original := Config.UserDefaults.UI.DarkMode
|
||||
t.Cleanup(func() { Config.UserDefaults.UI.DarkMode = original })
|
||||
|
||||
Config.UserDefaults.UI.DarkMode = users.DarkModeAuto
|
||||
if got := DefaultDarkModePreference(); got != users.DarkModeAuto {
|
||||
t.Fatalf("DefaultDarkModePreference() = %q, want auto", got)
|
||||
}
|
||||
if DefaultDarkMode() {
|
||||
t.Fatal("DefaultDarkMode() should be false when preference is auto")
|
||||
}
|
||||
if !DefaultDarkModeIsAuto() {
|
||||
t.Fatal("DefaultDarkModeIsAuto() should be true when preference is auto")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(DefaultDarkModePreference())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != `"auto"` {
|
||||
t.Fatalf("json.Marshal preference = %s, want \"auto\"", raw)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func ProfileFromUser(u *users.User) UserProfile {
|
||||
DisableOptions: u.DisableSearchOptions,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: u.DarkMode,
|
||||
DarkMode: boolPtr(u.DarkMode),
|
||||
ThemeColor: u.ThemeColor,
|
||||
CustomTheme: u.CustomTheme,
|
||||
Locale: u.Locale,
|
||||
@@ -142,7 +142,7 @@ func ExpandProfileIntoUser(u *users.User, p UserProfile) {
|
||||
|
||||
u.DisableSearchOptions = p.Search.DisableOptions
|
||||
|
||||
u.DarkMode = p.UI.DarkMode.Or(users.DarkModeDark)
|
||||
u.DarkMode = boolValueOrDefault(p.UI.DarkMode, true)
|
||||
u.ThemeColor = p.UI.ThemeColor
|
||||
u.CustomTheme = p.UI.CustomTheme
|
||||
u.Locale = p.UI.Locale
|
||||
|
||||
+52
-104
@@ -3217,24 +3217,6 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"get": {
|
||||
"description": "Returns the media settings the caller is allowed to see.\nAdmins get the full settings, including ffmpeg and hardware options, so they can edit them.\nEveryone else gets a small read-only view: whether transcoding is on, how many\ntranscodes they can run at once, which quality presets are enabled, and which image\npreviews the server can generate. Keys in the view are the same for admins and users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get runtime media settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Media settings",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.mediaSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.",
|
||||
"consumes": [
|
||||
@@ -6317,58 +6299,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.FullAdminTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabledProfiles": {
|
||||
"description": "derived at read time (player-menu profile ids); never stored",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.Http": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6710,12 +6640,57 @@ const docTemplate = `{
|
||||
"description": "deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.MediaTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7598,12 +7573,8 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"locale": {
|
||||
"description": "language to use: eg. de, en, or fr",
|
||||
@@ -8119,19 +8090,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"users.DarkMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"dark",
|
||||
"light"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DarkModeAuto",
|
||||
"DarkModeDark",
|
||||
"DarkModeLight"
|
||||
]
|
||||
},
|
||||
"users.FileLoading": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8176,12 +8134,8 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dateFormat": {
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
@@ -8988,6 +8942,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"web.mediaSettingsResponse": {
|
||||
"description": "Admins receive the DB-backed media integration settings; every authenticated user receives the player-relevant subset (transcode enabled, profiles, per-user limit).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accelDevice": {
|
||||
@@ -9030,13 +8985,6 @@ const docTemplate = `{
|
||||
"description": "enable hardware acceleration for ffmpeg if available",
|
||||
"type": "boolean"
|
||||
},
|
||||
"imagePreview": {
|
||||
"description": "image formats that can be converted",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"lockMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9050,7 +8998,7 @@ const docTemplate = `{
|
||||
"description": "HLS transcoding for media playback",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3206,24 +3206,6 @@
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"get": {
|
||||
"description": "Returns the media settings the caller is allowed to see.\nAdmins get the full settings, including ffmpeg and hardware options, so they can edit them.\nEveryone else gets a small read-only view: whether transcoding is on, how many\ntranscodes they can run at once, which quality presets are enabled, and which image\npreviews the server can generate. Keys in the view are the same for admins and users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get runtime media settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Media settings",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.mediaSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.",
|
||||
"consumes": [
|
||||
@@ -6306,58 +6288,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.FullAdminTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabledProfiles": {
|
||||
"description": "derived at read time (player-menu profile ids); never stored",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.Http": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6699,12 +6629,57 @@
|
||||
"description": "deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.MediaTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7587,12 +7562,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"locale": {
|
||||
"description": "language to use: eg. de, en, or fr",
|
||||
@@ -8108,19 +8079,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"users.DarkMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"dark",
|
||||
"light"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DarkModeAuto",
|
||||
"DarkModeDark",
|
||||
"DarkModeLight"
|
||||
]
|
||||
},
|
||||
"users.FileLoading": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8165,12 +8123,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"darkMode": {
|
||||
"description": "theme preference: \"auto\" (follow system), \"dark\", or \"light\"",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.DarkMode"
|
||||
}
|
||||
]
|
||||
"description": "should dark mode be enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dateFormat": {
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
@@ -8977,6 +8931,7 @@
|
||||
}
|
||||
},
|
||||
"web.mediaSettingsResponse": {
|
||||
"description": "Admins receive the DB-backed media integration settings; every authenticated user receives the player-relevant subset (transcode enabled, profiles, per-user limit).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accelDevice": {
|
||||
@@ -9019,13 +8974,6 @@
|
||||
"description": "enable hardware acceleration for ffmpeg if available",
|
||||
"type": "boolean"
|
||||
},
|
||||
"imagePreview": {
|
||||
"description": "image formats that can be converted",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"lockMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9039,7 +8987,7 @@
|
||||
"description": "HLS transcoding for media playback",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -480,46 +480,6 @@ definitions:
|
||||
styling:
|
||||
$ref: '#/definitions/settings.StylingConfig'
|
||||
type: object
|
||||
settings.FullAdminTranscode:
|
||||
properties:
|
||||
cacheMaxSizeMB:
|
||||
description: 'disk cache size limit in MB; 0 = unlimited (default: 10240)'
|
||||
type: integer
|
||||
cacheRetentionH:
|
||||
description: 'completed cache retention in hours (default: 24)'
|
||||
type: integer
|
||||
enabled:
|
||||
description: 'enable HLS transcoding for authenticated playback (default:
|
||||
false)'
|
||||
type: boolean
|
||||
enabledProfiles:
|
||||
description: derived at read time (player-menu profile ids); never stored
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
encoderBackend:
|
||||
description: 'default video encoder backend for transcodes: "" (auto), "cpu",
|
||||
or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)'
|
||||
type: string
|
||||
maxConcurrent:
|
||||
description: 'global concurrent transcode sessions (default: 2)'
|
||||
type: integer
|
||||
maxConcurrentPerUser:
|
||||
description: 'hard per-user concurrent transcode ceiling (default: 1)'
|
||||
type: integer
|
||||
maxResolution:
|
||||
description: 'superseded by profiles.<name>.maxResolution. deprecated: kept
|
||||
for config compatibility'
|
||||
type: integer
|
||||
profiles:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TranscodeProfiles'
|
||||
description: per-profile transcode settings shown in the player menu
|
||||
readRate:
|
||||
description: 'VOD producer input pacing in Nx realtime (-readrate); unset
|
||||
or <1 clamps to >=1 (default: 2)'
|
||||
type: number
|
||||
type: object
|
||||
settings.Http:
|
||||
properties:
|
||||
baseURL:
|
||||
@@ -804,10 +764,45 @@ definitions:
|
||||
type: boolean
|
||||
transcode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.FullAdminTranscode'
|
||||
- $ref: '#/definitions/settings.MediaTranscode'
|
||||
description: 'deprecated: HLS transcoding for media playback. deprecated:
|
||||
manage via the admin media settings API'
|
||||
type: object
|
||||
settings.MediaTranscode:
|
||||
properties:
|
||||
cacheMaxSizeMB:
|
||||
description: 'disk cache size limit in MB; 0 = unlimited (default: 10240)'
|
||||
type: integer
|
||||
cacheRetentionH:
|
||||
description: 'completed cache retention in hours (default: 24)'
|
||||
type: integer
|
||||
enabled:
|
||||
description: 'enable HLS transcoding for authenticated playback (default:
|
||||
false)'
|
||||
type: boolean
|
||||
encoderBackend:
|
||||
description: 'default video encoder backend for transcodes: "" (auto), "cpu",
|
||||
or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)'
|
||||
type: string
|
||||
maxConcurrent:
|
||||
description: 'global concurrent transcode sessions (default: 2)'
|
||||
type: integer
|
||||
maxConcurrentPerUser:
|
||||
description: 'hard per-user concurrent transcode ceiling (default: 1)'
|
||||
type: integer
|
||||
maxResolution:
|
||||
description: 'superseded by profiles.<name>.maxResolution. deprecated: kept
|
||||
for config compatibility'
|
||||
type: integer
|
||||
profiles:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TranscodeProfiles'
|
||||
description: per-profile transcode settings shown in the player menu
|
||||
readRate:
|
||||
description: 'VOD producer input pacing in Nx realtime (-readrate); unset
|
||||
or <1 clamps to >=1 (default: 2)'
|
||||
type: number
|
||||
type: object
|
||||
settings.OidcConfig:
|
||||
properties:
|
||||
adminGroup:
|
||||
@@ -1462,9 +1457,8 @@ definitions:
|
||||
description: Name of theme to use chosen from custom themes config
|
||||
type: string
|
||||
darkMode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/users.DarkMode'
|
||||
description: 'theme preference: "auto" (follow system), "dark", or "light"'
|
||||
description: should dark mode be enabled
|
||||
type: boolean
|
||||
locale:
|
||||
description: 'language to use: eg. de, en, or fr'
|
||||
type: string
|
||||
@@ -1812,16 +1806,6 @@ definitions:
|
||||
description: default view mode for anonymous users
|
||||
type: string
|
||||
type: object
|
||||
users.DarkMode:
|
||||
enum:
|
||||
- auto
|
||||
- dark
|
||||
- light
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- DarkModeAuto
|
||||
- DarkModeDark
|
||||
- DarkModeLight
|
||||
users.FileLoading:
|
||||
properties:
|
||||
clearAll:
|
||||
@@ -1852,9 +1836,8 @@ definitions:
|
||||
description: Name of theme to use chosen from custom themes config.
|
||||
type: string
|
||||
darkMode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/users.DarkMode'
|
||||
description: 'theme preference: "auto" (follow system), "dark", or "light"'
|
||||
description: should dark mode be enabled
|
||||
type: boolean
|
||||
dateFormat:
|
||||
description: when false, the date is relative, when true, the date is an exact
|
||||
timestamp
|
||||
@@ -2428,6 +2411,9 @@ definitions:
|
||||
description: File metadata for non-text files
|
||||
type: object
|
||||
web.mediaSettingsResponse:
|
||||
description: Admins receive the DB-backed media integration settings; every authenticated
|
||||
user receives the player-relevant subset (transcode enabled, profiles, per-user
|
||||
limit).
|
||||
properties:
|
||||
accelDevice:
|
||||
description: 'acceleration device: "auto", "software"/"cpu", "igpu", "dgpu",
|
||||
@@ -2460,11 +2446,6 @@ definitions:
|
||||
hardwareAcceleration:
|
||||
description: enable hardware acceleration for ffmpeg if available
|
||||
type: boolean
|
||||
imagePreview:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: image formats that can be converted
|
||||
type: object
|
||||
lockMessage:
|
||||
type: string
|
||||
lockedFromConfigPaths:
|
||||
@@ -2473,7 +2454,7 @@ definitions:
|
||||
type: array
|
||||
transcode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.FullAdminTranscode'
|
||||
- $ref: '#/definitions/settings.MediaTranscode'
|
||||
description: HLS transcoding for media playback
|
||||
type: object
|
||||
web.pinnedItemPatchRequest:
|
||||
@@ -4774,23 +4755,6 @@ paths:
|
||||
tags:
|
||||
- Settings
|
||||
/api/settings/media:
|
||||
get:
|
||||
description: |-
|
||||
Returns the media settings the caller is allowed to see.
|
||||
Admins get the full settings, including ffmpeg and hardware options, so they can edit them.
|
||||
Everyone else gets a small read-only view: whether transcoding is on, how many
|
||||
transcodes they can run at once, which quality presets are enabled, and which image
|
||||
previews the server can generate. Keys in the view are the same for admins and users.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Media settings
|
||||
schema:
|
||||
$ref: '#/definitions/web.mediaSettingsResponse'
|
||||
summary: Get runtime media settings
|
||||
tags:
|
||||
- Settings
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
|
||||
@@ -215,7 +215,7 @@ userDefaults: # optional signup/CLI defaults; per-us
|
||||
search:
|
||||
disableOptions: false # disable the search options in the search bar
|
||||
ui:
|
||||
darkMode: "dark" # theme preference: "auto" (follow system), "dark", or "light"
|
||||
darkMode: true # should dark mode be enabled
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config
|
||||
locale: "en" # language to use: eg. de, en, or fr
|
||||
|
||||
@@ -3,14 +3,6 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{{- if .htmlVars.autoDarkMode }}
|
||||
<!-- Default theme follows the system preference; resolve before first paint to avoid a flash -->
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.classList.add("dark-mode");
|
||||
}
|
||||
</script>
|
||||
{{- end }}
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>{{ .htmlVars.title }}</title>
|
||||
|
||||
@@ -28,9 +28,6 @@ const EXTRA_ICONS = [
|
||||
'arrow_downward',
|
||||
'arrow_upward',
|
||||
'autorenew',
|
||||
'dark_mode',
|
||||
'light_mode',
|
||||
'routine',
|
||||
'build',
|
||||
'check',
|
||||
'cloud_off',
|
||||
|
||||
@@ -1085,9 +1085,6 @@ export async function checksum(source, path, algo) {
|
||||
/**
|
||||
* URL to open a file inline in a new browser tab via the download endpoint.
|
||||
* Uses inline disposition and respects download permissions and share limits.
|
||||
* @param {string} source
|
||||
* @param {string} path
|
||||
* @param {{ path?: string, hash?: string } | null} [shareInfo]
|
||||
*/
|
||||
export function getOpenFileURL(source, path, shareInfo = null) {
|
||||
if (isMediaFile(path)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { normalizeSharedMediaSettings } from "@/utils/mediaSettings.js";
|
||||
import { normalizeMediaPlayerSettings } from "@/utils/mediaSettings.js";
|
||||
import { fetchJSON, fetchURL, requestTimeoutSignal } from "./utils.ts";
|
||||
|
||||
const analyticsRequestTimeoutMs = 5000;
|
||||
@@ -124,14 +124,13 @@ export function getMediaSettings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared runtime media state (player + general UI), normalized to a flat shape.
|
||||
* Non-admins receive the PUBLIC UserMedia payload; admins get the full payload on the
|
||||
* same endpoint, which carries the same transcode and imagePreview keys.
|
||||
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, imagePreview: object }>}
|
||||
* Effective media settings for playback. Every authenticated user may read this;
|
||||
* admins receive the full settings object and everyone else the player subset.
|
||||
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, hardwareAcceleration: boolean }>}
|
||||
*/
|
||||
export async function getSharedMediaSettings() {
|
||||
export async function getMediaPlayerSettings() {
|
||||
const data = await fetchJSON(getApiPath("settings/media"));
|
||||
return normalizeSharedMediaSettings(data);
|
||||
return normalizeMediaPlayerSettings(data);
|
||||
}
|
||||
|
||||
export async function patchMediaSettings(partial) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="icon === 'close_back'"
|
||||
type="button"
|
||||
:disabled="isDisabled"
|
||||
@click="onClick"
|
||||
@click="actionMultiIcon"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
@@ -31,7 +31,7 @@
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
@click="onClick"
|
||||
@click="action"
|
||||
>
|
||||
<i :class="iconClass">{{ icon }}</i>
|
||||
<span>{{ label }}</span>
|
||||
@@ -39,59 +39,58 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const OUTLINED_ICONS = new Set([
|
||||
"table_rows_narrow",
|
||||
"content_copy",
|
||||
"file_copy",
|
||||
"copy_all",
|
||||
"insert_drive_file",
|
||||
]);
|
||||
<script>
|
||||
import { getters, mutations, state } from "@/store";
|
||||
|
||||
export function actionIconClass(icon: string, customIconClass?: string): string {
|
||||
if (customIconClass) return customIconClass;
|
||||
return OUTLINED_ICONS.has(icon) ? "material-symbols-outlined" : "material-symbols";
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getters, mutations } from "@/store";
|
||||
import type { Prompt } from "@/store/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
label?: string;
|
||||
counter?: number;
|
||||
show?: Prompt | string | null;
|
||||
isDisabled?: boolean;
|
||||
customIconClass?: string;
|
||||
}>(),
|
||||
{
|
||||
icon: "",
|
||||
label: "",
|
||||
counter: 0,
|
||||
show: null,
|
||||
isDisabled: false,
|
||||
customIconClass: "",
|
||||
export default {
|
||||
name: "action",
|
||||
props: ["icon", "label", "counter", "show", "isDisabled", "customIconClass"],
|
||||
computed: {
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
multistate() {
|
||||
return getters.multibuttonState();
|
||||
},
|
||||
stickSidebar() {
|
||||
return state.user?.stickySidebar;
|
||||
},
|
||||
currentView() {
|
||||
return getters.currentView();
|
||||
},
|
||||
iconClass() {
|
||||
if (this.customIconClass) {
|
||||
return this.customIconClass;
|
||||
}
|
||||
const outlinedIcons = [
|
||||
'table_rows_narrow',
|
||||
'content_copy',
|
||||
'file_copy',
|
||||
'copy_all',
|
||||
'insert_drive_file'
|
||||
];
|
||||
return outlinedIcons.includes(this.icon)
|
||||
? 'material-symbols-outlined'
|
||||
: 'material-symbols';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
|
||||
const multistate = computed(() => getters.multibuttonState());
|
||||
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
|
||||
|
||||
function onClick() {
|
||||
if (props.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(props.show);
|
||||
}
|
||||
emit("action");
|
||||
}
|
||||
methods: {
|
||||
actionMultiIcon() {
|
||||
if (this.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(this.show);
|
||||
}
|
||||
this.$emit("action");
|
||||
},
|
||||
action() {
|
||||
if (this.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(this.show);
|
||||
}
|
||||
this.$emit("action");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store } = vi.hoisted(() => ({
|
||||
store: { share: false, creatable: [], user: { scopes: [{ name: "data" }, { name: "inbox" }] } },
|
||||
}));
|
||||
vi.mock("@/store", () => ({
|
||||
state: { get user() { return store.user; } },
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => store.share,
|
||||
canCreateInSource: (source) => store.creatable.includes(source),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
|
||||
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
const showCopy = (permissions) =>
|
||||
ContextMenu.computed.showCopy.call({ showLimitedOptions: false, showCreate: false, selectedCount: 1, permissions });
|
||||
|
||||
describe("ContextMenu Copy", () => {
|
||||
beforeEach(() => {
|
||||
store.share = false;
|
||||
store.creatable = ["inbox"];
|
||||
});
|
||||
|
||||
it("is offered in a read-only source, since copying only reads it", () => {
|
||||
expect(showCopy({ download: true, modify: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("is hidden when no source accepts the copy", () => {
|
||||
store.creatable = [];
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden without download", () => {
|
||||
expect(showCopy({ download: false, modify: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("in a share, still requires modify, which the server checks for every change", () => {
|
||||
store.share = true;
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
expect(showCopy({ download: true, modify: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, type App } from "vue";
|
||||
|
||||
const { store } = vi.hoisted(() => ({
|
||||
store: {
|
||||
share: false,
|
||||
creatable: [] as string[],
|
||||
scopes: [{ name: "data" }, { name: "inbox" }],
|
||||
permissions: { view: true, download: true, modify: false, create: false, delete: false },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
state: {
|
||||
selected: [],
|
||||
multiple: false,
|
||||
isSearchActive: false,
|
||||
shareInfo: {},
|
||||
navigation: {},
|
||||
sources: { current: "data" },
|
||||
req: { items: [], source: "data", path: "/" },
|
||||
user: {
|
||||
get scopes() {
|
||||
return store.scopes;
|
||||
},
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
setContextMenuHasItems: vi.fn(),
|
||||
closeHovers: vi.fn(),
|
||||
closeTopPrompt: vi.fn(),
|
||||
showPrompt: vi.fn(),
|
||||
},
|
||||
getters: {
|
||||
isShare: () => store.share,
|
||||
canCreateInSource: (source: string) => store.creatable.includes(source),
|
||||
globalPermissions: () => ({}),
|
||||
sourcePermissions: () => store.permissions,
|
||||
currentPromptName: () => "ContextMenu",
|
||||
currentPrompt: () => ({ props: {} }),
|
||||
currentTool: () => null,
|
||||
currentView: () => "listingView",
|
||||
isMobile: () => false,
|
||||
isAdmin: () => false,
|
||||
isLoggedIn: () => true,
|
||||
isPreviewView: () => false,
|
||||
isSplitViewActive: () => false,
|
||||
},
|
||||
}));
|
||||
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: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/url.js", () => ({ goToItem: vi.fn(), getParentDir: vi.fn(), removeLastDir: vi.fn() }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: () => false, nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({
|
||||
isRichTextPreviewMimeType: () => false,
|
||||
getTypeInfoFromExt: () => ({ materialSymbol: "", classes: "" }),
|
||||
}));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: () => false }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
let app: App | null = null;
|
||||
let host: HTMLElement | null = null;
|
||||
|
||||
/** Mounts the menu on one selected item and returns the icons of the actions it offers. */
|
||||
function offeredIcons(permissions: Partial<typeof store.permissions>): string[] {
|
||||
store.permissions = { view: true, download: false, modify: false, create: false, delete: false, ...permissions };
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
app = createApp({
|
||||
render: () => h(ContextMenu, { items: [{ name: "a.txt", path: "/a.txt", source: "data" }] }),
|
||||
});
|
||||
app.mount(host);
|
||||
return [...host.querySelectorAll("#context-menu .action > i")].map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("ContextMenu Copy", () => {
|
||||
beforeEach(() => {
|
||||
store.share = false;
|
||||
store.creatable = ["inbox"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
app = null;
|
||||
host = null;
|
||||
});
|
||||
|
||||
it("is offered in a read-only source, since copying only reads it", () => {
|
||||
expect(offeredIcons({ download: true, modify: false })).toContain("file_copy");
|
||||
});
|
||||
|
||||
it("is hidden when no source accepts the copy", () => {
|
||||
store.creatable = [];
|
||||
expect(offeredIcons({ download: true, modify: false })).not.toContain("file_copy");
|
||||
});
|
||||
|
||||
it("is hidden without download", () => {
|
||||
expect(offeredIcons({ download: false, modify: true })).not.toContain("file_copy");
|
||||
});
|
||||
|
||||
it("in a share, still requires modify, which the server checks for every change", () => {
|
||||
store.share = true;
|
||||
expect(offeredIcons({ download: true, modify: false })).not.toContain("file_copy");
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
expect(offeredIcons({ download: true, modify: true })).toContain("file_copy");
|
||||
});
|
||||
});
|
||||
+1031
-802
File diff suppressed because it is too large.
Load diff
@@ -731,7 +731,7 @@ function selectResultAt(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
#results {
|
||||
animation: slide-down 0.22s cubic-bezier(0.2, 0, 0, 1) forwards;
|
||||
animation: slide-down 0.5s forwards;
|
||||
border-radius: var(--borderRadius);
|
||||
border-top: none;
|
||||
border-top-left-radius: 0;
|
||||
@@ -785,7 +785,7 @@ function selectResultAt(target: EventTarget | null): boolean {
|
||||
right: calc(2.5% + var(--safe-area-right));
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
animation: expand 0.22s cubic-bezier(0.2, 0, 0, 1) backwards;
|
||||
animation: expand 0.3s ease 0.1s backwards;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
@@ -1019,7 +1019,7 @@ body.rtl #search #result ul>* {
|
||||
animation: none;
|
||||
}
|
||||
.search-input-container {
|
||||
transition: height 0.22s cubic-bezier(0.2, 0, 0, 1), background-color 0.22s ease, box-shadow 0.22s ease;
|
||||
transition: height 1s ease, background-color 1s ease, box-shadow 1s ease;
|
||||
}
|
||||
#search.active .search-input-container {
|
||||
box-shadow: 0 0 10px rgb(0 0 0 / 10%);
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
moveWithSidebar() {
|
||||
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
|
||||
return {
|
||||
left: "var(--sidebar-width)",
|
||||
left: `${state.sidebar.width}em`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
@@ -207,7 +207,7 @@ export default {
|
||||
z-index: 1000;
|
||||
right: 0;
|
||||
left: 0;
|
||||
transition: left 0.22s cubic-bezier(0.2, 0, 0, 1);
|
||||
transition: left 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
moveWithSidebar() {
|
||||
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
|
||||
return {
|
||||
left: "var(--sidebar-width)",
|
||||
left: `${state.sidebar.width}em`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
@@ -247,7 +247,7 @@ export default {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: bottom 0.5s ease, left 0.22s cubic-bezier(0.2, 0, 0, 1), width 0.22s cubic-bezier(0.2, 0, 0, 1);
|
||||
transition: bottom 0.5s ease, left 0.2s ease, width 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<i class="material-symbols">{{ btn.icon }}</i>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<transition :css="false" @enter="popEnter" @leave="popLeave">
|
||||
<transition name="expand" @before-enter="expandBeforeEnter" @enter="expandEnter" @leave="expandLeave">
|
||||
<ul v-if="openMenu === btn.menu" :ref="(el) => setIconMenuEl(btn.menu as 'align' | 'clipboard', el as HTMLElement | null)" class="editor-toolbar-menu editor-toolbar-menu--icon-menu floating-window border-radius" :style="menuStyle">
|
||||
<li v-for="item in iconMenuItems(btn.menu)" :key="item.id">
|
||||
<button
|
||||
@@ -112,7 +112,7 @@
|
||||
<i class="material-symbols">more_horiz</i>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<transition :css="false" @enter="popEnter" @leave="popLeave">
|
||||
<transition name="expand" @before-enter="expandBeforeEnter" @enter="expandEnter" @leave="expandLeave">
|
||||
<ul v-if="openMenu === 'extra'" ref="extraMenu" class="editor-toolbar-menu floating-window border-radius" :style="menuStyle">
|
||||
<li v-for="item in extraMenuItems" :key="item.id">
|
||||
<button
|
||||
@@ -141,7 +141,7 @@ import type { Ace } from "ace-builds";
|
||||
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 { expandBeforeEnter, expandEnter, expandLeave } from "@/utils/expandTransition";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
@@ -329,7 +329,7 @@ const extraMenuItems = computed((): ToolbarButton[] => {
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
if (openMenu.value === "align" || openMenu.value === "clipboard") {
|
||||
return { top: `${menuPosition.value.top}px`, left: `${menuPosition.value.left}px`, translate: "-50% 0" };
|
||||
return { top: `${menuPosition.value.top}px`, left: `${menuPosition.value.left}px`, transform: "translateX(-50%)" };
|
||||
}
|
||||
return { top: `${menuPosition.value.top}px`, right: `${menuPosition.value.right}px` };
|
||||
});
|
||||
@@ -984,11 +984,9 @@ defineExpose({ save });
|
||||
padding-bottom: 0.65em;
|
||||
list-style: none;
|
||||
z-index: 9999;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.editor-toolbar-menu--icon-menu {
|
||||
transform-origin: top center;
|
||||
display: flex;
|
||||
gap: 0.15em;
|
||||
padding-bottom: 0.5em;
|
||||
@@ -1006,4 +1004,9 @@ defineExpose({ save });
|
||||
.editor-toolbar-menu-btn .material-symbols {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -40,9 +40,9 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from '@/store';
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
|
||||
import throttle from "@/utils/throttle";
|
||||
|
||||
@@ -122,40 +122,7 @@ export default {
|
||||
isLoaded() {
|
||||
return !("preview-img" in (state?.loading || {}));
|
||||
},
|
||||
isHeicOrHeif() {
|
||||
const type = state.req?.type;
|
||||
return type === "image/heic" || type === "image/heif";
|
||||
},
|
||||
/** Full-resolution source for the viewer (Safari HEIC/HEIF uses the native file, not size=original). */
|
||||
fullImageSrc() {
|
||||
if (!this.isHeicOrHeif || !state.isSafari || !state.req) {
|
||||
return this.src;
|
||||
}
|
||||
const viewToken = state.req.viewToken;
|
||||
const typeHint = state.req.type || state.req.name;
|
||||
if (getters.isShare()) {
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
}
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
null,
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
},
|
||||
// Cached preview thumbs (from listing's preview API) shown while the full image loads.
|
||||
// Which cached entry to look, or null when no thumbnail should be shown
|
||||
thumbnailLookup() {
|
||||
if (!state?.req) {
|
||||
return null;
|
||||
@@ -163,9 +130,11 @@ export default {
|
||||
if (!this.path || !state.req.hasPreview) {
|
||||
return null;
|
||||
}
|
||||
const skipHeicThumbnail = this.isHeicOrHeif && !state.isSafari && state.req.hasPreview;
|
||||
const skipRawThumbnail = isRawImageMimeType(state.req?.type);
|
||||
if (skipHeicThumbnail || skipRawThumbnail) {
|
||||
// Don't use thumbnail when we show original embedded preview (HEIC/HEIF or raw)
|
||||
const isHeicOrHeif = state.req?.type === "image/heic" || state.req?.type === "image/heif";
|
||||
const useOriginalForHeic = isHeicOrHeif && (state.isSafari || (globalVars.ffmpegAvailable && globalVars.enableHeicConversion));
|
||||
const useOriginalForRaw = isRawImageMimeType(state.req?.type);
|
||||
if (useOriginalForHeic || useOriginalForRaw) {
|
||||
return null;
|
||||
}
|
||||
// For shares, use shareInfo.hash as the source; otherwise use this.source
|
||||
@@ -330,8 +299,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async loadFullImage() {
|
||||
const fullSrc = this.fullImageSrc;
|
||||
if (!fullSrc) return;
|
||||
if (!this.src) return;
|
||||
mutations.setLoading("preview-img", true);
|
||||
|
||||
// Set src directly via JavaScript to avoid Vue's HTML entity encoding in template bindings
|
||||
@@ -339,7 +307,7 @@ export default {
|
||||
await this.$nextTick();
|
||||
if (this.$refs.imgex && 'src' in this.$refs.imgex) {
|
||||
// Decode any HTML entities (Vue shouldn't encode props, but decode just in case)
|
||||
const cleanSrc = String(fullSrc).replace(/&/g, '&');
|
||||
const cleanSrc = String(this.src).replace(/&/g, '&');
|
||||
this.$refs.imgex.src = cleanSrc;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
dragging: dragState.type === 'previous',
|
||||
active: (dragState.atFullExtent && dragState.type === 'previous') || (gestureHint === 'previous' && gestureHintCommitReady),
|
||||
'media-mode': isMediaQueueMode,
|
||||
'sidebar-resizing': isSidebarResizing,
|
||||
'nav-button--hidden': !showNav,
|
||||
}"
|
||||
:style="previousButtonStyle"
|
||||
@@ -86,6 +87,7 @@
|
||||
active: gestureHint === 'close' && gestureHintCommitReady,
|
||||
'media-mode': isMediaQueueMode,
|
||||
'gesture-flash': gestureHintFlashClose,
|
||||
'sidebar-resizing': isSidebarResizing,
|
||||
'nav-button--hidden': !showCloseNavChrome,
|
||||
}"
|
||||
:style="closeButtonStyle"
|
||||
@@ -144,6 +146,7 @@ export default {
|
||||
tapTimeout: null,
|
||||
triggered: false
|
||||
},
|
||||
isSidebarResizing: false, // Track if sidebar is being resized
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -196,7 +199,8 @@ export default {
|
||||
closeButtonStyle() {
|
||||
const styles = { right: 'auto' };
|
||||
if (this.moveWithSidebar) {
|
||||
styles.left = 'calc(50% + var(--sidebar-width) / 2)';
|
||||
const half = this.sidebarWidth / 2;
|
||||
styles.left = `calc(50% + ${half}em)`;
|
||||
} else {
|
||||
styles.left = '50%';
|
||||
}
|
||||
@@ -242,7 +246,7 @@ export default {
|
||||
width: '5em',
|
||||
};
|
||||
if (this.moveWithSidebar) {
|
||||
styles.left = 'var(--sidebar-width)';
|
||||
styles.left = `${this.sidebarWidth}em`;
|
||||
} else {
|
||||
styles.left = '0';
|
||||
}
|
||||
@@ -255,7 +259,7 @@ export default {
|
||||
}
|
||||
// Calculate left position based on sidebar
|
||||
if (this.moveWithSidebar) {
|
||||
styles.left = 'calc(var(--sidebar-width) + 1em)'; // tiny padding next to the sticky sidebar
|
||||
styles.left = `calc(${this.sidebarWidth}em + 1em)`; // When sidebar is sticky the position of the button will have a tiny padding
|
||||
} else {
|
||||
styles.left = '1em';
|
||||
}
|
||||
@@ -321,6 +325,10 @@ export default {
|
||||
'state.navigation.nextLink'() {
|
||||
this.showInitialNavigation();
|
||||
},
|
||||
'state.sidebar.isResizing'(newVal) {
|
||||
// Track when sidebar is being resized to disable transitions
|
||||
this.isSidebarResizing = newVal;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
window.addEventListener("keydown", this.keyEvent);
|
||||
@@ -1064,6 +1072,11 @@ export default {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Disable transitions during sidebar resizing */
|
||||
.nav-button.sidebar-resizing {
|
||||
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-button.nav-close {
|
||||
/* Below header; +4em vs previous placement; horizontal position from closeButtonStyle when sidebar offsets main */
|
||||
top: calc(1.25em + 4em);
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
if (!skipped) {
|
||||
this.media = media;
|
||||
}
|
||||
void mutations.syncMediaSettings();
|
||||
void mutations.syncMediaPlayerSettings();
|
||||
notify.showSuccess(this.$t("general.saved"));
|
||||
eventBus.emit(MEDIA_SETTINGS_CHANGED_EVENT);
|
||||
this.close();
|
||||
|
||||
@@ -238,7 +238,7 @@ export default {
|
||||
if (!skipped) {
|
||||
this.media = media;
|
||||
}
|
||||
void mutations.syncMediaSettings();
|
||||
void mutations.syncMediaPlayerSettings();
|
||||
notify.showSuccess(this.$t("general.saved"));
|
||||
eventBus.emit(MEDIA_SETTINGS_CHANGED_EVENT);
|
||||
this.close();
|
||||
|
||||
@@ -16,7 +16,7 @@ const { store, router, notify } = vi.hoisted(() => {
|
||||
setCurrentUser: vi.fn((user) => { state.user = user; }),
|
||||
setSession: vi.fn(),
|
||||
syncEnforcedUserDefaults: vi.fn(),
|
||||
syncMediaSettings: vi.fn(),
|
||||
syncMediaPlayerSettings: vi.fn(),
|
||||
syncSidebarLinkDefaultsPolicy: vi.fn(),
|
||||
syncToolAccessDefaultsPolicy: vi.fn(),
|
||||
syncShareDefaultsPolicy: vi.fn(),
|
||||
|
||||
@@ -295,8 +295,6 @@ const PROFILE_SNAPSHOT_FIELDS = [
|
||||
"debugOffice",
|
||||
"viewMode",
|
||||
"gallerySize",
|
||||
"promptRightCloseButton",
|
||||
"newFileTemplate",
|
||||
];
|
||||
|
||||
export default {
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<transition
|
||||
:css="false"
|
||||
name="expand"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
@after-leave="onPanelAfterLeave"
|
||||
@@ -108,7 +109,8 @@
|
||||
</div>
|
||||
<transition
|
||||
v-else
|
||||
:css="false"
|
||||
name="expand"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
@after-leave="onPanelAfterLeave"
|
||||
@@ -144,12 +146,15 @@
|
||||
|
||||
<script>
|
||||
import MenuOptionList from "@/components/MenuOptionList.vue";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
import {
|
||||
expandBeforeEnter,
|
||||
expandEnter,
|
||||
expandLeave,
|
||||
} from "@/utils/expandTransition";
|
||||
|
||||
let expandDropdownIdCounter = 0;
|
||||
const EXPAND_OPEN_MS = 200;
|
||||
const EXPAND_CLOSE_MS = 120;
|
||||
const PANEL_FROM_SCALE_X = 1;
|
||||
const EXPAND_OPEN_MS = 300;
|
||||
const EXPAND_CLOSE_MS = 150;
|
||||
|
||||
export default {
|
||||
name: "ExpandDropdown",
|
||||
@@ -529,13 +534,13 @@ export default {
|
||||
if (this.allowSearch) {
|
||||
this.$refs.menuOptions?.focusSearch();
|
||||
}
|
||||
popEnter(el, () => {
|
||||
expandEnter(el, () => {
|
||||
this.updateOverlayPosition();
|
||||
done();
|
||||
}, EXPAND_OPEN_MS, PANEL_FROM_SCALE_X);
|
||||
}, EXPAND_OPEN_MS);
|
||||
},
|
||||
leave(el, done) {
|
||||
popLeave(el, done, EXPAND_CLOSE_MS, PANEL_FROM_SCALE_X);
|
||||
expandLeave(el, done, EXPAND_CLOSE_MS);
|
||||
},
|
||||
observePanelResize() {
|
||||
const panel = this.$refs.panel;
|
||||
@@ -621,6 +626,7 @@ export default {
|
||||
this.$emit("update:modelValue", option.value);
|
||||
this.close();
|
||||
},
|
||||
beforeEnter: expandBeforeEnter,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -764,7 +770,6 @@ export default {
|
||||
}
|
||||
|
||||
.expand-dropdown-body:not(.expand-upward) {
|
||||
transform-origin: top center;
|
||||
margin-top: -2px;
|
||||
border-width: 0 var(--borderWidth) var(--borderWidth);
|
||||
border-top-left-radius: 0 !important;
|
||||
@@ -772,7 +777,6 @@ export default {
|
||||
}
|
||||
|
||||
.expand-dropdown-body.expand-upward {
|
||||
transform-origin: bottom center;
|
||||
margin-bottom: -2px;
|
||||
border-width: var(--borderWidth) var(--borderWidth) 0;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
@@ -827,4 +831,20 @@ export default {
|
||||
.expand-dropdown--transparent:not(.expand-dropdown--open) .expand-dropdown-chevron {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.expand-enter-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-leave-active {
|
||||
transition: height 0.15s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import { getters } from "@/store/index.js";
|
||||
import { state, getters } from "@/store";
|
||||
|
||||
const buttonGroup = new Map<string, { visible: boolean; zoneActive: boolean; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
|
||||
@@ -211,7 +211,7 @@ export default {
|
||||
const isLeftPositioned = this.position === "top-left" || this.position === "bottom-left";
|
||||
const pushedBySidebar = isLeftPositioned && getters.isSidebarVisible() && getters.isStickySidebar();
|
||||
if (pushedBySidebar && style.left === undefined) {
|
||||
style.left = `calc(var(--fab-edge-offset-media) + var(--sidebar-width))`;
|
||||
style.left = `calc(var(--fab-edge-offset-media) + ${state.sidebar.width}em)`;
|
||||
}
|
||||
|
||||
return style;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<transition :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<transition name="expand" @before-enter="beforeEnter" @enter="enter" @leave="leave">
|
||||
<div v-show="!actuallyCollapsed" class="settings-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
<script>
|
||||
import { SETTINGS_ACCORDION_KEY } from "./settingsAccordion.js";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions.ts";
|
||||
|
||||
export default {
|
||||
name: 'SettingsItem',
|
||||
@@ -90,8 +89,45 @@ export default {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.$emit('toggle', !this.isCollapsed);
|
||||
},
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
/**
|
||||
* @param {Element} el
|
||||
*/
|
||||
beforeEnter(el) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
enter(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = '';
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
void element.offsetHeight;
|
||||
element.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
element.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
element.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
leave(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
void element.offsetHeight;
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -129,9 +165,27 @@ export default {
|
||||
padding: 0 0.5em 1em;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-group-title.unclickable {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Prevent content height issues during animation */
|
||||
.settings-content .input,
|
||||
.settings-content .settings-items {
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -77,7 +77,7 @@
|
||||
step="1"
|
||||
:value="account.maxConcurrentTranscodes"
|
||||
@change="emitAccountChange('maxConcurrentTranscodes', clampMaxConcurrentTranscodes($event.target.value))"
|
||||
:disabled="isFieldDisabled('maxConcurrentTranscodes') || !maxConcurrentTranscodesLimitKnown"
|
||||
:disabled="isFieldDisabled('maxConcurrentTranscodes')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,6 +138,7 @@ import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import { getObjectProperty } from "@/utils/object.js";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { state } from "@/store";
|
||||
|
||||
let transcodeInputCounter = 0;
|
||||
@@ -191,15 +192,14 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
maxConcurrentTranscodesLimitKnown() {
|
||||
return this.maxConcurrentTranscodesLimit !== undefined;
|
||||
},
|
||||
maxConcurrentTranscodesLimit() {
|
||||
const live = state.mediaSettings?.maxConcurrentPerUser;
|
||||
// Prefer the live media settings; globalVars are static page constants.
|
||||
const live = state.mediaPlayerSettings?.maxConcurrentPerUser;
|
||||
if (Number.isFinite(live) && live > 0) {
|
||||
return live;
|
||||
}
|
||||
return undefined;
|
||||
const limit = globalVars?.transcodeMaxPerUser;
|
||||
return Number.isFinite(limit) && limit > 0 ? limit : undefined;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -27,29 +27,18 @@
|
||||
:name="$t('profileSettings.disableHideSidebar')"
|
||||
:description="$t('profileSettings.disableHideSidebarDescription')"
|
||||
/>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t('profileSettings.darkMode') }}</h4>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@mouseenter="showEnforcedTooltipIfLocked($event, 'ui', 'darkMode')"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<ExpandDropdown
|
||||
v-model="darkMode"
|
||||
:options="darkModeChoices"
|
||||
:aria-label="$t('profileSettings.darkMode')"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
/>
|
||||
</div>
|
||||
<template #enforce>
|
||||
<ProfileEnforceSwitch
|
||||
:visible="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
:disabled="disabled"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
/>
|
||||
</template>
|
||||
</ProfileEnforceableField>
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
:enforceable="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
v-model="darkMode"
|
||||
@change="() => emitSectionChange('ui', 'darkMode')"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
:enforcement-locked="isEnforcementLocked('ui', 'darkMode')"
|
||||
:name="$t('profileSettings.darkMode')"
|
||||
:description="helpText('ui', 'darkMode', $t('index.toggleDark'))"
|
||||
/>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t("settings.themeColor") }}</h4>
|
||||
<div
|
||||
@@ -637,29 +626,18 @@
|
||||
:start-collapsed="sectionStartsCollapsed('themeLanguage')"
|
||||
>
|
||||
<div class="settings-items">
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t('profileSettings.darkMode') }}</h4>
|
||||
<div
|
||||
class="form-flex-group"
|
||||
@mouseenter="showEnforcedTooltipIfLocked($event, 'ui', 'darkMode')"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<ExpandDropdown
|
||||
v-model="darkMode"
|
||||
:options="darkModeChoices"
|
||||
:aria-label="$t('profileSettings.darkMode')"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
/>
|
||||
</div>
|
||||
<template #enforce>
|
||||
<ProfileEnforceSwitch
|
||||
:visible="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
:disabled="disabled"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
/>
|
||||
</template>
|
||||
</ProfileEnforceableField>
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
:enforceable="enforceable"
|
||||
:enforced="enforcedFlag('ui', 'darkMode')"
|
||||
v-model="darkMode"
|
||||
@change="() => emitSectionChange('ui', 'darkMode')"
|
||||
@update:enforced="(v) => emitEnforced('ui', 'darkMode', v)"
|
||||
:disabled="fieldDisabled('ui', 'darkMode')"
|
||||
:enforcement-locked="isEnforcementLocked('ui', 'darkMode')"
|
||||
:name="$t('profileSettings.darkMode')"
|
||||
:description="helpText('ui', 'darkMode', $t('index.toggleDark'))"
|
||||
/>
|
||||
<ProfileEnforceableField :enforceable="enforceable">
|
||||
<h4>{{ $t("settings.themeColor") }}</h4>
|
||||
<div
|
||||
@@ -767,7 +745,6 @@ import {
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import { getters, mutations } from "@/store";
|
||||
import { getObjectProperty, setObjectProperty } from "@/utils/object.js";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import ProfilePreferenceToggle from "@/components/settings/ProfilePreferenceToggle.vue";
|
||||
import ProfileEnforceSwitch from "@/components/settings/ProfileEnforceSwitch.vue";
|
||||
@@ -943,21 +920,14 @@ export default {
|
||||
},
|
||||
darkMode: {
|
||||
get() {
|
||||
return normalizeDarkMode(this.sections.ui?.darkMode);
|
||||
const v = this.sections.ui?.darkMode;
|
||||
return v === undefined || v === null ? true : !!v;
|
||||
},
|
||||
set(val) {
|
||||
const next = { ...this.sections, ui: { ...(this.sections.ui || {}), darkMode: val } };
|
||||
this.sections = next;
|
||||
this.emitSectionChange("ui", "darkMode");
|
||||
},
|
||||
},
|
||||
darkModeChoices() {
|
||||
return [
|
||||
{ value: "auto", label: this.$t("profileSettings.darkModeSystem") },
|
||||
{ value: "light", label: this.$t("profileSettings.darkModeLight") },
|
||||
{ value: "dark", label: this.$t("profileSettings.darkModeDark") },
|
||||
];
|
||||
},
|
||||
autoplayMedia: {
|
||||
get() {
|
||||
const v = this.sections.fileViewer?.autoplayMedia;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/utils/auth", () => ({ logout: vi.fn() }));
|
||||
vi.mock("@/utils/constants", () => ({ globalVars: {} }));
|
||||
vi.mock("@/store", () => ({ state: {}, getters: {}, mutations: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("./Links.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
import General from "./General.vue";
|
||||
|
||||
describe("sidebar File actions leave transition", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("finishes even when no transitionend ever fires", () => {
|
||||
vi.useFakeTimers();
|
||||
const el = document.createElement("div");
|
||||
const done = vi.fn();
|
||||
General.methods.leave(el, done);
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(done).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -38,9 +38,9 @@
|
||||
<i class="material-symbols">ads_click</i>
|
||||
</div>
|
||||
<div aria-label="Toggle Theme" v-if="darkModeTogglePossible" class="clickable"
|
||||
:class="{ active: isDarkMode }" @click="cycleDarkMode"
|
||||
@mouseenter="showTooltip($event, darkModeTooltip)" @mouseleave="hideTooltip">
|
||||
<i class="material-symbols">{{ darkModeIcon }}</i>
|
||||
:class="{ active: user?.darkMode }" @click="toggleDarkMode"
|
||||
@mouseenter="showTooltip($event, $t('index.toggleDark'))" @mouseleave="hideTooltip">
|
||||
<i class="material-symbols">dark_mode</i>
|
||||
</div>
|
||||
<div class="clickable" :class="{ active: isStickySidebar }" @click="toggleSticky"
|
||||
@mouseenter="showTooltip($event, $t('index.toggleSticky'))" @mouseleave="hideTooltip" v-if="!isMobile">
|
||||
@@ -50,7 +50,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar file actions -->
|
||||
<transition v-if="shareInfo.shareType !== 'upload'" :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<transition v-if="shareInfo.shareType !== 'upload'" name="expand" @before-enter="beforeEnter" @enter="enter"
|
||||
@leave="leave">
|
||||
<div v-if="showFileActions" class="card-wrapper">
|
||||
<button type="button" @click="openContextMenu" aria-label="File-Actions" data-testid="file-actions-button" class="action file-actions">
|
||||
<i class="material-symbols">add</i>
|
||||
@@ -80,8 +81,7 @@ import {
|
||||
} from "@/utils/tooltipHelp.js";
|
||||
import SidebarLinks from "./Links.vue";
|
||||
import { url } from "@/utils";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions";
|
||||
import { expandLeave } from "@/utils/expandTransition";
|
||||
|
||||
export default {
|
||||
name: "SidebarGeneral",
|
||||
@@ -133,20 +133,6 @@ export default {
|
||||
route: () => state.route,
|
||||
realtimeActive: () => state.realtimeActive,
|
||||
darkModeTogglePossible: () => state.shareInfo?.enforceDarkLightMode !== "dark" && state.shareInfo?.enforceDarkLightMode !== "light",
|
||||
isDarkMode: () => getters.isDarkMode(),
|
||||
darkModeIcon() {
|
||||
const icons = { auto: "routine", light: "light_mode", dark: "dark_mode" };
|
||||
return icons[normalizeDarkMode(state.user?.darkMode)];
|
||||
},
|
||||
darkModeTooltip() {
|
||||
const labels = {
|
||||
auto: this.$t("profileSettings.darkModeSystem"),
|
||||
light: this.$t("profileSettings.darkModeLight"),
|
||||
dark: this.$t("profileSettings.darkModeDark"),
|
||||
};
|
||||
const mode = labels[normalizeDarkMode(state.user?.darkMode)];
|
||||
return `${this.$t("index.toggleDark")} — ${mode}`;
|
||||
},
|
||||
shouldShowLogin() {
|
||||
if (getters.isShare()) {
|
||||
// Don't show login until shareInfo is fully loaded
|
||||
@@ -187,8 +173,8 @@ export default {
|
||||
toggleClick() {
|
||||
void mutations.updateCurrentUser({ singleClick: !state.user.singleClick });
|
||||
},
|
||||
cycleDarkMode() {
|
||||
mutations.cycleDarkMode();
|
||||
toggleDarkMode() {
|
||||
mutations.toggleDarkMode();
|
||||
},
|
||||
toggleSticky() {
|
||||
// keep sidebar open if disabling sticky sidebar
|
||||
@@ -229,8 +215,29 @@ export default {
|
||||
}
|
||||
await logout(sharePath);
|
||||
},
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
beforeEnter(el) {
|
||||
el.style.maxHeight = '0';
|
||||
el.style.opacity = '0';
|
||||
},
|
||||
enter(el, done) {
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = 'max-height 0.2s ease, opacity 0.15s ease';
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
const onTransitionEnd = () => {
|
||||
el.style.maxHeight = '';
|
||||
el.removeEventListener('transitionend', onTransitionEnd);
|
||||
done();
|
||||
};
|
||||
el.addEventListener('transitionend', onTransitionEnd);
|
||||
});
|
||||
},
|
||||
leave(el, done) {
|
||||
// Finish on a timer, not transitionend: a card shown on first render never ran enter() and has no
|
||||
// transition, and sidebar resizing sets `transition: none`. Either way transitionend never fires,
|
||||
// done() is never called, and the card stays mounted, invisible but still clickable.
|
||||
expandLeave(el, done, 200);
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
if (text) {
|
||||
showHoverTooltip(text, event);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div v-if="isShare && !disableShareCard" class="share-info-section">
|
||||
<ShareInfo :hash="hash" :token="token" :sub-path="subPath" />
|
||||
</div>
|
||||
<transition-group :css="false" tag="div" class="inner-card" @enter="slideEnter" @leave="slideLeave">
|
||||
<transition-group name="expand" tag="div" class="inner-card">
|
||||
<template v-for="(link, index) in sidebarLinksToDisplay" :key="`link-${index}-${link.category}`">
|
||||
<!-- Divider: renders as hr line or text label -->
|
||||
<div v-if="link.category === 'divider'" class="sidebar-divider-container">
|
||||
@@ -178,7 +178,6 @@ import { showShareDownloadPrompt } from "@/utils/download.js";
|
||||
import ShareInfo from "@/components/files/ShareInfo.vue";
|
||||
import FileTree from '@/components/files/FileTree.vue';
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import { slideEnter, slideLeave } from "@/utils/transitions";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
export default {
|
||||
name: "SidebarLinks",
|
||||
@@ -298,8 +297,6 @@ export default {
|
||||
hideInteractiveTooltip(true);
|
||||
},
|
||||
methods: {
|
||||
slideEnter,
|
||||
slideLeave,
|
||||
isSourceCategory(category) {
|
||||
return isSourceSidebarCategory(category);
|
||||
},
|
||||
@@ -534,6 +531,32 @@ export default {
|
||||
name: "SidebarLinks",
|
||||
});
|
||||
},
|
||||
beforeEnter(el) {
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
},
|
||||
enter(el, done) {
|
||||
el.style.transition = '';
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
// Force reflow
|
||||
void el.offsetHeight;
|
||||
el.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
leave(el, done) {
|
||||
el.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
void el.offsetHeight;
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
if (text) {
|
||||
showHoverTooltip(text, event);
|
||||
@@ -768,6 +791,19 @@ a.sidebar-link-button {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
will-change: opacity, max-height;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.source-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -41,29 +41,27 @@
|
||||
keyboard_arrow_down
|
||||
</i>
|
||||
</div>
|
||||
<transition :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<div v-show="expandedSections[setting.id]" class="settings-card-sub-item">
|
||||
<div class="settings-card-sub-item-inner">
|
||||
<div
|
||||
v-for="section in visibleSections(setting)"
|
||||
:key="section.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="settings-card-collapsible-sub-item settings-card clickable"
|
||||
:class="{ 'active-settings': active(`${setting.id}-${section.id}`) }"
|
||||
@click.stop="setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.enter.stop.prevent="!$event.repeat && setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.space.stop.prevent
|
||||
@keyup.space.stop="setView(`${setting.id}-${section.id}`)"
|
||||
>
|
||||
<span class="settings-item-content">
|
||||
<span class="material-symbols-outlined settings-icon">{{ section.icon }}</span>
|
||||
{{ $t(section.label, { suffix: "" }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-card-sub-item" :class="{ 'settings-card-collapsible--expanded': expandedSections[setting.id] }">
|
||||
<div class="settings-card-sub-item-inner">
|
||||
<div
|
||||
v-for="section in visibleSections(setting)"
|
||||
:key="section.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="settings-card-collapsible-sub-item settings-card clickable"
|
||||
:class="{ 'active-settings': active(`${setting.id}-${section.id}`) }"
|
||||
@click.stop="setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.enter.stop.prevent="!$event.repeat && setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.space.stop.prevent
|
||||
@keyup.space.stop="setView(`${setting.id}-${section.id}`)"
|
||||
>
|
||||
<span class="settings-item-content">
|
||||
<span class="material-symbols-outlined settings-icon">{{ section.icon }}</span>
|
||||
{{ $t(section.label, { suffix: "" }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -92,7 +90,6 @@ import { state, getters, mutations } from "@/store";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { settings } from "@/utils/constants";
|
||||
import { router } from "@/router";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions";
|
||||
|
||||
export default {
|
||||
name: "SidebarSettings",
|
||||
@@ -125,8 +122,6 @@ export default {
|
||||
requestAnimationFrame(() => this.syncExpanded());
|
||||
},
|
||||
methods: {
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
syncExpanded() {
|
||||
for (const setting of this.expandableSettings) {
|
||||
this.expandedSections[setting.id] = this.isSectionActive(setting) && this.canExpand(setting);
|
||||
@@ -236,7 +231,21 @@ export default {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.settings-card-sub-item {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.settings-card-collapsible--expanded {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.settings-card-sub-item-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-card-collapsible--expanded .settings-card-sub-item-inner {
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<nav
|
||||
id="sidebar"
|
||||
:class="{ active: active, 'behind-overlay': behindOverlay, 'scrollable': isSettings }"
|
||||
:style="{ width: 'var(--sidebar-width)', translate: active ? 'none' : '-100%' }"
|
||||
:style="{ width: `${sidebarWidth}em`, left: active ? '0' : `-${sidebarWidth}em` }"
|
||||
>
|
||||
<div v-if="shouldShow" class="button release-banner">
|
||||
<a :href="releaseUrl">{{ $t("sidebar.updateIsAvailable") }}</a>
|
||||
@@ -44,9 +44,6 @@
|
||||
<div v-if="active && !isMobile" class="sidebar-resizer" @mousedown="startResize" @touchstart="startResize" >
|
||||
<div class="resizer-handle"></div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="isResizing" class="sidebar-is-resizing"></div>
|
||||
</Teleport>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -67,9 +64,6 @@ export default {
|
||||
return {
|
||||
resizeStartX: 0,
|
||||
resizeStartWidth: 0,
|
||||
resizeBaseFontSize: 16,
|
||||
resizePendingWidth: 0,
|
||||
resizeFrame: 0,
|
||||
previousSidebarSize: null, // Remember the previous width when switching from desktop to mobile.
|
||||
pwaInstallDismissed: localStorage.getItem("pwaInstallDismissed") === "true",
|
||||
};
|
||||
@@ -100,12 +94,7 @@ export default {
|
||||
document.addEventListener('touchend', this.stopResize);
|
||||
},
|
||||
beforeUnmount() {
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
if (state.sidebar.isResizing) {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${state.sidebar.width}em`);
|
||||
mutations.setSidebarResizing(false);
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
}
|
||||
// Clean up event listener
|
||||
if (this.handleKeydown) {
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
@@ -133,7 +122,6 @@ export default {
|
||||
active: () => getters.isSidebarVisible(),
|
||||
behindOverlay: () => state.isSearchActive || (state.prompts && state.prompts.length > 0),
|
||||
sidebarWidth: () => getters.sidebarWidth(),
|
||||
isResizing: () => state.sidebar.isResizing,
|
||||
shouldShow() {
|
||||
return (
|
||||
globalVars.updateAvailable !== "" &&
|
||||
@@ -171,8 +159,6 @@ export default {
|
||||
const clientX = event.clientX || (event.touches?.[0].clientX);
|
||||
this.resizeStartX = clientX;
|
||||
this.resizeStartWidth = this.sidebarWidth;
|
||||
this.resizePendingWidth = this.sidebarWidth;
|
||||
this.resizeBaseFontSize = this.getBaseFontSize();
|
||||
mutations.setSidebarResizing(true);
|
||||
document.body.classList.add('sidebar-resizing');
|
||||
},
|
||||
@@ -182,23 +168,15 @@ export default {
|
||||
// Same here
|
||||
const clientX = event.clientX || (event.touches?.[0].clientX);
|
||||
if (!clientX) return;
|
||||
const deltaEm = (clientX - this.resizeStartX) / this.resizeBaseFontSize;
|
||||
const deltaX = clientX - this.resizeStartX;
|
||||
// Convert pixels to em
|
||||
const baseFontSize = this.getBaseFontSize();
|
||||
const deltaEm = deltaX / baseFontSize;
|
||||
const newWidth = this.resizeStartWidth + deltaEm;
|
||||
this.resizePendingWidth = Math.max(state.sidebar.minWidth, Math.min(newWidth, state.sidebar.maxWidth));
|
||||
if (!this.resizeFrame) {
|
||||
this.resizeFrame = requestAnimationFrame(this.applyResizeWidth);
|
||||
}
|
||||
},
|
||||
// Only the CSS variable moves during the drag, so no component re-renders per mouse move.
|
||||
applyResizeWidth() {
|
||||
this.resizeFrame = 0;
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${this.resizePendingWidth}em`);
|
||||
mutations.setSidebarWidth(newWidth);
|
||||
},
|
||||
stopResize() {
|
||||
if (!state.sidebar.isResizing) return;
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
this.applyResizeWidth();
|
||||
mutations.setSidebarWidth(this.resizePendingWidth);
|
||||
mutations.setSidebarResizing(false);
|
||||
mutations.persistSidebarWidth();
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
@@ -231,24 +209,22 @@ export default {
|
||||
padding: 1em;
|
||||
width: 20em;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
transform: translateZ(0);
|
||||
height: 100%;
|
||||
/* `translate` slides on the compositor; animating `left`/`width` re-lays out every frame. */
|
||||
transition: translate 0.22s cubic-bezier(0.2, 0, 0, 1), z-index 0s linear 0.11s;
|
||||
transition: left 0.4s ease, width 0.4s ease, z-index 0s linear 0.2s;
|
||||
top: var(--header-height);
|
||||
padding-bottom: 4em;
|
||||
background-color: var(--panel-bg);
|
||||
backdrop-filter: var(--panel-blur);
|
||||
will-change: translate;
|
||||
will-change: left;
|
||||
backface-visibility: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
#sidebar.behind-overlay {
|
||||
z-index: 3;
|
||||
transition: translate 0.22s cubic-bezier(0.2, 0, 0, 1), z-index 0s;
|
||||
transition: left 0.4s ease, width 0.4s ease, z-index 0s;
|
||||
}
|
||||
|
||||
#sidebar.sticky {
|
||||
@@ -278,14 +254,10 @@ body.rtl .action {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sidebar-is-resizing {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
body.sidebar-resizing :is(#main, #status-bar, #shelf, .progress-line, .nav-button) {
|
||||
body.sidebar-resizing,
|
||||
body.sidebar-resizing * {
|
||||
cursor: col-resize !important;
|
||||
pointer-events: none;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -293,6 +265,10 @@ body.sidebar-resizing :is(#main, #status-bar, #shelf, .progress-line, .nav-butto
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* * * * * * * * * * * * * * * *
|
||||
* FOOTER *
|
||||
* * * * * * * * * * * * * * * */
|
||||
|
||||
.credits {
|
||||
font-size: 1em;
|
||||
color: var(--textPrimary);
|
||||
|
||||
@@ -796,9 +796,6 @@
|
||||
"singleClick": "انقر مرة واحدة لفتح الملفات",
|
||||
"stickySidebar": "الشريط الجانبي الثابت",
|
||||
"darkMode": "الوضع المظلم",
|
||||
"darkModeSystem": "حسب النظام",
|
||||
"darkModeLight": "فاتح",
|
||||
"darkModeDark": "داكن",
|
||||
"defaultViewMode": "وضع العرض الافتراضي",
|
||||
"defaultViewModeDescription": "وضع العرض الافتراضي في المتصفحات أو الأجهزة الجديدة. لا يلغي تفضيلات العرض التي تم حفظها مسبقًا محليًّا.",
|
||||
"defaultGallerySize": "الحجم الافتراضي للصورة المصغرة",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "تم العثور على مجموعات مكررة{suffix}",
|
||||
"totalWastedSpace": "إجمالي المساحة المهدرة{suffix}",
|
||||
"wasted": "ضائع{suffix}",
|
||||
"emptyState": "أدخل معايير البحث وانقر فوق البحث عن التكرارات لتحديد الملفات المكررة",
|
||||
"noResults": "اكتمل البحث — لم يتم العثور على أي ملفات مكررة."
|
||||
"emptyState": "أدخل معايير البحث وانقر فوق البحث عن التكرارات لتحديد الملفات المكررة"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
|
||||
@@ -612,9 +612,6 @@
|
||||
"singleClick": "Отваряне на файлове с едно кликване",
|
||||
"stickySidebar": "Залепена странична лента",
|
||||
"darkMode": "Тъмен режим",
|
||||
"darkModeSystem": "Според системата",
|
||||
"darkModeLight": "Светъл",
|
||||
"darkModeDark": "Тъмен",
|
||||
"defaultViewMode": "Режим на изглед по подразбиране",
|
||||
"defaultViewModeDescription": "Режим на първоначално показване в нови браузъри или устройства. Не отменя вече запазените локално настройки за показване.",
|
||||
"defaultGallerySize": "Стандартен размер на миниатюрите",
|
||||
@@ -1148,8 +1145,7 @@
|
||||
"groupsFound": "Намерени групи с дубликати{suffix}",
|
||||
"totalWastedSpace": "Общо загубено пространство{suffix}",
|
||||
"wasted": "Загубено{suffix}",
|
||||
"emptyState": "Въведете критерии за търсене и кликнете Намиране на дубликати, за да идентифицирате дублирани файлове",
|
||||
"noResults": "Търсенето приключи — не бяха открити дублиращи се файлове."
|
||||
"emptyState": "Въведете критерии за търсене и кликнете Намиране на дубликати, за да идентифицирате дублирани файлове"
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "Сървърът беше изключен.",
|
||||
|
||||
@@ -426,9 +426,6 @@
|
||||
"singleClick": "Otevření souborů jedním kliknutím",
|
||||
"stickySidebar": "Lepivý postranní panel",
|
||||
"darkMode": "Tmavý režim",
|
||||
"darkModeSystem": "Podle systému",
|
||||
"darkModeLight": "Světlý",
|
||||
"darkModeDark": "Tmavý",
|
||||
"defaultViewMode": "Výchozí režim zobrazení",
|
||||
"defaultViewModeDescription": "Výchozí režim zobrazení v nových prohlížečích nebo na nových zařízeních. Nepřepíše nastavení zobrazení, která jsou již uložena lokálně.",
|
||||
"defaultGallerySize": "Výchozí velikost miniatury",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Nalezeny duplicitní skupiny{suffix}",
|
||||
"totalWastedSpace": "Celkový promarněný prostor{suffix}",
|
||||
"wasted": "Promarněné{suffix}",
|
||||
"emptyState": "Zadejte kritéria vyhledávání a kliknutím na tlačítko Najít duplikáty identifikujte duplicitní soubory.",
|
||||
"noResults": "Vyhledávání dokončeno – nebyly nalezeny žádné duplicitní soubory."
|
||||
"emptyState": "Zadejte kritéria vyhledávání a kliknutím na tlačítko Najít duplikáty identifikujte duplicitní soubory."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Oznámení",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "Dateien mit einem Klick öffnen",
|
||||
"stickySidebar": "Feste Seitenleiste",
|
||||
"darkMode": "Dunkler Modus",
|
||||
"darkModeSystem": "Systemeinstellung folgen",
|
||||
"darkModeLight": "Hell",
|
||||
"darkModeDark": "Dunkel",
|
||||
"defaultViewMode": "Standard-Anzeigemodus",
|
||||
"defaultViewModeDescription": "Standardansichtsmodus in neuen Browsern oder auf neuen Geräten. Überschreibt keine bereits lokal gespeicherten Anzeigeeinstellungen.",
|
||||
"defaultGallerySize": "Standardgröße der Miniaturansicht",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Doppelte Gruppen gefunden{suffix}",
|
||||
"totalWastedSpace": "Insgesamt verschwendeter Platz{suffix}",
|
||||
"wasted": "Vergeudet{suffix}",
|
||||
"emptyState": "Geben Sie Suchkriterien ein und klicken Sie auf Duplikate suchen, um doppelte Dateien zu identifizieren",
|
||||
"noResults": "Suche abgeschlossen – es wurden keine doppelten Dateien gefunden."
|
||||
"emptyState": "Geben Sie Suchkriterien ein und klicken Sie auf Duplikate suchen, um doppelte Dateien zu identifizieren"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "Ανοίξτε τα αρχεία με ένα μόνο κλικ",
|
||||
"stickySidebar": "Πλευρική στήλη που παραμένει σταθερή",
|
||||
"darkMode": "Σκοτεινή λειτουργία",
|
||||
"darkModeSystem": "Με βάση το σύστημα",
|
||||
"darkModeLight": "Φωτεινό",
|
||||
"darkModeDark": "Σκούρο",
|
||||
"defaultViewMode": "Προεπιλεγμένη λειτουργία προβολής",
|
||||
"defaultViewModeDescription": "Λειτουργία αρχικής προβολής σε νέους περιηγητές ή συσκευές. Δεν αντικαθιστά τις προτιμήσεις προβολής που έχουν ήδη αποθηκευτεί τοπικά.",
|
||||
"defaultGallerySize": "Προεπιλεγμένο μέγεθος μικρογραφίας",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Βρέθηκαν διπλές ομάδες{suffix}",
|
||||
"totalWastedSpace": "Συνολικός σπαταλημένος χώρος{suffix}",
|
||||
"wasted": "Σπατάλη{suffix}",
|
||||
"emptyState": "Εισάγετε τα κριτήρια αναζήτησης και κάντε κλικ στο Find Duplicates για να εντοπίσετε τα διπλότυπα αρχεία.",
|
||||
"noResults": "Η αναζήτηση ολοκληρώθηκε — δεν εντοπίστηκαν διπλά αρχεία."
|
||||
"emptyState": "Εισάγετε τα κριτήρια αναζήτησης και κάντε κλικ στο Find Duplicates για να εντοπίσετε τα διπλότυπα αρχεία."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Ειδοποιήσεις",
|
||||
|
||||
@@ -581,9 +581,6 @@
|
||||
"singleClick": "Single-click to open files",
|
||||
"stickySidebar": "Sticky sidebar",
|
||||
"darkMode": "Dark mode",
|
||||
"darkModeSystem": "Follow system",
|
||||
"darkModeLight": "Light",
|
||||
"darkModeDark": "Dark",
|
||||
"showHiddenFiles": "Show hidden files",
|
||||
"showHiddenFilesDescription": "Show hidden files in listing (e.g. .thumbnails, ProgramData), but they are still manually accessible if configured to be indexed.",
|
||||
"showQuickDownload": "Show download icon for quick access",
|
||||
@@ -1208,8 +1205,7 @@
|
||||
"groupsFound": "Duplicate Groups Found{suffix}",
|
||||
"totalWastedSpace": "Total Wasted Space{suffix}",
|
||||
"wasted": "Wasted{suffix}",
|
||||
"emptyState": "Enter search criteria and click Find Duplicates to identify duplicate files",
|
||||
"noResults": "Search complete — no duplicate files found."
|
||||
"emptyState": "Enter search criteria and click Find Duplicates to identify duplicate files"
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "The server was shutdown.",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "Abre los archivos con un solo clic",
|
||||
"stickySidebar": "Barra lateral fija",
|
||||
"darkMode": "Modo oscuro",
|
||||
"darkModeSystem": "Seguir el sistema",
|
||||
"darkModeLight": "Claro",
|
||||
"darkModeDark": "Oscuro",
|
||||
"defaultViewMode": "Modo de visualización predeterminado",
|
||||
"defaultViewModeDescription": "Modo de visualización inicial en navegadores o dispositivos nuevos. No anula las preferencias de visualización ya guardadas localmente.",
|
||||
"defaultGallerySize": "Tamaño predeterminado de las miniaturas",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Grupos de duplicados{suffix}",
|
||||
"totalWastedSpace": "Espacio desperdiciado{suffix}",
|
||||
"wasted": "Desperdiciado{suffix}",
|
||||
"emptyState": "Introduce los criterios de búsqueda y haz clic en \"Buscar duplicados\" para encontrar que archivos pueden estar gastando espacio.",
|
||||
"noResults": "Búsqueda completada: no se han encontrado archivos duplicados."
|
||||
"emptyState": "Introduce los criterios de búsqueda y haz clic en \"Buscar duplicados\" para encontrar que archivos pueden estar gastando espacio."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
|
||||
@@ -581,9 +581,6 @@
|
||||
"singleClick": "Klik bakarra fitxategiak irekitzeko",
|
||||
"stickySidebar": "Alboko barra finkoa",
|
||||
"darkMode": "Modu iluna",
|
||||
"darkModeSystem": "Sistema jarraitu",
|
||||
"darkModeLight": "Argia",
|
||||
"darkModeDark": "Iluna",
|
||||
"showHiddenFiles": "Erakutsi ezkutuko fitxategiak",
|
||||
"showHiddenFilesDescription": "Erakutsi ezkutuko fitxategiak zerrendan (adib. .thumbnails, ProgramData); hala ere, eskuz eskuragarri daude indexatzeko konfiguratuta badaude.",
|
||||
"showQuickDownload": "Erakutsi deskarga-ikonoa sarbide azkarrerako",
|
||||
@@ -1168,8 +1165,7 @@
|
||||
"groupsFound": "Aurkitutako bikoiztu-taldeak{suffix}",
|
||||
"totalWastedSpace": "Alferrik galdutako lekua guztira{suffix}",
|
||||
"wasted": "Alferrik galdua{suffix}",
|
||||
"emptyState": "Idatzi bilaketa-irizpideak eta egin klik Bilatu bikoiztuak botoian fitxategi bikoiztuak identifikatzeko",
|
||||
"noResults": "Bilaketa amaituta — ez da fitxategi bikoizturik aurkitu."
|
||||
"emptyState": "Idatzi bilaketa-irizpideak eta egin klik Bilatu bikoiztuak botoian fitxategi bikoiztuak identifikatzeko"
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "Zerbitzaria itzali da.",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "Un simple clic pour ouvrir les fichiers",
|
||||
"stickySidebar": "Barre latérale fixe",
|
||||
"darkMode": "Mode sombre",
|
||||
"darkModeSystem": "Suivre le système",
|
||||
"darkModeLight": "Clair",
|
||||
"darkModeDark": "Sombre",
|
||||
"defaultViewMode": "Mode d'affichage par défaut",
|
||||
"defaultViewModeDescription": "Mode d'affichage par défaut sur les nouveaux navigateurs ou appareils. Ne remplace pas les préférences d'affichage déjà enregistrées localement.",
|
||||
"defaultGallerySize": "Taille par défaut des vignettes",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Groupes en double trouvés{suffix}",
|
||||
"totalWastedSpace": "Total de l'espace perdu{suffix}",
|
||||
"wasted": "Gaspillage{suffix}",
|
||||
"emptyState": "Saisissez des critères de recherche et cliquez sur Rechercher les doublons pour identifier les fichiers en double.",
|
||||
"noResults": "Recherche terminée — aucun fichier en double n'a été trouvé."
|
||||
"emptyState": "Saisissez des critères de recherche et cliquez sur Rechercher les doublons pour identifier les fichiers en double."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "לחיצה אחת לפתיחת קבצים",
|
||||
"stickySidebar": "סרגל צדדי קבוע",
|
||||
"darkMode": "מצב כהה",
|
||||
"darkModeSystem": "לפי המערכת",
|
||||
"darkModeLight": "בהיר",
|
||||
"darkModeDark": "כהה",
|
||||
"defaultViewMode": "מצב תצוגה ברירת מחדל",
|
||||
"defaultViewModeDescription": "מצב תצוגה ראשוני בדפדפנים או במכשירים חדשים. אינו מבטל את הגדרות התצוגה שכבר נשמרו באופן מקומי.",
|
||||
"defaultGallerySize": "גודל תמונה ממוזערת ברירת מחדל",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "נמצאו קבוצות כפולות{suffix}",
|
||||
"totalWastedSpace": "סך השטח המבוזבז{suffix}",
|
||||
"wasted": "מבוזבז{suffix}",
|
||||
"emptyState": "הזן את קריטריוני החיפוש ולחץ על 'מצא כפילויות' כדי לזהות קבצים כפולים.",
|
||||
"noResults": "החיפוש הסתיים — לא נמצאו קבצים כפולים."
|
||||
"emptyState": "הזן את קריטריוני החיפוש ולחץ על 'מצא כפילויות' כדי לזהות קבצים כפולים."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "הודעות",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "A fájlok megnyitásához elég egy kattintás",
|
||||
"stickySidebar": "Rögzített oldalsáv",
|
||||
"darkMode": "Sötét mód",
|
||||
"darkModeSystem": "Rendszer követése",
|
||||
"darkModeLight": "Világos",
|
||||
"darkModeDark": "Sötét",
|
||||
"defaultViewMode": "Alapértelmezett megjelenítési mód",
|
||||
"defaultViewModeDescription": "Alapértelmezett megjelenítési mód új böngészőkön vagy eszközökön. Nem írja felül a már helyileg elmentett megjelenítési beállításokat.",
|
||||
"defaultGallerySize": "Alapértelmezett miniatűrméret",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Duplikált csoportokat találtak{suffix}",
|
||||
"totalWastedSpace": "Teljes elpazarolt hely{suffix}",
|
||||
"wasted": "Elpazarolt{suffix}",
|
||||
"emptyState": "Adja meg a keresési feltételeket, és kattintson a Duplikátumok keresése gombra a duplikált fájlok azonosításához.",
|
||||
"noResults": "A keresés befejeződött — nem találtak duplikált fájlokat."
|
||||
"emptyState": "Adja meg a keresési feltételeket, és kattintson a Duplikátumok keresése gombra a duplikált fájlok azonosításához."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Értesítések",
|
||||
|
||||
@@ -612,9 +612,6 @@
|
||||
"singleClick": "Basta un clic per aprire i file",
|
||||
"stickySidebar": "Barra laterale fissa",
|
||||
"darkMode": "Modalità scura",
|
||||
"darkModeSystem": "Segui il sistema",
|
||||
"darkModeLight": "Chiaro",
|
||||
"darkModeDark": "Scuro",
|
||||
"defaultViewMode": "Modalità di visualizzazione predefinita",
|
||||
"defaultViewModeDescription": "Modalità di visualizzazione iniziale su nuovi browser o dispositivi. Non sovrascrive le preferenze di visualizzazione già salvate localmente.",
|
||||
"defaultGallerySize": "Dimensione predefinita delle miniature",
|
||||
@@ -1137,8 +1134,7 @@
|
||||
"groupsFound": "Gruppi Duplicati Trovati{suffix}",
|
||||
"totalWastedSpace": "Spazio Sprecato Totale{suffix}",
|
||||
"wasted": "Sprecato{suffix}",
|
||||
"emptyState": "Inserisci i criteri e clicca Trova Duplicati per identificare i file",
|
||||
"noResults": "Ricerca completata — non sono stati trovati file duplicati."
|
||||
"emptyState": "Inserisci i criteri e clicca Trova Duplicati per identificare i file"
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "Il server è stato spento.",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "ワンクリックでファイルを開く",
|
||||
"stickySidebar": "固定サイドバー",
|
||||
"darkMode": "ダークモード",
|
||||
"darkModeSystem": "システムに従う",
|
||||
"darkModeLight": "ライト",
|
||||
"darkModeDark": "ダーク",
|
||||
"defaultViewMode": "デフォルトの表示モード",
|
||||
"defaultViewModeDescription": "新しいブラウザやデバイスでの初期表示モードです。ローカルにすでに保存されている表示設定を上書きすることはありません。",
|
||||
"defaultGallerySize": "デフォルトのサムネイルサイズ",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "重複グループが見つかりました{suffix}",
|
||||
"totalWastedSpace": "無駄なスペース{suffix}",
|
||||
"wasted": "無駄{suffix}",
|
||||
"emptyState": "検索条件を入力し、「重複ファイルの検索」をクリックすると、重複ファイルが特定されます。",
|
||||
"noResults": "検索が完了しました。重複するファイルは見つかりませんでした。"
|
||||
"emptyState": "検索条件を入力し、「重複ファイルの検索」をクリックすると、重複ファイルが特定されます。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "お知らせ",
|
||||
|
||||
@@ -804,9 +804,6 @@
|
||||
"singleClick": "한 번의 클릭으로 파일 열기",
|
||||
"stickySidebar": "고정 사이드바",
|
||||
"darkMode": "다크 모드",
|
||||
"darkModeSystem": "시스템 설정 따르기",
|
||||
"darkModeLight": "라이트",
|
||||
"darkModeDark": "다크",
|
||||
"defaultViewMode": "기본 보기 모드",
|
||||
"defaultViewModeDescription": "새로운 브라우저나 기기에서 초기 보기 모드로 설정됩니다. 이미 로컬에 저장된 보기 환경설정은 무시되지 않습니다.",
|
||||
"defaultGallerySize": "기본 썸네일 크기",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "중복 그룹 발견{suffix}",
|
||||
"totalWastedSpace": "낭비되는 총 공간{suffix}",
|
||||
"wasted": "낭비{suffix}",
|
||||
"emptyState": "검색 기준을 입력하고 중복 파일 찾기를 클릭하여 중복 파일을 식별합니다.",
|
||||
"noResults": "검색이 완료되었습니다. 중복 파일은 발견되지 않았습니다."
|
||||
"emptyState": "검색 기준을 입력하고 중복 파일 찾기를 클릭하여 중복 파일을 식별합니다."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "알림",
|
||||
|
||||
@@ -591,9 +591,6 @@
|
||||
"singleClick": "Bestanden openen met één muisklik",
|
||||
"stickySidebar": "Vaste zijbalk",
|
||||
"darkMode": "Donkere modus",
|
||||
"darkModeSystem": "Systeem volgen",
|
||||
"darkModeLight": "Licht",
|
||||
"darkModeDark": "Donker",
|
||||
"defaultViewMode": "Standaardweergavemodus",
|
||||
"defaultViewModeDescription": "Standaardweergavemodus in nieuwe browsers of op nieuwe apparaten. Hiermee worden de lokaal opgeslagen weergavevoorkeuren niet overschreven.",
|
||||
"defaultGallerySize": "Standaardgrootte van miniatuurafbeeldingen",
|
||||
@@ -1119,8 +1116,7 @@
|
||||
"groupsFound": "Dubbele groepen gevonden{suffix}",
|
||||
"totalWastedSpace": "Totale verspilde ruimte{suffix}",
|
||||
"wasted": "Verspild{suffix}",
|
||||
"emptyState": "Voer zoekcriteria in en klik op Duplicaten zoeken om dubbele bestanden te identificeren",
|
||||
"noResults": "Zoekopdracht voltooid — er zijn geen dubbele bestanden gevonden."
|
||||
"emptyState": "Voer zoekcriteria in en klik op Duplicaten zoeken om dubbele bestanden te identificeren"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Meldingen",
|
||||
|
||||
@@ -585,9 +585,6 @@
|
||||
"singleClick": "Bestanden openen met één muisklik",
|
||||
"stickySidebar": "Vaste zijbalk",
|
||||
"darkMode": "Donkere modus",
|
||||
"darkModeSystem": "Systeem volgen",
|
||||
"darkModeLight": "Licht",
|
||||
"darkModeDark": "Donker",
|
||||
"showHiddenFiles": "Verborgen bestanden weergeven",
|
||||
"showHiddenFilesDescription": "Toon verborgen bestanden in de lijst (bijv. .thumbnails, ProgramData), maar ze zijn nog steeds handmatig toegankelijk als ze zijn geconfigureerd om te worden geïndexeerd.",
|
||||
"showQuickDownload": "Downloadpictogram weergeven voor snelle toegang",
|
||||
@@ -1168,8 +1165,7 @@
|
||||
"groupsFound": "Dubbele groepen gevonden{suffix}",
|
||||
"totalWastedSpace": "Totale verspilde ruimte{suffix}",
|
||||
"wasted": "Verspild{suffix}",
|
||||
"emptyState": "Voer zoekcriteria in en klik op Dubbele bestanden zoeken om deze te vinden",
|
||||
"noResults": "Zoekopdracht voltooid — er zijn geen dubbele bestanden gevonden."
|
||||
"emptyState": "Voer zoekcriteria in en klik op Dubbele bestanden zoeken om deze te vinden"
|
||||
},
|
||||
"events": {
|
||||
"serverShutdown": "De server is uitgeschakeld.",
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user