mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 08:42:06 -05:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8415d5f1ff | ||
|
|
85f934208c | ||
|
|
1022ecafef | ||
|
|
062ac9fb2d | ||
|
|
cecdd79fcc | ||
|
|
ac50ecfd18 | ||
|
|
71cc2f3f1b | ||
|
|
1b3f47f176 | ||
|
|
219b64b5ec | ||
|
|
3ef4928ed7 | ||
|
|
df0a5ad10c | ||
|
|
a43d0ab2d3 | ||
|
|
9de09e5ec8 | ||
|
|
9d38c686a1 | ||
|
|
1cfca112e4 | ||
|
|
f540e22dc9 | ||
|
|
dade856ebe | ||
|
|
2f1da21253 | ||
|
|
de8d2acd43 | ||
|
|
6375be202e | ||
|
|
816b99cee0 | ||
|
|
bf349a7dcd | ||
|
|
db6504bf80 | ||
|
|
2019d573f8 | ||
|
|
d716fc15ec | ||
|
|
56e16608e4 | ||
|
|
c471fc135b | ||
|
|
5112cf87b1 | ||
|
|
3d7bac47ee | ||
|
|
2a38d2fa1e | ||
|
|
82e110c08d | ||
|
|
f4c96078a1 | ||
|
|
975adedabb | ||
|
|
14ec0eba0d | ||
|
|
307d5f6591 | ||
|
|
ff9df43356 | ||
|
|
802e91aa05 | ||
|
|
28ef44cdec | ||
|
|
0ee0f35301 | ||
|
|
cc99aa542e | ||
|
|
8514da4a9a | ||
|
|
4625b89528 | ||
|
|
b2a07196b7 | ||
|
|
0a8698ee4d | ||
|
|
ec8ad36c95 | ||
|
|
2c0c1b4bb2 | ||
|
|
6fa71bd13d | ||
|
|
06aa16e7d7 | ||
|
|
b0f6d53f49 | ||
|
|
d27ab6a00b | ||
|
|
f0fb859479 | ||
|
|
4c8a913377 | ||
|
|
c1684baffd | ||
|
|
bb45ebcce1 | ||
|
|
18138b1ab5 | ||
|
|
26665efc13 | ||
|
|
98851e029f | ||
|
|
4a1a3bbe09 | ||
|
|
a6d7f7c64c | ||
|
|
ada7a18620 | ||
|
|
4f4ea2370e | ||
|
|
52aa314e1d | ||
|
|
1d6f67cc20 | ||
|
|
d89b7e7deb | ||
|
|
e26bd29848 | ||
|
|
9c2b98655a | ||
|
|
a379f047d1 | ||
|
|
f4879be6bf | ||
|
|
7751da745d | ||
|
|
80cedcb4d5 | ||
|
|
ac19abf7d2 | ||
|
|
38e868e5af | ||
|
|
b3af5cc2d7 | ||
|
|
b399de3d92 | ||
|
|
99f1a14e7b | ||
|
|
84afd8b0b7 | ||
|
|
78be5d8c0f | ||
|
|
5e29e1ee60 | ||
|
|
367a7f019c | ||
|
|
fa9c094160 | ||
|
|
36a9a6dc49 | ||
|
|
8fe6f1d5c3 | ||
|
|
6dc3935c57 | ||
|
|
5e73036657 | ||
|
|
e505900975 | ||
|
|
26f320ee43 | ||
|
|
184ae798c4 | ||
|
|
7ae8bab062 | ||
|
|
1af606e751 | ||
|
|
3683bc607c | ||
|
|
08d3e43c22 | ||
|
|
d76f9373c4 | ||
|
|
a72651e7aa | ||
|
|
73d0b20821 | ||
|
|
3e447307f7 | ||
|
|
264cc81c41 | ||
|
|
aa5b24119a | ||
|
|
52cf624711 | ||
|
|
5753f63970 | ||
|
|
8f1cf54cb0 | ||
|
|
a8cfcdd6eb |
No files matched your search
@@ -9,6 +9,7 @@ backend/filebrowser.sqlite*
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/internal/web/dist/**
|
||||
backend/vendor/**
|
||||
|
||||
frontend/dist/**
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the comma-separated Docker tag list for a release image.
|
||||
#
|
||||
# Usage: docker-release-tags.sh <metadata.json> <variant> <update_track> <update_major>
|
||||
# metadata.json JSON output of docker/metadata-action (uses .tags)
|
||||
# variant default | slim | media
|
||||
# default and media: floating tags include latest
|
||||
# slim: every tag gets a -slim suffix and latest is not added
|
||||
# update_track true when the semver policy allows floating beta/stable/latest tags
|
||||
# update_major true when the semver policy allows major-only version tags
|
||||
#
|
||||
# Image repositories are fixed per variant so that each image only gets its own tags.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 4 ]]; then
|
||||
echo "usage: $0 <metadata.json> <default|slim|media> <true|false> <true|false>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
metadata_file=$1
|
||||
variant=$2
|
||||
update_track=$3
|
||||
update_major=$4
|
||||
|
||||
case "$variant" in
|
||||
default)
|
||||
suffix=""
|
||||
add_latest=true
|
||||
repos=(gtstef/filebrowser ghcr.io/gtsteffaniak/filebrowser)
|
||||
;;
|
||||
slim)
|
||||
suffix="-slim"
|
||||
add_latest=false
|
||||
repos=(gtstef/filebrowser ghcr.io/gtsteffaniak/filebrowser)
|
||||
;;
|
||||
media)
|
||||
suffix=""
|
||||
add_latest=true
|
||||
repos=(gtstef/filebrowser-media ghcr.io/gtsteffaniak/filebrowser-media)
|
||||
;;
|
||||
*)
|
||||
echo "unknown variant: $variant" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
jq -r \
|
||||
--arg suffix "$suffix" \
|
||||
--argjson addLatest "$add_latest" \
|
||||
--argjson updateTrack "$update_track" \
|
||||
--argjson updateMajor "$update_major" \
|
||||
--arg dh "${repos[0]}" \
|
||||
--arg gh "${repos[1]}" '
|
||||
# Strip the v prefix, drop metadata latest, and append the variant suffix.
|
||||
.tags |= map(
|
||||
sub(":v"; ":")
|
||||
| select(test(":latest") | not)
|
||||
| . + $suffix
|
||||
) |
|
||||
# Version tags with a prerelease suffix (X.Y.Z-suffix) get major.minor, and major when allowed.
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $tag_suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "\($dh):\($major_minor)-\($tag_suffix)",
|
||||
"\($gh):\($major_minor)-\($tag_suffix)",
|
||||
if $updateMajor then
|
||||
"\($dh):\($major)-\($tag_suffix)",
|
||||
"\($gh):\($major)-\($tag_suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Floating beta tag.
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta" + $suffix)) then
|
||||
.tags += ["\($dh):beta\($suffix)", "\($gh):beta\($suffix)"]
|
||||
else . end |
|
||||
# Floating stable tag, plus latest for variants that have it.
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable" + $suffix)) then
|
||||
.tags += (
|
||||
["\($dh):stable\($suffix)", "\($gh):stable\($suffix)"]
|
||||
+ (if $addLatest then ["\($dh):latest", "\($gh):latest"] else [] end)
|
||||
)
|
||||
else . end |
|
||||
.tags | join(",")
|
||||
' "$metadata_file"
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm ci && npm run build
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm ci && npm run build
|
||||
run: npm i && npm run build
|
||||
- name: Upload frontend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: backend/internal/web/dist
|
||||
path: frontend/dist
|
||||
- name: Download backend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -105,6 +105,10 @@ jobs:
|
||||
path: backend/
|
||||
- name: Make backend executable
|
||||
run: chmod +x backend/filebrowser
|
||||
- name: Copy frontend build to backend
|
||||
run: |
|
||||
mkdir -p backend/internal/web/embed
|
||||
cp -r frontend/dist/* backend/internal/web/embed
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -162,30 +166,3 @@ jobs:
|
||||
REVISION=${{ steps.meta.outputs.revision }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build_media_docker_image:
|
||||
name: Build media docker image
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.media
|
||||
push: false # Do not push the image for now
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
cache-from: type=gha,scope=pr-media
|
||||
cache-to: type=gha,mode=max,scope=pr-media
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm ci && npm run lint
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
@@ -57,17 +57,7 @@ jobs:
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm ci && npm run test
|
||||
typecheck-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm ci && npm run typecheck
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
@@ -77,7 +67,7 @@ jobs:
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm ci && npm run i18n:check
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
@@ -97,4 +87,4 @@ jobs:
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm ci && npm run icons:check
|
||||
run: npm i && npm run icons:check
|
||||
+88
-71
@@ -15,22 +15,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies and build frontend
|
||||
run: npm ci && npm run build
|
||||
run: npm i && npm run build
|
||||
working-directory: frontend
|
||||
- name: Install OS Requirements
|
||||
run: sudo apt update && sudo apt install -y musl-tools
|
||||
run: sudo apt update && sudo apt install -y upx build-essential musl-tools
|
||||
- uses: goto-bus-stop/setup-zig@v2
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
@@ -75,8 +68,50 @@ jobs:
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
printf '%s' "$JSON" > metadata.json
|
||||
TAGS_CSV=$(bash .github/scripts/docker-release-tags.sh metadata.json default '${{ steps.floating.outputs.update_track }}' '${{ steps.floating.outputs.update_major }}')
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq \
|
||||
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
|
||||
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not)
|
||||
) |
|
||||
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
if $updateMajor then
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Add suffix-only tags for beta when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta"
|
||||
]
|
||||
else . end |
|
||||
# Add suffix-only tags for stable (and latest) when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable",
|
||||
"gtstef/filebrowser:latest",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:latest"
|
||||
]
|
||||
else . end
|
||||
')
|
||||
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
|
||||
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
|
||||
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -90,62 +125,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-main
|
||||
cache-to: type=gha,mode=max,scope=release-main
|
||||
push_media_release_to_registry:
|
||||
name: Push media release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.PAT }}
|
||||
- name: Extract metadata (tags, labels) for Docker and GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
gtstef/filebrowser-media
|
||||
ghcr.io/gtsteffaniak/filebrowser-media
|
||||
- name: Decide floating Docker tags
|
||||
id: floating
|
||||
env:
|
||||
IMAGE_REPO: gtstef/filebrowser-media
|
||||
run: bash .github/scripts/docker-floating-tag-policy.sh
|
||||
- name: Modify tags (strip 'v' and add version tags)
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
printf '%s' "$JSON" > metadata.json
|
||||
TAGS_CSV=$(bash .github/scripts/docker-release-tags.sh metadata.json media '${{ steps.floating.outputs.update_track }}' '${{ steps.floating.outputs.update_major }}')
|
||||
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./_docker/Dockerfile.media
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-media
|
||||
cache-to: type=gha,mode=max,scope=release-media
|
||||
push_slim_release_to_registry:
|
||||
name: Push slim release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -183,8 +162,48 @@ jobs:
|
||||
id: modify-json-slim
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
printf '%s' "$JSON" > metadata.json
|
||||
TAGS_CSV=$(bash .github/scripts/docker-release-tags.sh metadata.json slim '${{ steps.floating.outputs.update_track }}' '${{ steps.floating.outputs.update_major }}')
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq \
|
||||
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
|
||||
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not) # Remove the "latest" tag
|
||||
| . + "-slim" # Append "-slim" to the versioned tag
|
||||
) |
|
||||
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
|
||||
.tags += [
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
| ($version | split("-")) as $version_split
|
||||
| ($version_split[0] | split(".")) as $version_parts
|
||||
| ($version_split[1:] | join("-")) as $suffix
|
||||
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
if $updateMajor then
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Add suffix-only tags for beta-slim when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
|
||||
]
|
||||
else . end |
|
||||
# Add suffix-only tags for stable-slim when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
|
||||
]
|
||||
else . end
|
||||
')
|
||||
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
|
||||
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
|
||||
- name: Build and push slim image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -198,5 +217,3 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.modify-json-slim.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-slim
|
||||
cache-to: type=gha,mode=max,scope=release-slim
|
||||
+2
-2
@@ -43,8 +43,8 @@ frontend/.tmp-icons-*
|
||||
/backend/*test_config.yaml
|
||||
/backend/generated.yaml
|
||||
/backend/srv
|
||||
/backend/internal/web/dist/*
|
||||
!/backend/internal/web/dist/.gitkeep
|
||||
/backend/internal/web/dist
|
||||
/backend/internal/web/embed
|
||||
/backend/debug
|
||||
/backend/http
|
||||
|
||||
|
||||
@@ -2,40 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v2.2.0
|
||||
|
||||
**New Features**:
|
||||
- Core web assets are loaded into memory instead of served from disk.
|
||||
- Media enhancements
|
||||
- All server media settings can be managed in settings by admins.
|
||||
- Transcoding for media playback with quality presets -- requires ffmpeg.
|
||||
- Hardware Acceleration device selector (Software / detected GPU devices)
|
||||
- More operations use native api operations rather than ffmpeg, improving performance in many cases. Added temporary config option to force ffmpeg to be used
|
||||
- Running without ffmpeg can still view metadata and create thumbnails for many common file formats.
|
||||
# office previews can use embedded thumbnail if possible
|
||||
# text file previews are more efficient direct creation
|
||||
|
||||
**Notes**:
|
||||
- Smaller binaries: the Swagger UI serves only the four files it needs instead of the full swagger-ui bundle
|
||||
- UPX compression is removed from release builds.
|
||||
- Runtime debug tooling (pprof) is only compiled in with `-tags debug`. Dev targets (`make run`, `make dev`) enable it automatically.
|
||||
- The CGO SQLite driver and the `cgosql` build tag are removed.
|
||||
- A `filebrowser-media` image (`_docker/Dockerfile.media`) is published alongside the main and slim images.
|
||||
- Non-dev builds (including Docker) load the frontend into memory at startup when preload succeeds; if preload fails, assets are served from disk. Dev mode still serves from disk.
|
||||
- Frontend build output lives only in `backend/internal/web/dist`. Release builds compile that tree into the binary with `go:embed`; Docker and other disk-only runs preload or serve the same `dist` folder at runtime.
|
||||
- Release image tags are generated by one script (`.github/scripts/docker-release-tags.sh`) for the main, slim and media images.
|
||||
- `make build-docker-media` builds the media image locally.
|
||||
|
||||
**Bug Fixes**:
|
||||
- Closing a video preview from the header bar now stops playback immediately instead of leaving audio running in the background.
|
||||
- Closing a preview no longer navigates back to the same file when a stale "previous item" was remembered from an earlier navigation.
|
||||
- Transcoding recovers from unreliable hardware decoding without discarding the segments already produced: the retry resumes at the encode head with software decode instead of wiping the generation and re-encoding from the start.
|
||||
- The player no longer aborts a segment request after 10 seconds while the transcode producer is repositioning or restarting, which previously surfaced as a 503 and a skipped segment.
|
||||
- getDirectoryPreview no longer requests metadata, so folder previews skip the per-file probe and subtitle scan.
|
||||
- Media probes are cached by file identity and concurrent reads are coalesced, so opening a file no longer triggers repeated ffprobe runs.
|
||||
- Client-initiated stream cancels (seek/navigation) no longer log spurious write-error responses.
|
||||
- Gzipped `.woff` font files are no longer generated without being served; only `.js` and `.woff2` are precompressed.
|
||||
|
||||
## v2.1.4
|
||||
|
||||
**Security**:
|
||||
|
||||
+5
-8
@@ -1,27 +1,24 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
FROM gtstef/ffmpeg:9.0.2 AS ffmpeg
|
||||
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go run ./scripts/setup-gofitz-cgo
|
||||
RUN go mod download && go run ./scripts/setup-gofitz-cgo
|
||||
ENV CGO_ENABLED=1
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
|
||||
RUN cd frontend && npm ci --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go run ./scripts/setup-gofitz-cgo
|
||||
ENV CGO_ENABLED=1
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
# copy config to two locations for compatibility
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -1,81 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# GPU-capable media image: mostly-static glibc FFmpeg + dynamic VA/Vulkan/OpenCL drivers (amd64).
|
||||
# Codecs/TLS are linked into gtstef/ffmpeg:<ver> (full track, not *-static).
|
||||
#
|
||||
# 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
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY ./backend ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go run ./scripts/setup-gofitz-cgo
|
||||
ENV CGO_ENABLED=1
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics,mupdf -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
mailcap \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (dpkg --print-architecture | grep -q amd64 \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libva2 \
|
||||
libva-drm2 \
|
||||
libdrm2 \
|
||||
libvulkan1 \
|
||||
mesa-va-drivers \
|
||||
mesa-vulkan-drivers \
|
||||
ocl-icd-libopencl1 \
|
||||
intel-media-va-driver \
|
||||
libmfx1 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|| true)
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,video,utility,graphics
|
||||
|
||||
RUN useradd -m -u 1000 -s /usr/sbin/nologin filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
|
||||
RUN filebrowser version && ffmpeg -version && ffprobe -version
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -12,7 +12,7 @@ WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# generate duplicate 1.1MB files
|
||||
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
|
||||
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser user set testuser1 --password testuser1
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /app/backend/
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -23,7 +23,7 @@ WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
ENV CI=true
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
FROM gtstef/ffmpeg:9.0.2 AS ffmpeg
|
||||
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/previews/", "./" ]
|
||||
@@ -13,7 +10,8 @@ COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -13,7 +13,7 @@ WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY ./backend ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
# copy config to two locations for compatibility
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:80/health || exit 1
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
@@ -20,7 +20,3 @@ userDefaults:
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -51,7 +51,3 @@ userDefaults:
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -64,7 +64,4 @@ userDefaults:
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
highQuality: true
|
||||
@@ -50,7 +50,4 @@ userDefaults:
|
||||
permissions:
|
||||
admin: false
|
||||
share: false
|
||||
api: false
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
api: false
|
||||
@@ -18,7 +18,3 @@ userDefaults:
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -39,10 +39,3 @@ userDefaults:
|
||||
office: true
|
||||
models: true
|
||||
highQuality: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
# Real HLS transcoding for the preview specs (requires ffmpeg in the image).
|
||||
transcode:
|
||||
enabled: true
|
||||
@@ -13,20 +13,23 @@ export default defineConfig({
|
||||
globalSetup: "./tests/playwright/previews-setup",
|
||||
timeout: 10000,
|
||||
testDir: "./tests/playwright/previews",
|
||||
fullyParallel: false,
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
workers: 10,
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--disable-gpu",
|
||||
"--use-gl=swiftshader",
|
||||
"--enable-webgl",
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
|
||||
'--enable-webgl',
|
||||
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
|
||||
'--no-sandbox', // Required for Docker
|
||||
],
|
||||
},
|
||||
storageState: "previews.json",
|
||||
|
||||
@@ -40,7 +40,3 @@ userDefaults:
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -19,7 +19,4 @@ userDefaults:
|
||||
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
|
||||
gallerySize: 3
|
||||
account:
|
||||
showAdvancedProfile: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
showAdvancedProfile: true
|
||||
@@ -48,7 +48,4 @@ auth:
|
||||
|
||||
userDefaults:
|
||||
listing:
|
||||
showHidden: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
showHidden: true
|
||||
@@ -27,7 +27,4 @@ frontend:
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
disableEventThemes: true
|
||||
+1
-1
@@ -7,7 +7,7 @@ env_files = [".air.env"]
|
||||
entrypoint = "./tmp/filebrowser"
|
||||
args_bin = ["-c", "test_config.yaml"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
|
||||
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
|
||||
poll = true
|
||||
poll_interval = 1000
|
||||
log = "air.log"
|
||||
|
||||
@@ -17,7 +17,7 @@ linters:
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- mapliteral
|
||||
- map
|
||||
exhaustruct:
|
||||
exclude:
|
||||
- ^net/http.Client$
|
||||
|
||||
+14
-16
@@ -21,20 +21,19 @@ builds:
|
||||
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA={{ .ShortCommit }}
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux arm64
|
||||
- id: linux-arm64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target aarch64-linux-musl
|
||||
@@ -46,14 +45,15 @@ builds:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux riscv64
|
||||
- id: linux-riscv64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
env:
|
||||
- CC=zig cc -target riscv64-linux-musl
|
||||
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
|
||||
@@ -70,8 +70,6 @@ builds:
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
@@ -81,6 +79,9 @@ builds:
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for windows without arm
|
||||
- id: windows
|
||||
@@ -94,20 +95,19 @@ builds:
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for freebsd without arm
|
||||
# Build configuration for freebsd without arm & upx
|
||||
- id: freeBSD and freebsd-arm
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
@@ -115,13 +115,11 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
# Build configuration for macos (no mupdf: CGO cross-compile from Linux CI is unsupported)
|
||||
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
|
||||
- id: macos
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
flags:
|
||||
- -trimpath
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
|
||||
+53
-1
@@ -56,6 +56,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
@@ -120,6 +122,8 @@ github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+j
|
||||
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
|
||||
github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU=
|
||||
github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM=
|
||||
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
|
||||
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
|
||||
github.com/breml/bidichk v0.3.4 h1:70G6w80G6GDZTUURHTC5gBKVjMLMw2736CzXquSf5Ek=
|
||||
github.com/breml/bidichk v0.3.4/go.mod h1:GOHprnh4i0V7HrvWjtbvbpicTUcqoYt9zYESKcJkk4M=
|
||||
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
|
||||
@@ -138,6 +142,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
|
||||
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
@@ -282,6 +288,8 @@ github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJ
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
|
||||
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
|
||||
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f h1:pOnwRNeWLPkADfkgqKcCP7RPQAS9OIzffaBaktRsKnk=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f/go.mod h1:aXo+AVSX0pC3TRo2wAfR92YwBKfG6C2eal+omjKXwYw=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
|
||||
@@ -290,8 +298,14 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0 h1:ot8QffRa4LzAAEgtvNYrVs9esxQ7xoTwoAv6uhp4ngA=
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0/go.mod h1:amHimCzNDP1GfVno3hIiNssjvqXmuW+CPZAHOdR4cIc=
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
|
||||
github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
@@ -300,6 +314,8 @@ github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3H
|
||||
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
|
||||
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
|
||||
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 h1:0kPqfqytQizeQ9WBk487NcsGcRdS8YhHeUhXxkfEAhA=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241/go.mod h1:lczq5oJ8PxpMESQG6dO3iFYTQPhpqoMOYw//n2FEnHA=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 h1:oN042y1yEcpldrqY81puyNA4ZjQZJzt0U/t7HyYrMic=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8/go.mod h1:T1Bg58Sk3N352aBbPLJPpHykFWxEEwV3o4/Enn+PPU8=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
|
||||
@@ -357,6 +373,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
|
||||
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI=
|
||||
github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU=
|
||||
github.com/kisielk/errcheck v1.30.0 h1:ZIxNDAfAd4larXHTceoZP/dYPAB90dHg1OYhA2sVh5k=
|
||||
github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
@@ -370,6 +388,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
|
||||
github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
|
||||
github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
|
||||
github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/kunwardeep/paralleltest v1.0.16 h1:I0gQkLNgfigjpxWAGhx7PYj3VpswzfGrSV/715t6g+o=
|
||||
github.com/kunwardeep/paralleltest v1.0.16/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
@@ -408,12 +428,18 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
|
||||
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
|
||||
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
|
||||
github.com/mgechev/revive v1.17.0 h1:hwTTVC3svSv9X2FA2hiYT17Uws+lfxDWhPxzBZZCREg=
|
||||
github.com/mgechev/revive v1.17.0/go.mod h1:uiVNUF8ruVh4WHm/aPs7kKEeuo+m6oO3tuOfniyzP/g=
|
||||
github.com/mgechev/revive v1.17.1 h1:z8hV0L3uJ6yrwOMnJbkc5qBJpxXa2AxYKFKERGJyA4s=
|
||||
github.com/mgechev/revive v1.17.1/go.mod h1:FRd64pJwWSEE6j8ESoaX0PixPzjh2rpNSfckbEu3rIE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -434,8 +460,13 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81
|
||||
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
|
||||
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/nishanths/exhaustive v0.13.0 h1:Rof0axOSNQaaPaPmLK5RxcRrJV2Vcv+vfUmuRy+2EAM=
|
||||
github.com/nishanths/exhaustive v0.13.0/go.mod h1:3/adQjJgvD/UFHHSYBjpK9Wbz8DOvf0FBtzx+zI0Rlo=
|
||||
github.com/nishanths/exhaustive v0.15.0 h1:uFjiO/WvrwQG+ojSZ+vtl/jAFbqA2oAtfpUWK/iKQ8M=
|
||||
github.com/nishanths/exhaustive v0.15.0/go.mod h1:2by9rYmg9FnsnLQC4Ixig4fQaWxRsY7RluxJuAu9sa0=
|
||||
github.com/nishanths/exhaustive v0.16.0/go.mod h1:/nBwAMRn2QmCjWQ3PiJ939yRSwvWLKSa21L2C6Tp84E=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nishanths/predeclared v0.3.0 h1:W3i3JK/DZS5h7QwsEIyU8ler4Z2vwEwQSxNrZzhnlfk=
|
||||
github.com/nishanths/predeclared v0.3.0/go.mod h1:gN4uryPExDME/zmZdqkKIPSAqcfprj316ZDI6UTqz70=
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
|
||||
@@ -567,10 +598,16 @@ github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
|
||||
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
|
||||
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
|
||||
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
|
||||
github.com/timonwong/loggercheck v0.13.0 h1:6amIOmLLPFYt24rXUbNWzcJeNGuJc+3c31tZtO85VHs=
|
||||
github.com/timonwong/loggercheck v0.13.0/go.mod h1:thn3allpox0fI5JeUWTCwiwp45peQxCZZHkHuA1Oc6I=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
@@ -581,8 +618,12 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW
|
||||
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/gocognit v1.3.0 h1:Xdw5JwAoYmX3WAB+wsXY3nxEwdw7+MWAnM0wCAAv7A4=
|
||||
github.com/uudashr/gocognit v1.3.0/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA=
|
||||
github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
|
||||
github.com/uudashr/iface v1.5.3 h1:N61bzr11L7BHP6Rct+C6zQePHjSTMeyVBq+s03uB/Ds=
|
||||
github.com/uudashr/iface v1.5.3/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
|
||||
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
|
||||
@@ -649,6 +690,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -660,8 +703,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -669,6 +713,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.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=
|
||||
@@ -684,6 +730,8 @@ golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.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=
|
||||
@@ -706,6 +754,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t
|
||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
@@ -724,6 +774,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
|
||||
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
|
||||
honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
|
||||
honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
|
||||
mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
|
||||
|
||||
+11
-16
@@ -293,23 +293,18 @@ func createConfig(configpath string, noInput bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupConfigYAMLCandidates lists on-disk setup templates for config.generated.yaml.
|
||||
var setupConfigYAMLCandidates = []string{
|
||||
"frontend/public/config.generated.yaml",
|
||||
"../frontend/public/config.generated.yaml",
|
||||
"internal/web/dist/config.generated.yaml",
|
||||
"../internal/web/dist/config.generated.yaml",
|
||||
"backend/internal/web/dist/config.generated.yaml",
|
||||
}
|
||||
|
||||
func readSetupConfigTemplateYAML() (string, error) {
|
||||
return settings.ReadFirstExistingFile(setupConfigYAMLCandidates)
|
||||
}
|
||||
|
||||
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
|
||||
embeddedYaml, err := readSetupConfigTemplateYAML()
|
||||
if err == nil {
|
||||
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, embeddedYaml)
|
||||
embeddedPaths := []string{
|
||||
"frontend/public/config.generated.yaml",
|
||||
"../frontend/public/config.generated.yaml",
|
||||
"internal/web/dist/config.generated.yaml",
|
||||
"../internal/web/dist/config.generated.yaml",
|
||||
}
|
||||
for _, path := range embeddedPaths {
|
||||
embeddedYaml, err := os.ReadFile(path)
|
||||
if err == nil && len(embeddedYaml) > 0 {
|
||||
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
|
||||
}
|
||||
}
|
||||
return settings.GenerateConfigYaml(config, true, false, false)
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeSetupConfigYAML(t *testing.T, root, relPath, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSetupConfigTemplateYAML_fromDist(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSetupConfigYAML(t, root, "internal/web/dist/config.generated.yaml", "dist-content")
|
||||
t.Chdir(root)
|
||||
|
||||
got, err := readSetupConfigTemplateYAML()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "dist-content" {
|
||||
t.Fatalf("readSetupConfigTemplateYAML() = %q, want dist-content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSetupConfigTemplateYAML_prefersFrontendPublic(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSetupConfigYAML(t, root, "frontend/public/config.generated.yaml", "from-public")
|
||||
writeSetupConfigYAML(t, root, "internal/web/dist/config.generated.yaml", "from-dist")
|
||||
t.Chdir(root)
|
||||
|
||||
got, err := readSetupConfigTemplateYAML()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "from-public" {
|
||||
t.Fatalf("readSetupConfigTemplateYAML() = %q, want from-public", got)
|
||||
}
|
||||
}
|
||||
+12
-19
@@ -9,13 +9,14 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/analytics"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/transcoding"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/web"
|
||||
@@ -51,12 +52,6 @@ func initializeDatabase(configFile string, cliMode bool) bool {
|
||||
if err != nil {
|
||||
logger.Fatalf("could not initialize state: %v", err)
|
||||
}
|
||||
// state.Open applies persisted media settings, so initialize ffmpeg once here
|
||||
// (before wiring, which reads Env.FFmpegAvailable) instead of during config
|
||||
// load, which would run the multi-second hardware codec probe twice.
|
||||
if !cliMode {
|
||||
settings.InitFFmpeg()
|
||||
}
|
||||
runtimeApp, err = app.WireServices(store)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not wire services: %v", err)
|
||||
@@ -92,7 +87,14 @@ func StartFilebrowser() {
|
||||
done := make(chan struct{}) // Signals server has stopped
|
||||
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
|
||||
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
_, err := os.Stat("internal/web/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if internal/web/dist exists
|
||||
if !settings.Env.IsDevMode {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
err = utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to set security hash: %v", err)
|
||||
}
|
||||
@@ -178,13 +180,6 @@ func StartFilebrowser() {
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
<-shutdownComplete
|
||||
if transcodeMgr := transcoding.Default(); transcodeMgr != nil {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := transcodeMgr.Close(shutdownCtx); err != nil {
|
||||
logger.Warningf("transcode shutdown: %v", err)
|
||||
}
|
||||
shutdownCancel()
|
||||
}
|
||||
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
|
||||
logger.Warningf("failed to clear download spool on shutdown: %v", err)
|
||||
}
|
||||
@@ -199,16 +194,14 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
|
||||
// Initialize asset filesystem before starting services
|
||||
if !settings.Env.IsDevMode && web.EmbeddedFrontendAvailable() {
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedAssets := web.GetEmbeddedAssets()
|
||||
subAssets, err := fs.Sub(embeddedAssets, "dist")
|
||||
subAssets, err := fs.Sub(embeddedAssets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
settings.Env.EmbeddedFs = true
|
||||
fileutils.InitAssetFS(subAssets, true, false)
|
||||
} else {
|
||||
settings.Env.EmbeddedFs = false
|
||||
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,3 @@ userDefaults:
|
||||
#office:
|
||||
# url: "http://localhost:9052"
|
||||
# secret: "6H1H7GzUk2alRkNxAPav0W02g8JS5oUd"
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
Binary file not shown.
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"generatedAt": "2026-10-27T14:46:52Z",
|
||||
"gitHead": "880900c18dc7655899398cfb7327ab4e191fc3f0",
|
||||
"profileSha256": "371d012d4ed5bc3907667f6d35876c998af98a6cc3c71f6ea4ddf57ab4ed4bbd",
|
||||
"workloads": [
|
||||
"internal/thumb BenchmarkMJPEGSmall/Large",
|
||||
"internal/thumb BenchmarkMP4H264Large",
|
||||
"internal/thumb BenchmarkMP4H264Small (full pipeline)",
|
||||
"internal/thumb BenchmarkMP4H264Baseline1080Small (CAVLC + thumb)",
|
||||
"internal/thumb BenchmarkMP4H264Main1080Large",
|
||||
"internal/thumb BenchmarkMP4H264High1080Large",
|
||||
"internal/thumb BenchmarkThumbnailLargeWebM",
|
||||
"internal/h264 BenchmarkBaselineThumbnailDecode (CAVLC IDR)"
|
||||
],
|
||||
"hotPathGlobs": [
|
||||
"internal/thumb/**/*.go",
|
||||
"internal/h264/**/*.go",
|
||||
"internal/kernels/**/*.go",
|
||||
"internal/isobmff/**/*.go",
|
||||
"internal/matroska/**/*.go",
|
||||
"internal/jpegfast/**/*.go"
|
||||
]
|
||||
}
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
module github.com/gtsteffaniak/filebrowser/backend
|
||||
|
||||
go 1.27.1
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/evanoberholster/imagemeta v1.1.0
|
||||
github.com/gen2brain/go-fitz v1.28.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-playground/validator/v10 v10.30.5
|
||||
github.com/go-webauthn/webauthn v0.18.2
|
||||
@@ -18,21 +19,20 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2
|
||||
github.com/gtsteffaniak/go-fitz v1.0.1
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/gtsteffaniak/go-media v1.1.1-0.20261101153307-79181a6c2ed9
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.58.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.24.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/text v0.43.0
|
||||
@@ -44,10 +44,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.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
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
@@ -55,8 +52,6 @@ require (
|
||||
github.com/ebitengine/purego v0.11.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/gen2brain/h265 v0.2.4 // 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
|
||||
@@ -79,8 +74,6 @@ 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 v0.0.0-20261101031208-03767868b89d // 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
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
@@ -92,10 +85,12 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
@@ -104,3 +99,8 @@ require (
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
exclude (
|
||||
github.com/alecthomas/go-check-sumtype v0.5.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.1.0
|
||||
)
|
||||
+14
-35
@@ -2,14 +2,10 @@ 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.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=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/abema/go-mp4 v1.7.4 h1:MztDjMx3nac+Lktig9wrUa962Mnyi4+ZtDtpbHuckww=
|
||||
github.com/abema/go-mp4 v1.7.4/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
|
||||
@@ -20,8 +16,6 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/at-wat/ebml-go v0.19.4 h1:6F767ThzH/JqNUsF79PwRFP0pwUF5ALXWJ6pXuiolI8=
|
||||
github.com/at-wat/ebml-go v0.19.4/go.mod h1:wu3fQj+DWo6SOOl9c4yDi+gFs9Oa96Z2B5qxo1OxFYY=
|
||||
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=
|
||||
@@ -29,7 +23,6 @@ github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBq
|
||||
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=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -62,10 +55,8 @@ github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7
|
||||
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
|
||||
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||
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/vpx v0.2.1 h1:UD6tibcs/zlaIOKNFXeVyZuHBEaW9GYfxKSxvAN9cc8=
|
||||
github.com/gen2brain/vpx v0.2.1/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
|
||||
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
|
||||
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=
|
||||
@@ -113,8 +104,6 @@ github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dm
|
||||
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
||||
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
||||
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
|
||||
@@ -150,23 +139,14 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
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 v0.0.0-20261101031208-03767868b89d h1:2Z15ATqB4V8B6kIM08DAxSOD8N7y3GPhU9woKzM/a58=
|
||||
github.com/gtsteffaniak/go-av1 v0.0.0-20261101031208-03767868b89d/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-fitz v1.0.1 h1:SKNVc0ES73Y99NQ1TtOwa2Uf8xpfQpFtMnTTwfVusBk=
|
||||
github.com/gtsteffaniak/go-fitz v1.0.1/go.mod h1:L3RzZGaMEY+KAzY1KR9hraHqcgG5VmoK9iw8SGjkiaI=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0 h1:XkNFlx6koCCh8cFXn9HxYEEpvLlbnxmIvWX+5HLuN+8=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0/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.1.1-0.20261101153307-79181a6c2ed9 h1:q9HnKLhwuETihYSZnZrljULviE27cK2k+7xnSrPMK3w=
|
||||
github.com/gtsteffaniak/go-media v1.1.1-0.20261101153307-79181a6c2ed9/go.mod h1:WMn3EjlWNcuaoc47+7YyYHXc7C5sVTYBt2zaaPf3KwU=
|
||||
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=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -198,7 +178,6 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
@@ -206,10 +185,10 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
|
||||
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
|
||||
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -230,10 +209,12 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||
@@ -269,6 +250,7 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
@@ -278,7 +260,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -288,10 +269,12 @@ 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -299,6 +282,7 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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=
|
||||
@@ -318,15 +302,10 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
@@ -141,14 +141,6 @@ func TestUserUpdateChangesExpandsNestedStructFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mock source paths used only as lookup keys in tests. They never touch the
|
||||
// filesystem. They are relative (and not equal to the source names) so the
|
||||
// tests are portable across machines.
|
||||
const (
|
||||
testDownloadsPath = "./testdata/Downloads"
|
||||
testAccessPath = "./testdata/playwright-files"
|
||||
)
|
||||
|
||||
type activityChangePair struct {
|
||||
from string
|
||||
to string
|
||||
@@ -158,9 +150,9 @@ func testDownloadsSourceConfig() *users.SourceConfigProvider {
|
||||
return &users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
switch path {
|
||||
case testDownloadsPath:
|
||||
case "/Users/steffag/Downloads":
|
||||
return users.SourceInfo{Path: path, Name: "Downloads"}, true
|
||||
case testAccessPath:
|
||||
case "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files":
|
||||
return users.SourceInfo{Path: path, Name: "access"}, true
|
||||
default:
|
||||
return users.SourceInfo{}, false
|
||||
@@ -237,8 +229,8 @@ func TestUserUpdateChangesExpandsPreviewBulkToggle(t *testing.T) {
|
||||
func TestUserUpdateChangesExpandsBackendSourcePermissions(t *testing.T) {
|
||||
withTestSourceConfig(t, testDownloadsSourceConfig())
|
||||
|
||||
downloadsPath := testDownloadsPath
|
||||
accessPath := testAccessPath
|
||||
downloadsPath := "/Users/steffag/Downloads"
|
||||
accessPath := "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files"
|
||||
beforePerms := users.MarkSourceFilePermissionsConfigured(users.SourceFilePermissions{
|
||||
View: true, Download: true,
|
||||
})
|
||||
@@ -282,8 +274,8 @@ func TestUserUpdateChangesExpandsBackendSourcePermissions(t *testing.T) {
|
||||
func TestUserUpdateChangesExpandsScopePermissions(t *testing.T) {
|
||||
withTestSourceConfig(t, testDownloadsSourceConfig())
|
||||
|
||||
downloadsPath := testDownloadsPath
|
||||
accessPath := testAccessPath
|
||||
downloadsPath := "/Users/steffag/Downloads"
|
||||
accessPath := "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files"
|
||||
basePerms := users.MarkSourceFilePermissionsConfigured(users.SourceFilePermissions{
|
||||
View: true, Download: true,
|
||||
})
|
||||
@@ -361,9 +353,9 @@ func TestSidebarLinksFieldChangeIgnoresPathVsName(t *testing.T) {
|
||||
withTestSourceConfig(t, &users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
switch path {
|
||||
case testDownloadsPath:
|
||||
case "/Users/steffag/Downloads":
|
||||
return users.SourceInfo{Path: path, Name: "Downloads"}, true
|
||||
case testAccessPath:
|
||||
case "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files":
|
||||
return users.SourceInfo{Path: path, Name: "access"}, true
|
||||
default:
|
||||
return users.SourceInfo{}, false
|
||||
@@ -372,9 +364,9 @@ func TestSidebarLinksFieldChangeIgnoresPathVsName(t *testing.T) {
|
||||
GetSourceByName: func(name string) (users.SourceInfo, bool) {
|
||||
switch name {
|
||||
case "Downloads":
|
||||
return users.SourceInfo{Path: testDownloadsPath, Name: "Downloads"}, true
|
||||
return users.SourceInfo{Path: "/Users/steffag/Downloads", Name: "Downloads"}, true
|
||||
case "access":
|
||||
return users.SourceInfo{Path: testAccessPath, Name: "access"}, true
|
||||
return users.SourceInfo{Path: "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files", Name: "access"}, true
|
||||
default:
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
@@ -388,8 +380,8 @@ func TestSidebarLinksFieldChangeIgnoresPathVsName(t *testing.T) {
|
||||
ShowToolsInSidebar: true,
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
toolsLink,
|
||||
{Name: "Downloads", Category: "source", Target: "/", SourceName: testDownloadsPath},
|
||||
{Name: "access", Category: "source", Target: "/", SourceName: testAccessPath},
|
||||
{Name: "Downloads", Category: "source", Target: "/", SourceName: "/Users/steffag/Downloads"},
|
||||
{Name: "access", Category: "source", Target: "/", SourceName: "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,13 +16,11 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
@@ -95,7 +93,7 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && !settings.Config.Integrations.OnlyOffice.IsCollabora() && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
|
||||
@@ -382,7 +380,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s (%s): %v", info.RealPath, info.Name, err)
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
@@ -392,13 +390,10 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
subtitles, ok := gomedia.EmbeddedSubtitles(info.RealPath, info.ModTime, func() ([]utils.SubtitleTrack, bool) {
|
||||
return ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime), true
|
||||
})
|
||||
if !ok {
|
||||
subtitles = ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -468,29 +463,6 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return fmt.Errorf("file with size %d MB exceeds metadata check limit: %d MB", fileInfo.Size()/1024/1024, maxSize)
|
||||
}
|
||||
|
||||
// go-media fast path: tags, lyrics availability, and artwork. Unsupported or
|
||||
// failed reads fall through to the existing dhowden/tag code below.
|
||||
if info, ok := gomedia.Audio(realPath, getArt, func() (gomedia.AudioInfo, bool) { return legacyAudioInfo(realPath, getArt) }); ok {
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Title: info.Tags.Title,
|
||||
Artist: info.Tags.Artist,
|
||||
Album: info.Tags.Album,
|
||||
Year: info.Tags.Year,
|
||||
Genre: info.Tags.Genre,
|
||||
Track: info.Tags.Track,
|
||||
HasLyrics: info.HasLyrics,
|
||||
}
|
||||
if getDuration {
|
||||
audioDuration(ctx, item, realPath, ffmpegService)
|
||||
}
|
||||
if n := len(info.Artwork); n > 0 && n <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = info.Artwork
|
||||
} else if n > 5*1024*1024 {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -514,7 +486,15 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
audioDuration(ctx, item, realPath, ffmpegService)
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
}
|
||||
if service != nil {
|
||||
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !getArt {
|
||||
@@ -534,30 +514,6 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// audioDuration sets the duration on item.Metadata. go-media reads it from the
|
||||
// container header first, then ffmpeg is used.
|
||||
func audioDuration(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) {
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
}
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacy := func() (float64, bool) {
|
||||
d, err := service.GetMediaDuration(shadowCtx, realPath)
|
||||
return d, err == nil
|
||||
}
|
||||
if duration, ok := gomedia.Duration(realPath, legacy); ok {
|
||||
item.Metadata.Duration = int(duration)
|
||||
return
|
||||
}
|
||||
if service != nil {
|
||||
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lyricsSidecarExts = []string{".elrc", ".lrc", ".srt", ".vtt"}
|
||||
|
||||
func lyricsSidecar(dir, nameWithoutExt, ext string) (path string, ok bool) {
|
||||
@@ -627,11 +583,6 @@ func hasLyricsSidecar(realPath string) bool {
|
||||
|
||||
// Returns raw lyrics from an audio file (from embedded tags or from sidecar files with the same name).
|
||||
func ExtractLyrics(realPath string) (string, string, error) {
|
||||
// go-media fast path: sidecar first, then embedded tags. Falls through to the
|
||||
// existing code below when unsupported or failed.
|
||||
if text, format, ok := gomedia.Lyrics(realPath, func() (gomedia.LyricsResult, bool) { return legacyLyrics(realPath) }); ok {
|
||||
return text, format, nil
|
||||
}
|
||||
if data, ext, ok := findLyricsSidecar(realPath); ok {
|
||||
return string(data), ext, nil
|
||||
}
|
||||
@@ -650,100 +601,27 @@ func ExtractLyrics(realPath string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// legacyAudioInfo is the dhowden tag read used as the shadow-mode comparison
|
||||
// for gomedia.Audio. It returns the same fields go-media returns, so the two
|
||||
// can be compared. Cover bytes are read only when withArt is true.
|
||||
func legacyAudioInfo(realPath string, withArt bool) (gomedia.AudioInfo, bool) {
|
||||
f, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return gomedia.AudioInfo{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return gomedia.AudioInfo{}, false
|
||||
}
|
||||
track, _ := m.Track()
|
||||
info := gomedia.AudioInfo{
|
||||
Tags: media.Tags{
|
||||
Title: m.Title(),
|
||||
Artist: m.Artist(),
|
||||
Album: m.Album(),
|
||||
Year: m.Year(),
|
||||
Genre: m.Genre(),
|
||||
Track: track,
|
||||
},
|
||||
HasLyrics: strings.TrimSpace(m.Lyrics()) != "" || hasLyricsSidecar(realPath),
|
||||
}
|
||||
if withArt {
|
||||
if pic := m.Picture(); pic != nil {
|
||||
info.Artwork = pic.Data
|
||||
}
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// legacyLyrics is the sidecar and dhowden lyrics read used as the shadow-mode
|
||||
// comparison for gomedia.Lyrics. Sidecar first, then embedded, as in go-media.
|
||||
func legacyLyrics(realPath string) (gomedia.LyricsResult, bool) {
|
||||
if data, ext, ok := findLyricsSidecar(realPath); ok {
|
||||
return gomedia.LyricsResult{Text: strings.TrimSpace(string(data)), Format: ext}, true
|
||||
}
|
||||
f, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return gomedia.LyricsResult{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return gomedia.LyricsResult{}, false
|
||||
}
|
||||
text := strings.TrimSpace(m.Lyrics())
|
||||
if text == "" {
|
||||
return gomedia.LyricsResult{}, false
|
||||
}
|
||||
return gomedia.LyricsResult{Text: text, Format: "embedded"}, true
|
||||
}
|
||||
|
||||
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
// go-media fast path. Works without ffmpeg installed.
|
||||
probeService := ffmpegService
|
||||
if probeService == nil {
|
||||
probeService = ffmpeg.Get()
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
}
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacyProbe := func() (gomedia.ProbeResult, bool) {
|
||||
info, err := probeService.ProbeFile(shadowCtx, realPath)
|
||||
if err != nil || info == nil {
|
||||
return gomedia.ProbeResult{}, false
|
||||
}
|
||||
return *info, true
|
||||
if service == nil {
|
||||
return nil
|
||||
}
|
||||
info, ok := gomedia.Probe(realPath, legacyProbe)
|
||||
if !ok {
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
}
|
||||
if service == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
probed, err := service.ProbeFile(ctx, realPath)
|
||||
if err != nil {
|
||||
duration, durErr := service.GetMediaDuration(ctx, realPath)
|
||||
if durErr != nil {
|
||||
return err
|
||||
}
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{Duration: int(duration)}
|
||||
}
|
||||
return nil
|
||||
info, err := service.ProbeFile(ctx, realPath)
|
||||
if err != nil {
|
||||
duration, durErr := service.GetMediaDuration(ctx, realPath)
|
||||
if durErr != nil {
|
||||
return err
|
||||
}
|
||||
info = *probed
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{Duration: int(duration)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
meta := &iteminfo.MediaMetadata{}
|
||||
|
||||
@@ -55,8 +55,6 @@ const (
|
||||
var types = map[string]string{
|
||||
".3dm": "x-world/x-3dmf",
|
||||
".3dmf": "x-world/x-3dmf",
|
||||
".3g2": "video/3gpp2",
|
||||
".3gp": "video/3gpp",
|
||||
".7z": "application/x-7z-compressed",
|
||||
".a": "application/octet-stream",
|
||||
".aab": "application/x-authorware-bin",
|
||||
@@ -527,7 +525,6 @@ var types = map[string]string{
|
||||
".wmlc": "application/vnd.wap.wmlc",
|
||||
".wmls": "text/vnd.wap.wmlscript",
|
||||
".wmlsc": "application/vnd.wap.wmlscriptc",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".word": "application/msword",
|
||||
".wp5": "application/wordperfect",
|
||||
".wp6": "application/wordperfect",
|
||||
@@ -653,55 +650,55 @@ var types = map[string]string{
|
||||
".tmp": "text/tmp",
|
||||
".temp": "text/tmp",
|
||||
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
|
||||
".adp": "audio/adpcm",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp2a": "audio/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".sil": "audio/silk",
|
||||
".uva": "audio/vnd.dece.audio",
|
||||
".uvva": "audio/vnd.dece.audio",
|
||||
".eol": "audio/vnd.digital-winds",
|
||||
".dra": "audio/vnd.dra",
|
||||
".dts": "audio/vnd.dts",
|
||||
".dtshd": "audio/vnd.dts.hd",
|
||||
".lvp": "audio/vnd.lucent.voice",
|
||||
".pya": "audio/vnd.ms-playready.media.pya",
|
||||
".rip": "audio/vnd.rip",
|
||||
".mka": "audio/x-matroska",
|
||||
".h261": "video/h261",
|
||||
".h263": "video/h263",
|
||||
".h264": "video/h264",
|
||||
".jpgv": "video/jpeg",
|
||||
".jpm": "video/jpm",
|
||||
".jpgm": "video/jpm",
|
||||
".mj2": "video/mj2",
|
||||
".mjp2": "video/mj2",
|
||||
".m2t": "video/mp2t",
|
||||
".m2ts": "video/mp2t",
|
||||
".mts": "video/mp2t",
|
||||
".mp4v": "video/mp4",
|
||||
".mpg4": "video/mp4",
|
||||
".uvh": "video/vnd.dece.hd",
|
||||
".uvvh": "video/vnd.dece.hd",
|
||||
".uvm": "video/vnd.dece.mobile",
|
||||
".uvvm": "video/vnd.dece.mobile",
|
||||
".uvp": "video/vnd.dece.pd",
|
||||
".uvvp": "video/vnd.dece.pd",
|
||||
".uvs": "video/vnd.dece.sd",
|
||||
".uvvs": "video/vnd.dece.sd",
|
||||
".uvv": "video/vnd.dece.video",
|
||||
".uvvv": "video/vnd.dece.video",
|
||||
".dvb": "video/vnd.dvb.file",
|
||||
".fvt": "video/vnd.fvt",
|
||||
".mxu": "video/vnd.mpegurl",
|
||||
".pyv": "video/vnd.ms-playready.media.pyv",
|
||||
".uvu": "video/vnd.uvvu.mp4",
|
||||
".uvvu": "video/vnd.uvvu.mp4",
|
||||
".f4v": "video/x-f4v",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".vob": "video/x-ms-vob",
|
||||
".smv": "video/x-smv",
|
||||
".adp": "audio/adpcm",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp2a": "audio/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".sil": "audio/silk",
|
||||
".uva": "audio/vnd.dece.audio",
|
||||
".uvva": "audio/vnd.dece.audio",
|
||||
".eol": "audio/vnd.digital-winds",
|
||||
".dra": "audio/vnd.dra",
|
||||
".dts": "audio/vnd.dts",
|
||||
".dtshd": "audio/vnd.dts.hd",
|
||||
".lvp": "audio/vnd.lucent.voice",
|
||||
".pya": "audio/vnd.ms-playready.media.pya",
|
||||
".rip": "audio/vnd.rip",
|
||||
".mka": "audio/x-matroska",
|
||||
".h261": "video/h261",
|
||||
".h263": "video/h263",
|
||||
".h264": "video/h264",
|
||||
".jpgv": "video/jpeg",
|
||||
".jpm": "video/jpm",
|
||||
".jpgm": "video/jpm",
|
||||
".mj2": "video/mj2",
|
||||
".mjp2": "video/mj2",
|
||||
".m2t": "video/mp2t",
|
||||
".m2ts": "video/mp2t",
|
||||
".mts": "video/mp2t",
|
||||
".mp4v": "video/mp4",
|
||||
".mpg4": "video/mp4",
|
||||
".uvh": "video/vnd.dece.hd",
|
||||
".uvvh": "video/vnd.dece.hd",
|
||||
".uvm": "video/vnd.dece.mobile",
|
||||
".uvvm": "video/vnd.dece.mobile",
|
||||
".uvp": "video/vnd.dece.pd",
|
||||
".uvvp": "video/vnd.dece.pd",
|
||||
".uvs": "video/vnd.dece.sd",
|
||||
".uvvs": "video/vnd.dece.sd",
|
||||
".uvv": "video/vnd.dece.video",
|
||||
".uvvv": "video/vnd.dece.video",
|
||||
".dvb": "video/vnd.dvb.file",
|
||||
".fvt": "video/vnd.fvt",
|
||||
".mxu": "video/vnd.mpegurl",
|
||||
".pyv": "video/vnd.ms-playready.media.pyv",
|
||||
".uvu": "video/vnd.uvvu.mp4",
|
||||
".uvvu": "video/vnd.uvvu.mp4",
|
||||
".f4v": "video/x-f4v",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".vob": "video/x-ms-vob",
|
||||
".smv": "video/x-smv",
|
||||
}
|
||||
|
||||
//nolint:gochecknoinits
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Video containers that browsers cannot play must still be detected as video so
|
||||
// the preview routes to the transcode UI instead of showing "no preview".
|
||||
func TestVideoContainerExtensionsAreRegisteredAsVideo(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".3gp": "video/3gpp",
|
||||
".3g2": "video/3gpp2",
|
||||
".mp4": "video/mp4",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
}
|
||||
for ext, want := range tests {
|
||||
got := mime.TypeByExtension(ext)
|
||||
if got != want {
|
||||
t.Errorf("mime.TypeByExtension(%q) = %q, want %q", ext, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package fileutils
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,17 +28,7 @@ func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool, devMode bool) {
|
||||
// Dev mode: serve frontend source assets (templates, fonts, icons)
|
||||
assetFs = dirFS{Dir: http.Dir("../frontend/public")}
|
||||
} else {
|
||||
// Non-dev disk build (e.g. Docker): dist is shipped beside the binary. Preload it
|
||||
// into memory when possible; otherwise serve from disk.
|
||||
diskAssets := dirFS{Dir: http.Dir("internal/web/dist")}
|
||||
cached, err := loadMemFS(diskAssets)
|
||||
if err != nil {
|
||||
logger.Warningf("could not preload frontend assets into memory, serving from disk: %v", err)
|
||||
assetFs = diskAssets
|
||||
} else {
|
||||
logger.Debugf("preloaded %d frontend assets (%d bytes) into memory", cached.Len(), cached.Size())
|
||||
assetFs = cached
|
||||
}
|
||||
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memFS is a read-only fs.FS that holds every file's contents in memory.
|
||||
// It is built once at startup from a disk-backed filesystem so that asset
|
||||
// requests never touch the disk.
|
||||
type memFS struct {
|
||||
files map[string]memFile
|
||||
}
|
||||
|
||||
type memFile struct {
|
||||
data []byte
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// loadMemFS reads every regular file under src into memory.
|
||||
func loadMemFS(src fs.FS) (*memFS, error) {
|
||||
m := &memFS{files: make(map[string]memFile)}
|
||||
err := fs.WalkDir(src, ".", func(name string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := fs.ReadFile(src, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.files[name] = memFile{data: data, modTime: info.ModTime()}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Size returns the total number of bytes held in memory.
|
||||
func (m *memFS) Size() int {
|
||||
total := 0
|
||||
for _, f := range m.files {
|
||||
total += len(f.data)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// Len returns the number of files held in memory.
|
||||
func (m *memFS) Len() int {
|
||||
return len(m.files)
|
||||
}
|
||||
|
||||
// Open implements fs.FS. Only regular files are supported, which is all the
|
||||
// asset callers need (ReadFile, Stat and template parsing).
|
||||
func (m *memFS) Open(name string) (fs.File, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
f, ok := m.files[name]
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return &memOpenFile{
|
||||
Reader: bytes.NewReader(f.data),
|
||||
info: memFileInfo{
|
||||
name: baseName(name),
|
||||
size: int64(len(f.data)),
|
||||
modTime: f.modTime,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type memOpenFile struct {
|
||||
*bytes.Reader
|
||||
info memFileInfo
|
||||
}
|
||||
|
||||
func (f *memOpenFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *memOpenFile) Close() error { return nil }
|
||||
|
||||
type memFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func (i memFileInfo) Name() string { return i.name }
|
||||
func (i memFileInfo) Size() int64 { return i.size }
|
||||
func (i memFileInfo) Mode() fs.FileMode { return 0o444 }
|
||||
func (i memFileInfo) ModTime() time.Time { return i.modTime }
|
||||
func (i memFileInfo) IsDir() bool { return false }
|
||||
func (i memFileInfo) Sys() any { return nil }
|
||||
|
||||
func baseName(name string) string {
|
||||
for i := len(name) - 1; i >= 0; i-- {
|
||||
if name[i] == '/' {
|
||||
return name[i+1:]
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMemFSServesFromMemory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"index.html": "<html>index</html>",
|
||||
"assets/app.js.gz": "gzip-bytes",
|
||||
"fonts/icons.woff2": "font-bytes",
|
||||
}
|
||||
for name, content := range files {
|
||||
path := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
m, err := loadMemFS(dirFS{Dir: http.Dir(dir)})
|
||||
if err != nil {
|
||||
t.Fatalf("loadMemFS: %v", err)
|
||||
}
|
||||
if m.Len() != len(files) {
|
||||
t.Fatalf("loaded %d files, want %d", m.Len(), len(files))
|
||||
}
|
||||
|
||||
// Remove everything from disk: the cache must still serve every file.
|
||||
if removeErr := os.RemoveAll(dir); removeErr != nil {
|
||||
t.Fatal(removeErr)
|
||||
}
|
||||
for name, want := range files {
|
||||
got, readErr := fs.ReadFile(m, name)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(%s) after disk removal: %v", name, readErr)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("ReadFile(%s) = %q, want %q", name, got, want)
|
||||
}
|
||||
info, statErr := fs.Stat(m, name)
|
||||
if statErr != nil {
|
||||
t.Fatalf("Stat(%s): %v", name, statErr)
|
||||
}
|
||||
if info.IsDir() || info.Size() != int64(len(want)) || info.Name() != filepath.Base(name) {
|
||||
t.Errorf("Stat(%s) = %+v, unexpected", name, info)
|
||||
}
|
||||
}
|
||||
|
||||
// Template-style glob on a plain name must resolve via Stat.
|
||||
matches, globErr := fs.Glob(m, "index.html")
|
||||
if globErr != nil || len(matches) != 1 {
|
||||
t.Fatalf("Glob(index.html) = %v, %v", matches, globErr)
|
||||
}
|
||||
|
||||
if _, readErr := fs.ReadFile(m, "missing.css"); readErr == nil {
|
||||
t.Error("ReadFile(missing.css) should fail")
|
||||
}
|
||||
if _, readErr := fs.ReadFile(m, "../escape"); readErr == nil {
|
||||
t.Error("invalid path should be rejected")
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,8 @@ func collectPayload(versionLabel string) (snapshotPayload, error) {
|
||||
Integrations: integrationsPayload{
|
||||
OnlyOfficeConfigured: strings.TrimSpace(settings.Config.Integrations.OnlyOffice.Url) != "",
|
||||
OnlyOfficeViewOnly: settings.Config.Integrations.OnlyOffice.ViewOnly,
|
||||
MediaEnabled: settings.FFmpegEnabled(),
|
||||
HardwareAcceleration: settings.HardwareAccelerationEnabled(),
|
||||
MediaEnabled: settings.MediaEnabled(),
|
||||
HardwareAcceleration: settings.Config.Integrations.Media.HardwareAcceleration,
|
||||
},
|
||||
Features: featuresPayload{
|
||||
DisablePreviews: settings.Config.Server.DisablePreviews,
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/transcoding"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// App holds wired dependencies for the running process.
|
||||
@@ -22,11 +17,6 @@ type App struct {
|
||||
|
||||
// WireServices connects domain packages to the state store after Open.
|
||||
func WireServices(store *state.Store) (*App, error) {
|
||||
return WireServicesWithContext(context.Background(), store)
|
||||
}
|
||||
|
||||
// WireServicesWithContext wires services using ctx for background workers.
|
||||
func WireServicesWithContext(ctx context.Context, store *state.Store) (*App, error) {
|
||||
filesSvc := files.New(store, store, store)
|
||||
authSvc := auth.New(store)
|
||||
files.SetDefault(filesSvc)
|
||||
@@ -36,24 +26,6 @@ func WireServicesWithContext(ctx context.Context, store *state.Store) (*App, err
|
||||
if err := auth.InitWebAuthn(store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Always create the manager: the engine re-checks ffmpeg availability per
|
||||
// request, so a runtime admin settings change takes effect without restart.
|
||||
storeDir, err := transcoding.NewStore(settings.TranscodeCacheDir())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Delivery URLs stay root-relative so a subpath deployment or alternate
|
||||
// hostname still resolves and the session cookie is sent.
|
||||
transcodeMgr, err := transcoding.NewManager(ctx, transcoding.NewMediaEngine(), storeDir,
|
||||
strings.TrimRight(settings.Config.Http.BaseURL, "/"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Exported so the web layer (and shutdown) can reach it without threading
|
||||
// it through StartHttp's Deps.
|
||||
transcoding.SetDefault(transcodeMgr)
|
||||
|
||||
return &App{
|
||||
Store: store,
|
||||
Files: filesSvc,
|
||||
|
||||
@@ -45,8 +45,9 @@ type FrontendShareInfo struct {
|
||||
type ShareLimits struct {
|
||||
MaxBandwidth int `json:"maxBandwidth,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"`
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
QuotaLimitBytes int64 `json:"quotaLimitBytes,omitempty"`
|
||||
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
|
||||
Banner string `json:"banner,omitempty"`
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgosql
|
||||
// +build cgosql
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const SqliteDriver = "sqlite3"
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
@@ -1,131 +0,0 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// supportsFTS5 reports whether the driver can create an FTS5 table with the trigram
|
||||
// tokenizer (SQLite >= 3.34 with FTS5 compiled in).
|
||||
func (db *IndexDB) supportsFTS5() bool {
|
||||
if _, err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.__fts_probe USING fts5(x, tokenize='trigram')`); err != nil {
|
||||
return false
|
||||
}
|
||||
_, _ = db.Exec(`DROP TABLE IF EXISTS temp.__fts_probe`)
|
||||
return true
|
||||
}
|
||||
|
||||
// ftsTableExists reports whether the FTS search table is present.
|
||||
func (db *IndexDB) ftsTableExists() bool {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items_fts'`).Scan(&n); err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// FTSReady reports whether the trigram search index is available for queries.
|
||||
func (db *IndexDB) FTSReady() bool {
|
||||
return db.ftsReady.Load()
|
||||
}
|
||||
|
||||
const ftsDDL = `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS index_items_fts USING fts5(
|
||||
name, spath,
|
||||
content='index_items', content_rowid='rowid',
|
||||
tokenize='trigram', detail=none
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS index_items_fts_ai AFTER INSERT ON index_items BEGIN
|
||||
INSERT INTO index_items_fts(rowid, name, spath) VALUES (new.rowid, new.name, new.spath);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS index_items_fts_ad AFTER DELETE ON index_items BEGIN
|
||||
INSERT INTO index_items_fts(index_items_fts, rowid, name, spath) VALUES ('delete', old.rowid, old.name, old.spath);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS index_items_fts_au AFTER UPDATE OF name ON index_items
|
||||
WHEN old.name IS NOT new.name BEGIN
|
||||
INSERT INTO index_items_fts(index_items_fts, rowid, name, spath) VALUES ('delete', old.rowid, old.name, old.spath);
|
||||
INSERT INTO index_items_fts(rowid, name, spath) VALUES (new.rowid, new.name, new.spath);
|
||||
END;
|
||||
`
|
||||
|
||||
// syncSearchIndex creates the FTS5 trigram index when enabled and drops it when
|
||||
// disabled. The index is derived data: enabling it builds from existing rows (no
|
||||
// rescan), and disabling it removes the table and triggers.
|
||||
func (db *IndexDB) syncSearchIndex(enabled bool) {
|
||||
if !enabled {
|
||||
db.dropSearchIndex()
|
||||
return
|
||||
}
|
||||
if !db.supportsFTS5() {
|
||||
logger.Errorf("indexSqlConfig.fts5 is enabled but FTS5 trigram support is unavailable in this build; search will use the unindexed fallback.")
|
||||
db.ftsReady.Store(false)
|
||||
return
|
||||
}
|
||||
if err := db.ensureSpathColumn(); err != nil {
|
||||
logger.Errorf("failed to add the spath column (%v); search will use the unindexed fallback", err)
|
||||
db.ftsReady.Store(false)
|
||||
return
|
||||
}
|
||||
existed := db.ftsTableExists()
|
||||
if _, err := db.Exec(ftsDDL); err != nil {
|
||||
logger.Errorf("failed to create the FTS search index (%v); search will use the unindexed fallback", err)
|
||||
db.ftsReady.Store(false)
|
||||
return
|
||||
}
|
||||
if !existed {
|
||||
if _, err := db.Exec(`INSERT INTO index_items_fts(index_items_fts) VALUES('rebuild')`); err != nil {
|
||||
logger.Errorf("failed to populate the FTS search index (%v); search will use the unindexed fallback", err)
|
||||
db.ftsReady.Store(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
db.ftsReady.Store(true)
|
||||
}
|
||||
|
||||
// dropSearchIndex removes the FTS table and its triggers (used when the feature is
|
||||
// disabled or the index needs a rebuild).
|
||||
func (db *IndexDB) dropSearchIndex() {
|
||||
db.ftsReady.Store(false)
|
||||
if !db.ftsTableExists() {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
DROP TRIGGER IF EXISTS index_items_fts_ai;
|
||||
DROP TRIGGER IF EXISTS index_items_fts_ad;
|
||||
DROP TRIGGER IF EXISTS index_items_fts_au;
|
||||
DROP TABLE IF EXISTS index_items_fts;
|
||||
`); err != nil {
|
||||
logger.Warningf("failed to drop the FTS search index: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkFTSHealth compares the FTS index against the base table and runs a cheap
|
||||
// internal consistency check. The full content check (rank=1) is far slower and is
|
||||
// skipped.
|
||||
func (db *IndexDB) checkFTSHealth() error {
|
||||
var items, docs int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM index_items`).Scan(&items); err != nil {
|
||||
return fmt.Errorf("count index_items: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM index_items_fts_docsize`).Scan(&docs); err != nil {
|
||||
return fmt.Errorf("count fts docsize: %w", err)
|
||||
}
|
||||
if items != docs {
|
||||
return fmt.Errorf("search index row mismatch: index_items=%d fts=%d", items, docs)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO index_items_fts(index_items_fts, rank) VALUES('integrity-check', 0)`); err != nil {
|
||||
return fmt.Errorf("fts integrity check: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// optimizeFTS merges the FTS index segments; call after bulk deletes or heavy churn.
|
||||
func (db *IndexDB) optimizeFTS() {
|
||||
if !db.FTSReady() {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO index_items_fts(index_items_fts) VALUES('optimize')`); err != nil {
|
||||
logger.Warningf("FTS optimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqlitebusy"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
// It wraps the underlying sql.DB connection and provides type-safe methods.
|
||||
type IndexDB struct {
|
||||
*TempDB
|
||||
ftsReady atomic.Bool
|
||||
}
|
||||
|
||||
func createIndexDB(name string, journalMode string, lockingMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, error) {
|
||||
@@ -113,17 +111,6 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
idxDB.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// The FTS5 search index is optional (indexSqlConfig.fts5, default off). Enabling it
|
||||
// builds from existing rows; disabling it drops the table and triggers.
|
||||
idxDB.syncSearchIndex(settings.Config.Server.IndexSqlConfig.FTS5)
|
||||
if !disableReuse && !isNewDb && idxDB.FTSReady() {
|
||||
if err := idxDB.checkFTSHealth(); err != nil {
|
||||
logger.Warningf("[DB_INIT] FTS search index is unhealthy (%v); rebuilding", err)
|
||||
idxDB.dropSearchIndex()
|
||||
idxDB.syncSearchIndex(true)
|
||||
}
|
||||
}
|
||||
go idxDB.startPeriodicCleanup()
|
||||
return idxDB, isNewDb, nil
|
||||
}
|
||||
@@ -247,7 +234,6 @@ func (db *IndexDB) startPeriodicCleanup() {
|
||||
} else if deletedCount > 0 {
|
||||
logger.Infof("[DB_MAINTENANCE] Cleaned up %d stale index entries", deletedCount)
|
||||
}
|
||||
db.optimizeFTS()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,43 +259,11 @@ func (db *IndexDB) CreateIndexTable() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_source_size ON index_items(source, size);
|
||||
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
|
||||
`
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ensureCreatedTimeColumn()
|
||||
}
|
||||
|
||||
// ensureSpathColumn adds the generated spath column (source:path) used by the FTS
|
||||
// search index when it is missing from an existing table. PRAGMA table_xinfo is used
|
||||
// because table_info omits generated columns.
|
||||
func (db *IndexDB) ensureSpathColumn() error {
|
||||
rows, err := db.Query(`PRAGMA table_xinfo(index_items)`)
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk, hidden int
|
||||
var name, columnType string
|
||||
var defaultValue interface{}
|
||||
if scanErr := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk, &hidden); scanErr != nil {
|
||||
rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
if name == "spath" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
rows.Close()
|
||||
return rowsErr
|
||||
}
|
||||
rows.Close()
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN spath TEXT GENERATED ALWAYS AS (source || ':' || path) VIRTUAL`)
|
||||
return err
|
||||
return db.ensureCreatedTimeColumn()
|
||||
}
|
||||
|
||||
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
|
||||
|
||||
@@ -56,17 +56,10 @@ func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
FTS5: true,
|
||||
StartupIntegrityCheck: mode,
|
||||
}
|
||||
}
|
||||
|
||||
func testIndexSQLConfigNoFTS(mode string) settings.IndexSqlConfig {
|
||||
cfg := testIndexSQLConfig(mode)
|
||||
cfg.FTS5 = false
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
|
||||
@@ -136,123 +129,3 @@ func TestNewIndexDB_StartupOff(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
}
|
||||
|
||||
// TestNewIndexDB_ReusesHealthyDB checks that a matching schema is reused, not recreated.
|
||||
func TestNewIndexDB_ReusesHealthyDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
|
||||
defer pop()
|
||||
|
||||
db, isNew, err := NewIndexDB("reuse_ok", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isNew {
|
||||
t.Fatalf("fresh database should report isNewDb=true")
|
||||
}
|
||||
if err = db.InsertItem("src1", "/a.txt", &iteminfo.FileInfo{
|
||||
Path: "/a.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "a.txt", Type: "text/plain", ModTime: time.Unix(1, 0)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db2, isNew2, err := NewIndexDB("reuse_ok", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
if isNew2 {
|
||||
t.Fatalf("a healthy database should be reused, not recreated")
|
||||
}
|
||||
if !db2.FTSReady() {
|
||||
t.Fatalf("expected the FTS index to be ready on reuse")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewIndexDB_FTSDisabledWhenConfigOff checks that FTS is not created when disabled.
|
||||
func TestNewIndexDB_FTSDisabledWhenConfigOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfigNoFTS(settings.IndexStartupIntegrityOff))
|
||||
defer pop()
|
||||
|
||||
db, _, err := NewIndexDB("fts_off", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if db.FTSReady() {
|
||||
t.Fatalf("FTS should be disabled when indexSqlConfig.fts5 is false")
|
||||
}
|
||||
if db.ftsTableExists() {
|
||||
t.Fatalf("the FTS table should not exist when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewIndexDB_FTSToggleBuildsAndDrops checks that enabling FTS builds from existing
|
||||
// rows (no rescan) and disabling it removes the table.
|
||||
func TestNewIndexDB_FTSToggleBuildsAndDrops(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Start with FTS off and insert a row.
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfigNoFTS(settings.IndexStartupIntegrityOff))
|
||||
db, _, err := NewIndexDB("fts_toggle", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.InsertItem("s1", "/a/report.txt", &iteminfo.FileInfo{
|
||||
Path: "/a/report.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "report.txt", Type: "text/plain", ModTime: time.Unix(1, 0)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.FTSReady() {
|
||||
t.Fatalf("FTS should be off")
|
||||
}
|
||||
if err = db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pop()
|
||||
|
||||
// Reopen with FTS on: it should build from the existing row without a rescan.
|
||||
pop2 := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
|
||||
db2, isNew, err := NewIndexDB("fts_toggle", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isNew {
|
||||
t.Fatalf("reused database should not be recreated when enabling FTS")
|
||||
}
|
||||
if !db2.FTSReady() {
|
||||
t.Fatalf("FTS should be ready after enabling")
|
||||
}
|
||||
if err = db2.checkFTSHealth(); err != nil {
|
||||
t.Fatalf("health after enabling: %v", err)
|
||||
}
|
||||
filter := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
|
||||
if got := runSearchItems(t, db2, "s1", "/", filter); !sameSet(got, []string{"/a/report.txt"}) {
|
||||
t.Fatalf("existing row should be searchable via FTS, got %v", got)
|
||||
}
|
||||
if err = db2.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pop2()
|
||||
|
||||
// Disable again: the table and triggers should be dropped.
|
||||
pop3 := pushTestIndexConfig(t, dir, testIndexSQLConfigNoFTS(settings.IndexStartupIntegrityOff))
|
||||
defer pop3()
|
||||
db3, _, err := NewIndexDB("fts_toggle", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db3.Close()
|
||||
if db3.FTSReady() {
|
||||
t.Fatalf("FTS should be off after disabling")
|
||||
}
|
||||
if db3.ftsTableExists() {
|
||||
t.Fatalf("the FTS table should be dropped when disabled")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// SearchItem represents a single search result row from the database
|
||||
@@ -22,15 +19,19 @@ type SearchItem struct {
|
||||
HasPreview bool
|
||||
}
|
||||
|
||||
// appendGlob adds "AND (col GLOB ? [OR/AND] ...)" when patterns is non-empty.
|
||||
func appendGlob(query string, args []interface{}, column string, patterns []string, joinAnd bool) (string, []interface{}) {
|
||||
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
|
||||
func appendNameGlobSQL(query string, args []interface{}, patterns []string, joinAnd, caseExact bool) (string, []interface{}) {
|
||||
if len(patterns) == 0 {
|
||||
return query, args
|
||||
}
|
||||
nameExpr := "name"
|
||||
if !caseExact {
|
||||
nameExpr = "lower(name)"
|
||||
}
|
||||
parts := make([]string, len(patterns))
|
||||
for i, p := range patterns {
|
||||
parts[i] = column + " GLOB ?"
|
||||
args = append(args, p)
|
||||
for i := range patterns {
|
||||
parts[i] = nameExpr + " GLOB ?"
|
||||
args = append(args, patterns[i])
|
||||
}
|
||||
join := " OR "
|
||||
if joinAnd {
|
||||
@@ -39,173 +40,96 @@ func appendGlob(query string, args []interface{}, column string, patterns []stri
|
||||
return query + " AND (" + strings.Join(parts, join) + ")", args
|
||||
}
|
||||
|
||||
// appendLike adds "AND (col LIKE ? [OR/AND] ...)" when patterns is non-empty.
|
||||
func appendLike(query string, args []interface{}, column string, patterns []string, joinAnd bool) (string, []interface{}) {
|
||||
if len(patterns) == 0 {
|
||||
return query, args
|
||||
}
|
||||
parts := make([]string, len(patterns))
|
||||
for i, p := range patterns {
|
||||
parts[i] = column + " LIKE ?"
|
||||
args = append(args, p)
|
||||
}
|
||||
join := " OR "
|
||||
if joinAnd {
|
||||
join = " AND "
|
||||
}
|
||||
return query + " AND (" + strings.Join(parts, join) + ")", args
|
||||
}
|
||||
|
||||
// useFTSPath reports whether the trigram index can serve this name filter.
|
||||
func useFTSPath(db *IndexDB, filter *iteminfo.NameFilter) bool {
|
||||
return filter != nil && filter.UseFTS && db.FTSReady()
|
||||
}
|
||||
|
||||
// appendNamePrefilter narrows rows using the FTS trigram index (a superset of the
|
||||
// exact predicate). It is only valid in the FTS query path.
|
||||
func appendNamePrefilter(query string, args []interface{}, filter *iteminfo.NameFilter) (string, []interface{}) {
|
||||
if filter == nil || len(filter.FTS) == 0 {
|
||||
return query, args
|
||||
}
|
||||
if filter.PrefilterLike {
|
||||
return appendLike(query, args, "f.name", filter.FTS, filter.JoinAnd)
|
||||
}
|
||||
return appendGlob(query, args, "f.name", filter.FTS, filter.JoinAnd)
|
||||
}
|
||||
|
||||
// appendNameExact adds the exact name predicate applied to the base table. alias is
|
||||
// "" for the single-table query or "i." in the FTS join.
|
||||
func appendNameExact(query string, args []interface{}, filter *iteminfo.NameFilter, alias string) (string, []interface{}) {
|
||||
if filter == nil || len(filter.Exact) == 0 {
|
||||
return query, args
|
||||
}
|
||||
column := alias + "name"
|
||||
if filter.ExactLower {
|
||||
column = "lower(" + alias + "name)"
|
||||
}
|
||||
return appendGlob(query, args, column, filter.Exact, filter.JoinAnd)
|
||||
}
|
||||
|
||||
// SearchItems queries the database for items matching the search criteria for a single source.
|
||||
// When the filter is usable and the FTS index is ready, rows are narrowed with the trigram
|
||||
// index and then filtered by the exact name predicate; otherwise the base table is scanned.
|
||||
func (db *IndexDB) SearchItems(ctx context.Context, source, scope string, largest bool, filter *iteminfo.NameFilter) (*sql.Rows, error) {
|
||||
if largest {
|
||||
query := `
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ?
|
||||
`
|
||||
args := []interface{}{source}
|
||||
if scope != "" {
|
||||
query += " AND path GLOB ?"
|
||||
args = append(args, iteminfo.EscapeGlobLiteral(scope)+"*")
|
||||
}
|
||||
query += " ORDER BY size DESC"
|
||||
return db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
if useFTSPath(db, filter) {
|
||||
query := `
|
||||
SELECT i.path, i.name, i.size, i.created_time, i.mod_time, i.type, i.is_dir, i.has_preview
|
||||
FROM index_items_fts f CROSS JOIN index_items i ON i.rowid = f.rowid
|
||||
WHERE i.source = ?
|
||||
`
|
||||
args := []interface{}{source}
|
||||
if scope != "" {
|
||||
query += " AND i.path GLOB ?"
|
||||
args = append(args, iteminfo.EscapeGlobLiteral(scope)+"*")
|
||||
}
|
||||
// Narrow by source+scope inside the global FTS index (superset; exact filters below).
|
||||
query += " AND f.spath LIKE ?"
|
||||
args = append(args, source+":"+scope+"%")
|
||||
query, args = appendNamePrefilter(query, args, filter)
|
||||
query, args = appendNameExact(query, args, filter, "i.")
|
||||
return db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// When nameGlobPatterns is non-empty (and largest is false), rows are restricted with SQLite name GLOB ... OR ....
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
|
||||
query := `
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ?
|
||||
`
|
||||
args := []interface{}{source}
|
||||
|
||||
// Apply scope filter
|
||||
if scope != "" {
|
||||
// Use GLOB for prefix matching which is supported by SQLite and efficient
|
||||
query += " AND path GLOB ?"
|
||||
args = append(args, iteminfo.EscapeGlobLiteral(scope)+"*")
|
||||
}
|
||||
query, args = appendNameExact(query, args, filter, "")
|
||||
return db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
|
||||
func (db *IndexDB) SearchItemsMultiSource(ctx context.Context, sources []string, sourceScopes map[string]string, largest bool, filter *iteminfo.NameFilter) (*sql.Rows, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required")
|
||||
args = append(args, scope+"*")
|
||||
}
|
||||
|
||||
args := []interface{}{}
|
||||
placeholders := make([]string, len(sources))
|
||||
for i, source := range sources {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, source)
|
||||
}
|
||||
sourceClause := "source = ?"
|
||||
if len(sources) > 1 {
|
||||
sourceClause = "source IN (" + strings.Join(placeholders, ",") + ")"
|
||||
}
|
||||
|
||||
// Per-source scope conditions (on the base table).
|
||||
scopeConditions := []string{}
|
||||
scopeArgs := []interface{}{}
|
||||
spathConditions := []string{}
|
||||
spathArgs := []interface{}{}
|
||||
for _, source := range sources {
|
||||
scope, hasScope := sourceScopes[source]
|
||||
if hasScope && scope != "" {
|
||||
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
|
||||
scopeArgs = append(scopeArgs, source, iteminfo.EscapeGlobLiteral(scope)+"*")
|
||||
spathConditions = append(spathConditions, "f.spath LIKE ?")
|
||||
spathArgs = append(spathArgs, source+":"+scope+"%")
|
||||
} else {
|
||||
scopeConditions = append(scopeConditions, "source = ?")
|
||||
scopeArgs = append(scopeArgs, source)
|
||||
}
|
||||
if !largest && len(nameGlobPatterns) > 0 {
|
||||
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd, caseExact)
|
||||
}
|
||||
|
||||
if largest {
|
||||
query := `
|
||||
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE ` + sourceClause
|
||||
query += " AND (" + strings.Join(scopeConditions, " OR ") + ")"
|
||||
query += " ORDER BY size DESC"
|
||||
allArgs := append(args, scopeArgs...)
|
||||
return db.QueryContext(ctx, query, allArgs...)
|
||||
}
|
||||
|
||||
if useFTSPath(db, filter) {
|
||||
query := `
|
||||
SELECT i.source, i.path, i.name, i.size, i.created_time, i.mod_time, i.type, i.is_dir, i.has_preview
|
||||
FROM index_items_fts f CROSS JOIN index_items i ON i.rowid = f.rowid
|
||||
WHERE i.` + sourceClause
|
||||
query += " AND (" + strings.Join(scopeConditions, " OR ") + ")"
|
||||
allArgs := append(args, scopeArgs...)
|
||||
if len(spathConditions) > 0 {
|
||||
query += " AND (" + strings.Join(spathConditions, " OR ") + ")"
|
||||
allArgs = append(allArgs, spathArgs...)
|
||||
}
|
||||
query, allArgs = appendNamePrefilter(query, allArgs, filter)
|
||||
query, allArgs = appendNameExact(query, allArgs, filter, "i.")
|
||||
return db.QueryContext(ctx, query, allArgs...)
|
||||
return db.Query(query, args...)
|
||||
}
|
||||
|
||||
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
|
||||
// When nameGlobPatterns is non-empty (and largest is false), restricts rows with SQLite name GLOB ... OR ....
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE ` + sourceClause
|
||||
query += " AND (" + strings.Join(scopeConditions, " OR ") + ")"
|
||||
allArgs := append(args, scopeArgs...)
|
||||
query, allArgs = appendNameExact(query, allArgs, filter, "")
|
||||
return db.QueryContext(ctx, query, allArgs...)
|
||||
`
|
||||
args := []interface{}{}
|
||||
whereClauses := []string{}
|
||||
|
||||
// Filter by sources using IN clause
|
||||
if len(sources) == 1 {
|
||||
whereClauses = append(whereClauses, "source = ?")
|
||||
args = append(args, sources[0])
|
||||
} else {
|
||||
placeholders := make([]string, len(sources))
|
||||
for i, source := range sources {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, source)
|
||||
}
|
||||
whereClauses = append(whereClauses, "source IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
// Apply scope filters - need OR conditions for each source+scope combination
|
||||
// Build conditions for each source: if it has a scope, filter by it; otherwise include all paths for that source
|
||||
scopeConditions := []string{}
|
||||
for _, source := range sources {
|
||||
scope, hasScope := sourceScopes[source]
|
||||
if hasScope && scope != "" {
|
||||
// Source has a scope - filter by it
|
||||
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
|
||||
args = append(args, source, scope+"*")
|
||||
} else {
|
||||
// Source has no scope or empty scope - include all paths for this source
|
||||
// The source IN clause already filters by source, so we just need to ensure
|
||||
// this source is included in the OR condition (which it already is via source IN)
|
||||
// We add a simple source check to make the OR condition work correctly
|
||||
scopeConditions = append(scopeConditions, "source = ?")
|
||||
args = append(args, source)
|
||||
}
|
||||
}
|
||||
if len(scopeConditions) > 0 {
|
||||
whereClauses = append(whereClauses, "("+strings.Join(scopeConditions, " OR ")+")")
|
||||
}
|
||||
|
||||
if len(whereClauses) > 0 {
|
||||
query += " WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
if !largest && len(nameGlobPatterns) > 0 {
|
||||
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd, caseExact)
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
return db.Query(query, args...)
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func newSearchTestDB(t *testing.T, name string) *IndexDB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
|
||||
t.Cleanup(pop)
|
||||
db, _, err := NewIndexDB(name, "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewIndexDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func insertSearchItem(t *testing.T, db *IndexDB, source, path, name string) {
|
||||
t.Helper()
|
||||
info := &iteminfo.FileInfo{
|
||||
Path: path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: name,
|
||||
Type: "text/plain",
|
||||
ModTime: time.Unix(1, 0),
|
||||
},
|
||||
}
|
||||
if err := db.InsertItem(source, path, info); err != nil {
|
||||
t.Fatalf("InsertItem(%s): %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runSearchItems(t *testing.T, db *IndexDB, source, scope string, filter *iteminfo.NameFilter) []string {
|
||||
t.Helper()
|
||||
rows, err := db.SearchItems(context.Background(), source, scope, false, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchItems: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var path, name string
|
||||
var size, createdTime, modTime int64
|
||||
var mimeType string
|
||||
var isDir, hasPreview bool
|
||||
if err := rows.Scan(&path, &name, &size, &createdTime, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
got = append(got, path)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func seedSearchItems(t *testing.T, db *IndexDB) {
|
||||
t.Helper()
|
||||
insertSearchItem(t, db, "s1", "/a/Report 2024.pdf", "Report 2024.pdf")
|
||||
insertSearchItem(t, db, "s1", "/a/my_file.txt", "my_file.txt")
|
||||
insertSearchItem(t, db, "s1", "/a/myXfile.txt", "myXfile.txt")
|
||||
insertSearchItem(t, db, "s1", "/a/abc.txt", "abc.txt")
|
||||
insertSearchItem(t, db, "s1", "/b/report.txt", "report.txt")
|
||||
insertSearchItem(t, db, "s2", "/a/report-other.txt", "report-other.txt")
|
||||
}
|
||||
|
||||
func TestSearchItemsFTSDefault(t *testing.T) {
|
||||
db := newSearchTestDB(t, "search_fts_default")
|
||||
if !db.FTSReady() {
|
||||
t.Fatal("expected the FTS index to be ready")
|
||||
}
|
||||
seedSearchItems(t, db)
|
||||
|
||||
filter := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
|
||||
if filter == nil || !filter.UseFTS {
|
||||
t.Fatalf("expected an FTS filter, got %#v", filter)
|
||||
}
|
||||
got := runSearchItems(t, db, "s1", "/", filter)
|
||||
want := []string{"/a/Report 2024.pdf", "/b/report.txt"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("default search got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The LIKE prefilter treats '_' as a wildcard; the exact lower(name) GLOB must remove
|
||||
// the false positive myXfile.txt.
|
||||
filter = iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"my_file"}}, false, false)
|
||||
got = runSearchItems(t, db, "s1", "/", filter)
|
||||
want = []string{"/a/my_file.txt"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("underscore search got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Scope is respected.
|
||||
filter = iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
|
||||
got = runSearchItems(t, db, "s1", "/b/", filter)
|
||||
want = []string{"/b/report.txt"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("scoped search got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchItemsCaseExactAndWildcard(t *testing.T) {
|
||||
db := newSearchTestDB(t, "search_fts_modes")
|
||||
seedSearchItems(t, db)
|
||||
|
||||
// case:exact is case-sensitive.
|
||||
exact := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{
|
||||
Terms: []string{"Report"},
|
||||
Conditions: map[string]bool{"exact": true},
|
||||
}, false, false)
|
||||
got := runSearchItems(t, db, "s1", "/", exact)
|
||||
want := []string{"/a/Report 2024.pdf"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("case:exact got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Wildcard stays on the legacy (case-insensitive) path.
|
||||
wild := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report*"}}, true, false)
|
||||
if wild == nil || wild.UseFTS {
|
||||
t.Fatalf("wildcard should use the legacy path, got %#v", wild)
|
||||
}
|
||||
got = runSearchItems(t, db, "s1", "/", wild)
|
||||
want = []string{"/a/Report 2024.pdf", "/b/report.txt"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("wildcard got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Too short for trigrams => legacy path, still correct.
|
||||
short := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"ab"}}, false, false)
|
||||
if short == nil || short.UseFTS {
|
||||
t.Fatalf("short term should use the legacy path, got %#v", short)
|
||||
}
|
||||
got = runSearchItems(t, db, "s1", "/", short)
|
||||
want = []string{"/a/abc.txt"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("short search got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchItemsMultiSource(t *testing.T) {
|
||||
db := newSearchTestDB(t, "search_fts_multi")
|
||||
seedSearchItems(t, db)
|
||||
|
||||
filter := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
|
||||
rows, err := db.SearchItemsMultiSource(context.Background(),
|
||||
[]string{"s1", "s2"}, map[string]string{"s1": "/", "s2": "/"}, false, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchItemsMultiSource: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var source, path, name string
|
||||
var size, ct, mt int64
|
||||
var mime string
|
||||
var isDir, hasPreview bool
|
||||
if err := rows.Scan(&source, &path, &name, &size, &ct, &mt, &mime, &isDir, &hasPreview); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
got = append(got, source+":"+path)
|
||||
}
|
||||
want := []string{"s1:/a/Report 2024.pdf", "s1:/b/report.txt", "s2:/a/report-other.txt"}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("multi-source got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchItemsQueryPlan guards the planner fix: the FTS table must drive the query.
|
||||
func TestSearchItemsQueryPlan(t *testing.T) {
|
||||
db := newSearchTestDB(t, "search_fts_plan")
|
||||
seedSearchItems(t, db)
|
||||
|
||||
plan, err := db.ExplainQueryPlan(
|
||||
`SELECT i.path FROM index_items_fts f CROSS JOIN index_items i ON i.rowid = f.rowid
|
||||
WHERE i.source = ? AND i.path GLOB ? AND f.spath LIKE ? AND f.name LIKE ? AND lower(i.name) GLOB ?`,
|
||||
"s1", "/%", "s1:/%", "%report%", "*report*",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ExplainQueryPlan: %v", err)
|
||||
}
|
||||
if !strings.Contains(plan, "VIRTUAL TABLE") {
|
||||
t.Fatalf("expected the FTS table to drive the query, plan:\n%s", plan)
|
||||
}
|
||||
if !strings.Contains(plan, "SEARCH i USING INTEGER PRIMARY KEY") {
|
||||
t.Fatalf("expected a rowid lookup into index_items, plan:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchItemsFTSHealthAfterDelete(t *testing.T) {
|
||||
db := newSearchTestDB(t, "search_fts_health")
|
||||
seedSearchItems(t, db)
|
||||
if err := db.checkFTSHealth(); err != nil {
|
||||
t.Fatalf("health before delete: %v", err)
|
||||
}
|
||||
if err := db.DeleteItem("s1", "/a/abc.txt", false); err != nil {
|
||||
t.Fatalf("DeleteItem: %v", err)
|
||||
}
|
||||
if err := db.checkFTSHealth(); err != nil {
|
||||
t.Fatalf("health after delete: %v", err)
|
||||
}
|
||||
// A name-changing upsert must also keep the index in sync.
|
||||
insertSearchItem(t, db, "s1", "/a/abc.txt", "renamed.txt")
|
||||
if err := db.checkFTSHealth(); err != nil {
|
||||
t.Fatalf("health after re-insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sameSet(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
set := make(map[string]int, len(got))
|
||||
for _, g := range got {
|
||||
set[g]++
|
||||
}
|
||||
for _, w := range want {
|
||||
set[w]--
|
||||
if set[w] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -24,7 +23,11 @@ const (
|
||||
)
|
||||
|
||||
func init() {
|
||||
logger.Debugf("SQLite driver initialized (pure Go)")
|
||||
if SqliteDriver == "sqlite3" {
|
||||
logger.Debugf("CGO SQLite driver initialized")
|
||||
} else {
|
||||
logger.Debugf("Default SQLite driver initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TempDB manages a temporary SQLite database for operations that need
|
||||
@@ -224,6 +227,7 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
}
|
||||
|
||||
// Open SQLite database with basic connection string.
|
||||
// Driver is selected at compile time: "sqlite3" (CGO) or "sqlite" (pure Go).
|
||||
// busy_timeout is set in the DSN so every pooled connection honors it.
|
||||
db, err := sql.Open(SqliteDriver, sqlitebusy.WithBusyTimeout(tmpPath))
|
||||
if err != nil {
|
||||
@@ -401,14 +405,6 @@ func (t *TempDB) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
return t.db.Query(query, args...)
|
||||
}
|
||||
|
||||
// QueryContext executes a query that returns rows, honoring context cancellation so
|
||||
// an abandoned request interrupts the running statement instead of holding the connection.
|
||||
func (t *TempDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that is expected to return at most one row.
|
||||
// This is a convenience method that handles locking.
|
||||
func (t *TempDB) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgosql
|
||||
// +build cgosql
|
||||
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const SqliteDriver = "sqlite3"
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
@@ -14,7 +14,7 @@ var ErrBusy = errors.New("sqlite database is busy or locked")
|
||||
|
||||
// WithBusyTimeout appends the busy_timeout connection parameter to dsn so that
|
||||
// every connection in the pool honors the timeout. Both supported drivers
|
||||
// (modernc.org/sqlite) applies the parameter at
|
||||
// (mattn/go-sqlite3 and modernc.org/sqlite) apply the parameter at
|
||||
// connection-open time, unlike a one-off PRAGMA which only configures the
|
||||
// single connection that database/sql happens to pick.
|
||||
func WithBusyTimeout(dsn string) string {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build cgosql
|
||||
// +build cgosql
|
||||
|
||||
package sqlitebusy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const testDriver = "sqlite3"
|
||||
|
||||
// driverErrorCode returns the driver's extended result code for err, so tests
|
||||
// can assert which busy/locked variant was produced. mattn reports the
|
||||
// extended code in Error.ExtendedCode.
|
||||
func driverErrorCode(err error) int {
|
||||
var sqliteErr sqlite3.Error
|
||||
if errors.As(err, &sqliteErr) {
|
||||
return int(sqliteErr.ExtendedCode)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sqlitebusy
|
||||
|
||||
import (
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build cgosql
|
||||
// +build cgosql
|
||||
|
||||
package sqlitebusy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// isBusyOrLocked reports whether err is a SQLITE_BUSY or SQLITE_LOCKED error
|
||||
// from the CGO (mattn/go-sqlite3) driver. Error.Code is already the primary
|
||||
// result code; extended codes are reported separately in Error.ExtendedCode.
|
||||
func isBusyOrLocked(err error) bool {
|
||||
var sqliteErr sqlite3.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
return sqliteErr.Code == sqlite3.ErrBusy || sqliteErr.Code == sqlite3.ErrLocked
|
||||
}
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sqlitebusy
|
||||
|
||||
import (
|
||||
@@ -233,21 +233,18 @@ type Preview struct {
|
||||
// FrontendUser holds fields safe to return from user APIs (embedded on User).
|
||||
type FrontendUser struct {
|
||||
NonAdminEditable
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
// MaxConcurrentTranscodes is admin-set: it stays out of NonAdminEditable so a
|
||||
// user cannot raise their own limit through a self-service PATCH.
|
||||
MaxConcurrentTranscodes int `json:"maxConcurrentTranscodes"`
|
||||
Username string `json:"username"`
|
||||
FrontendScopes []FrontendScope `json:"scopes"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
RequirePasswordChange bool `json:"requirePasswordChange"`
|
||||
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
|
||||
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
|
||||
Perm Permissions `json:"perm,omitzero"`
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
Username string `json:"username"`
|
||||
FrontendScopes []FrontendScope `json:"scopes"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
RequirePasswordChange bool `json:"requirePasswordChange"`
|
||||
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
|
||||
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
|
||||
Perm Permissions `json:"perm,omitzero"`
|
||||
}
|
||||
|
||||
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/platform"
|
||||
)
|
||||
|
||||
func TestAccelDeviceOptionsOmitsAuto(t *testing.T) {
|
||||
s := &Service{}
|
||||
opts := s.AccelDeviceOptions()
|
||||
for _, opt := range opts {
|
||||
if opt.ID == "auto" {
|
||||
t.Fatalf("options must not include auto: %+v", opts)
|
||||
}
|
||||
}
|
||||
if len(opts) != 1 || opts[0].ID != "software" {
|
||||
t.Fatalf("without capabilities want software only, got %+v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccelDeviceWithoutCapabilities(t *testing.T) {
|
||||
s := &Service{}
|
||||
if got := s.ResolveAccelDevice("auto"); got != "software" {
|
||||
t.Fatalf("ResolveAccelDevice(auto) = %q, want software", got)
|
||||
}
|
||||
if got := s.ResolveAccelDevice("/dev/dri/renderD128"); got != "/dev/dri/renderD128" {
|
||||
t.Fatalf("ResolveAccelDevice(explicit) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccelDeviceWSLNVIDIA(t *testing.T) {
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.Platform = capabilities.PlatformInfo{OS: "linux", WSL: true, NVIDIA: true}
|
||||
caps.Platform.Details = map[string]string{"gpu": "NVIDIA GeForce RTX 3050"}
|
||||
// Inject caps through a minimal service wrapper is not available; test helper directly.
|
||||
id, label := wslNVIDIAAccelDefault(caps)
|
||||
if id != "dgpu" || label != "Dedicated GPU (NVIDIA GeForce RTX 3050)" {
|
||||
t.Fatalf("wsl default = %q / %q", id, label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccelDeviceFallbackReason(t *testing.T) {
|
||||
// A blank or auto/software value is not a device selection.
|
||||
for _, dev := range []string{"", "auto", "software", "cpu", "none", "AUTO"} {
|
||||
if _, ok := accelDeviceFallbackReason(dev); ok {
|
||||
t.Fatalf("%q must not report a fallback", dev)
|
||||
}
|
||||
}
|
||||
// A GPU name that is not on this host must be reported as falling back.
|
||||
// The synthetic device will not match host enumeration in CI.
|
||||
if reason, ok := accelDeviceFallbackReason("Definitely Not A Real GPU 9999"); !ok {
|
||||
t.Fatal("an unmatched device name must report a fallback reason")
|
||||
} else if reason == "" {
|
||||
t.Fatal("the fallback reason must be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func wslCaps() *capabilities.Capabilities {
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.Platform = capabilities.PlatformInfo{OS: "linux", WSL: true, NVIDIA: true}
|
||||
caps.Platform.Details = map[string]string{"gpu": "NVIDIA GeForce RTX 3050"}
|
||||
return caps
|
||||
}
|
||||
|
||||
// WSL enumerates the NVIDIA GPU without a render node; the device menu must
|
||||
// list it once, whether it is resolved through enumeration or the WSL default.
|
||||
func TestAccelDeviceOptionsWSLListsNVIDIAOnce(t *testing.T) {
|
||||
gpus := []platform.GPUDevice{{Name: "NVIDIA GeForce RTX 3050", Vendor: "nvidia", DeviceClass: "dgpu"}}
|
||||
label, id, opts := resolvedAccelDeviceOptions(wslCaps(), gpus)
|
||||
if id == "" || label == "" {
|
||||
t.Fatalf("expected a resolved default, got %q/%q", id, label)
|
||||
}
|
||||
for _, o := range opts {
|
||||
if o.ID == id || strings.Contains(o.Label, "RTX 3050") {
|
||||
t.Fatalf("device listed twice: default=%q opts=%+v", id, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccelDeviceOptionsKeepsDistinctGPUs(t *testing.T) {
|
||||
gpus := []platform.GPUDevice{
|
||||
{Name: "AMD Radeon Graphics", Vendor: "amd", DeviceClass: "igpu", Integrated: true, RenderNode: "/dev/dri/renderD128"},
|
||||
{Name: "NVIDIA GeForce RTX 3050", Vendor: "nvidia", DeviceClass: "dgpu", RenderNode: "/dev/dri/renderD129"},
|
||||
}
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.Platform.Details = map[string]string{"render_device": "/dev/dri/renderD128"}
|
||||
_, id, opts := resolvedAccelDeviceOptions(caps, gpus)
|
||||
if id != "/dev/dri/renderD128" || len(opts) != 1 || opts[0].ID != "/dev/dri/renderD129" {
|
||||
t.Fatalf("id=%q opts=%+v", id, opts)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/platform"
|
||||
)
|
||||
|
||||
func TestEncoderBackendLabelDeviceAndEncoder(t *testing.T) {
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.SelectedGPU = &platform.GPUChoice{
|
||||
Enabled: true,
|
||||
Name: "NVIDIA RTX",
|
||||
RenderDevice: "/dev/dri/renderD128",
|
||||
}
|
||||
if caps.Platform.Details == nil {
|
||||
caps.Platform.Details = map[string]string{}
|
||||
}
|
||||
caps.Platform.Details["render_device"] = "/dev/dri/renderD128"
|
||||
label := encoderBackendLabel(caps, capabilities.EncodeOption{
|
||||
Accel: capabilities.AccelNVENC,
|
||||
Encoder: "h264_nvenc",
|
||||
})
|
||||
if !strings.Contains(label, "/dev/dri/renderD128") || !strings.Contains(label, "h264_nvenc") {
|
||||
t.Fatalf("label = %q, want device - encoder", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncoderBackendOptionIDUsesEncoderNameForHardware(t *testing.T) {
|
||||
id := encoderBackendOptionID(capabilities.EncodeOption{
|
||||
Accel: capabilities.AccelVAAPI,
|
||||
Encoder: "h264_vaapi",
|
||||
})
|
||||
if id != "h264_vaapi" {
|
||||
t.Fatalf("id = %q, want h264_vaapi", id)
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/encode"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/ops"
|
||||
)
|
||||
|
||||
// StreamInfo is re-exported for transcoding callers.
|
||||
type StreamInfo = goffmpeg.StreamInfo
|
||||
|
||||
// HLSPipelineOptions configures remux/copy/transcode path selection.
|
||||
type HLSPipelineOptions = goffmpeg.HLSPipelineOptions
|
||||
|
||||
// HLSSegmentParams holds resolved encode/remux settings for one HLS session.
|
||||
type HLSSegmentParams = goffmpeg.HLSSegmentParams
|
||||
|
||||
// HLSSegmentBuildInput describes remux/copy/transcode path selection for one file.
|
||||
type HLSSegmentBuildInput = goffmpeg.HLSSegmentBuildInput
|
||||
|
||||
// HLSCacheIdentity describes inputs for a stable on-demand HLS disk cache key.
|
||||
type HLSCacheIdentity = goffmpeg.HLSCacheIdentity
|
||||
|
||||
// VideoDecodeProfile selects input decode settings.
|
||||
type VideoDecodeProfile = encode.VideoDecodeProfile
|
||||
|
||||
// VideoProfile holds encode settings.
|
||||
type VideoProfile = encode.VideoProfile
|
||||
|
||||
// ProbeMediaStream probes a local media file for transcoding.
|
||||
func (s *Service) ProbeMediaStream(ctx context.Context, path string) (StreamInfo, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return StreamInfo{}, errUnavailable()
|
||||
}
|
||||
return s.inner.ProbeFile(ctx, path)
|
||||
}
|
||||
|
||||
// BuildHLSSegmentBuildInput derives remux/copy/transcode flags.
|
||||
func BuildHLSSegmentBuildInput(info StreamInfo, opts HLSPipelineOptions) HLSSegmentBuildInput {
|
||||
return goffmpeg.BuildHLSSegmentBuildInput(info, opts)
|
||||
}
|
||||
|
||||
// BuildHLSSegmentParams resolves GOP from fps when probeFPS is true.
|
||||
func (s *Service) BuildHLSSegmentParams(ctx context.Context, path string, in HLSSegmentBuildInput, probeFPS bool) (HLSSegmentParams, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return HLSSegmentParams{}, errUnavailable()
|
||||
}
|
||||
defaults := goffmpeg.DefaultOnDemandHLSDefaults()
|
||||
return s.inner.BuildHLSSegmentParams(ctx, path, in, defaults, probeFPS)
|
||||
}
|
||||
|
||||
// HLSCacheFingerprint returns a stable directory name for a transcode cache entry.
|
||||
func HLSCacheFingerprint(id HLSCacheIdentity) string {
|
||||
return goffmpeg.HLSCacheFingerprint(id)
|
||||
}
|
||||
|
||||
// DefaultOnDemandHLSDefaults returns on-demand segment defaults.
|
||||
func DefaultOnDemandHLSDefaults() goffmpeg.OnDemandHLSDefaults {
|
||||
return goffmpeg.DefaultOnDemandHLSDefaults()
|
||||
}
|
||||
|
||||
// PrepareHLSMediaTimeline normalizes keyframes and duration for HLS segment
|
||||
// planning: container start_time is subtracted and corrupt keyframe indexes
|
||||
// are filtered out.
|
||||
func PrepareHLSMediaTimeline(keyframes []float64, durationSec, startTimeSec float64) (sanitizedKeyframes []float64, mediaDurationSec float64) {
|
||||
return goffmpeg.PrepareHLSMediaTimeline(keyframes, durationSec, startTimeSec)
|
||||
}
|
||||
|
||||
// KeyframeSeekBefore returns the largest keyframe time <= sec, or 0 when none.
|
||||
func KeyframeSeekBefore(keyframes []float64, sec float64) float64 {
|
||||
return goffmpeg.KeyframeSeekBefore(keyframes, sec)
|
||||
}
|
||||
|
||||
// HLSDecodeProfileForOnDemand selects input decode for short on-demand HLS segments.
|
||||
func HLSDecodeProfileForOnDemand(info StreamInfo) VideoDecodeProfile {
|
||||
return goffmpeg.HLSDecodeProfileForOnDemand(info)
|
||||
}
|
||||
|
||||
// DefaultHLSVideoProfile returns safe H.264 transcode defaults.
|
||||
func DefaultHLSVideoProfile(maxHeight int) VideoProfile {
|
||||
return goffmpeg.DefaultHLSVideoProfile(maxHeight)
|
||||
}
|
||||
|
||||
// ProbeVideoKeyframeTimes returns keyframe presentation times in seconds.
|
||||
func (s *Service) ProbeVideoKeyframeTimes(ctx context.Context, path string) ([]float64, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return nil, errUnavailable()
|
||||
}
|
||||
return s.inner.ProbeVideoKeyframeTimes(ctx, path)
|
||||
}
|
||||
|
||||
// ProbeVideoKeyframeTimesBounded returns keyframe times from the first windowSec
|
||||
// of the file only. Used as a cheap pre-flight to prove stream copy impossible
|
||||
// before paying for the full-file keyframe demux.
|
||||
func (s *Service) ProbeVideoKeyframeTimesBounded(ctx context.Context, path string, windowSec float64) ([]float64, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return nil, errUnavailable()
|
||||
}
|
||||
return s.inner.ProbeVideoKeyframeTimesBounded(ctx, path, windowSec)
|
||||
}
|
||||
|
||||
func errUnavailable() error {
|
||||
return fmt.Errorf("ffmpeg service not available")
|
||||
}
|
||||
|
||||
// InputSource describes ffmpeg input.
|
||||
type InputSource = ops.InputSource
|
||||
|
||||
// StreamFile is the file stream type constant.
|
||||
const StreamFile = goffmpeg.StreamFile
|
||||
@@ -1,128 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/ops"
|
||||
)
|
||||
|
||||
// VODSegmentList is the segment timeline for Model A VOD playlists.
|
||||
type VODSegmentList = ops.VODSegmentList
|
||||
|
||||
// VODPlaylistOptions configures URIs in BuildVODPlaylist.
|
||||
type VODPlaylistOptions = ops.VODPlaylistOptions
|
||||
|
||||
// HLSVODJobOptions configures StartHLSVODSegmentJob.
|
||||
type HLSVODJobOptions = ops.HLSVODJobOptions
|
||||
|
||||
// HLSVODSegmentJob runs a forward ffmpeg HLS encode for on-demand VOD.
|
||||
type HLSVODSegmentJob = ops.HLSVODSegmentJob
|
||||
|
||||
// NOTE: the transcoding manager runs its own wait/restart loop; it only uses
|
||||
// the session's AcquireServePath/ReleaseServePath leases and job installation.
|
||||
//
|
||||
// HLSVODSession coordinates one cache directory / transcode profile
|
||||
// (Jellyfin DynamicHls-style): job tracking, wait/restart decisions, partial
|
||||
// segment cleanup, and per-path read leases so in-flight readers survive
|
||||
// segment rewrites.
|
||||
type HLSVODSession = ops.HLSVODSession
|
||||
|
||||
// HLSVODSessionOptions configures NewHLSVODSession.
|
||||
type HLSVODSessionOptions = ops.HLSVODSessionOptions
|
||||
|
||||
// HLSVODDecisionConfig tunes Jellyfin-style wait/restart thresholds.
|
||||
type HLSVODDecisionConfig = ops.HLSVODDecisionConfig
|
||||
|
||||
// HLSVODJobView is a snapshot of the active encode job for segment decisions.
|
||||
type HLSVODJobView = ops.HLSVODJobView
|
||||
|
||||
// HLSVODSegmentAction is the host/library decision for an on-demand segment request.
|
||||
type HLSVODSegmentAction = ops.HLSVODSegmentAction
|
||||
|
||||
const (
|
||||
// HLSVODActionServe serves the segment immediately.
|
||||
HLSVODActionServe = ops.HLSVODActionServe
|
||||
// HLSVODActionWait waits for production to reach the segment.
|
||||
HLSVODActionWait = ops.HLSVODActionWait
|
||||
// HLSVODActionRestart repositions the encode job to the segment.
|
||||
HLSVODActionRestart = ops.HLSVODActionRestart
|
||||
)
|
||||
|
||||
// DefaultHLSVODForwardGapSec is the forward wait window before restarting a
|
||||
// job (Jellyfin DynamicHls default: 24s of media).
|
||||
const DefaultHLSVODForwardGapSec = ops.DefaultHLSVODForwardGapSec
|
||||
|
||||
// NewHLSVODSession returns a session coordinating VOD output under outDir.
|
||||
func NewHLSVODSession(opts HLSVODSessionOptions) *HLSVODSession {
|
||||
return ops.NewHLSVODSession(opts)
|
||||
}
|
||||
|
||||
// DecideHLSVODSegmentAction implements the Model A serve/wait/restart rules.
|
||||
func DecideHLSVODSegmentAction(cfg HLSVODDecisionConfig, job HLSVODJobView, k int, ready bool) HLSVODSegmentAction {
|
||||
return ops.DecideHLSVODSegmentAction(cfg, job, k, ready)
|
||||
}
|
||||
|
||||
// HLSVODMaxWaitSegmentGap returns how many segment indices ahead of EncodeHead
|
||||
// may wait instead of restarting (Jellyfin: 24s of media by default).
|
||||
func HLSVODMaxWaitSegmentGap(segmentSec, gapMediaSec float64) int {
|
||||
return ops.HLSVODMaxWaitSegmentGap(segmentSec, gapMediaSec)
|
||||
}
|
||||
|
||||
// BuildVODSegmentList plans segment boundaries (fixed grid or keyframe snap).
|
||||
func BuildVODSegmentList(mediaDurationSec, segmentSec, startTimeSec float64, keyframes []float64) VODSegmentList {
|
||||
return goffmpeg.BuildVODSegmentList(mediaDurationSec, segmentSec, startTimeSec, keyframes)
|
||||
}
|
||||
|
||||
// SegmentIndexForTime maps playlist seconds to segment index and start time.
|
||||
func SegmentIndexForTime(timeSec float64, starts []float64) (index int, startSec float64) {
|
||||
return goffmpeg.SegmentIndexForTime(timeSec, starts)
|
||||
}
|
||||
|
||||
// DefaultVODPlaylistOptions returns standard init/segment URIs.
|
||||
func DefaultVODPlaylistOptions() VODPlaylistOptions {
|
||||
return goffmpeg.DefaultVODPlaylistOptions()
|
||||
}
|
||||
|
||||
// BuildVODPlaylist renders a synthetic #EXT-X-PLAYLIST-TYPE:VOD media playlist.
|
||||
func BuildVODPlaylist(list VODSegmentList, opts VODPlaylistOptions) string {
|
||||
return goffmpeg.BuildVODPlaylist(list, opts)
|
||||
}
|
||||
|
||||
// HLSVODSegmentPath returns the absolute path to a segment under a job directory.
|
||||
func HLSVODSegmentPath(jobDir string, index int) string {
|
||||
return ops.HLSVODSegmentPath(jobDir, index)
|
||||
}
|
||||
|
||||
// HLSVODSegmentReady reports whether a segment file is a complete fMP4 fragment.
|
||||
func HLSVODSegmentReady(path string) (bool, error) {
|
||||
return ops.HLSVODSegmentReady(path)
|
||||
}
|
||||
|
||||
// HLSVODOpenReadySegment opens path only when it is a publishable fMP4 media
|
||||
// fragment. The caller must close the returned file.
|
||||
func HLSVODOpenReadySegment(path string) (*os.File, bool, error) {
|
||||
return ops.HLSVODOpenReadySegment(path)
|
||||
}
|
||||
|
||||
// HLSVODExplicitCopySegmentReady reports readiness for explicit-cut (-f segment)
|
||||
// copy jobs: segment k is complete only when k+1 is listed in the live CSV, or
|
||||
// the encode job has ended.
|
||||
func HLSVODExplicitCopySegmentReady(outDir string, index int, jobRunning bool) (bool, error) {
|
||||
return ops.HLSVODExplicitCopySegmentReady(outDir, index, jobRunning)
|
||||
}
|
||||
|
||||
// HLSVODOpenReadyExplicitCopySegment opens a publishable explicit-cut copy
|
||||
// segment. The caller must close the returned file.
|
||||
func HLSVODOpenReadyExplicitCopySegment(outDir string, index int, jobRunning bool) (*os.File, bool, error) {
|
||||
return ops.HLSVODOpenReadyExplicitCopySegment(outDir, index, jobRunning)
|
||||
}
|
||||
|
||||
// StartHLSVODSegmentJob launches ffmpeg writing numbered fMP4 segments (Model A).
|
||||
func (s *Service) StartHLSVODSegmentJob(ctx context.Context, opts HLSVODJobOptions) (*HLSVODSegmentJob, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return nil, errUnavailable()
|
||||
}
|
||||
return s.inner.StartHLSVODSegmentJob(ctx, opts)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
@@ -74,25 +75,21 @@ func (s *Service) ConvertHEICToJPEG(ctx context.Context, heicPath string, target
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
jpegBytes, err := s.runHEICConversion(ctx, heicPath, targetWidth, targetHeight, quality, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg image conversion failed: %w", err)
|
||||
}
|
||||
return s.CorrectHEICJPEGOrientation(ctx, heicPath, jpegBytes), nil
|
||||
}
|
||||
|
||||
// CorrectHEICJPEGOrientation applies irot / display-matrix correction after a full-resolution
|
||||
// HEIC decode when pixels are still in coded orientation (go-media and ffmpeg filter-free paths).
|
||||
func (s *Service) CorrectHEICJPEGOrientation(ctx context.Context, heicPath string, jpegBytes []byte) []byte {
|
||||
orientation, err := s.GetHEICOrientation(heicPath)
|
||||
if err != nil {
|
||||
orientation = "Horizontal (normal)"
|
||||
}
|
||||
|
||||
jpegBytes, err := s.runHEICConversion(ctx, heicPath, targetWidth, targetHeight, quality, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg image conversion failed: %w", err)
|
||||
}
|
||||
|
||||
displayRotation, displayKnown := s.GetHEICDisplayMatrixRotation(ctx, heicPath)
|
||||
if orientationNeedsPostCorrection(orientation, displayRotation, displayKnown) {
|
||||
return applyOrientationToJPEG(jpegBytes, orientation)
|
||||
jpegBytes = applyOrientationToJPEG(jpegBytes, orientation)
|
||||
}
|
||||
return jpegBytes
|
||||
return jpegBytes, nil
|
||||
}
|
||||
|
||||
// ConvertImageToJPEG converts any supported image to JPEG using ffmpeg.
|
||||
@@ -159,3 +156,14 @@ func (s *Service) runHEICConversion(ctx context.Context, inputPath string, targe
|
||||
}
|
||||
return os.ReadFile(outputFile)
|
||||
}
|
||||
|
||||
func parseQuality(quality string) int {
|
||||
n, err := strconv.Atoi(quality)
|
||||
if err != nil || n < 1 {
|
||||
return 5
|
||||
}
|
||||
if n > 10 {
|
||||
return 10
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -161,11 +161,10 @@ func BenchmarkGetOrientationFilter(b *testing.B) {
|
||||
"Unknown Orientation",
|
||||
}
|
||||
|
||||
i := 0
|
||||
for b.Loop() {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
orientation := orientations[i%len(orientations)]
|
||||
_ = service.GetOrientationFilter(orientation)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,20 +21,21 @@ var blockedMediaPathPrefixes = []string{
|
||||
|
||||
// resolveLocalMediaPath restricts ffprobe to absolute local files (no remote protocols).
|
||||
func resolveLocalMediaPath(mediaPath string) (string, os.FileInfo, error) {
|
||||
if strings.TrimSpace(mediaPath) == "" {
|
||||
trimmed := strings.TrimSpace(mediaPath)
|
||||
if trimmed == "" {
|
||||
return "", nil, fmt.Errorf("media path is empty")
|
||||
}
|
||||
if strings.Contains(mediaPath, "://") {
|
||||
if strings.Contains(trimmed, "://") {
|
||||
return "", nil, fmt.Errorf("remote media URLs are not allowed")
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(mediaPath))
|
||||
lower := strings.ToLower(trimmed)
|
||||
for _, prefix := range blockedMediaPathPrefixes {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return "", nil, fmt.Errorf("remote media protocols are not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
clean := filepath.Clean(mediaPath)
|
||||
clean := filepath.Clean(trimmed)
|
||||
if !filepath.IsAbs(clean) {
|
||||
return "", nil, fmt.Errorf("media path must be absolute")
|
||||
}
|
||||
|
||||
@@ -29,16 +29,6 @@ func TestResolveLocalMediaPath(t *testing.T) {
|
||||
{name: "relative path", path: "video.mp4", wantErr: true},
|
||||
}
|
||||
|
||||
trailingSpacePath := filepath.Join(filepath.Dir(tmpPath), "probe-trailing-space ")
|
||||
if err := os.WriteFile(trailingSpacePath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(trailing space) error = %v", err)
|
||||
}
|
||||
tests = append(tests, struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
}{name: "trailing space in filename", path: trailingSpacePath})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gtsteffaniak/go-ffmpeg/encode"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultLegacyMJPEGQScale is the historical 1–10 -q:v value used when no quality is given.
|
||||
defaultLegacyMJPEGQScale = 5
|
||||
)
|
||||
|
||||
// frameQualityForLegacyMJPEGQScale maps the pre–go-ffmpeg v1.5.4 scale where quality 1–10 was
|
||||
// passed directly to ffmpeg -q:v (lower is better) onto the 1–100 frame quality scale.
|
||||
func frameQualityForLegacyMJPEGQScale(legacy int) int {
|
||||
if legacy < 1 {
|
||||
legacy = 1
|
||||
}
|
||||
if legacy > 10 {
|
||||
legacy = 10
|
||||
}
|
||||
if legacy == 1 {
|
||||
// -q:v 1 is not reachable on the new scale; quality 100 is the best MJPEG output.
|
||||
return 100
|
||||
}
|
||||
target := strconv.Itoa(legacy)
|
||||
var matches []int
|
||||
for q := 1; q <= 100; q++ {
|
||||
if encode.MJPEGQualityQScale(q) == target {
|
||||
matches = append(matches, q)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return encode.DefaultFrameQuality
|
||||
}
|
||||
idx := (legacy - 1) * (len(matches) - 1) / 9
|
||||
return matches[idx]
|
||||
}
|
||||
|
||||
// parseQuality interprets a quality string for go-ffmpeg ConvertHEIC / MJPEG encode.
|
||||
// Values 1–10 are treated as the legacy -q:v scale (only used internally by preview today).
|
||||
// Values 11–100 use the new frame quality scale. Invalid or empty input keeps the historical default.
|
||||
// PreviewQualityFromLegacyQScale maps preview's legacy "1"–"10" quality strings to
|
||||
// frame quality 1–100 (same mapping as ConvertHEICToJPEG / MJPEG encode).
|
||||
func PreviewQualityFromLegacyQScale(quality string) int {
|
||||
return parseQuality(quality)
|
||||
}
|
||||
|
||||
func parseQuality(quality string) int {
|
||||
if quality == "" {
|
||||
return frameQualityForLegacyMJPEGQScale(defaultLegacyMJPEGQScale)
|
||||
}
|
||||
n, err := strconv.Atoi(quality)
|
||||
if err != nil || n < 1 {
|
||||
return frameQualityForLegacyMJPEGQScale(defaultLegacyMJPEGQScale)
|
||||
}
|
||||
if n <= 10 {
|
||||
return frameQualityForLegacyMJPEGQScale(n)
|
||||
}
|
||||
if n > 100 {
|
||||
return 100
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-ffmpeg/encode"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseQuality_DefaultAndNewScale(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, frameQualityForLegacyMJPEGQScale(5), parseQuality(""))
|
||||
require.Equal(t, frameQualityForLegacyMJPEGQScale(5), parseQuality("not-a-number"))
|
||||
require.Equal(t, 85, parseQuality("85"))
|
||||
require.Equal(t, 100, parseQuality("150"))
|
||||
}
|
||||
|
||||
func TestParseQuality_LegacyQScaleMapsToSameMJPEGQScale(t *testing.T) {
|
||||
t.Parallel()
|
||||
for legacy := 2; legacy <= 10; legacy++ {
|
||||
got := parseQuality(itoa(legacy))
|
||||
require.Equal(t, encode.MJPEGQualityQScale(got), itoa(legacy),
|
||||
"legacy q:v %d", legacy)
|
||||
}
|
||||
require.Equal(t, 100, parseQuality("1"))
|
||||
require.Equal(t, "2", encode.MJPEGQualityQScale(parseQuality("1")))
|
||||
}
|
||||
|
||||
func TestFrameQualityForLegacyMJPEGQScale_KnownPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, 89, frameQualityForLegacyMJPEGQScale(5))
|
||||
require.Equal(t, 74, frameQualityForLegacyMJPEGQScale(10))
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -6,13 +6,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/ops"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/platform"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -20,30 +17,12 @@ import (
|
||||
type Service struct {
|
||||
inner *goffmpeg.Service
|
||||
cacheDir string
|
||||
|
||||
// gpus caches the host GPU enumeration, which shells out on some platforms
|
||||
// (PowerShell on Windows) and so must not run per settings request.
|
||||
gpusOnce sync.Once
|
||||
gpus []platform.GPUDevice
|
||||
}
|
||||
|
||||
// gpuList returns the cached host GPU enumeration. A Service is replaced on
|
||||
// re-initialization, so the cache is refreshed whenever ffmpeg is re-detected.
|
||||
func (s *Service) gpuList() []platform.GPUDevice {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.gpusOnce.Do(func() { s.gpus, _ = platform.ListGPUs() })
|
||||
return s.gpus
|
||||
}
|
||||
|
||||
// FFmpegService is kept for existing callers.
|
||||
type FFmpegService = Service
|
||||
|
||||
// global holds the process-wide ffmpeg service. It is swapped wholesale by
|
||||
// ApplyMediaSettings, so it is stored atomically to keep concurrent readers
|
||||
// (transcode admission, previews, media handlers) race-free.
|
||||
var global atomic.Pointer[Service]
|
||||
var global *Service
|
||||
|
||||
// InitOptions configures startup initialization.
|
||||
type InitOptions struct {
|
||||
@@ -51,50 +30,25 @@ type InitOptions struct {
|
||||
MaxConcurrent int // deprecated: use Concurrency
|
||||
Concurrency goffmpeg.Concurrency
|
||||
CacheDir string
|
||||
SkipHWTests bool
|
||||
HardwareAcceleration bool
|
||||
// AccelDevice scopes hardware acceleration to one device: "auto" (default),
|
||||
// "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name
|
||||
// substring. Parsed by go-ffmpeg; an unavailable device falls back to auto.
|
||||
AccelDevice string
|
||||
// VerboseFFmpeg streams ffmpeg process output to stderr and raises the ffmpeg
|
||||
// log level. Wired to the media debug setting.
|
||||
VerboseFFmpeg bool
|
||||
// SkipHWTests skips the hardware encoder probe. Only set this when hardware
|
||||
// acceleration is pinned off in the config file, otherwise the admin cannot
|
||||
// enable it without a restart.
|
||||
SkipHWTests bool
|
||||
// EncoderHierarchy restricts which acceleration backends are preferred.
|
||||
// Pass []capabilities.AccelType{capabilities.AccelNone} to keep detection
|
||||
// (so hardware availability stays known) while preferring software.
|
||||
EncoderHierarchy []capabilities.AccelType
|
||||
}
|
||||
|
||||
// Initialize creates the global ffmpeg service and runs capability detection.
|
||||
// Hardware encoder smoke tests run so hardware acceleration can be enabled or
|
||||
// disabled at runtime without a restart, unless the config file pins it off.
|
||||
func Initialize(ctx context.Context, opts InitOptions) error {
|
||||
if opts.CacheDir == "" {
|
||||
opts.CacheDir = os.TempDir()
|
||||
}
|
||||
|
||||
if opts.SkipHWTests {
|
||||
logger.Info("Skipping ffmpeg hardware codec detection (hardware acceleration disabled in config)")
|
||||
} else {
|
||||
logger.Info("Detecting ffmpeg codec support...")
|
||||
if opts.HardwareAcceleration {
|
||||
logger.Info("Detecting ffmpeg hardware codec support...")
|
||||
}
|
||||
|
||||
cfg := goffmpeg.Config{
|
||||
FFmpegPath: opts.FFmpegPath,
|
||||
Concurrency: opts.Concurrency,
|
||||
Logger: logger.GetGlobalLogger(),
|
||||
VerboseFFmpeg: opts.VerboseFFmpeg,
|
||||
SkipHWTests: opts.SkipHWTests,
|
||||
EncoderHierarchy: opts.EncoderHierarchy,
|
||||
}
|
||||
if sel, selErr := goffmpeg.ParseGPUSelector(opts.AccelDevice); selErr == nil {
|
||||
// GPUStrictness stays at the zero value: a selected device that is not
|
||||
// present falls back to auto instead of failing ffmpeg init.
|
||||
cfg.GPUSelector = sel
|
||||
FFmpegPath: opts.FFmpegPath,
|
||||
Concurrency: opts.Concurrency,
|
||||
Logger: goffmpeg.NopLogger(),
|
||||
SkipHWTests: opts.SkipHWTests,
|
||||
}
|
||||
if opts.Concurrency.MaxProbe == 0 && opts.Concurrency.MaxDecode == 0 && opts.Concurrency.MaxEncode == 0 {
|
||||
if opts.MaxConcurrent < 1 {
|
||||
@@ -105,307 +59,27 @@ func Initialize(ctx context.Context, opts InitOptions) error {
|
||||
|
||||
svc, err := goffmpeg.New(ctx, cfg)
|
||||
if err != nil {
|
||||
global.Store(nil)
|
||||
global = nil
|
||||
return err
|
||||
}
|
||||
|
||||
global.Store(&Service{
|
||||
global = &Service{
|
||||
inner: svc,
|
||||
cacheDir: opts.CacheDir,
|
||||
})
|
||||
}
|
||||
|
||||
logCapabilities(svc, opts.HardwareAcceleration, opts.AccelDevice)
|
||||
logCapabilities(svc, opts.HardwareAcceleration)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HardwareAccelerationAvailable reports whether any hardware encoder is usable.
|
||||
func HardwareAccelerationAvailable() bool {
|
||||
return global.Load().HardwareAccelerationAvailable()
|
||||
}
|
||||
|
||||
// HardwareAccelerationAvailable reports whether any hardware encoder is usable.
|
||||
func (s *Service) HardwareAccelerationAvailable() bool {
|
||||
if s == nil || s.inner == nil {
|
||||
return false
|
||||
}
|
||||
caps := s.inner.Capabilities()
|
||||
if caps == nil {
|
||||
return false
|
||||
}
|
||||
for _, enc := range caps.Encoders {
|
||||
if enc.Available && enc.Kind != "" && enc.Kind != "software" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Get returns the initialized service, or nil when ffmpeg is unavailable.
|
||||
func Get() *Service {
|
||||
return global.Load()
|
||||
}
|
||||
|
||||
// ResolveVideoEncoder returns the encoder selection for an encode profile so
|
||||
// callers can surface which encoder/accel a transcode will use.
|
||||
func (s *Service) ResolveVideoEncoder(profile goffmpeg.VideoProfile) (capabilities.EncoderSelection, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return capabilities.EncoderSelection{}, fmt.Errorf("ffmpeg service unavailable")
|
||||
}
|
||||
return s.inner.ResolveVideoEncoder(profile)
|
||||
}
|
||||
|
||||
// Capabilities exposes the detected ffmpeg capability matrix, or nil when the
|
||||
// service is unavailable.
|
||||
func (s *Service) Capabilities() *capabilities.Capabilities {
|
||||
if s == nil || s.inner == nil {
|
||||
return nil
|
||||
}
|
||||
return s.inner.Capabilities()
|
||||
}
|
||||
|
||||
// AccelDeviceOption is one selectable entry in the acceleration device list.
|
||||
type AccelDeviceOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// AccelDeviceOptions lists concrete acceleration devices for the admin UI.
|
||||
// The default auto-selected GPU (when stored as "auto") is included by name;
|
||||
// there is no separate "auto" entry.
|
||||
func (s *Service) AccelDeviceOptions() []AccelDeviceOption {
|
||||
out := []AccelDeviceOption{}
|
||||
if caps := s.Capabilities(); caps != nil {
|
||||
defaultLabel, defaultID, gpuOpts := resolvedAccelDeviceOptions(caps, s.gpuList())
|
||||
if defaultID != "" {
|
||||
out = append(out, AccelDeviceOption{ID: defaultID, Label: defaultLabel})
|
||||
}
|
||||
out = append(out, gpuOpts...)
|
||||
}
|
||||
out = append(out, AccelDeviceOption{ID: "software", Label: "CPU (software)"})
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveAccelDevice maps stored "auto"/empty to the concrete device the UI
|
||||
// should display; explicit software aliases and device IDs are unchanged.
|
||||
func (s *Service) ResolveAccelDevice(device string) string {
|
||||
dev := strings.TrimSpace(device)
|
||||
switch strings.ToLower(dev) {
|
||||
case "software", "cpu", "none":
|
||||
return "software"
|
||||
}
|
||||
if dev != "" && !strings.EqualFold(dev, "auto") {
|
||||
return dev
|
||||
}
|
||||
caps := s.Capabilities()
|
||||
if caps == nil {
|
||||
return "software"
|
||||
}
|
||||
defaultLabel, defaultID, gpuOpts := resolvedAccelDeviceOptions(caps, s.gpuList())
|
||||
if defaultID != "" {
|
||||
return defaultID
|
||||
}
|
||||
if len(gpuOpts) == 1 {
|
||||
return gpuOpts[0].ID
|
||||
}
|
||||
for _, opt := range gpuOpts {
|
||||
if opt.Label == defaultLabel {
|
||||
return opt.ID
|
||||
}
|
||||
}
|
||||
if id, _ := wslNVIDIAAccelDefault(caps); id != "" {
|
||||
return id
|
||||
}
|
||||
return "software"
|
||||
}
|
||||
|
||||
// resolvedAccelDeviceOptions labels the default auto-selected GPU and lists
|
||||
// every other enumerated device.
|
||||
func resolvedAccelDeviceOptions(caps *capabilities.Capabilities, gpus []platform.GPUDevice) (autoLabel, resolvedID string, opts []AccelDeviceOption) {
|
||||
resolved := caps.Platform.Details["render_device"]
|
||||
autoAccel := autoGPUAccel(caps)
|
||||
wslID, wslLabel := wslNVIDIAAccelDefault(caps)
|
||||
seen := map[string]bool{}
|
||||
for _, g := range gpus {
|
||||
label := gpuClassLabel(g) + " (" + gpuDisplayName(g) + ")"
|
||||
if seen[accelDeviceID(g)] {
|
||||
continue
|
||||
}
|
||||
seen[accelDeviceID(g)] = true
|
||||
switch {
|
||||
case resolvedID == "" && wslID != "" && g.Vendor == "nvidia" && g.RenderNode == "" && resolved == "" && autoAccel.Vendor == "":
|
||||
// WSL exposes the NVIDIA GPU without a DRM node; the enumerated
|
||||
// entry is the auto default, so it must not be listed twice.
|
||||
autoLabel, resolvedID = label, accelDeviceID(g)
|
||||
continue
|
||||
case g.RenderNode != "" && g.RenderNode == resolved:
|
||||
autoLabel, resolvedID = label, g.RenderNode
|
||||
continue
|
||||
case resolvedID == "" && autoAccel.Vendor != "" && g.Vendor == autoAccel.Vendor:
|
||||
// Vendor-level default for platforms without a render node (macOS,
|
||||
// synthetic GPUs): this device backs the preferred hardware encoder,
|
||||
// so it is the resolved auto selection, not an extra option.
|
||||
autoLabel, resolvedID = label, accelDeviceID(g)
|
||||
continue
|
||||
}
|
||||
if id := accelDeviceID(g); id != "" {
|
||||
opts = append(opts, AccelDeviceOption{ID: id, Label: label})
|
||||
}
|
||||
}
|
||||
if autoLabel == "" {
|
||||
autoLabel = autoAccel.Name
|
||||
}
|
||||
if resolvedID == "" {
|
||||
if wslID != "" {
|
||||
if autoLabel == "" {
|
||||
autoLabel = wslLabel
|
||||
}
|
||||
resolvedID = wslID
|
||||
}
|
||||
}
|
||||
return autoLabel, resolvedID, opts
|
||||
}
|
||||
|
||||
// wslNVIDIAAccelDefault exposes dgpu when WSL has an NVIDIA driver but no DRM nodes.
|
||||
func wslNVIDIAAccelDefault(caps *capabilities.Capabilities) (id, label string) {
|
||||
if caps == nil || !caps.Platform.WSL || !caps.Platform.NVIDIA {
|
||||
return "", ""
|
||||
}
|
||||
name := strings.TrimSpace(caps.Platform.Details["gpu"])
|
||||
if name == "" {
|
||||
name = platform.NVIDIAGPUName()
|
||||
}
|
||||
if name == "" {
|
||||
name = "NVIDIA GPU"
|
||||
}
|
||||
return "dgpu", "Dedicated GPU (" + name + ")"
|
||||
}
|
||||
|
||||
// accelDeviceID prefers the render node so the selection can be passed back to
|
||||
// ffmpeg; GPU name is the fallback on platforms without DRM render nodes.
|
||||
func accelDeviceID(g platform.GPUDevice) string {
|
||||
if g.RenderNode != "" {
|
||||
return g.RenderNode
|
||||
}
|
||||
return g.Name
|
||||
}
|
||||
|
||||
// gpuClassLabel names the device class shown in the dropdown.
|
||||
func gpuClassLabel(g platform.GPUDevice) string {
|
||||
switch g.DeviceClass {
|
||||
case "soc":
|
||||
return "SoC GPU"
|
||||
case "igpu":
|
||||
return "Integrated GPU"
|
||||
case "dgpu":
|
||||
return "Dedicated GPU"
|
||||
}
|
||||
if g.Integrated {
|
||||
return "Integrated GPU"
|
||||
}
|
||||
return "Dedicated GPU"
|
||||
}
|
||||
|
||||
func gpuDisplayName(g platform.GPUDevice) string {
|
||||
if g.Name != "" {
|
||||
return g.Name
|
||||
}
|
||||
return g.RenderNode
|
||||
}
|
||||
|
||||
// autoGPUAccel describes the GPU driving the detected default hardware
|
||||
// encoder (e.g. Apple VideoToolbox → "Apple GPU"/vendor "apple").
|
||||
func autoGPUAccel(caps *capabilities.Capabilities) capabilities.AccelGPU {
|
||||
var fallback capabilities.AccelType
|
||||
for _, opt := range caps.EncodeOptions {
|
||||
if !opt.Available || opt.Accel == "" || opt.Accel == capabilities.AccelNone {
|
||||
continue
|
||||
}
|
||||
if opt.Codec != capabilities.CodecH264 {
|
||||
continue
|
||||
}
|
||||
if opt.Default {
|
||||
if gpu := capabilities.GPUForAccel(caps, opt.Accel); gpu.Name != "" {
|
||||
return gpu
|
||||
}
|
||||
}
|
||||
fallback = opt.Accel
|
||||
}
|
||||
gpu := capabilities.GPUForAccel(caps, fallback)
|
||||
if gpu.Name == "CPU" || gpu.Vendor == "software" {
|
||||
return capabilities.AccelGPU{}
|
||||
}
|
||||
return gpu
|
||||
}
|
||||
|
||||
// EncoderBackendOption is one selectable entry in the encoder backend list.
|
||||
type EncoderBackendOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// EncoderBackendOptions lists concrete H.264 encode paths for the admin UI.
|
||||
// Labels are "device - encoder" so multiple hardware backends stay distinct.
|
||||
// An empty stored encoderBackend (Default in the UI) lets go-ffmpeg pick from
|
||||
// the selected acceleration device.
|
||||
func (s *Service) EncoderBackendOptions() []EncoderBackendOption {
|
||||
caps := s.Capabilities()
|
||||
if caps == nil {
|
||||
return []EncoderBackendOption{{ID: "cpu", Label: encoderBackendLabel(nil, capabilities.EncodeOption{Encoder: "libx264"})}}
|
||||
}
|
||||
var out []EncoderBackendOption
|
||||
seen := map[string]bool{}
|
||||
for _, opt := range caps.EncodeOptions {
|
||||
if opt.Codec != capabilities.CodecH264 || !opt.Available {
|
||||
continue
|
||||
}
|
||||
id := encoderBackendOptionID(opt)
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, EncoderBackendOption{ID: id, Label: encoderBackendLabel(caps, opt)})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []EncoderBackendOption{{ID: "cpu", Label: encoderBackendLabel(caps, capabilities.EncodeOption{Encoder: "libx264"})}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encoderBackendOptionID(opt capabilities.EncodeOption) string {
|
||||
if opt.Accel == "" || opt.Accel == capabilities.AccelNone {
|
||||
return "cpu"
|
||||
}
|
||||
return opt.Encoder
|
||||
}
|
||||
|
||||
func encoderBackendLabel(caps *capabilities.Capabilities, opt capabilities.EncodeOption) string {
|
||||
device := "CPU (software)"
|
||||
if opt.Accel != "" && opt.Accel != capabilities.AccelNone {
|
||||
if caps != nil {
|
||||
gpu := capabilities.GPUForAccel(caps, opt.Accel)
|
||||
switch {
|
||||
case gpu.RenderNode != "":
|
||||
device = gpu.RenderNode
|
||||
case gpu.Name != "" && gpu.Name != "CPU":
|
||||
device = gpu.Name
|
||||
default:
|
||||
device = string(opt.Accel)
|
||||
}
|
||||
} else {
|
||||
device = string(opt.Accel)
|
||||
}
|
||||
}
|
||||
encoder := opt.Encoder
|
||||
if encoder == "" {
|
||||
encoder = "libx264"
|
||||
}
|
||||
return device + " - " + encoder
|
||||
return global
|
||||
}
|
||||
|
||||
// Enabled reports whether ffmpeg initialized successfully.
|
||||
func Enabled() bool {
|
||||
svc := global.Load()
|
||||
return svc != nil && svc.inner != nil
|
||||
return global != nil && global.inner != nil
|
||||
}
|
||||
|
||||
// FFmpegPath returns the resolved ffmpeg binary path.
|
||||
@@ -424,40 +98,27 @@ func (s *Service) FFprobePath() string {
|
||||
return s.inner.FFprobePath()
|
||||
}
|
||||
|
||||
// ProbeVideoFPS returns the average frame rate of the first video stream.
|
||||
func (s *Service) ProbeVideoFPS(ctx context.Context, path string) (float64, error) {
|
||||
func (s *Service) Acquire(ctx context.Context) error {
|
||||
if s == nil || s.inner == nil {
|
||||
return 0, fmt.Errorf("ffmpeg service not available")
|
||||
return fmt.Errorf("ffmpeg service not available")
|
||||
}
|
||||
return s.inner.ProbeVideoFPS(ctx, path)
|
||||
return s.inner.Acquire(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Release() {
|
||||
if s == nil || s.inner == nil {
|
||||
return
|
||||
}
|
||||
s.inner.Release()
|
||||
}
|
||||
|
||||
// VideoPreview extracts a JPEG preview frame to w.
|
||||
//
|
||||
// go-ffmpeg deprecated Service.VideoPreview in favor of CaptureFrame, so this
|
||||
// wrapper keeps the PreviewOptions call shape used by the preview service while
|
||||
// delegating to the supported capture API. go-ffmpeg Service methods acquire
|
||||
// concurrency slots automatically; do not Acquire before calling.
|
||||
// go-ffmpeg Service methods acquire concurrency slots automatically; do not Acquire before calling.
|
||||
func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.PreviewOptions) error {
|
||||
if s == nil || s.inner == nil {
|
||||
return fmt.Errorf("ffmpeg service not available")
|
||||
}
|
||||
|
||||
frame := ops.PreviewOptionsToFrame(opts, 0)
|
||||
// Mirror the legacy ResolvePreviewSeekSec behavior: an explicit SeekSec
|
||||
// wins, otherwise SeekPercent (defaulting to 10%) is resolved against the
|
||||
// duration inside CaptureFrame.
|
||||
if opts.SeekSec > 0 {
|
||||
frame.At = ops.AtSec(opts.SeekSec)
|
||||
} else {
|
||||
percent := opts.SeekPercent
|
||||
if percent <= 0 || percent > 100 {
|
||||
percent = 10
|
||||
}
|
||||
frame.At = ops.AtPercent(percent)
|
||||
}
|
||||
|
||||
return s.inner.CaptureFrame(ctx, w, frame)
|
||||
return s.inner.VideoPreview(ctx, w, opts)
|
||||
}
|
||||
|
||||
// ExtractSubtitle returns embedded subtitle content as WebVTT.
|
||||
@@ -484,7 +145,7 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func logCapabilities(svc *goffmpeg.Service, wantsHardware bool, accelDevice string) {
|
||||
func logCapabilities(svc *goffmpeg.Service, detectHardware bool) {
|
||||
caps := svc.Capabilities()
|
||||
if caps == nil {
|
||||
return
|
||||
@@ -492,104 +153,22 @@ func logCapabilities(svc *goffmpeg.Service, wantsHardware bool, accelDevice stri
|
||||
|
||||
logger.Debugf("Media Enabled: version %s @ %s", caps.FFmpegVersion, caps.FFmpegPath)
|
||||
|
||||
warnIfAccelDeviceUnavailable(caps, accelDevice)
|
||||
if !detectHardware {
|
||||
return
|
||||
}
|
||||
|
||||
hw := hardwareCodecSummary(svc)
|
||||
if hw == "" {
|
||||
if wantsHardware {
|
||||
logger.Warning("hardware acceleration is enabled but no ffmpeg hardware codec support was found")
|
||||
} else {
|
||||
logger.Debug("no ffmpeg hardware codec support found")
|
||||
}
|
||||
logHWEncodeProbeFailures(caps)
|
||||
return
|
||||
}
|
||||
if wantsHardware {
|
||||
// go-ffmpeg already logs a single hardware support overview line at
|
||||
// startup; keep the detailed per-codec list at debug only.
|
||||
logger.Debugf("ffmpeg hardware codecs: %s", hw)
|
||||
logHWEncodeProbeFailures(caps)
|
||||
logger.Warning("no ffmpeg hardware codec support found")
|
||||
return
|
||||
}
|
||||
logger.Debugf("supported ffmpeg hardware codecs: %s", hw)
|
||||
}
|
||||
|
||||
// warnIfAccelDeviceUnavailable surfaces a stale configured device. The stored
|
||||
// device (e.g. a GPU name saved on another host) may no longer be present; the
|
||||
// resolver falls back to auto, but silently, so the transcodes look like a
|
||||
// plain software encode with no explanation. Resolve with no side effects and
|
||||
// report the fallback at info.
|
||||
func warnIfAccelDeviceUnavailable(caps *capabilities.Capabilities, accelDevice string) {
|
||||
if reason, ok := accelDeviceFallbackReason(accelDevice); ok {
|
||||
logger.Warningf("ffmpeg accel device %q is unavailable (%s); using automatic selection instead", strings.TrimSpace(accelDevice), reason)
|
||||
}
|
||||
}
|
||||
|
||||
// accelDeviceFallbackReason reports whether a configured accel device cannot be
|
||||
// used on this host and why. A blank/"auto"/"software" value is not a device
|
||||
// selection, so it never warns.
|
||||
func accelDeviceFallbackReason(accelDevice string) (string, bool) {
|
||||
dev := strings.TrimSpace(accelDevice)
|
||||
switch strings.ToLower(dev) {
|
||||
case "", "auto", "software", "cpu", "none":
|
||||
return "", false
|
||||
}
|
||||
sel, err := goffmpeg.ParseGPUSelector(dev)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("unparseable device (%v)", err), true
|
||||
}
|
||||
choice, err := goffmpeg.ResolveGPUSelector(sel, goffmpeg.GPUFallbackOnUnavailable)
|
||||
if err != nil {
|
||||
return err.Error(), true
|
||||
}
|
||||
if choice.Fallback {
|
||||
if choice.FallbackReason != "" {
|
||||
return choice.FallbackReason, true
|
||||
}
|
||||
return "not present on this host", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// logHWEncodeProbeFailures lists hardware encoders that failed their functional
|
||||
// probe, distinctly from decoders, so a "hardware decode works but encode
|
||||
// silently used libx264" case is visible. Only reports when there is at least
|
||||
// one failure, and only for h264/hevc/av1 (the codecs transcode uses).
|
||||
func logHWEncodeProbeFailures(caps *capabilities.Capabilities) {
|
||||
var failures []string
|
||||
for _, row := range caps.EncoderProbeResults() {
|
||||
if row.Available || row.Accel == "" || row.Accel == capabilities.AccelNone {
|
||||
continue
|
||||
}
|
||||
entry := fmt.Sprintf("%s (%s, %s)", row.Encoder, row.Accel, row.Code)
|
||||
if row.Hint != "" {
|
||||
entry += ": " + row.Hint
|
||||
}
|
||||
failures = append(failures, entry)
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Debugf("ffmpeg hardware encoders unavailable: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
|
||||
// hardwareCodecSummary lists detected hardware encode/decode paths. It reads
|
||||
// the capability maps directly so hardware is still reported when detected
|
||||
// but not preferred (e.g. hardware acceleration disabled).
|
||||
func hardwareCodecSummary(svc *goffmpeg.Service) string {
|
||||
if svc == nil {
|
||||
return ""
|
||||
}
|
||||
return hardwareCodecSummaryFromCaps(svc.Capabilities())
|
||||
}
|
||||
|
||||
func hardwareCodecSummaryFromCaps(caps *capabilities.Capabilities) string {
|
||||
if caps == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var parts []string
|
||||
|
||||
add := func(entry string) {
|
||||
if entry == "" {
|
||||
return
|
||||
@@ -601,28 +180,18 @@ func hardwareCodecSummaryFromCaps(caps *capabilities.Capabilities) string {
|
||||
parts = append(parts, entry)
|
||||
}
|
||||
|
||||
for _, name := range caps.HardwareEncoders() {
|
||||
add(fmt.Sprintf("%s encode via %s (%s)", encoderCodec(name), name,
|
||||
capabilities.AccelLabel(capabilities.EncoderAccel(name))))
|
||||
for _, opt := range svc.AvailableEncodeOptions() {
|
||||
if opt.Accel == capabilities.AccelNone {
|
||||
continue
|
||||
}
|
||||
add(fmt.Sprintf("%s encode via %s (%s)", opt.Codec, opt.Encoder, capabilities.AccelLabel(opt.Accel)))
|
||||
}
|
||||
for _, name := range caps.HardwareDecoders() {
|
||||
add(fmt.Sprintf("%s decode via %s (%s)", caps.Decoders[name].Codec, name,
|
||||
capabilities.AccelLabel(capabilities.DecoderAccel(name))))
|
||||
for _, opt := range svc.AvailableDecodeOptions() {
|
||||
if opt.Accel == capabilities.AccelNone {
|
||||
continue
|
||||
}
|
||||
add(fmt.Sprintf("%s decode via %s (%s)", opt.Codec, opt.Decoder, capabilities.AccelLabel(opt.Accel)))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func encoderCodec(name string) capabilities.VideoCodec {
|
||||
for _, codec := range []capabilities.VideoCodec{
|
||||
capabilities.CodecH264,
|
||||
capabilities.CodecHEVC,
|
||||
capabilities.CodecAV1,
|
||||
capabilities.CodecVP9,
|
||||
} {
|
||||
if capabilities.EncoderMatchesCodec(name, codec) {
|
||||
return codec
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
)
|
||||
|
||||
func requireFFmpegBinaries(t *testing.T) {
|
||||
@@ -59,13 +58,13 @@ func TestGetMediaDurationConcurrentNoDeadlock(t *testing.T) {
|
||||
MaxEncode: 2,
|
||||
},
|
||||
CacheDir: t.TempDir(),
|
||||
SkipHWTests: true,
|
||||
HardwareAcceleration: false,
|
||||
EncoderHierarchy: []capabilities.AccelType{capabilities.AccelNone},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { global.Store(nil) })
|
||||
t.Cleanup(func() { global = nil })
|
||||
|
||||
svc := Get()
|
||||
if svc == nil {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
)
|
||||
|
||||
func TestHardwareCodecSummaryFromCaps(t *testing.T) {
|
||||
t.Run("reports detected hardware independent of the preference hierarchy", func(t *testing.T) {
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.EncoderHierarchy = []capabilities.AccelType{capabilities.AccelNone}
|
||||
caps.Encoders["libx264"] = capabilities.EncoderCapability{Name: "libx264", Kind: "software", Compiled: true, Available: true}
|
||||
caps.Encoders["h264_videotoolbox"] = capabilities.EncoderCapability{Name: "h264_videotoolbox", Kind: "videotoolbox", Compiled: true, Available: true}
|
||||
caps.Encoders["hevc_videotoolbox"] = capabilities.EncoderCapability{Name: "hevc_videotoolbox", Kind: "videotoolbox", Compiled: true, Available: true}
|
||||
caps.Decoders["hwaccel:videotoolbox:h264"] = capabilities.DecoderCapability{
|
||||
Name: "hwaccel:videotoolbox:h264", Kind: "videotoolbox", Codec: capabilities.CodecH264, Compiled: true, Available: true,
|
||||
}
|
||||
|
||||
got := hardwareCodecSummaryFromCaps(caps)
|
||||
if !strings.Contains(got, "h264 encode via h264_videotoolbox (Apple VideoToolbox)") {
|
||||
t.Fatalf("summary = %q, want videotoolbox encode entry", got)
|
||||
}
|
||||
if !strings.Contains(got, "h264 decode via hwaccel:videotoolbox:h264 (Apple VideoToolbox)") {
|
||||
t.Fatalf("summary = %q, want videotoolbox decode entry", got)
|
||||
}
|
||||
if strings.Contains(got, "libx264") {
|
||||
t.Fatalf("summary = %q, must not list software encoders", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips hardware that failed detection", func(t *testing.T) {
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.Encoders["h264_nvenc"] = capabilities.EncoderCapability{Name: "h264_nvenc", Kind: "nvenc", Compiled: true, Available: false}
|
||||
if got := hardwareCodecSummaryFromCaps(caps); got != "" {
|
||||
t.Fatalf("summary = %q, want empty when hardware is unavailable", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty when only software is present", func(t *testing.T) {
|
||||
caps := capabilities.NewCapabilities()
|
||||
caps.Encoders["libx264"] = capabilities.EncoderCapability{Name: "libx264", Kind: "software", Compiled: true, Available: true}
|
||||
caps.Decoders["h264"] = capabilities.DecoderCapability{Name: "h264", Kind: "software", Compiled: true, Available: true}
|
||||
if got := hardwareCodecSummaryFromCaps(caps); got != "" {
|
||||
t.Fatalf("summary = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil capabilities", func(t *testing.T) {
|
||||
if got := hardwareCodecSummaryFromCaps(nil); got != "" {
|
||||
t.Fatalf("summary = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -35,41 +35,22 @@ func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.Subtit
|
||||
return subtitles
|
||||
}
|
||||
|
||||
// SubtitleStream is one embedded subtitle stream, independent of the probe
|
||||
// backend (ffprobe via go-ffmpeg, or go-media).
|
||||
type SubtitleStream struct {
|
||||
Index int
|
||||
Codec string
|
||||
Language string
|
||||
Title string
|
||||
}
|
||||
|
||||
// BuildSubtitleTracks maps embedded subtitle streams to track entries. Shared by
|
||||
// the ffmpeg and go-media paths so both produce identical track lists.
|
||||
func BuildSubtitleTracks(streams []SubtitleStream) []utils.SubtitleTrack {
|
||||
subtitles := make([]utils.SubtitleTrack, 0, len(streams))
|
||||
func mapSubtitleTracks(tracks []goffmpeg.SubtitleTrack) []utils.SubtitleTrack {
|
||||
subtitles := make([]utils.SubtitleTrack, 0, len(tracks))
|
||||
mapper := &subtitleTrackMapper{
|
||||
seenLang: make(map[string]int),
|
||||
}
|
||||
for _, stream := range streams {
|
||||
for _, stream := range tracks {
|
||||
subtitles = append(subtitles, mapper.track(stream))
|
||||
}
|
||||
return subtitles
|
||||
}
|
||||
|
||||
func mapSubtitleTracks(tracks []goffmpeg.SubtitleTrack) []utils.SubtitleTrack {
|
||||
streams := make([]SubtitleStream, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
streams = append(streams, SubtitleStream{Index: t.Index, Codec: t.Codec, Language: t.Language, Title: t.Title})
|
||||
}
|
||||
return BuildSubtitleTracks(streams)
|
||||
}
|
||||
|
||||
type subtitleTrackMapper struct {
|
||||
seenLang map[string]int
|
||||
}
|
||||
|
||||
func (m *subtitleTrackMapper) track(stream SubtitleStream) utils.SubtitleTrack {
|
||||
func (m *subtitleTrackMapper) track(stream goffmpeg.SubtitleTrack) utils.SubtitleTrack {
|
||||
index := stream.Index
|
||||
lang := strings.TrimSpace(stream.Language)
|
||||
return utils.SubtitleTrack{
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
// Package gomedia is the go-media fast path for probing, duration, tags, lyrics,
|
||||
// embedded subtitles, video thumbnails, HEIC decode, and camera RAW previews.
|
||||
//
|
||||
// Every function returns ok=false when the caller must use the existing ffmpeg
|
||||
// (or dhowden) code instead. That happens when:
|
||||
// - the admin "Always use ffmpeg" setting is on (no log, go-media is skipped);
|
||||
// - go-media reports the input as unsupported (logged as a warning);
|
||||
// - go-media fails for any other reason (logged as an error).
|
||||
//
|
||||
// Callers never resize or crop after go-media. Output sizes are requested from
|
||||
// go-media directly.
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var (
|
||||
subtitleListCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour)
|
||||
subtitleContentCache = cache.NewCache[string](24 * time.Hour)
|
||||
|
||||
// probeCache caches successful probe results by file identity (path, modtime,
|
||||
// size). A directory listing probes every video child, and the preview flow
|
||||
// probes the focused file again; without this each request re-read the same
|
||||
// containers. Keying on modtime+size means an edited file is re-probed.
|
||||
probeCache = cache.NewCache[ProbeResult](24 * time.Hour)
|
||||
|
||||
// probeGroup coalesces concurrent probes of the same file so a burst of
|
||||
// requests for one container reads it once.
|
||||
probeGroup singleflight.Group
|
||||
)
|
||||
|
||||
// Enabled reports whether the go-media fast path may run. The admin
|
||||
// "Always use ffmpeg" setting turns it off.
|
||||
func Enabled() bool {
|
||||
return !settings.AlwaysUseFfmpegEnabled()
|
||||
}
|
||||
|
||||
// videoDimensions returns the first video stream's width and height.
|
||||
func videoDimensions(info *media.ProbeInfo) (w, h int, ok bool) {
|
||||
for _, s := range info.Streams {
|
||||
if s.Type == media.StreamVideo && s.Width > 0 && s.Height > 0 {
|
||||
return s.Width, s.Height, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// videoContainerExts are the containers go-media parses for video probes,
|
||||
// thumbnails, and embedded subtitles. Other video containers (AVI, WMV, TS, ...)
|
||||
// are not go-media targets, so they go straight to ffmpeg with no file open and
|
||||
// no log line. go-media reads MOV as MP4.
|
||||
var videoContainerExts = map[string]bool{
|
||||
".mp4": true,
|
||||
".m4v": true,
|
||||
".mov": true,
|
||||
".mkv": true,
|
||||
".webm": true,
|
||||
}
|
||||
|
||||
// heifImageExts are still-image containers go-media decodes for HEIC preview.
|
||||
var heifImageExts = map[string]bool{
|
||||
".heic": true,
|
||||
".heif": true,
|
||||
".heics": true,
|
||||
}
|
||||
|
||||
const heicDecodeMaxEdge = 16384
|
||||
|
||||
// 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,
|
||||
".orf": true, ".rw2": true, ".raw": true, ".dng": true, ".raf": true, ".pef": true, ".ptx": true,
|
||||
".rwl": true, ".3fr": true, ".fff": true, ".erf": true, ".mrw": true, ".dcr": true, ".kdc": true,
|
||||
".dc2": true, ".x3f": true, ".iiq": true, ".nkc": true, ".r3d": true,
|
||||
}
|
||||
|
||||
// supportsVideoContainer reports whether go-media targets path's container.
|
||||
func supportsVideoContainer(path string) bool {
|
||||
return videoContainerExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
func supportsHEIFImage(path string) bool {
|
||||
return heifImageExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
func supportsCameraRAW(path string) bool {
|
||||
return cameraRawExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// logFallback records why go-media was not used for op on path.
|
||||
func logFallback(op, path string, err error) {
|
||||
// ErrNotMedia means the file is not a container go-media reads. That is an
|
||||
// expected fallback, not a failure, so it logs as a warning.
|
||||
if errors.Is(err, media.ErrUnsupported) || errors.Is(err, media.ErrUnsupportedProfile) || errors.Is(err, media.ErrNotMedia) {
|
||||
logger.Warningf("go-media does not support %s for %s (%v); using ffmpeg", op, path, err)
|
||||
return
|
||||
}
|
||||
logger.Errorf("go-media %s failed for %s: %v; falling back to ffmpeg", op, path, err)
|
||||
}
|
||||
|
||||
// logUnsupported records a container or codec that go-media does not handle.
|
||||
func logUnsupported(op, path, reason string) {
|
||||
logger.Warningf("go-media does not support %s for %s (%s); using ffmpeg", op, path, reason)
|
||||
}
|
||||
|
||||
// openFile opens path for go-media, which reads through io.ReaderAt.
|
||||
func openFile(path string) (*os.File, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// ffprobeFormatNames maps go-media containers to the format_name strings ffprobe
|
||||
// reports, so the stored container value does not change for existing users.
|
||||
// go-media appends the ftyp brand to MP4 names (for example ",isom"), which
|
||||
// ffprobe does not include, so MP4 is normalized as well.
|
||||
var ffprobeFormatNames = map[media.Format]string{
|
||||
media.FormatMP4: "mov,mp4,m4a,3gp,3g2,mj2",
|
||||
media.FormatMKV: "matroska,webm",
|
||||
media.FormatWebM: "matroska,webm",
|
||||
media.FormatWAV: "wav",
|
||||
media.FormatFLAC: "flac",
|
||||
media.FormatOGG: "ogg",
|
||||
media.FormatMP3: "mp3",
|
||||
media.FormatAIFF: "aiff",
|
||||
media.FormatAU: "au",
|
||||
}
|
||||
|
||||
// ProbeResult is the same shape as the ffprobe result, so callers can use one
|
||||
// code path for both backends.
|
||||
type ProbeResult = ffmpeg.FileProbeInfo
|
||||
|
||||
// Probe returns duration, codecs, and container name for a local media file.
|
||||
// legacy is the ffmpeg probe, run only in shadow mode.
|
||||
func Probe(path string, legacy Legacy[ProbeResult]) (ProbeResult, bool) {
|
||||
return timed("probe", path, legacy, probeDiff, func() (ProbeResult, bool) { return probeFast(path) })
|
||||
}
|
||||
|
||||
func probeFast(path string) (ProbeResult, bool) {
|
||||
if !Enabled() {
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("probe", path, err)
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
logFallback("probe", path, err)
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
cacheKey := probeCacheKey(path, info.ModTime().UnixNano(), size)
|
||||
if res, ok := probeCache.Get(cacheKey); ok {
|
||||
return res, true
|
||||
}
|
||||
|
||||
// Coalesce concurrent probes of the same file into one container read.
|
||||
v, err, _ := probeGroup.Do(cacheKey, func() (any, error) {
|
||||
res, ok := parseProbe(path, f, size)
|
||||
if !ok {
|
||||
return nil, errProbeUnsupported
|
||||
}
|
||||
probeCache.Set(cacheKey, res)
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
res, ok := v.(ProbeResult)
|
||||
if !ok {
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
|
||||
// errProbeUnsupported marks a probe that go-media chose not to answer. It carries
|
||||
// no detail because parseProbe already logged the specific reason.
|
||||
var errProbeUnsupported = errors.New("probe unsupported")
|
||||
|
||||
// probeCacheKey identifies a file version for caching: path plus modtime and size.
|
||||
// An edited file gets a new key, so stale metadata is never served.
|
||||
func probeCacheKey(path string, modTimeNanos, size int64) string {
|
||||
return fmt.Sprintf("probe:%s:%d:%d", path, modTimeNanos, size)
|
||||
}
|
||||
|
||||
// parseProbe reads a container and builds a ProbeResult. It reports false when
|
||||
// go-media should defer to ffmpeg, having logged why.
|
||||
func parseProbe(path string, f *os.File, size int64) (ProbeResult, bool) {
|
||||
info, err := media.Probe(f, size)
|
||||
if err != nil {
|
||||
logFallback("probe", path, err)
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
|
||||
if info.Duration <= 0 {
|
||||
// Fragmented or unusual files can report no length. ffprobe answers instead,
|
||||
// the same as durationFast does.
|
||||
logUnsupported("probe", path, "duration not in container header")
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
res := ProbeResult{Duration: info.Duration.Seconds(), FormatName: info.FormatName}
|
||||
if name, ok := ffprobeFormatNames[info.Format]; ok {
|
||||
res.FormatName = name
|
||||
}
|
||||
for _, s := range info.Streams {
|
||||
switch s.Type {
|
||||
case media.StreamVideo:
|
||||
if s.Codec == "" {
|
||||
// The UI would show no codec, so let ffprobe answer instead.
|
||||
logUnsupported("probe", path, "video codec not identified")
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
if res.VideoCodec == "" {
|
||||
res.VideoCodec = s.Codec
|
||||
}
|
||||
case media.StreamAudio:
|
||||
if s.Codec == "" {
|
||||
logUnsupported("probe", path, "audio codec not identified")
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
if res.AudioCodec == "" {
|
||||
res.AudioCodec = s.Codec
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
|
||||
// Duration returns the media length in seconds from container headers.
|
||||
// go-media may report an estimate (MP3 without a VBR header); the source is not
|
||||
// surfaced to callers.
|
||||
// legacy is the ffmpeg duration probe, run only in shadow mode.
|
||||
func Duration(path string, legacy Legacy[float64]) (float64, bool) {
|
||||
return timed("duration", path, legacy, durationDiff, func() (float64, bool) { return durationFast(path) })
|
||||
}
|
||||
|
||||
func durationFast(path string) (float64, bool) {
|
||||
if !Enabled() {
|
||||
return 0, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("duration", path, err)
|
||||
return 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
res, err := media.Duration(f, size)
|
||||
if err != nil {
|
||||
logFallback("duration", path, err)
|
||||
return 0, false
|
||||
}
|
||||
if res.Duration <= 0 {
|
||||
// Unknown duration is not an error; ffmpeg may still find one.
|
||||
return 0, false
|
||||
}
|
||||
return res.Duration.Seconds(), true
|
||||
}
|
||||
|
||||
// AudioInfo is the tag, lyrics availability, and cover art FileBrowser reads
|
||||
// from an audio file.
|
||||
type AudioInfo struct {
|
||||
Tags media.Tags
|
||||
HasLyrics bool
|
||||
Artwork []byte
|
||||
}
|
||||
|
||||
// Audio reads tags and lyrics availability. Artwork is read only when withArt
|
||||
// is true. A missing cover is not an error.
|
||||
// legacy is the dhowden tag read, run only in shadow mode.
|
||||
func Audio(path string, withArt bool, legacy Legacy[AudioInfo]) (AudioInfo, bool) {
|
||||
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt) })
|
||||
}
|
||||
|
||||
func audioFast(path string, withArt bool) (AudioInfo, bool) {
|
||||
if !Enabled() {
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("tags", path, err)
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
|
||||
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
|
||||
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
|
||||
if err != nil {
|
||||
logFallback("tags", path, err)
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
info := AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
|
||||
if !info.HasLyrics {
|
||||
// Sidecar lyrics are only checked when the file has no embedded lyrics.
|
||||
_, _, info.HasLyrics = media.LyricsSidecar(path)
|
||||
}
|
||||
if withArt {
|
||||
info.Artwork = audio.Artwork
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// Lyrics returns lyrics from a sidecar file, or else from embedded tags. The
|
||||
// format is "embedded" for tags or the sidecar extension. An empty result with
|
||||
// ok=true means go-media checked and found no lyrics.
|
||||
// legacy is the sidecar and dhowden lyrics read, run only in shadow mode.
|
||||
func Lyrics(path string, legacy Legacy[LyricsResult]) (text, format string, ok bool) {
|
||||
res, ok := timed("lyrics", path, legacy, lyricsDiff, func() (LyricsResult, bool) {
|
||||
t, f, found := lyricsFast(path)
|
||||
return LyricsResult{Text: t, Format: f}, found
|
||||
})
|
||||
return res.Text, res.Format, ok
|
||||
}
|
||||
|
||||
func lyricsFast(path string) (text, format string, ok bool) {
|
||||
if !Enabled() {
|
||||
return "", "", false
|
||||
}
|
||||
if sidecarText, ext, found := media.LyricsSidecar(path); found {
|
||||
return sidecarText, ext, true
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("lyrics", path, err)
|
||||
return "", "", false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// InspectAudio puts embedded lyrics in Tags.Raw["lyrics"] from the same parse.
|
||||
audio, err := media.InspectAudio(f, size, media.AudioOptions{})
|
||||
if err != nil {
|
||||
logFallback("lyrics", path, err)
|
||||
return "", "", false
|
||||
}
|
||||
text = audio.Tags.Raw["lyrics"]
|
||||
if text == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return text, "embedded", true
|
||||
}
|
||||
|
||||
// EmbeddedSubtitles lists embedded subtitle tracks. go-media supports MKV, WebM,
|
||||
// and MP4 only. Other containers are reported as unsupported, not as "no
|
||||
// subtitles", because go-media returns an empty list for them.
|
||||
// legacy is the ffmpeg subtitle detection, run only in shadow mode.
|
||||
func EmbeddedSubtitles(path string, modtime time.Time, legacy Legacy[[]utils.SubtitleTrack]) ([]utils.SubtitleTrack, bool) {
|
||||
return timed("subtitle list", path, legacy, subtitleListDiff, func() ([]utils.SubtitleTrack, bool) {
|
||||
return embeddedSubtitlesFast(path, modtime)
|
||||
})
|
||||
}
|
||||
|
||||
func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrack, bool) {
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
key := fmt.Sprintf("embedded_subtitles:%s:%d", path, modtime.Unix())
|
||||
if cached, ok := subtitleListCache.Get(key); ok {
|
||||
return cached, true
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle list", path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := media.Probe(f, size)
|
||||
if err != nil {
|
||||
logFallback("subtitle list", path, err)
|
||||
return nil, false
|
||||
}
|
||||
switch info.Format {
|
||||
case media.FormatMKV, media.FormatWebM, media.FormatMP4:
|
||||
default:
|
||||
logUnsupported("subtitle list", path, "container "+string(info.Format))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
streams := make([]ffmpeg.SubtitleStream, 0, len(info.SubtitleTracks))
|
||||
for _, t := range info.SubtitleTracks {
|
||||
streams = append(streams, ffmpeg.SubtitleStream{
|
||||
Index: t.Index,
|
||||
Codec: t.Codec,
|
||||
Language: t.Language,
|
||||
Title: t.Title,
|
||||
})
|
||||
}
|
||||
tracks := ffmpeg.BuildSubtitleTracks(streams)
|
||||
subtitleListCache.Set(key, tracks)
|
||||
return tracks, true
|
||||
}
|
||||
|
||||
// ExtractSubtitle returns the content of an embedded subtitle stream as WebVTT.
|
||||
// streamIndex is the same index EmbeddedSubtitles reports.
|
||||
// legacy is the ffmpeg subtitle extraction, run only in shadow mode.
|
||||
func ExtractSubtitle(path string, streamIndex int, legacy Legacy[string]) (string, bool) {
|
||||
return timed("subtitle extract", path, legacy, subtitleExtractDiff, func() (string, bool) {
|
||||
return extractSubtitleFast(path, streamIndex)
|
||||
})
|
||||
}
|
||||
|
||||
func extractSubtitleFast(path string, streamIndex int) (string, bool) {
|
||||
if !Enabled() {
|
||||
return "", false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
return "", false
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
key := fmt.Sprintf("subtitle_content:%s:%d:%d", path, streamIndex, st.ModTime().Unix())
|
||||
if content, ok := subtitleContentCache.Get(key); ok {
|
||||
return content, true
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
vtt, err := media.ExtractEmbeddedSubtitle(f, size, streamIndex)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(vtt) == "" {
|
||||
logFallback("subtitle extract", path, errors.New("empty subtitle output"))
|
||||
return "", false
|
||||
}
|
||||
subtitleContentCache.Set(key, vtt)
|
||||
return vtt, true
|
||||
}
|
||||
|
||||
// VideoThumbnail returns a JPEG frame at seekPercent that fits inside
|
||||
// width x height with aspect ratio kept. Crop is never applied. Quality is JPEG
|
||||
// quality 1-100.
|
||||
//
|
||||
// A zero width or height means "original size". go-media defaults a zero bound
|
||||
// to 320px, so the source's own dimensions are passed as the bound instead.
|
||||
// Then no scaling happens in go-media either.
|
||||
// legacy is the ffmpeg preview frame, run only in shadow mode.
|
||||
func VideoThumbnail(path string, width, height, quality int, seekPercent float64, legacy Legacy[[]byte]) ([]byte, bool) {
|
||||
return timed("video thumbnail", path, legacy, thumbnailDiff, func() ([]byte, bool) {
|
||||
return videoThumbnailFast(path, width, height, quality, seekPercent)
|
||||
})
|
||||
}
|
||||
|
||||
func videoThumbnailFast(path string, width, height, quality int, seekPercent float64) ([]byte, bool) {
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("video thumbnail", path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if width <= 0 || height <= 0 {
|
||||
info, probeErr := media.Probe(f, size)
|
||||
if probeErr != nil {
|
||||
logFallback("video thumbnail", path, probeErr)
|
||||
return nil, false
|
||||
}
|
||||
srcW, srcH, found := videoDimensions(info)
|
||||
if !found {
|
||||
logUnsupported("video thumbnail", path, "source dimensions not found")
|
||||
return nil, false
|
||||
}
|
||||
if width <= 0 {
|
||||
width = srcW
|
||||
}
|
||||
if height <= 0 {
|
||||
height = srcH
|
||||
}
|
||||
}
|
||||
|
||||
res, err := media.Thumbnail(f, size, media.ThumbnailOptions{
|
||||
MaxWidth: width,
|
||||
MaxHeight: height,
|
||||
SeekPercent: seekPercent,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
})
|
||||
if err != nil {
|
||||
logFallback("video thumbnail", path, err)
|
||||
return nil, false
|
||||
}
|
||||
if len(res.Data) == 0 {
|
||||
logFallback("video thumbnail", path, errors.New("empty thumbnail output"))
|
||||
return nil, false
|
||||
}
|
||||
return res.Data, true
|
||||
}
|
||||
|
||||
// HEICJPEG decodes HEIC/HEIF to JPEG at full resolution (bounds heicDecodeMaxEdge).
|
||||
// Resize for small/large/xlarge previews is done later by the preview service.
|
||||
// legacy is ffmpeg full decode, run only in shadow mode.
|
||||
func HEICJPEG(path string, quality int, legacy Legacy[[]byte]) ([]byte, bool) {
|
||||
return timed("heic jpeg", path, legacy, thumbnailDiff, func() ([]byte, bool) {
|
||||
return heicJPEGFast(path, quality)
|
||||
})
|
||||
}
|
||||
|
||||
func heicJPEGFast(path string, quality int) ([]byte, bool) {
|
||||
return stillImageJPEG(path, heicDecodeMaxEdge, heicDecodeMaxEdge, quality, "heic jpeg", supportsHEIFImage)
|
||||
}
|
||||
|
||||
// CameraRAWJPEG decodes camera RAW to JPEG at the requested bounds (0 means full
|
||||
// resolution up to heicDecodeMaxEdge). legacy is ffmpeg ConvertImageToJPEG, shadow only.
|
||||
func CameraRAWJPEG(path string, width, height, quality int, legacy Legacy[[]byte]) ([]byte, bool) {
|
||||
return timed("raw jpeg", path, legacy, thumbnailDiff, func() ([]byte, bool) {
|
||||
return cameraRAWJPEGFast(path, width, height, quality)
|
||||
})
|
||||
}
|
||||
|
||||
func cameraRAWJPEGFast(path string, width, height, quality int) ([]byte, bool) {
|
||||
maxW, maxH := width, height
|
||||
if maxW <= 0 || maxH <= 0 {
|
||||
maxW, maxH = heicDecodeMaxEdge, heicDecodeMaxEdge
|
||||
}
|
||||
return stillImageJPEG(path, maxW, maxH, quality, "raw jpeg", supportsCameraRAW)
|
||||
}
|
||||
|
||||
func stillImageJPEG(path string, maxW, maxH, quality int, op string, supported func(string) bool) ([]byte, bool) {
|
||||
if !Enabled() {
|
||||
if ShadowEnabled() {
|
||||
logger.Debugf("go-media skipped for %s (%s): always use ffmpeg enabled", op, path)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if !supported(path) {
|
||||
return nil, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback(op, path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if quality <= 0 {
|
||||
quality = 85
|
||||
}
|
||||
res, err := media.Thumbnail(f, size, media.ThumbnailOptions{
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
})
|
||||
if err != nil {
|
||||
logFallback(op, path, err)
|
||||
return nil, false
|
||||
}
|
||||
if len(res.Data) == 0 {
|
||||
logFallback(op, path, fmt.Errorf("empty %s output", op))
|
||||
return nil, false
|
||||
}
|
||||
return res.Data, true
|
||||
}
|
||||
|
||||
// SidecarSubtitleExts are the sidecar formats go-media lists and converts.
|
||||
// Other subtitle extensions in iteminfo.SubtitleExts keep the directory scan.
|
||||
var SidecarSubtitleExts = map[string]bool{
|
||||
".srt": true,
|
||||
".vtt": true,
|
||||
".ass": true,
|
||||
".ssa": true,
|
||||
}
|
||||
|
||||
// ExternalSubtitles lists sidecar subtitles next to videoName, for the formats in
|
||||
// SidecarSubtitleExts, using go-media. siblingNames are the other file names in
|
||||
// the same directory. legacy is the directory scan, run only in shadow mode.
|
||||
func ExternalSubtitles(videoName string, siblingNames []string, legacy Legacy[[]utils.SubtitleTrack]) ([]utils.SubtitleTrack, bool) {
|
||||
return timed("external subtitle list", videoName, legacy, subtitleListDiff, func() ([]utils.SubtitleTrack, bool) {
|
||||
return externalSubtitlesFast(videoName, siblingNames)
|
||||
})
|
||||
}
|
||||
|
||||
func externalSubtitlesFast(videoName string, siblingNames []string) ([]utils.SubtitleTrack, bool) {
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
tracks := media.ExternalSubtitleTracks(videoName, siblingNames)
|
||||
out := make([]utils.SubtitleTrack, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
if !SidecarSubtitleExts["."+t.Codec] {
|
||||
continue
|
||||
}
|
||||
out = append(out, utils.SubtitleTrack{Name: t.Name, Language: t.Language, Codec: t.Codec})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAudioSidecarLyricsSetsFlag checks that a sidecar .lrc alone sets HasLyrics.
|
||||
func TestAudioSidecarLyricsSetsFlag(t *testing.T) {
|
||||
withGate(t, false)
|
||||
mp3 := makeMP3(t)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(mp3), "tone.lrc"), []byte("[00:01.00]hello"), 0o644))
|
||||
|
||||
info, ok := Audio(mp3, false, nil)
|
||||
require.True(t, ok)
|
||||
require.True(t, info.HasLyrics)
|
||||
|
||||
text, format, ok := Lyrics(mp3, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "lrc", format)
|
||||
require.Contains(t, text, "hello")
|
||||
}
|
||||
|
||||
// TestAudioNoLyricsNoFlag checks that a plain file reports no lyrics.
|
||||
func TestAudioNoLyricsNoFlag(t *testing.T) {
|
||||
withGate(t, false)
|
||||
mp3 := makeMP3(t)
|
||||
|
||||
info, ok := Audio(mp3, false, nil)
|
||||
require.True(t, ok)
|
||||
require.False(t, info.HasLyrics)
|
||||
|
||||
_, _, ok = Lyrics(mp3, nil)
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Fixtures shipped with the Playwright suite. Paths are relative to this package.
|
||||
const (
|
||||
fixtureDir = "../../../frontend/tests/playwright-files/myfolder/testdata"
|
||||
fixtureMP4 = fixtureDir + "/sample-640x480.mp4"
|
||||
fixtureWebM = fixtureDir + "/clip-a.webm"
|
||||
)
|
||||
|
||||
// withGate sets the admin "Always use ffmpeg" setting for one test.
|
||||
func withGate(t *testing.T, alwaysFfmpeg bool) {
|
||||
t.Helper()
|
||||
prev := settings.CurrentMediaSettings()
|
||||
cur := prev
|
||||
cur.AlwaysUseFfmpeg = alwaysFfmpeg
|
||||
settings.ApplyMediaSettings(cur)
|
||||
t.Cleanup(func() { settings.ApplyMediaSettings(prev) })
|
||||
}
|
||||
|
||||
// requireTool skips the test when an ffmpeg binary is not on PATH.
|
||||
func requireTool(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
t.Skipf("%s not installed", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ffprobeValue returns one ffprobe value for path. args select the field.
|
||||
func ffprobeValue(t *testing.T, path string, args ...string) string {
|
||||
t.Helper()
|
||||
requireTool(t, "ffprobe")
|
||||
full := append([]string{"-v", "error"}, args...)
|
||||
full = append(full, "-of", "default=nw=1:nk=1", path)
|
||||
out, err := exec.Command("ffprobe", full...).Output()
|
||||
require.NoError(t, err)
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// ffprobeMedia returns the ffprobe duration, format name, and first video and
|
||||
// audio codec names. One ffprobe call per field keeps the parsing simple.
|
||||
func ffprobeMedia(t *testing.T, path string) (dur float64, format, video, audio string) {
|
||||
t.Helper()
|
||||
dur, _ = strconv.ParseFloat(ffprobeValue(t, path, "-show_entries", "format=duration"), 64)
|
||||
format = ffprobeValue(t, path, "-show_entries", "format=format_name")
|
||||
video = ffprobeValue(t, path, "-select_streams", "v:0", "-show_entries", "stream=codec_name")
|
||||
audio = ffprobeValue(t, path, "-select_streams", "a:0", "-show_entries", "stream=codec_name")
|
||||
return dur, format, video, audio
|
||||
}
|
||||
|
||||
// makeMP3 generates a 2s MP3 with an ID3 title tag. The repo's MP3 fixture is
|
||||
// empty, so ffmpeg makes a real one.
|
||||
func makeMP3(t *testing.T) string {
|
||||
t.Helper()
|
||||
requireTool(t, "ffmpeg")
|
||||
path := filepath.Join(t.TempDir(), "tone.mp3")
|
||||
out, err := exec.Command("ffmpeg", "-v", "error", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
||||
"-metadata", "title=Gomedia Title",
|
||||
"-c:a", "libmp3lame", path).CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestEnabledFollowsAdminGate(t *testing.T) {
|
||||
withGate(t, true)
|
||||
require.False(t, Enabled(), "admin gate on must disable go-media")
|
||||
|
||||
withGate(t, false)
|
||||
require.True(t, Enabled(), "default must keep go-media on")
|
||||
}
|
||||
|
||||
func TestGateOffSkipsEveryPath(t *testing.T) {
|
||||
withGate(t, true)
|
||||
mp3 := makeMP3(t)
|
||||
|
||||
_, ok := Probe(fixtureMP4, nil)
|
||||
require.False(t, ok, "Probe must skip go-media when the gate is on")
|
||||
|
||||
_, ok = Duration(fixtureMP4, nil)
|
||||
require.False(t, ok, "Duration must skip go-media when the gate is on")
|
||||
|
||||
_, ok = Audio(mp3, true, nil)
|
||||
require.False(t, ok, "Audio must skip go-media when the gate is on")
|
||||
|
||||
_, _, ok = Lyrics(mp3, nil)
|
||||
require.False(t, ok, "Lyrics must skip go-media when the gate is on")
|
||||
|
||||
_, ok = VideoThumbnail(fixtureMP4, 256, 256, 72, 10, nil)
|
||||
require.False(t, ok, "VideoThumbnail must skip go-media when the gate is on")
|
||||
|
||||
_, ok = EmbeddedSubtitles(fixtureMP4, time.Now(), nil)
|
||||
require.False(t, ok, "EmbeddedSubtitles must skip go-media when the gate is on")
|
||||
|
||||
_, ok = ExtractSubtitle(fixtureMP4, 0, nil)
|
||||
require.False(t, ok, "ExtractSubtitle must skip go-media when the gate is on")
|
||||
}
|
||||
|
||||
func TestProbeMP4MatchesFFprobe(t *testing.T) {
|
||||
withGate(t, false)
|
||||
wantDur, wantFormat, wantVideo, wantAudio := ffprobeMedia(t, fixtureMP4)
|
||||
|
||||
got, ok := Probe(fixtureMP4, nil)
|
||||
require.True(t, ok)
|
||||
require.InDelta(t, wantDur, got.Duration, 0.1)
|
||||
require.Equal(t, wantFormat, got.FormatName)
|
||||
require.Equal(t, wantVideo, got.VideoCodec)
|
||||
require.Equal(t, wantAudio, got.AudioCodec)
|
||||
}
|
||||
|
||||
// TestProbeUsesCache checks that a populated cache entry is returned without
|
||||
// re-reading the container.
|
||||
func TestProbeUsesCache(t *testing.T) {
|
||||
withGate(t, false)
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
data, err := os.ReadFile(fixtureMP4)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
|
||||
st, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
key := probeCacheKey(path, st.ModTime().UnixNano(), st.Size())
|
||||
sentinel := ProbeResult{Duration: 123, VideoCodec: "sentinel", AudioCodec: "sentinel", FormatName: "sentinel"}
|
||||
probeCache.Set(key, sentinel)
|
||||
t.Cleanup(func() { probeCache.Delete(key) })
|
||||
|
||||
got, ok := Probe(path, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sentinel, got, "probe must return the cached value for an unchanged file")
|
||||
}
|
||||
|
||||
// TestProbeCacheKeyTracksFileIdentity checks that path, modtime, and size all
|
||||
// participate in the cache key, so an edited file is never served stale metadata.
|
||||
func TestProbeCacheKeyTracksFileIdentity(t *testing.T) {
|
||||
base := probeCacheKey("f.mp4", 100, 200)
|
||||
require.Equal(t, base, probeCacheKey("f.mp4", 100, 200), "identical identity must hash the same")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 101, 200), "modtime change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "size change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("g.mp4", 100, 200), "path change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "key must not collide across fields")
|
||||
}
|
||||
|
||||
func TestProbeWebMMatchesFFprobe(t *testing.T) {
|
||||
withGate(t, false)
|
||||
wantDur, _, wantVideo, wantAudio := ffprobeMedia(t, fixtureWebM)
|
||||
|
||||
got, ok := Probe(fixtureWebM, nil)
|
||||
if !ok {
|
||||
t.Skip("go-media does not handle this WebM file; ffmpeg fallback covers it")
|
||||
}
|
||||
// ffprobe reports "matroska,webm" for WebM; go-media's normalized name must match it.
|
||||
require.Equal(t, "matroska,webm", got.FormatName)
|
||||
require.InDelta(t, wantDur, got.Duration, 0.1)
|
||||
require.Equal(t, wantVideo, got.VideoCodec)
|
||||
require.Equal(t, wantAudio, got.AudioCodec)
|
||||
}
|
||||
|
||||
func TestDurationMP3MatchesFFprobe(t *testing.T) {
|
||||
withGate(t, false)
|
||||
mp3 := makeMP3(t)
|
||||
wantDur, _, _, _ := ffprobeMedia(t, mp3)
|
||||
|
||||
got, ok := Duration(mp3, nil)
|
||||
if !ok {
|
||||
t.Skip("go-media returned no duration for this MP3; ffmpeg fallback covers it")
|
||||
}
|
||||
// MP3 durations may be estimates; allow a looser tolerance.
|
||||
require.InDelta(t, wantDur, got, 0.5)
|
||||
}
|
||||
|
||||
func TestAudioTagsFromMP3(t *testing.T) {
|
||||
withGate(t, false)
|
||||
mp3 := makeMP3(t)
|
||||
|
||||
info, ok := Audio(mp3, true, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "Gomedia Title", info.Tags.Title)
|
||||
require.False(t, info.HasLyrics)
|
||||
require.Empty(t, info.Artwork, "generated MP3 has no cover art")
|
||||
}
|
||||
|
||||
func TestVideoThumbnailFitsBoxAndKeepsAspect(t *testing.T) {
|
||||
withGate(t, false)
|
||||
|
||||
data, ok := VideoThumbnail(fixtureMP4, 256, 256, 72, 10, nil)
|
||||
require.True(t, ok)
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
b := img.Bounds()
|
||||
require.LessOrEqual(t, b.Dx(), 256)
|
||||
require.LessOrEqual(t, b.Dy(), 256)
|
||||
// 640x480 source is 4:3, so a 256 box gives 256x192. Allow 1px rounding.
|
||||
require.InDelta(t, 256, b.Dx(), 1)
|
||||
require.InDelta(t, 192, b.Dy(), 1)
|
||||
}
|
||||
|
||||
func TestVideoThumbnailOriginalSizeIsNotScaled(t *testing.T) {
|
||||
withGate(t, false)
|
||||
|
||||
// Zero bounds mean "original": the output must match the source size exactly.
|
||||
data, ok := VideoThumbnail(fixtureMP4, 0, 0, 90, 10, nil)
|
||||
require.True(t, ok)
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 640, img.Bounds().Dx())
|
||||
require.Equal(t, 480, img.Bounds().Dy())
|
||||
}
|
||||
|
||||
func TestSubtitleListSkipsUnsupportedContainer(t *testing.T) {
|
||||
withGate(t, false)
|
||||
|
||||
// MP3 has no subtitle streams in go-media. Report unsupported so the caller
|
||||
// uses ffmpeg. Do not return an empty list, which would hide real subtitles.
|
||||
_, ok := EmbeddedSubtitles(makeMP3(t), time.Now(), nil)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestGarbageFileFallsBack(t *testing.T) {
|
||||
withGate(t, false)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "not-media.mp4")
|
||||
require.NoError(t, os.WriteFile(path, bytes.Repeat([]byte("x"), 4096), 0o600))
|
||||
|
||||
_, ok := Probe(path, nil)
|
||||
require.False(t, ok)
|
||||
_, ok = Duration(path, nil)
|
||||
require.False(t, ok)
|
||||
_, ok = VideoThumbnail(path, 256, 256, 72, 10, nil)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestEmbeddedSubtitleIndexMatchesFFprobe builds an MKV with a known subtitle
|
||||
// track and checks that go-media's index is the one ffmpeg uses. A wrong index
|
||||
// would extract the wrong track.
|
||||
func TestEmbeddedSubtitleIndexMatchesFFprobe(t *testing.T) {
|
||||
withGate(t, false)
|
||||
requireTool(t, "ffmpeg")
|
||||
|
||||
dir := t.TempDir()
|
||||
srt := filepath.Join(dir, "in.srt")
|
||||
require.NoError(t, os.WriteFile(srt, []byte("1\n00:00:00,000 --> 00:00:01,000\nHello gomedia\n"), 0o600))
|
||||
mkv := filepath.Join(dir, "subs.mkv")
|
||||
out, err := exec.Command("ffmpeg", "-v", "error", "-y",
|
||||
"-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
|
||||
"-i", srt,
|
||||
"-map", "0:v", "-map", "1:s",
|
||||
"-c:v", "mpeg4", "-c:s", "srt", mkv).CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
tracks, ok := EmbeddedSubtitles(mkv, time.Now(), nil)
|
||||
require.True(t, ok, "go-media must list MKV subtitle tracks")
|
||||
require.Len(t, tracks, 1)
|
||||
require.NotNil(t, tracks[0].Index)
|
||||
|
||||
// ffprobe's subtitle stream index is the ground truth.
|
||||
ffIndex := ffprobeValue(t, mkv, "-select_streams", "s:0", "-show_entries", "stream=index")
|
||||
require.Equal(t, ffIndex, strconv.Itoa(*tracks[0].Index))
|
||||
|
||||
vtt, ok := ExtractSubtitle(mkv, *tracks[0].Index, nil)
|
||||
require.True(t, ok)
|
||||
require.True(t, strings.HasPrefix(strings.TrimSpace(vtt), "WEBVTT"), "output must be WebVTT")
|
||||
require.Contains(t, vtt, "Hello gomedia")
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Legacy runs the existing ffmpeg (or dhowden) implementation of an operation
|
||||
// and returns its result and whether it succeeded. It is only called in shadow
|
||||
// mode, in a background goroutine.
|
||||
type Legacy[T any] func() (T, bool)
|
||||
|
||||
// Differ compares the go-media value with the legacy value and returns one
|
||||
// human-readable entry per difference. An empty result means they match.
|
||||
type Differ[T any] func(fast, legacy T) []string
|
||||
|
||||
// ShadowEnabled reports whether shadow mode is on. Shadow mode runs the
|
||||
// ffmpeg implementation after every successful go-media call, so the two can
|
||||
// be compared. It is only active in dev mode (FILEBROWSER_DEVMODE=true).
|
||||
func ShadowEnabled() bool {
|
||||
return settings.Env.IsDevMode
|
||||
}
|
||||
|
||||
// timed runs the go-media fast path and logs its latency when it succeeds.
|
||||
// In shadow mode, legacy runs in a background goroutine. The caller gets the
|
||||
// go-media result immediately. The goroutine logs the timing and, when the
|
||||
// results differ, a warning that says how they differ.
|
||||
//
|
||||
// When fast returns ok=false the caller falls back to its own ffmpeg code as
|
||||
// before, so legacy is not run here.
|
||||
func timed[T any](op, path string, legacy Legacy[T], diff Differ[T], fast func() (T, bool)) (T, bool) {
|
||||
start := time.Now()
|
||||
v, ok := fast()
|
||||
if !ok {
|
||||
return v, false
|
||||
}
|
||||
goLatency := time.Since(start)
|
||||
shadowLogf("go-media %s used for %s: %.2fms", op, path, ms(goLatency))
|
||||
|
||||
if !ShadowEnabled() || legacy == nil {
|
||||
return v, true
|
||||
}
|
||||
go runShadow(op, path, goLatency, v, legacy, diff)
|
||||
return v, true
|
||||
}
|
||||
|
||||
// runShadow times legacy, compares it with the go-media value, and logs the
|
||||
// result. It runs off the request path, so a panic is recovered and logged.
|
||||
func runShadow[T any](op, path string, goLatency time.Duration, fast T, legacy Legacy[T], diff Differ[T]) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Errorf("go-media shadow %s for %s panicked: %v", op, path, r)
|
||||
}
|
||||
}()
|
||||
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(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
|
||||
// difference, but not a wrong value, so it is logged at info level.
|
||||
logger.Infof("go-media shadow %s for %s: legacy failed while go-media returned a result", op, path)
|
||||
return
|
||||
}
|
||||
var diffs []string
|
||||
if diff != nil {
|
||||
diffs = diff(fast, legacyVal)
|
||||
}
|
||||
var warnings []string
|
||||
for _, d := range diffs {
|
||||
// Entries prefixed "expected:" are known, harmless differences (for
|
||||
// example two backends choosing different frames). They are logged at
|
||||
// info level so real differences still produce warnings.
|
||||
if strings.HasPrefix(d, expectedDiffPrefix) {
|
||||
logger.Infof("go-media shadow %s for %s: %s", op, path, d)
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, d)
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
logger.Warningf("go-media shadow %s DIFFERS for %s: %s", op, path, strings.Join(warnings, "; "))
|
||||
}
|
||||
}
|
||||
|
||||
// expectedDiffPrefix marks a difference that is expected between the backends.
|
||||
const expectedDiffPrefix = "expected: "
|
||||
|
||||
// diffNum formats a numeric comparison for a shadow warning.
|
||||
func diffNum(name string, fast, legacy float64, unit string) string {
|
||||
return fmt.Sprintf("%s go-media=%.4g%s ffmpeg=%.4g%s (delta %+.4g%s)", name, fast, unit, legacy, unit, fast-legacy, unit)
|
||||
}
|
||||
|
||||
// ms converts a duration to fractional milliseconds.
|
||||
func ms(d time.Duration) float64 {
|
||||
return float64(d) / float64(time.Millisecond)
|
||||
}
|
||||
|
||||
// shadowLogf logs go-media timing at debug level (including dev shadow runs).
|
||||
func shadowLogf(format string, args ...any) {
|
||||
logger.Debugf(format, args...)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg" // ffmpeg and go-media thumbnails are JPEG
|
||||
_ "image/png"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
// Tolerances for shadow comparisons. Differences below these are not reported.
|
||||
const (
|
||||
durationToleranceSec = 0.1 // container duration, seconds
|
||||
thumbLumaTolerance = 8.0 // mean absolute luma difference, 0-255 scale
|
||||
thumbSampleGrid = 64 // samples per axis when comparing thumbnails
|
||||
thumbHardLumaDiff = 60.0 // mean luma difference above this is a real mismatch
|
||||
thumbBlackLuma = 24.0 // mean luma below this counts as a black frame
|
||||
)
|
||||
|
||||
// LyricsResult is the text and format returned by Lyrics.
|
||||
type LyricsResult struct {
|
||||
Text string
|
||||
Format string
|
||||
}
|
||||
|
||||
func probeDiff(fast, legacy ProbeResult) []string {
|
||||
var d []string
|
||||
if math.Abs(fast.Duration-legacy.Duration) > durationToleranceSec {
|
||||
d = append(d, diffNum("duration", fast.Duration, legacy.Duration, "s"))
|
||||
}
|
||||
if fast.FormatName != legacy.FormatName {
|
||||
d = append(d, fmt.Sprintf("format go-media=%q ffmpeg=%q", fast.FormatName, legacy.FormatName))
|
||||
}
|
||||
if fast.VideoCodec != legacy.VideoCodec {
|
||||
d = append(d, fmt.Sprintf("video codec go-media=%q ffmpeg=%q", fast.VideoCodec, legacy.VideoCodec))
|
||||
}
|
||||
if fast.AudioCodec != legacy.AudioCodec {
|
||||
d = append(d, fmt.Sprintf("audio codec go-media=%q ffmpeg=%q", fast.AudioCodec, legacy.AudioCodec))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func durationDiff(fast, legacy float64) []string {
|
||||
if math.Abs(fast-legacy) > durationToleranceSec {
|
||||
return []string{diffNum("duration", fast, legacy, "s")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// audioDiffer compares tags, the lyrics flag, and (when requested) cover bytes.
|
||||
func audioDiffer(withArt bool) Differ[AudioInfo] {
|
||||
return func(fast, legacy AudioInfo) []string {
|
||||
var d []string
|
||||
str := func(name, a, b string) {
|
||||
if a != b {
|
||||
d = append(d, fmt.Sprintf("%s go-media=%q ffmpeg=%q", name, a, b))
|
||||
}
|
||||
}
|
||||
str("title", fast.Tags.Title, legacy.Tags.Title)
|
||||
str("artist", fast.Tags.Artist, legacy.Tags.Artist)
|
||||
str("album", fast.Tags.Album, legacy.Tags.Album)
|
||||
str("genre", fast.Tags.Genre, legacy.Tags.Genre)
|
||||
if fast.Tags.Year != legacy.Tags.Year {
|
||||
d = append(d, fmt.Sprintf("year go-media=%d ffmpeg=%d", fast.Tags.Year, legacy.Tags.Year))
|
||||
}
|
||||
if fast.Tags.Track != legacy.Tags.Track {
|
||||
d = append(d, fmt.Sprintf("track go-media=%d ffmpeg=%d", fast.Tags.Track, legacy.Tags.Track))
|
||||
}
|
||||
if fast.HasLyrics != legacy.HasLyrics {
|
||||
d = append(d, fmt.Sprintf("has lyrics go-media=%t ffmpeg=%t", fast.HasLyrics, legacy.HasLyrics))
|
||||
}
|
||||
if withArt && len(fast.Artwork) != len(legacy.Artwork) {
|
||||
d = append(d, fmt.Sprintf("cover art bytes go-media=%d ffmpeg=%d", len(fast.Artwork), len(legacy.Artwork)))
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func lyricsDiff(fast, legacy LyricsResult) []string {
|
||||
if fast.Text == legacy.Text && fast.Format == legacy.Format {
|
||||
return nil
|
||||
}
|
||||
var d []string
|
||||
if fast.Format != legacy.Format {
|
||||
d = append(d, fmt.Sprintf("format go-media=%q ffmpeg=%q", fast.Format, legacy.Format))
|
||||
}
|
||||
if fast.Text != legacy.Text {
|
||||
d = append(d, fmt.Sprintf("text go-media=%d chars ffmpeg=%d chars", len(fast.Text), len(legacy.Text)))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func subtitleListDiff(fast, legacy []utils.SubtitleTrack) []string {
|
||||
var d []string
|
||||
if len(fast) != len(legacy) {
|
||||
d = append(d, fmt.Sprintf("track count go-media=%d ffmpeg=%d", len(fast), len(legacy)))
|
||||
}
|
||||
for i := 0; i < len(fast) && i < len(legacy); i++ {
|
||||
a, b := fast[i], legacy[i]
|
||||
if a.Name != b.Name || a.Language != b.Language || a.Codec != b.Codec || a.Title != b.Title {
|
||||
d = append(d, fmt.Sprintf("track %d go-media=%q/%q/%q ffmpeg=%q/%q/%q (name/lang/codec)",
|
||||
i, a.Name, a.Language, a.Codec, b.Name, b.Language, b.Codec))
|
||||
}
|
||||
if !sameIndex(a.Index, b.Index) {
|
||||
d = append(d, fmt.Sprintf("track %d index go-media=%s ffmpeg=%s", i, indexString(a.Index), indexString(b.Index)))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func sameIndex(a, b *int) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func indexString(p *int) string {
|
||||
if p == nil {
|
||||
return "nil"
|
||||
}
|
||||
return fmt.Sprint(*p)
|
||||
}
|
||||
|
||||
func subtitleExtractDiff(fast, legacy string) []string {
|
||||
if fast == legacy {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("WebVTT go-media=%d cues/%d bytes ffmpeg=%d cues/%d bytes",
|
||||
strings.Count(fast, "-->"), len(fast), strings.Count(legacy, "-->"), len(legacy))}
|
||||
}
|
||||
|
||||
// thumbnailDiff compares two JPEG thumbnails by size and by mean luma over a
|
||||
// sampled grid. Pixel-exact comparison is not useful here, because the two
|
||||
// backends may pick different frames.
|
||||
//
|
||||
// Known false positive: when ffmpeg's seek lands on a black intro frame (for
|
||||
// example ElephantsDream at 10%) while go-media picks a later non-black frame,
|
||||
// blackMismatch fires even though both backends are behaving reasonably. Shadow
|
||||
// mode may still log DIFFERS for that case; treat it as expected frame choice
|
||||
// unless timestamp-matched comparison is added later.
|
||||
func thumbnailDiff(fast, legacy []byte) []string {
|
||||
a, _, errA := image.Decode(bytes.NewReader(fast))
|
||||
b, _, errB := image.Decode(bytes.NewReader(legacy))
|
||||
if errA != nil || errB != nil {
|
||||
return []string{fmt.Sprintf("decode failed go-media=%v ffmpeg=%v", errA, errB)}
|
||||
}
|
||||
var d []string
|
||||
ab, bb := a.Bounds(), b.Bounds()
|
||||
if ab.Dx() != bb.Dx() || ab.Dy() != bb.Dy() {
|
||||
d = append(d, fmt.Sprintf("size go-media=%dx%d ffmpeg=%dx%d", ab.Dx(), ab.Dy(), bb.Dx(), bb.Dy()))
|
||||
}
|
||||
mean := meanLumaDiff(a, b)
|
||||
if mean > thumbLumaTolerance {
|
||||
la, lb := meanLuma(a), meanLuma(b)
|
||||
blackMismatch := (la < thumbBlackLuma) != (lb < thumbBlackLuma)
|
||||
if mean > thumbHardLumaDiff || blackMismatch {
|
||||
d = append(d, fmt.Sprintf("mean luma difference %.1f/255 over %dx%d samples (mean luma go-media=%.1f ffmpeg=%.1f)",
|
||||
mean, thumbSampleGrid, thumbSampleGrid, la, lb))
|
||||
} else {
|
||||
// Both frames are plausible; the backends picked different frames of the video.
|
||||
d = append(d, fmt.Sprintf("%smean luma difference %.1f/255 (different frame, both not black)", expectedDiffPrefix, mean))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// meanLuma returns the mean luma (0-255) over the same sampled grid used by meanLumaDiff.
|
||||
func meanLuma(img image.Image) float64 {
|
||||
b := img.Bounds()
|
||||
if b.Dx() == 0 || b.Dy() == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
for j := 0; j < thumbSampleGrid; j++ {
|
||||
for i := 0; i < thumbSampleGrid; i++ {
|
||||
x := b.Min.X + i*b.Dx()/thumbSampleGrid
|
||||
y := b.Min.Y + j*b.Dy()/thumbSampleGrid
|
||||
total += float64(grayLuma(img.At(x, y)))
|
||||
}
|
||||
}
|
||||
return total / float64(thumbSampleGrid*thumbSampleGrid)
|
||||
}
|
||||
|
||||
// grayLuma returns the luma (Y) of a pixel. The comma-ok form avoids an
|
||||
// unchecked type assertion; GrayModel.Convert always yields color.Gray, so
|
||||
// the zero value is only a defensive fallback.
|
||||
func grayLuma(c color.Color) uint8 {
|
||||
g, _ := color.GrayModel.Convert(c).(color.Gray)
|
||||
return g.Y
|
||||
}
|
||||
|
||||
// meanLumaDiff samples both images on the same relative grid and returns the
|
||||
// mean absolute difference of their luma values.
|
||||
func meanLumaDiff(a, b image.Image) float64 {
|
||||
ab, bb := a.Bounds(), b.Bounds()
|
||||
if ab.Dx() == 0 || ab.Dy() == 0 || bb.Dx() == 0 || bb.Dy() == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
n := 0
|
||||
for j := 0; j < thumbSampleGrid; j++ {
|
||||
for i := 0; i < thumbSampleGrid; i++ {
|
||||
ax := ab.Min.X + i*ab.Dx()/thumbSampleGrid
|
||||
ay := ab.Min.Y + j*ab.Dy()/thumbSampleGrid
|
||||
bx := bb.Min.X + i*bb.Dx()/thumbSampleGrid
|
||||
by := bb.Min.Y + j*bb.Dy()/thumbSampleGrid
|
||||
la := grayLuma(a.At(ax, ay))
|
||||
lb := grayLuma(b.At(bx, by))
|
||||
total += math.Abs(float64(la) - float64(lb))
|
||||
n++
|
||||
}
|
||||
}
|
||||
return total / float64(n)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func solidJPEG(t *testing.T, w, h int, c color.Gray) []byte {
|
||||
t.Helper()
|
||||
img := image.NewGray(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetGray(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestProbeDiffMatchAndMismatch(t *testing.T) {
|
||||
a := ProbeResult{Duration: 10, FormatName: "mov,mp4", VideoCodec: "h264", AudioCodec: "aac"}
|
||||
require.Empty(t, probeDiff(a, a))
|
||||
|
||||
b := a
|
||||
b.Duration = 10.5
|
||||
b.VideoCodec = "hevc"
|
||||
d := probeDiff(a, b)
|
||||
require.Len(t, d, 2)
|
||||
require.Contains(t, d[0], "duration")
|
||||
require.Contains(t, d[1], "video codec")
|
||||
|
||||
// Within tolerance is not reported.
|
||||
b = a
|
||||
b.Duration = 10.05
|
||||
require.Empty(t, probeDiff(a, b))
|
||||
}
|
||||
|
||||
func TestDurationDiffReportsDelta(t *testing.T) {
|
||||
require.Empty(t, durationDiff(10.0, 10.02))
|
||||
d := durationDiff(10.0, 12.5)
|
||||
require.Len(t, d, 1)
|
||||
require.Contains(t, d[0], "delta")
|
||||
}
|
||||
|
||||
func TestAudioDiffFieldsAndArt(t *testing.T) {
|
||||
fast := AudioInfo{Tags: media.Tags{Title: "A", Year: 2020}, HasLyrics: true, Artwork: []byte{1, 2}}
|
||||
legacy := AudioInfo{Tags: media.Tags{Title: "B", Year: 2021}, HasLyrics: false, Artwork: nil}
|
||||
|
||||
d := audioDiffer(true)(fast, legacy)
|
||||
joined := ""
|
||||
for _, s := range d {
|
||||
joined += s + "|"
|
||||
}
|
||||
require.Contains(t, joined, "title")
|
||||
require.Contains(t, joined, "year")
|
||||
require.Contains(t, joined, "has lyrics")
|
||||
require.Contains(t, joined, "cover art bytes")
|
||||
|
||||
// Cover bytes are ignored when art was not requested.
|
||||
require.NotContains(t, func() string {
|
||||
s := ""
|
||||
for _, x := range audioDiffer(false)(fast, AudioInfo{Tags: fast.Tags, HasLyrics: true}) {
|
||||
s += x
|
||||
}
|
||||
return s
|
||||
}(), "cover art")
|
||||
|
||||
require.Empty(t, audioDiffer(true)(fast, fast))
|
||||
}
|
||||
|
||||
func TestLyricsDiff(t *testing.T) {
|
||||
require.Empty(t, lyricsDiff(LyricsResult{"hi", "lrc"}, LyricsResult{"hi", "lrc"}))
|
||||
d := lyricsDiff(LyricsResult{"hi", "lrc"}, LyricsResult{"hello there", "embedded"})
|
||||
require.Len(t, d, 2)
|
||||
}
|
||||
|
||||
func TestSubtitleListDiff(t *testing.T) {
|
||||
i0, i1 := 0, 1
|
||||
a := []utils.SubtitleTrack{{Name: "en", Language: "eng", Codec: "subrip", Index: &i0}}
|
||||
require.Empty(t, subtitleListDiff(a, a))
|
||||
|
||||
b := []utils.SubtitleTrack{
|
||||
{Name: "en", Language: "eng", Codec: "subrip", Index: &i1},
|
||||
{Name: "fr", Language: "fra", Codec: "subrip", Index: &i0},
|
||||
}
|
||||
d := subtitleListDiff(a, b)
|
||||
require.GreaterOrEqual(t, len(d), 2)
|
||||
require.Contains(t, d[0], "track count")
|
||||
}
|
||||
|
||||
func TestSubtitleExtractDiff(t *testing.T) {
|
||||
require.Empty(t, subtitleExtractDiff("WEBVTT\n", "WEBVTT\n"))
|
||||
d := subtitleExtractDiff("WEBVTT\n\n00:00.000 --> 00:01.000\nhi\n", "WEBVTT\n")
|
||||
require.Len(t, d, 1)
|
||||
require.Contains(t, d[0], "cues")
|
||||
}
|
||||
|
||||
func TestThumbnailDiffIdenticalAndSizeAndLuma(t *testing.T) {
|
||||
dark := solidJPEG(t, 64, 48, color.Gray{Y: 20})
|
||||
require.Empty(t, thumbnailDiff(dark, dark))
|
||||
|
||||
// Same brightness, different size: only the size is reported.
|
||||
bigDark := solidJPEG(t, 128, 96, color.Gray{Y: 20})
|
||||
d := thumbnailDiff(dark, bigDark)
|
||||
require.Len(t, d, 1)
|
||||
require.Contains(t, d[0], "size")
|
||||
|
||||
// Same size, very different brightness: luma is reported.
|
||||
bright := solidJPEG(t, 64, 48, color.Gray{Y: 230})
|
||||
d = thumbnailDiff(dark, bright)
|
||||
require.Len(t, d, 1)
|
||||
require.Contains(t, d[0], "mean luma")
|
||||
}
|
||||
|
||||
func TestThumbnailDiffUndecodable(t *testing.T) {
|
||||
d := thumbnailDiff([]byte("nope"), solidJPEG(t, 8, 8, color.Gray{Y: 0}))
|
||||
require.Len(t, d, 1)
|
||||
require.Contains(t, d[0], "decode failed")
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Different frames of the same video are an expected difference, logged at info.
|
||||
func TestThumbnailDiffFrameChoiceIsExpected(t *testing.T) {
|
||||
a := solidJPEG(t, 64, 48, color.Gray{Y: 100})
|
||||
b := solidJPEG(t, 64, 48, color.Gray{Y: 120})
|
||||
d := thumbnailDiff(a, b)
|
||||
require.Len(t, d, 1)
|
||||
require.True(t, strings.HasPrefix(d[0], expectedDiffPrefix), d[0])
|
||||
}
|
||||
|
||||
// A black frame on one side is a real bug, so it must stay a warning.
|
||||
func TestThumbnailDiffBlackFrameIsReal(t *testing.T) {
|
||||
black := solidJPEG(t, 64, 48, color.Gray{Y: 16})
|
||||
bright := solidJPEG(t, 64, 48, color.Gray{Y: 170})
|
||||
d := thumbnailDiff(black, bright)
|
||||
require.Len(t, d, 1)
|
||||
require.False(t, strings.HasPrefix(d[0], expectedDiffPrefix), d[0])
|
||||
require.Contains(t, d[0], "mean luma")
|
||||
}
|
||||
|
||||
// Moderate luma gaps below thumbHardLumaDiff are expected frame choice.
|
||||
func TestThumbnailDiffModerateLumaIsExpected(t *testing.T) {
|
||||
a := solidJPEG(t, 64, 48, color.Gray{Y: 80})
|
||||
b := solidJPEG(t, 64, 48, color.Gray{Y: 130})
|
||||
d := thumbnailDiff(a, b)
|
||||
require.Len(t, d, 1)
|
||||
require.True(t, strings.HasPrefix(d[0], expectedDiffPrefix), d[0])
|
||||
}
|
||||
|
||||
// Very large luma differences are real even when neither side is black.
|
||||
func TestThumbnailDiffLargeLumaIsReal(t *testing.T) {
|
||||
a := solidJPEG(t, 64, 48, color.Gray{Y: 60})
|
||||
b := solidJPEG(t, 64, 48, color.Gray{Y: 220})
|
||||
d := thumbnailDiff(a, b)
|
||||
require.Len(t, d, 1)
|
||||
require.False(t, strings.HasPrefix(d[0], expectedDiffPrefix), d[0])
|
||||
}
|
||||
|
||||
// go-media lists sidecars in the same order and with the same language rule as
|
||||
// the directory scan, so the shadow comparison matches.
|
||||
func TestExternalSubtitlesFromGoMedia(t *testing.T) {
|
||||
withGate(t, false)
|
||||
names := []string{"video.srt", "video.en.srt", "video.sbv", "video.ass", "other.srt"}
|
||||
|
||||
got, ok := ExternalSubtitles("video.mp4", names, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, []utils.SubtitleTrack{
|
||||
{Name: "video.srt", Codec: "srt"},
|
||||
{Name: "video.en.srt", Language: "en", Codec: "srt"},
|
||||
{Name: "video.ass", Codec: "ass"},
|
||||
}, got)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withDevMode sets FILEBROWSER_DEVMODE behaviour for one test.
|
||||
func withDevMode(t *testing.T, on bool) {
|
||||
t.Helper()
|
||||
prev := settings.Env.IsDevMode
|
||||
settings.Env.IsDevMode = on
|
||||
t.Cleanup(func() { settings.Env.IsDevMode = prev })
|
||||
}
|
||||
|
||||
func TestShadowRunsLegacyOnlyInDevMode(t *testing.T) {
|
||||
withGate(t, false)
|
||||
|
||||
var calls atomic.Int32
|
||||
legacy := func() (float64, bool) {
|
||||
calls.Add(1)
|
||||
return 0, true
|
||||
}
|
||||
|
||||
withDevMode(t, false)
|
||||
_, ok := Duration(fixtureMP4, legacy)
|
||||
require.True(t, ok)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
require.Equal(t, int32(0), calls.Load(), "legacy must not run outside dev mode")
|
||||
|
||||
withDevMode(t, true)
|
||||
_, ok = Duration(fixtureMP4, legacy)
|
||||
require.True(t, ok)
|
||||
require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, 5*time.Millisecond,
|
||||
"legacy must run once in the background after go-media succeeds in dev mode")
|
||||
}
|
||||
|
||||
// TestShadowDoesNotBlockResult checks that the caller gets the go-media result
|
||||
// while the legacy run is still blocked, and that legacy finishes later.
|
||||
func TestShadowDoesNotBlockResult(t *testing.T) {
|
||||
withGate(t, false)
|
||||
withDevMode(t, true)
|
||||
|
||||
release := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
legacy := func() (float64, bool) {
|
||||
<-release
|
||||
close(done)
|
||||
return 0, true
|
||||
}
|
||||
|
||||
resCh := make(chan bool, 1)
|
||||
go func() {
|
||||
_, ok := Duration(fixtureMP4, legacy)
|
||||
resCh <- ok
|
||||
}()
|
||||
select {
|
||||
case ok := <-resCh:
|
||||
require.True(t, ok)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Duration waited for the blocked shadow run")
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("background shadow run never finished")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowPanicIsRecovered checks that a panicking legacy run does not crash
|
||||
// the process and the go-media result is still returned.
|
||||
func TestShadowPanicIsRecovered(t *testing.T) {
|
||||
withGate(t, false)
|
||||
withDevMode(t, true)
|
||||
|
||||
_, ok := Duration(fixtureMP4, func() (float64, bool) { panic("boom") })
|
||||
require.True(t, ok)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestShadowSkipsLegacyWhenGoMediaFails(t *testing.T) {
|
||||
withGate(t, false)
|
||||
withDevMode(t, true)
|
||||
|
||||
var calls atomic.Int32
|
||||
// A missing file makes go-media fail, so the caller's own fallback handles it.
|
||||
_, ok := Probe("/does/not/exist.mp4", func() (ProbeResult, bool) {
|
||||
calls.Add(1)
|
||||
return ProbeResult{}, true
|
||||
})
|
||||
require.False(t, ok)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.Equal(t, int32(0), calls.Load())
|
||||
}
|
||||
|
||||
func TestShadowSkippedWhenAlwaysUseFfmpeg(t *testing.T) {
|
||||
withGate(t, true)
|
||||
withDevMode(t, true)
|
||||
|
||||
var calls atomic.Int32
|
||||
_, ok := Duration(fixtureMP4, func() (float64, bool) {
|
||||
calls.Add(1)
|
||||
return 0, true
|
||||
})
|
||||
require.False(t, ok)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.Equal(t, int32(0), calls.Load())
|
||||
}
|
||||
@@ -54,22 +54,10 @@ func TestExtractRAFEmbeddedPreviewIgnoresInvalidMagic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRAFEmbeddedPreviewRealSample runs against a real Fujifilm RAF file.
|
||||
// The file is not committed, so set FILEBROWSER_TEST_RAF to its path, for example:
|
||||
//
|
||||
// FILEBROWSER_TEST_RAF=/path/to/DSCF1276.RAF go test ./internal/imagemeta/ -run RealSample
|
||||
//
|
||||
// The test is skipped when the variable is unset or the file does not exist.
|
||||
func TestExtractRAFEmbeddedPreviewRealSample(t *testing.T) {
|
||||
path := os.Getenv("FILEBROWSER_TEST_RAF")
|
||||
if path == "" {
|
||||
t.Skip("set FILEBROWSER_TEST_RAF to a RAF sample to run this test")
|
||||
}
|
||||
func TestExtractRAFEmbeddedPreviewDSCF1276(t *testing.T) {
|
||||
path := "/Users/steffag/Downloads/DSCF1276.RAF"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
t.Skipf("RAF sample not found: %s", path)
|
||||
}
|
||||
t.Fatalf("RAF sample stat failed: %v", err)
|
||||
t.Skip(path)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(context.Background(), path)
|
||||
|
||||
@@ -4,4 +4,4 @@ package preview
|
||||
|
||||
// Link MuPDF static libraries through go-fitz so preview CGO can call fz_* without
|
||||
// duplicating platform-specific LDFLAGS in this package.
|
||||
import _ "github.com/gtsteffaniak/go-fitz"
|
||||
import _ "github.com/gen2brain/go-fitz"
|
||||
@@ -3,59 +3,11 @@ package preview
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func heicLegacyQualityString(previewSize string) string {
|
||||
switch previewSize {
|
||||
case "large":
|
||||
return "2"
|
||||
case "original":
|
||||
return "1"
|
||||
default:
|
||||
return "5"
|
||||
}
|
||||
}
|
||||
|
||||
// convertHEICToJPEG decodes HEIC to JPEG at full resolution. go-media is tried
|
||||
// first; ffmpeg is the fallback. Tile-grid iPhone HEIC cannot use ffmpeg -vf
|
||||
// scale; resize is done later by CreatePreview.
|
||||
func (s *Service) convertHEICToJPEG(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
|
||||
if s.ffmpegService == nil {
|
||||
return nil, fmt.Errorf("FFmpeg is not available")
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
qualityStr := heicLegacyQualityString(previewSize)
|
||||
goQuality := ffmpeg.PreviewQualityFromLegacyQScale(qualityStr)
|
||||
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacy := func() ([]byte, bool) {
|
||||
data, err := s.ffmpegService.ConvertHEICToJPEG(shadowCtx, filePath, 0, 0, qualityStr)
|
||||
return data, err == nil && len(data) > 0
|
||||
}
|
||||
if data, ok := gomedia.HEICJPEG(filePath, goQuality, legacy); ok {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if settings.Env.IsDevMode {
|
||||
logger.Debugf("go-media heic jpeg unavailable for %s; using ffmpeg", filePath)
|
||||
}
|
||||
return s.convertHEICToJPEGWithFFmpeg(ctx, filePath, previewSize)
|
||||
}
|
||||
|
||||
// convertHEICToJPEGWithFFmpeg decodes HEIC to JPEG via ffmpeg when go-media
|
||||
// cannot handle the file.
|
||||
// convertHEICToJPEGWithFFmpeg decodes HEIC to JPEG at full resolution via ffmpeg.
|
||||
// Tile-grid iPhone HEIC cannot use ffmpeg -vf scale; resize is done later by CreatePreview.
|
||||
// Passing non-zero width/height would add -vf scale and fail on those files.
|
||||
func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
|
||||
if s.ffmpegService == nil {
|
||||
return nil, fmt.Errorf("FFmpeg is not available")
|
||||
@@ -65,13 +17,21 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
qualityStr := heicLegacyQualityString(previewSize)
|
||||
return s.ffmpegService.ConvertHEICToJPEG(ctx, filePath, 0, 0, qualityStr)
|
||||
var quality string
|
||||
switch previewSize {
|
||||
case "large":
|
||||
quality = "2"
|
||||
case "original":
|
||||
quality = "1"
|
||||
default:
|
||||
quality = "5"
|
||||
}
|
||||
|
||||
return s.ffmpegService.ConvertHEICToJPEG(ctx, filePath, 0, 0, quality)
|
||||
}
|
||||
|
||||
// convertImageWithFFmpeg converts images to JPEG via go-media (camera RAW) or ffmpeg.
|
||||
// Problematic JPEGs that Go's decoder cannot read use ffmpeg only; go-media does not
|
||||
// treat standalone JPEG files as containers.
|
||||
// convertImageWithFFmpeg converts any image file (including problematic JPEGs) to resized JPEG using FFmpeg.
|
||||
// This is used as a fallback for JPEG files that Go's standard decoder can't handle (extended sequential, etc.)
|
||||
func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
|
||||
if s.ffmpegService == nil {
|
||||
return nil, fmt.Errorf("FFmpeg is not available for JPEG fallback")
|
||||
@@ -81,31 +41,19 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
width, height, quality := ffmpegImagePreviewBounds(previewSize)
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if iteminfo.IsRawImage(ext) {
|
||||
goQuality := ffmpeg.PreviewQualityFromLegacyQScale(quality)
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacy := func() ([]byte, bool) {
|
||||
data, err := s.ffmpegService.ConvertImageToJPEG(shadowCtx, filePath, width, height, quality)
|
||||
return data, err == nil && len(data) > 0
|
||||
}
|
||||
if data, ok := gomedia.CameraRAWJPEG(filePath, width, height, goQuality, legacy); ok {
|
||||
return data, nil
|
||||
}
|
||||
var width, height int
|
||||
var quality string
|
||||
switch previewSize {
|
||||
case "large":
|
||||
width, height = 640, 640
|
||||
quality = "2"
|
||||
case "original":
|
||||
width, height = 0, 0
|
||||
quality = "1"
|
||||
default:
|
||||
width, height = 256, 256
|
||||
quality = "5"
|
||||
}
|
||||
|
||||
return s.ffmpegService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
|
||||
}
|
||||
|
||||
func ffmpegImagePreviewBounds(previewSize string) (width, height int, quality string) {
|
||||
switch previewSize {
|
||||
case "large":
|
||||
return 640, 640, "2"
|
||||
case "original":
|
||||
return 0, 0, "1"
|
||||
default:
|
||||
return 256, 256, "5"
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
return &Service{
|
||||
fileCache: fileCache,
|
||||
cacheDir: actualCacheDir,
|
||||
debug: settings.MediaDebugEnabled(),
|
||||
debug: settings.Config.Integrations.Media.Debug,
|
||||
ffmpegService: ffmpegService,
|
||||
imageSem: imageSem,
|
||||
imageLargeSem: imageLargeSem,
|
||||
@@ -200,30 +200,17 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
|
||||
}
|
||||
|
||||
cacheKey := CacheKey(cacheHash, previewSize, seekPercentage)
|
||||
skipCache := skipPreviewCacheInDev(file)
|
||||
if !skipCache {
|
||||
if data, found, err := service.fileCache.Load(ctx, cacheKey); err != nil {
|
||||
return nil, fmt.Errorf("failed to load from cache: %w", err)
|
||||
} else if found {
|
||||
if len(data) < minPreviewSize {
|
||||
return nil, ErrPreviewTooSmall
|
||||
}
|
||||
return data, nil
|
||||
if data, found, err := service.fileCache.Load(ctx, cacheKey); err != nil {
|
||||
return nil, fmt.Errorf("failed to load from cache: %w", err)
|
||||
} else if found {
|
||||
if len(data) < minPreviewSize {
|
||||
return nil, ErrPreviewTooSmall
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
|
||||
}
|
||||
|
||||
// skipPreviewCacheInDev forces HEIC preview regeneration so go-media and shadow
|
||||
// logs are visible while developing. Disk cache still stores the result.
|
||||
func skipPreviewCacheInDev(file iteminfo.ExtendedFileInfo) bool {
|
||||
if !settings.Env.IsDevMode {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(file.Type, "image/hei") &&
|
||||
settings.CanConvertImage(string(settings.HEICImagePreview))
|
||||
}
|
||||
|
||||
// filePreviewType represents the type of preview generation needed
|
||||
type filePreviewType int
|
||||
|
||||
@@ -251,7 +238,7 @@ func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
|
||||
|
||||
// Check HEIC with conversion enabled
|
||||
if strings.HasPrefix(file.Type, "image/heic") &&
|
||||
settings.CanConvertImage(string(settings.HEICImagePreview)) {
|
||||
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
|
||||
return previewTypeHEIC
|
||||
}
|
||||
|
||||
@@ -325,9 +312,9 @@ func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.E
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateHEICPreview generates preview for HEIC files using go-media with ffmpeg fallback.
|
||||
// generateHEICPreview generates preview for HEIC files using FFmpeg
|
||||
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
|
||||
imageBytes, err := s.convertHEICToJPEG(ctx, file.RealPath, previewSize)
|
||||
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
||||
}
|
||||
@@ -406,7 +393,7 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
|
||||
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
|
||||
// This is preview-specific logic for user files, not used for icon generation
|
||||
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
|
||||
enableJPEGFallback := settings.CanConvertImage(string(settings.JPEGImagePreview))
|
||||
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
|
||||
|
||||
if !enableJPEGFallback {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
|
||||
@@ -509,11 +496,9 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
// When we got bytes from an embedded preview, we still need to resize to small/large/xlarge (original is never converted).
|
||||
// When we got bytes from type-specific path, regular images are already resized; others need resize below.
|
||||
previewType := determinePreviewType(file)
|
||||
// Video thumbnails come from go-media or ffmpeg already sized to the preview
|
||||
// box, so they are final and need no ffmpeg gate here.
|
||||
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
|
||||
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable) ||
|
||||
previewType == previewTypeVideo) {
|
||||
(previewType == previewTypeVideo && settings.Env.FFmpegAvailable)) {
|
||||
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache image: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/ops"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -17,6 +16,11 @@ import (
|
||||
// percentageSeek: percentage of video duration to seek to (0–100).
|
||||
// Returns: JPEG image bytes.
|
||||
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSize string, percentageSeek int) ([]byte, error) {
|
||||
if s.ffmpegService == nil {
|
||||
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
|
||||
return nil, fmt.Errorf("FFmpeg service not available")
|
||||
}
|
||||
|
||||
// Validate file exists before processing
|
||||
if _, err := os.Stat(videoPath); err != nil {
|
||||
logger.Errorf("Video file does not exist or is not accessible: '%s': %v", videoPath, err)
|
||||
@@ -28,49 +32,14 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// go-media fast path: fit within the box, no crop. Falls back to ffmpeg when
|
||||
// unsupported (e.g. HEVC, VP9, AV1) or failed.
|
||||
// In shadow mode (dev only) the ffmpeg preview also runs, for comparison.
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
legacy := func() ([]byte, bool) {
|
||||
if s.ffmpegService == nil {
|
||||
return nil, false
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
previewErr := s.ffmpegService.VideoPreview(shadowCtx, &buf, ops.PreviewOptions{
|
||||
Input: videoPath,
|
||||
SeekPercent: float64(percentageSeek),
|
||||
// Exact seek: the default fast seek returns the keyframe before the
|
||||
// target, which is black for many videos (e.g. Big Bunny at 10%).
|
||||
Accuracy: ops.CaptureExact,
|
||||
Width: params.width,
|
||||
Height: params.height,
|
||||
ScaleMode: params.scaleMode,
|
||||
Quality: params.quality,
|
||||
})
|
||||
return buf.Bytes(), previewErr == nil
|
||||
}
|
||||
if data, ok := gomedia.VideoThumbnail(videoPath, params.width, params.height, params.quality, float64(percentageSeek), legacy); ok {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if s.ffmpegService == nil {
|
||||
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
|
||||
return nil, fmt.Errorf("FFmpeg service not available")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = s.ffmpegService.VideoPreview(ctx, &buf, ops.PreviewOptions{
|
||||
Input: videoPath,
|
||||
SeekPercent: float64(percentageSeek),
|
||||
// Exact seek: the default fast seek returns the keyframe before the
|
||||
// target, which is black for many videos (e.g. Big Bunny at 10%).
|
||||
Accuracy: ops.CaptureExact,
|
||||
Width: params.width,
|
||||
Height: params.height,
|
||||
ScaleMode: params.scaleMode,
|
||||
Quality: params.quality,
|
||||
Width: params.width,
|
||||
Height: params.height,
|
||||
ScaleMode: params.scaleMode,
|
||||
Quality: params.quality,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user