Compare commits

...

12 Commits

26 changed files with 10487 additions and 492 deletions

9
.gitignore vendored
View File

@@ -37,3 +37,12 @@ build/
default.nix
Dockerfile.dev
filebrowser.log
filebrowser.pid
frontend-dev.log
frontend.pid
SWAG.mp3
test/M5.flac
test/MALIBU.flac
test/SWAG.mp3
test

View File

@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [2.57.0] (2026-01-28)
### Features
* Add "Modify metadata" action and modal for MP3/FLAC with batch edit support
* Expose audio tags in API (GET /api/resources?metadata=1) and implement metadata updates (PATCH action=metadata)
* Update i18n (EN/FR) with metadata labels and modal texts
* Include ffmpeg in s6 Docker image to enable metadata writes in containers
### Bug Fixes
* Avoid 400/409 errors on metadata PATCH by skipping parent/stat checks when destination is empty
* Preserve existing tags on update using ffmpeg `-map_metadata 0`; apply only non-empty fields; no-op when no changes
* Use temp file with original extension for ffmpeg; capture stderr to improve error messages
* Exclude raw APIC frames from returned tags to prevent large payloads; keep preview-friendly picture key
* Modal polish: fix column alignment jitter and title capitalization
## [2.56.0](https://github.com/filebrowser/filebrowser/compare/v2.55.0...v2.56.0) (2026-01-24)

86
Dockerfile.full Normal file
View File

@@ -0,0 +1,86 @@
# Multi-stage build: compile frontend + backend, then package
# This is a full build Dockerfile for filebrowser from source
##############################################################################
# Stage 1: Build frontend (Node.js / pnpm)
##############################################################################
FROM node:20-alpine AS frontend-builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm run build
##############################################################################
# Stage 2: Build backend (Go)
##############################################################################
FROM golang:1.25-alpine AS backend-builder
RUN apk add --no-cache git
WORKDIR /app
COPY . .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
RUN go mod tidy && go mod download
ARG VERSION=dev
ARG GIT_COMMIT=unknown
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w -X 'github.com/filebrowser/filebrowser/v2/version.Version=${VERSION}' -X 'github.com/filebrowser/filebrowser/v2/version.CommitSHA=${GIT_COMMIT}'" \
-o filebrowser .
##############################################################################
# Stage 3: Fetch runtime dependencies
##############################################################################
FROM alpine:3.23 AS fetcher
RUN apk update && \
apk --no-cache add ca-certificates mailcap tini-static && \
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
##############################################################################
# Stage 4: Final runtime image (Alpine with ffmpeg)
##############################################################################
FROM alpine:3.23
ENV UID=1000
ENV GID=1000
RUN addgroup -g $GID user && \
adduser -D -u $UID -G user user && \
apk add --no-cache ffmpeg flac
# Copy compiled binary from backend-builder
COPY --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser
# Copy scripts and configs from repo
COPY --chown=user:user docker/common/ /
COPY --chown=user:user docker/alpine/ /
# Copy runtime deps from fetcher
COPY --chown=user:user --from=fetcher /sbin/tini-static /bin/tini
COPY --from=fetcher /JSON.sh /JSON.sh
COPY --from=fetcher /etc/ca-certificates.conf /etc/ca-certificates.conf
COPY --from=fetcher /etc/ca-certificates /etc/ca-certificates
COPY --from=fetcher /etc/mime.types /etc/mime.types
COPY --from=fetcher /etc/ssl /etc/ssl
RUN mkdir -p /config /database /srv && \
chown -R user:user /config /database /srv && \
chmod +x /healthcheck.sh
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
USER user
VOLUME /srv /config /database
EXPOSE 80
ENTRYPOINT [ "tini", "--", "/init.sh" ]

View File

@@ -1,7 +1,7 @@
FROM ghcr.io/linuxserver/baseimage-alpine:3.23
RUN apk update && \
apk --no-cache add ca-certificates mailcap jq libcap
apk --no-cache add ca-certificates mailcap jq libcap ffmpeg
# Make user and create necessary directories
RUN mkdir -p /config /database /srv && \

View File

@@ -28,3 +28,4 @@ Contributions are always welcome. To start contributing to this project, read ou
## License
[Apache License 2.0](LICENSE) © File Browser Contributors
# Work in progress: metadata modifier

View File

@@ -48,6 +48,8 @@ type FileInfo struct {
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
// Tags holds audio metadata (ID3, VorbisComment, MP4 tags, ...)
Tags map[string]interface{} `json:"tags,omitempty"`
Token string `json:"token,omitempty"`
currentDir []os.FileInfo `json:"-"`
Resolution *ImageResolution `json:"resolution,omitempty"`

221
files/tags.go Normal file
View File

@@ -0,0 +1,221 @@
package files
import (
"bytes"
"encoding/base64"
"io"
"log"
"os/exec"
"path/filepath"
"strings"
"github.com/dhowden/tag"
)
// ReadAudioTags extracts common audio metadata from the file and stores it in
// the FileInfo.Tags map. It attempts to extract common fields and includes
// embedded picture(s) as base64 where present.
// Multi-valued tags like ARTISTS and ALBUMARTISTS are extracted as arrays.
func (i *FileInfo) ReadAudioTags() error {
if i.IsDir {
return nil
}
f, err := i.Fs.Open(i.Path)
if err != nil {
return err
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
// don't fail hard; log and return nil so callers still get file info
log.Printf("ReadAudioTags: failed to read tags for %s: %v", i.Path, err)
return nil
}
tags := map[string]interface{}{}
if v := m.Title(); v != "" {
tags["title"] = v
}
if v := m.Album(); v != "" {
tags["album"] = v
}
if v := m.Artist(); v != "" {
tags["artist"] = v
}
if v := m.AlbumArtist(); v != "" {
tags["albumartist"] = v
}
if v := m.Composer(); v != "" {
tags["composer"] = v
}
if v := m.Year(); v != 0 {
tags["year"] = v
}
if t, _ := m.Track(); t > 0 {
tags["track"] = t
}
if dnum, _ := m.Disc(); dnum > 0 {
tags["disc"] = dnum
}
if v := m.Genre(); v != "" {
tags["genre"] = v
}
// Pictures: encode as data URI (base64) so frontend can preview if needed.
if pic := m.Picture(); pic != nil {
// safe encode image bytes as base64
var buf bytes.Buffer
if _, err := io.Copy(&buf, bytes.NewReader(pic.Data)); err == nil {
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
dataURI := "data:" + pic.MIMEType + ";base64," + b64
tags["picture"] = map[string]interface{}{
"mime": pic.MIMEType,
"data": dataURI,
}
}
}
// Keep raw metadata map if available (some formats expose additional fields)
// Also extract multi-valued tags like ARTISTS and ALBUMARTISTS
if raw := m.Raw(); raw != nil {
// First pass: collect multi-valued tags (ARTISTS, ALBUMARTISTS)
artists := extractMultiValuedTag(raw, "ARTISTS", "artists")
albumArtists := extractMultiValuedTag(raw, "ALBUMARTISTS", "albumartists")
// Store multi-valued tags as arrays if present
if len(artists) > 0 {
tags["artists"] = artists
}
if len(albumArtists) > 0 {
tags["albumartists"] = albumArtists
}
for k, v := range raw {
// Skip raw APIC entries (attached picture frame) to avoid
// exposing large binary blobs. We already expose a friendly
// `picture` key above when a picture exists.
if strings.EqualFold(k, "APIC") {
continue
}
// Skip multi-valued tags we already handled
kLower := strings.ToLower(k)
if kLower == "artists" || kLower == "albumartists" {
continue
}
// avoid overwriting already set common fields
if _, ok := tags[k]; !ok {
tags[k] = v
}
}
}
// For FLAC files, use metaflac to read multi-valued tags properly
// since dhowden/tag doesn't handle them correctly
if realPath := i.RealPath(); realPath != "" && strings.EqualFold(filepath.Ext(realPath), ".flac") {
if multiTags := readMultiValuedTagsWithMetaflac(realPath); multiTags != nil {
if artists, ok := multiTags["ARTISTS"]; ok && len(artists) > 0 {
tags["artists"] = artists
}
if albumArtists, ok := multiTags["ALBUMARTISTS"]; ok && len(albumArtists) > 0 {
tags["albumartists"] = albumArtists
}
}
}
// Attach tags map
if len(tags) > 0 {
i.Tags = tags
}
return nil
}
// extractMultiValuedTag extracts values for a multi-valued tag from raw metadata.
// It handles both Vorbis-style (multiple entries with same key) and ID3-style
// (single string that may need splitting) formats.
func extractMultiValuedTag(raw map[string]interface{}, keys ...string) []string {
var result []string
seen := make(map[string]bool)
for _, key := range keys {
for rawKey, rawVal := range raw {
if !strings.EqualFold(rawKey, key) {
continue
}
// Handle different value types returned by the tag library
switch v := rawVal.(type) {
case string:
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
result = append(result, v)
seen[v] = true
}
case []string:
for _, s := range v {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
result = append(result, s)
seen[s] = true
}
}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
result = append(result, s)
seen[s] = true
}
}
}
}
}
}
return result
}
// readMultiValuedTagsWithMetaflac uses metaflac to read Vorbis comments from FLAC files.
// This properly handles multi-valued tags (multiple entries with same key).
func readMultiValuedTagsWithMetaflac(filepath string) map[string][]string {
if _, err := exec.LookPath("metaflac"); err != nil {
return nil
}
// Use metaflac to export all tags
cmd := exec.Command("metaflac", "--export-tags-to=-", filepath)
out, err := cmd.Output()
if err != nil {
log.Printf("metaflac export failed for %s: %v", filepath, err)
return nil
}
// Parse output: each line is TAG=VALUE
result := make(map[string][]string)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := strings.ToUpper(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
if value == "" {
continue
}
// Collect multi-valued artist tags (ARTISTS, ARTIST, ALBUMARTISTS)
// Normalize key variations
if key == "ARTISTS" || key == "ARTIST" {
result["ARTISTS"] = append(result["ARTISTS"], value)
} else if key == "ALBUMARTISTS" || key == "ALBUMARTIST" || key == "ALBUM ARTIST" || key == "ALBUM_ARTIST" {
result["ALBUMARTISTS"] = append(result["ALBUMARTISTS"], value)
}
}
return result
}

7
files/write_mp3.go Normal file
View File

@@ -0,0 +1,7 @@
package files
// This file is a placeholder to avoid accidental module resolution for
// non-implemented MP3 metadata writers. The actual implementation is not
// required at runtime, and we should not import external packages here.
// Intentionally left blank.

7924
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -188,6 +188,38 @@ export async function checksum(url: string, algo: ChecksumAlg) {
return (await data.json()).checksums[algo];
}
export async function metadata(url: string) {
// Fetch metadata for a resource. Backend support is required for this to work.
const res = await resourceAction(`${url}?metadata=1`, "GET");
try {
const data = await res.json();
// backend returns a FileInfo object; prefer returning the audio tags map when present
if (data && typeof data === "object" && data.tags) {
return data.tags;
}
// fallback: if server returned just the tags map or other structure
return data;
} catch (e) {
return null;
}
}
import { normalizeChanges, normalizeMixedChanges } from "@/utils/metadata";
export async function updateMetadata(url: string, content: any) {
// Update metadata for a resource. Backend must support PATCH with action=metadata.
// Normalize outgoing changes to canonical keys so backend gets stable names, preserving arrays for multi-valued tags.
if (content && content.__clear__) {
const clear = content.__clear__;
const { __clear__, ...rest } = content;
content = normalizeMixedChanges(rest || {});
content.__clear__ = clear;
} else {
content = normalizeMixedChanges(content || {});
}
return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
}
export function getDownloadURL(file: ResourceItem, inline: any) {
const params = {
...(inline && { inline: "true" }),

View File

@@ -0,0 +1,834 @@
<template>
<div class="card floating metadata-card">
<div class="card-title title-with-actions">
<h2>{{ trans('files.metadata','Metadata') }}</h2>
<div class="title-actions">
<button
type="button"
class="button button--flat"
@click="toggleAddField"
:aria-label="trans('buttons.addField','Add field')"
:title="trans('buttons.addField','Add field')"
>
{{ trans('buttons.addField','Add field') }}
</button>
<button
type="button"
class="button button--primary"
@click.prevent="applyAll"
:aria-label="trans('buttons.apply','Apply')"
:title="trans('buttons.apply','Apply')"
>
{{ trans('buttons.apply','Apply') }}
</button>
</div>
</div>
<div class="card-content">
<p v-if="selectedCount > 1">
{{ $t('prompts.filesSelected', { count: selectedCount }) }}
</p>
<template v-if="selectedCount === 0">
<p>{{ $t('prompts.noFileSelected') }}</p>
</template>
<template v-else>
<div v-if="addFieldVisible" class="add-field-row">
<div class="add-field-controls">
<input
v-model="addFieldKey"
type="text"
:placeholder="trans('prompts.newFieldName','Field name')"
list="tag-suggestions"
/>
<datalist id="tag-suggestions">
<option v-for="s in tagSuggestions" :key="s" :value="s">{{ s }}</option>
</datalist>
<input
v-model="addFieldValue"
type="text"
:placeholder="trans('prompts.newFieldValue','Value')"
/>
<button
type="button"
class="button button--primary"
@click="confirmAddField"
:aria-label="trans('buttons.add','Add')"
:title="trans('buttons.add','Add')"
>
{{ trans('buttons.add','Add') }}
</button>
<button
type="button"
class="button button--flat"
@click="cancelAddField"
>
{{ trans('buttons.cancel','Cancel') }}
</button>
</div>
</div>
<div class="metadata-table-wrapper">
<table class="metadata-table">
<colgroup>
<col class="col-label" />
<col class="col-current" />
<col class="col-new" />
</colgroup>
<thead>
<tr>
<th class="hdr-field">{{ trans('prompts.field','Field') }}</th>
<th class="hdr-current">{{ trans('prompts.current','Current') }}</th>
<th class="hdr-new">{{ trans('prompts.new','New') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="field in fields" :key="field" class="metadata-row">
<td class="metadata-title">
<strong>{{ fieldLabel(field) }}</strong>
<span v-if="isMultiValued(field)" class="multi-value-hint">{{ trans('prompts.multiValueHint','(multi-valued)') }}</span>
</td>
<td class="metadata-current">
<template v-if="isPicture(field)">
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
<span v-else class="metadata-muted">{{ trans('prompts.noCover','No cover') }}</span>
</template>
<template v-else-if="isMultiValued(field)">
<div class="multi-value-display">
<div v-for="(val, idx) in displayCurrentArray(field)" :key="idx" class="multi-value-item">
{{ val }}
</div>
<span v-if="displayCurrentArray(field).length === 0" class="metadata-muted"></span>
</div>
</template>
<template v-else>
{{ displayCurrent(field) }}
</template>
</td>
<td class="metadata-edit-cell">
<div class="edit-with-actions">
<template v-if="isMultiValued(field)">
<div class="multi-value-editor">
<div v-for="(val, idx) in getMultiValueEntries(field)" :key="idx" class="multi-value-entry">
<input
:value="val"
@input="updateMultiValueEntry(field, idx, $event.target.value)"
:placeholder="trans('prompts.artistName','Artist name')"
type="text"
class="multi-value-input"
/>
<button
type="button"
class="button button--flat button--red icon-button mini-button"
:aria-label="trans('buttons.removeEntry','Remove')"
:title="trans('buttons.removeEntry','Remove')"
@click="removeMultiValueEntry(field, idx)"
>
<i class="material-icons">close</i>
</button>
</div>
<button
type="button"
class="button button--flat button--small add-entry-btn"
@click="addMultiValueEntry(field)"
>
<i class="material-icons">add</i>
{{ trans('buttons.addEntry','Add entry') }}
</button>
</div>
</template>
<template v-else>
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
</template>
<button
type="button"
class="button button--flat button--red icon-button"
:aria-label="trans('buttons.remove','Remove field')"
:title="trans('buttons.remove','Remove field')"
@click="removeField(field)"
>
<i class="material-icons">delete</i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div class="card-action">
<button
id="focus-prompt"
type="button"
@click="closeHovers"
class="button button--flat"
:aria-label="trans('buttons.ok','OK')"
:title="trans('buttons.ok','OK')"
>
{{ trans('buttons.ok','OK') }}
</button>
<button
id="apply-metadata"
type="button"
@click.prevent="applyAll"
class="button button--primary"
:aria-label="trans('buttons.apply','Apply')"
:title="trans('buttons.apply','Apply')"
>
{{ trans('buttons.apply','Apply') }}
</button>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { metadata as apiMetadata, updateMetadata } from "@/api/files";
import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, isMultiValuedKey, splitMultiValues, normalizeMixedChanges, formatMultiValueDisplay, parseMultiValueInput } from "@/utils/metadata";
export default {
name: "modifyMetadata",
inject: ["$showError"],
data() {
return {
// fields will be derived dynamically from metadata across selected files
fields: [],
fieldLabels: {},
placeholders: {},
metadataList: [],
newValues: {},
toClear: new Set(),
// add field UI state
addFieldVisible: false,
addFieldKey: "",
addFieldValue: "",
tagSuggestions: navidromeTags,
};
},
computed: {
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
fieldLabel(key) {
return fieldLabelFor(key);
},
async mountedFetch() {
if (!this.req) return;
const files = this.selected.map((i) => this.req.items[i].url);
// Fetch metadata for each selected file if possible
try {
const promises = files.map((u) => apiMetadata(u));
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
} catch (e) {
this.metadataList = [];
}
// Build union of fields
const keys = new Set();
this.metadataList.forEach((m) => {
Object.keys(m).forEach((k) => keys.add(k));
});
// Ensure stable ordering: put common fields first (including multi-valued artists/albumartists)
const preferred = ["title", "artist", "artists", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "albumartists", "picture", "cover"];
const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort();
this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
// Setup field labels (use translation when available, otherwise humanize)
// For multi-valued fields, initialize newValues appropriately
this.fields.forEach((f) => {
this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f));
if (isMultiValuedKey(f)) {
// Initialize with current values or empty array
const current = this.getCurrentMultiValues(f);
this.newValues[f] = current.length > 0 ? [...current] : [];
} else {
this.newValues[f] = "";
}
});
},
// Get current multi-values directly from metadataList (for initialization)
getCurrentMultiValues(field) {
if (!this.metadataList || this.metadataList.length === 0) return [];
const allValues = [];
const seen = new Set();
for (const m of this.metadataList) {
const v = m[field];
if (Array.isArray(v)) {
for (const item of v) {
const s = String(item || '').trim();
if (s && !seen.has(s)) {
allValues.push(s);
seen.add(s);
}
}
} else if (v !== undefined && v !== null) {
const s = String(v).trim();
if (s && !seen.has(s)) {
allValues.push(s);
seen.add(s);
}
}
}
return allValues;
},
toggleAddField() {
this.addFieldVisible = !this.addFieldVisible;
if (this.addFieldVisible) {
this.addFieldKey = "";
this.addFieldValue = "";
}
},
cancelAddField() {
this.addFieldVisible = false;
this.addFieldKey = "";
this.addFieldValue = "";
},
confirmAddField() {
const key = String(this.addFieldKey || "").trim();
const val = String(this.addFieldValue || "").trim();
if (!key) return;
// If field doesn't exist yet, add it to the top for visibility
if (!this.fields.includes(key)) {
this.fields.unshift(key);
// keep labels in English technical form
// label is computed via fieldLabel()
}
// Initialize newValues entry and set initial value if provided
if (isMultiValuedTag(canonicalizeKey(key))) {
// For multi-valued tags, initialize as array
if (val) {
this.newValues[key] = splitMultiValues(val);
} else {
this.newValues[key] = [''];
}
} else {
if (!(key in this.newValues)) this.newValues[key] = "";
if (val !== "") this.newValues[key] = val;
}
this.cancelAddField();
},
// Check if a field is multi-valued (ARTISTS, ALBUMARTISTS)
isMultiValued(field) {
return isMultiValuedKey(field);
},
// Get current values as array for multi-valued display
displayCurrentArray(field) {
return this.getCurrentMultiValues(field);
},
// Get entries for editing multi-valued field
getMultiValueEntries(field) {
const val = this.newValues[field];
if (Array.isArray(val)) {
return val;
}
// Initialize from current if not yet edited
if (!val || val === '') {
const current = this.getCurrentMultiValues(field);
if (current.length > 0) {
this.newValues[field] = [...current];
return this.newValues[field];
}
// Return empty array with one entry for new field
this.newValues[field] = [''];
return this.newValues[field];
}
// Handle legacy string format
const arr = splitMultiValues(String(val));
this.newValues[field] = arr.length > 0 ? arr : [''];
return this.newValues[field];
},
// Update a single entry in a multi-valued field
updateMultiValueEntry(field, index, value) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field][index] = value;
},
// Remove an entry from a multi-valued field
removeMultiValueEntry(field, index) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field].splice(index, 1);
// Keep at least one empty entry for UX
if (this.newValues[field].length === 0) {
this.newValues[field] = [''];
}
},
// Add a new entry to a multi-valued field
addMultiValueEntry(field) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field].push('');
},
removeField(field) {
// Remove an added or existing field from the edit list (UI only)
this.fields = this.fields.filter((f) => f !== field);
delete this.newValues[field];
delete this.placeholders[field];
delete this.fieldLabels[field];
// Mark field for clearing on apply
const canon = canonicalizeKey(field);
this.toClear.add(canon);
},
displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return "";
const vals = this.metadataList.map((m) => {
const v = m[field];
if (v === undefined || v === null) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
});
const nonEmpty = vals.filter((v) => v !== "");
if (nonEmpty.length === 0) return "";
const allEqual = nonEmpty.every((v) => v === nonEmpty[0]);
if (allEqual) return nonEmpty[0];
// Count how many distinct values
const distinct = new Set(nonEmpty);
// If fields are objects (e.g., picture) avoid returning huge JSON blobs.
// Prefer a short summary.
const sample = nonEmpty[0];
if (typeof sample === 'string') {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
try {
const s = JSON.stringify(sample);
return s.length > 200 ? s.slice(0, 200) + '…' : s;
} catch (e) {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
},
isPicture(field) {
if (!this.metadataList || this.metadataList.length === 0) return false;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return true;
if (v.picture && v.picture.data) return true;
}
}
return false;
},
pictureSrc(field) {
if (!this.metadataList) return null;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return v.data;
if (v.picture && v.picture.data) return v.picture.data;
// sometimes library stores a raw buffer as base64 directly in 'data'
}
}
return null;
},
trans(key, fallback) {
// Fallback to a human readable string if translation key is missing
try {
if (this.$te && this.$te(key)) return this.$t(key);
} catch (e) {
// ignore
}
return fallback;
},
humanize(str) {
if (!str) return str;
// replace camelCase / snake_case / dots with spaces and capitalize
const s = String(str)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_\.\-]+/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
},
async applyAll() {
if (!this.req) return;
// collect changed fields
const changes = {};
Object.keys(this.newValues).forEach((k) => {
const v = this.newValues[k];
const canon = canonicalizeKey(k);
if (isMultiValuedTag(canon)) {
// For multi-valued tags, filter out empty entries
let arr = Array.isArray(v) ? v : splitMultiValues(String(v || ""));
arr = arr.map(s => String(s || '').trim()).filter(Boolean);
if (arr.length > 0) changes[canon] = arr;
} else if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[canon] = v;
}
});
// If no value changes but some fields were removed, proceed
const hasValueChanges = Object.keys(changes).length > 0;
const hasClears = this.toClear && this.toClear.size > 0;
if (!hasValueChanges && !hasClears) {
return;
}
const files = this.selected.map((i) => this.req.items[i].url);
try {
// apply changes to each file
// Normalize keys on client before sending
const toSend = normalizeMixedChanges(changes);
// Attach clear list (canonicalized) so backend can remove tags
toSend.__clear__ = canonicalizeKeys(Array.from(this.toClear));
await Promise.all(files.map((u) => updateMetadata(u, toSend)));
// refresh listing
const fileStore = useFileStore();
fileStore.reload = true;
this.closeHovers();
this.toClear = new Set();
} catch (e) {
this.$showError(e);
}
},
// per-field apply removed in favor of global applyAll
},
mounted() {
this.mountedFetch();
},
};
</script>
<style scoped>
.metadata-card {
max-height: calc(100vh - 120px);
display: flex;
flex-direction: column;
box-sizing: border-box;
/* prefer a wider layout on large screens but stay responsive */
/* increase target width to give more horizontal space */
width: min(1400px, 98vw);
max-width: 98vw;
min-width: 680px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.title-with-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.title-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.add-field-row {
padding: 0 1rem 0.5rem 1rem;
}
.add-field-controls {
display: flex;
gap: 0.5rem;
}
.add-field-controls input {
flex: 1 1 auto;
}
/* Override global small max-width for floating cards for this metadata modal */
.card.floating.metadata-card {
/* remove the global max-width:25em constraint */
max-width: none !important;
width: min(1400px, 98vw) !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
}
.metadata-field {
margin-bottom: 1rem;
}
.metadata-title {
margin: 0.25rem 0;
}
.metadata-current {
color: var(--color-muted, #666);
margin: 0.25rem 0 0.5rem 0;
}
.metadata-edit {
display: flex;
gap: 0.5rem;
}
.metadata-edit input {
flex: 1 1 auto;
}
.edit-with-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-button {
padding: 0.25em 0.4em;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button .material-icons {
font-size: 18px;
line-height: 1;
}
.metadata-table-wrapper {
overflow-x: auto;
width: 100%;
padding: 0 1rem; /* add horizontal padding so left column isn't flush to modal edge */
}
.metadata-table {
border-collapse: separate;
width: auto; /* allow table to size to content */
/* allow a comfortable minimum width for readable columns on very large screens
but let it shrink responsively in smaller viewports */
min-width: 0;
max-width: 100%;
table-layout: auto;
border-spacing: 0;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
overflow: hidden;
}
.metadata-table td, .metadata-table th { word-break: break-word; }
.metadata-table th,
.metadata-table td {
border: none;
padding: 0.75rem;
text-align: left;
vertical-align: top;
}
/* Header styling: subtle background and divider */
.metadata-table thead {
background: rgba(0,0,0,0.04);
}
.metadata-table thead th {
border-bottom: 1px solid rgba(0,0,0,0.08);
}
/* extra left padding for the first column so labels don't touch the modal edge */
.metadata-table td:first-child,
.metadata-table th:first-child,
.metadata-title {
padding-left: 1rem;
}
/* Use CSS Grid per row so columns adapt to content without one taking all space */
.metadata-table thead tr,
.metadata-table tbody tr {
display: grid;
/* Fix column widths to prevent jitter when a cell contains very long content.
First column (labels) is fixed; current/new columns are flexible with a comfortable minimum. */
grid-template-columns: 220px minmax(280px, 1fr) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
.metadata-table thead tr {
border-bottom: none;
}
.metadata-table thead th,
.metadata-table tbody td {
display: block; /* make cells behave as grid items */
}
.metadata-table thead th { font-weight: 600; }
/* column sizing helpers */
.metadata-table .col-label { width: 1%; }
.metadata-table .col-current { width: auto; }
.metadata-table .col-new { width: auto; }
.metadata-edit-cell input {
width: 100%;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.12);
padding: 0.5rem 0.6rem;
}
/* Ensure long labels wrap inside the first column without expanding it */
.metadata-title strong {
display: block;
word-break: break-word;
overflow-wrap: anywhere;
}
/* Row aesthetics: zebra striping and hover */
.metadata-table tbody tr {
border-top: 1px solid rgba(0,0,0,0.06);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.metadata-table tbody tr:nth-child(odd) {
background: rgba(0,0,0,0.02);
}
.metadata-table tbody tr:hover {
background: rgba(0,0,0,0.04);
}
/* Subtle vertical separators between columns */
.metadata-table tbody td:not(:last-child) {
border-right: 1px solid rgba(0,0,0,0.06);
}
/* On small screens, stack rows to improve readability */
@media (max-width: 640px) {
.metadata-table {
min-width: 0;
}
.metadata-table thead {
display: none;
}
.metadata-table tr {
display: block;
margin-bottom: 0.75rem;
border: 1px solid rgba(0,0,0,0.08);
padding: 0.75rem;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.metadata-table td {
display: block;
width: 100%;
padding: 0.25rem 0;
}
.metadata-title {
font-weight: 600;
}
}
/* On medium screens allow a comfortable min width; on very large screens keep roomy layout */
@media (min-width: 1200px) {
.metadata-table {
min-width: 100ch;
}
}
/* Make sure table cells wrap content and images don't overflow */
.metadata-table td, .metadata-table th {
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.metadata-table img {
max-width: 100%;
height: auto;
display: block;
}
.cover-preview {
max-width: 160px;
max-height: 160px;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
}
.metadata-muted { color: var(--color-muted, #666); font-size: 0.9em; }
.metadata-current-row td {
color: var(--color-muted, #666);
}
.metadata-edit-row td {
background: rgba(0,0,0,0.02);
}
/* Multi-valued tag styling */
.multi-value-hint {
display: block;
font-size: 0.75em;
color: var(--color-muted, #888);
font-weight: normal;
margin-top: 0.15em;
}
.multi-value-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.multi-value-item {
background: rgba(0,0,0,0.04);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9em;
}
.multi-value-editor {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
}
.multi-value-entry {
display: flex;
align-items: center;
gap: 0.25rem;
}
.multi-value-input {
flex: 1;
box-sizing: border-box;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.12);
padding: 0.35rem 0.5rem;
font-size: 0.9em;
}
.mini-button {
padding: 0.15em 0.25em !important;
min-width: auto !important;
}
.mini-button .material-icons {
font-size: 16px !important;
}
.add-entry-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85em;
padding: 0.25rem 0.5rem;
margin-top: 0.15rem;
}
.add-entry-btn .material-icons {
font-size: 16px;
}
/* Ensure multi-value editor aligns well in the edit cell */
.edit-with-actions {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.edit-with-actions > .multi-value-editor {
flex: 1;
}
.edit-with-actions > .icon-button {
flex-shrink: 0;
margin-top: 0.25rem;
}
</style>

View File

@@ -25,6 +25,7 @@ import Share from "./Share.vue";
import ShareDelete from "./ShareDelete.vue";
import Upload from "./Upload.vue";
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
import ModifyMetadata from "./ModifyMetadata.vue";
const layoutStore = useLayoutStore();
@@ -44,6 +45,7 @@ const components = new Map<string, any>([
["replace-rename", ReplaceRename],
["share", Share],
["upload", Upload],
["modifyMetadata", ModifyMetadata],
["share-delete", ShareDelete],
["deleteUser", DeleteUser],
["discardEditorChanges", DiscardEditorChanges],

View File

@@ -22,6 +22,9 @@
"new": "New",
"next": "Next",
"ok": "OK",
"apply": "Apply",
"addField": "Add field",
"add": "Add",
"permalink": "Get Permanent Link",
"previous": "Previous",
"preview": "Preview",
@@ -159,7 +162,41 @@
"uploadMessage": "Select an option to upload.",
"optionalPassword": "Optional password",
"resolution": "Resolution",
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?"
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files",
"noCover": "No cover",
"newFieldName": "Field name",
"newFieldValue": "Value",
"title": "Title",
"artist": "Artist",
"album": "Album",
"track": "Track",
"genre": "Genre",
"date": "Date",
"comment": "Comment",
"disc": "Disc",
"composer": "Composer",
"year": "Year",
"albumartist": "Album artist",
"picture": "Cover",
"cover": "Cover",
"musicbrainz_releasegroupid": "MusicBrainz Release Group ID",
"musicbrainz_trackid": "MusicBrainz Track ID",
"musicbrainz_albumid": "MusicBrainz Album ID",
"musicbrainz_artistid": "MusicBrainz Artist ID",
"tsrc": "ISRC",
"encoded_by": "Encoded by",
"encoder": "Encoder",
"copyright": "Copyright"
},
"metadata": {
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files"
},
"search": {
"images": "Images",

View File

@@ -22,6 +22,9 @@
"new": "Nouveau",
"next": "Suivant",
"ok": "OK",
"apply": "Appliquer",
"addField": "Ajouter un champ",
"add": "Ajouter",
"permalink": "Obtenir le lien permanent",
"previous": "Précédent",
"preview": "Prévisualiser",
@@ -159,7 +162,41 @@
"uploadMessage": "Sélectionnez une option d'import.",
"optionalPassword": "Mot de passe optionnel",
"resolution": "Résolution",
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?"
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?",
"field": "Champ",
"current": "Actuel",
"new": "Nouveau",
"multipleValuesCount": "Différent sur {count} fichiers",
"noCover": "Pas de pochette",
"newFieldName": "Nom du champ",
"newFieldValue": "Valeur",
"title": "Titre",
"artist": "Artiste",
"album": "Album",
"track": "Piste",
"genre": "Genre",
"date": "Date",
"comment": "Commentaire",
"disc": "Disque",
"composer": "Compositeur",
"year": "Année",
"albumartist": "Artiste de l'album",
"picture": "Pochette",
"cover": "Pochette",
"musicbrainz_releasegroupid": "ID du groupe de publication MusicBrainz",
"musicbrainz_trackid": "ID de piste MusicBrainz",
"musicbrainz_albumid": "ID d'album MusicBrainz",
"musicbrainz_artistid": "ID d'artiste MusicBrainz",
"tsrc": "ISRC",
"encoded_by": "Encodé par",
"encoder": "Encodeur",
"copyright": "Copyright"
},
"metadata": {
"field": "Champ",
"current": "Actuel",
"new": "Nouveau",
"multipleValuesCount": "Différent sur {count} fichiers"
},
"search": {
"images": "Images",

View File

@@ -0,0 +1,241 @@
// Frontend helpers for metadata fields: canonicalization, labels, and payload normalization
export type Changes = Record<string, string>;
export type MultiChanges = Record<string, string[]>; // for multi-valued tags
export type MixedChanges = Record<string, string | string[]>; // combined type
// Canonical names used for UI display
export const Canonical = {
Title: "Title",
Artist: "Artist",
Artists: "Artists",
Album: "Album",
AlbumArtist: "AlbumArtist",
AlbumArtists: "AlbumArtists",
Composer: "Composer",
Track: "Track",
TrackNumber: "TrackNumber",
Disc: "Disc",
DiscNumber: "DiscNumber",
Genre: "Genre",
Date: "Date",
Year: "Year",
Comment: "Comment",
Lyrics: "Lyrics",
} as const;
const allowedCanonicals = new Set<string>(Object.values(Canonical));
// Multi-valued tag canonical names
export const multiValuedTags = new Set<string>([
Canonical.Artists,
Canonical.AlbumArtists,
]);
// Map various inputs (singular/plural/localized) to canonical names
const canonicalMap: Record<string, string> = {
// title
"title": Canonical.Title,
"titre": Canonical.Title,
"song": Canonical.Title,
// artist
"artist": Canonical.Artist,
"artiste": Canonical.Artist,
// explicit multi-valued
"artists": Canonical.Artists,
// album
"album": Canonical.Album,
// album artist
"albumartist": Canonical.AlbumArtist,
"album artist": Canonical.AlbumArtist,
"album_artist": Canonical.AlbumArtist,
"artistesdelalbum": Canonical.AlbumArtist,
"artistealbum": Canonical.AlbumArtist,
// explicit multi-valued
"albumartists": Canonical.AlbumArtists,
// composer
"composer": Canonical.Composer,
"auteur": Canonical.Composer,
// track
"track": Canonical.Track,
"tracknumber": Canonical.Track,
"trackno": Canonical.Track,
"piste": Canonical.Track,
"track number": Canonical.TrackNumber,
"track_number": Canonical.TrackNumber,
// disc
"disc": Canonical.Disc,
"discnumber": Canonical.Disc,
"disque": Canonical.Disc,
"disc number": Canonical.DiscNumber,
"disc_number": Canonical.DiscNumber,
// genre
"genre": Canonical.Genre,
// date/year
"date": Canonical.Date,
"year": Canonical.Year,
"annee": Canonical.Year,
"année": Canonical.Year,
// comment
"comment": Canonical.Comment,
"commentaire": Canonical.Comment,
// lyrics
"lyrics": Canonical.Lyrics,
};
function normalizeToken(key: string): string {
return key
.trim()
.toLowerCase()
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ");
}
// Canonicalize a user-entered key (or backend key) to our display canonical
export function canonicalizeKey(key: string): string {
const token = normalizeToken(key);
const mapped = canonicalMap[token];
if (mapped) return mapped;
// If user already provided a canonical we accept, keep it
if (allowedCanonicals.has(key)) return key;
return key; // unknown: keep as-is
}
// Given a backend key like "albumartist" or "title", return English display label
export function fieldLabelFor(key: string): string {
const canon = canonicalizeKey(key);
// ensure nice spacing for camel case labels
if (allowedCanonicals.has(canon)) return canon;
// fallback: humanize
return humanize(key);
}
export function humanize(str: string): string {
if (!str) return str;
const s = String(str)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_\.\-]+/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
}
// Normalize outgoing payload so backend gets canonical/expected keys
export function normalizeChanges(changes: Changes): Changes {
const out: Changes = {};
for (const [k, v] of Object.entries(changes)) {
const val = String(v ?? "").trim();
if (!val) continue;
const canon = canonicalizeKey(k);
if (allowedCanonicals.has(canon)) {
out[canon] = val;
} else {
// keep unknowns but trimmed; backend will filter if unsupported
out[k] = val;
}
}
return out;
}
export function canonicalizeKeys(keys: string[]): string[] {
const out: string[] = [];
for (const k of keys) {
const canon = canonicalizeKey(k);
if (allowedCanonicals.has(canon)) out.push(canon);
}
return out;
}
// Recommended Navidrome-sensitive tags for Add Field suggestions
export const navidromeTags: string[] = [
Canonical.Title,
Canonical.Artist,
Canonical.Artists,
Canonical.Album,
Canonical.AlbumArtist,
Canonical.AlbumArtists,
Canonical.Track,
Canonical.TrackNumber,
Canonical.Disc,
Canonical.DiscNumber,
Canonical.Genre,
Canonical.Date,
Canonical.Year,
Canonical.Composer,
Canonical.Lyrics,
// common MusicBrainz IDs (Vorbis comments)
"MUSICBRAINZ_ARTISTID",
"MUSICBRAINZ_ALBUMID",
"MUSICBRAINZ_TRACKID",
"MUSICBRAINZ_RELEASEGROUPID",
];
export function isMultiValuedTag(canon: string): boolean {
return multiValuedTags.has(canon);
}
// Check if a key (after canonicalization) is a multi-valued tag
export function isMultiValuedKey(key: string): boolean {
const canon = canonicalizeKey(key);
return isMultiValuedTag(canon);
}
// Format multi-valued array for display (join with line breaks)
export function formatMultiValueDisplay(values: string[]): string {
if (!values || values.length === 0) return "";
return values.join("\n");
}
// Parse display string back to array (split by newlines)
export function parseMultiValueInput(input: string): string[] {
if (!input) return [];
return input
.split(/\n/)
.map((s) => s.trim())
.filter(Boolean);
}
// Split a user-entered multi-valued string into individual entries using
// safe separators. Avoid splitting on plain '/' to not break names like AC/DC;
// prefer ' / ' with spaces, ';', and ' feat. '.
export function splitMultiValues(input: string): string[] {
let s = (input || "").trim();
if (!s) return [];
const parts: string[] = [];
const separators = [/\s;\s?/, /\s\/\s/, /\sfeat\.\s/i];
// Normalize separators to a unified delimiter
for (const sep of separators) {
s = s.replace(sep, "|||");
}
s.split("|||").forEach((p) => {
const v = p.trim();
if (v) parts.push(v);
});
return parts;
}
// Normalize mixed changes (strings + arrays) for multi-valued tags.
export function normalizeMixedChanges(changes: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(changes)) {
const canon = canonicalizeKey(k);
if (Array.isArray(v)) {
const values = v.map((x) => String(x || "").trim()).filter(Boolean);
if (values.length === 0) continue;
out[canon] = values;
continue;
}
const val = String(v ?? "").trim();
if (!val) continue;
out[canon] = val;
}
return out;
}

View File

@@ -72,6 +72,12 @@
:label="t('buttons.upload')"
@action="uploadFunc"
/>
<action
v-if="headerButtons.metadata"
icon="library_music"
:label="t('metadata')"
@action="openMetadata"
/>
<action icon="info" :label="t('buttons.info')" show="info" />
<action
icon="check_circle"
@@ -302,6 +308,12 @@
@action="download"
:counter="fileStore.selectedCount"
/>
<action
v-if="headerButtons.metadata"
icon="library_music"
:label="t('metadata')"
@action="openMetadata"
/>
<action icon="info" :label="t('buttons.info')" show="info" />
</context-menu>
@@ -483,9 +495,29 @@ const headerButtons = computed(() => {
share: fileStore.selectedCount === 1 && authStore.user?.perm.share,
move: fileStore.selectedCount > 0 && authStore.user?.perm.rename,
copy: fileStore.selectedCount > 0 && authStore.user?.perm.create,
metadata:
fileStore.selectedCount > 0 &&
fileStore.req &&
fileStore.selected.every((i) => {
const name = fileStore.req!.items[i].name || "";
const idx = name.lastIndexOf(".");
const ext = idx === -1 ? "" : name.substring(idx).toLowerCase();
return [".mp3", ".flac", ".m4a", ".m4b", ".mp4"].includes(ext);
}),
};
});
const openMetadata = () => {
if (!fileStore.req) return;
const files: string[] = [];
for (const i of fileStore.selected) {
files.push(fileStore.req.items[i].url);
}
layoutStore.showHover({ prompt: "modifyMetadata", props: { files } });
};
const isMobile = computed(() => {
return width.value <= 736;
});

2
go.mod
View File

@@ -41,6 +41,8 @@ require (
github.com/bodgit/windows v1.0.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 // indirect
github.com/bogem/id3v2/v2 v2.1.4
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect

464
go.sum
View File

@@ -1,464 +0,0 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
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/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
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/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw=
github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
github.com/asticode/go-astisub v0.38.0 h1:Qh3IO8Cotn0wwok5maid7xqsIJTwn2DtABT1UajKJaI=
github.com/asticode/go-astisub v0.38.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No=
github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0=
github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc=
github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc=
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4 h1:vCeHcs8N7MOccOOsOVIy1xcYu+kBkA4J5urTgigww7c=
github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
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/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=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/marusama/semaphore/v2 v2.5.0 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM=
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/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=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
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.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=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@@ -48,9 +48,32 @@ func (d *data) Check(path string) bool {
}
func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler {
// trackingWriter wraps the ResponseWriter to detect if headers/body were already written.
type trackingWriter struct {
http.ResponseWriter
wroteHeader bool
wroteBody bool
}
func (tw *trackingWriter) WriteHeader(code int) {
if !tw.wroteHeader {
tw.wroteHeader = true
tw.ResponseWriter.WriteHeader(code)
}
}
func (tw *trackingWriter) Write(b []byte) (int, error) {
// Any Write implies headers committed (status 200 if none set)
if !tw.wroteHeader {
tw.wroteHeader = true
}
tw.wroteBody = true
return tw.ResponseWriter.Write(b)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wrap writer to prevent superfluous WriteHeader calls
tw := &trackingWriter{ResponseWriter: w}
for k, v := range globalHeaders {
w.Header().Set(k, v)
tw.Header().Set(k, v)
}
settings, err := store.Settings.Get()
@@ -59,7 +82,7 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
return
}
status, err := fn(w, r, &data{
status, err := fn(tw, r, &data{
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
store: store,
settings: settings,
@@ -76,7 +99,10 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
if status == http.StatusBadRequest && err != nil {
txt += " (" + err.Error() + ")"
}
http.Error(w, strconv.Itoa(status)+" "+txt, status)
// Only write an error response if nothing was written yet.
if !tw.wroteHeader && !tw.wroteBody {
http.Error(tw, strconv.Itoa(status)+" "+txt, status)
}
return
}
})

117
http/id3_clear.go Normal file
View File

@@ -0,0 +1,117 @@
package fbhttp
import (
"strings"
"unicode"
id3v2 "github.com/bogem/id3v2/v2"
)
// clearID3v2Frames removes specific ID3v2 frames from an MP3 file.
// frames: list of standard frame IDs (e.g., TIT2, TPE1, TPE2, TRCK, TPOS, TCON, TDRC, COMM, USLT)
// txxxDescs: list of UserDefinedText (TXXX) descriptions to remove (e.g., "Artists", "AlbumArtists").
func clearID3v2Frames(path string, frames []string, txxxDescs []string, removeNumericEmpty bool) error {
tag, err := id3v2.Open(path, id3v2.Options{Parse: true})
if err != nil {
return err
}
defer tag.Close()
// Delete standard frames
for _, id := range frames {
tag.DeleteFrames(id)
}
// Delete matching TXXX frames by description (preserve others)
if len(txxxDescs) > 0 {
fs := tag.GetFrames("TXXX")
keep := []id3v2.UserDefinedTextFrame{}
for _, f := range fs {
udf, ok := f.(id3v2.UserDefinedTextFrame)
if !ok {
continue
}
remove := false
for _, d := range txxxDescs {
if strings.EqualFold(udf.Description, d) {
remove = true
break
}
}
if !remove && removeNumericEmpty {
desc := strings.TrimSpace(udf.Description)
if desc == "" {
remove = true
} else {
// numeric-only descriptions like "0", "1", "2"
allDigits := true
for _, r := range desc {
if !unicode.IsDigit(r) {
allDigits = false
break
}
}
if allDigits {
remove = true
}
}
}
if !remove {
keep = append(keep, udf)
}
}
// Remove all TXXX frames, then re-add the ones we keep
tag.DeleteFrames("TXXX")
for _, udf := range keep {
tag.AddFrame("TXXX", udf)
}
}
return tag.Save()
}
// cleanupNumericEmptyTXXX removes any TXXX frames whose descriptions are empty
// or purely numeric (e.g., "0", "1", "2"…), which are common artifacts.
func cleanupNumericEmptyTXXX(path string) error {
tag, err := id3v2.Open(path, id3v2.Options{Parse: true})
if err != nil {
return err
}
defer tag.Close()
fs := tag.GetFrames("TXXX")
keep := []id3v2.UserDefinedTextFrame{}
for _, f := range fs {
udf, ok := f.(id3v2.UserDefinedTextFrame)
if !ok {
continue
}
desc := strings.TrimSpace(udf.Description)
remove := false
if desc == "" {
remove = true
} else {
allDigits := true
for _, r := range desc {
if !unicode.IsDigit(r) {
allDigits = false
break
}
}
if allDigits {
remove = true
}
}
if !remove {
keep = append(keep, udf)
}
}
// If nothing to change, return early
if len(keep) == len(fs) {
return nil
}
tag.DeleteFrames("TXXX")
for _, udf := range keep {
tag.AddFrame("TXXX", udf)
}
return tag.Save()
}

257
http/metadata.go Normal file
View File

@@ -0,0 +1,257 @@
package fbhttp
import (
"strings"
)
// canonicalMap maps various user-entered or localized keys to canonical tag names.
// Canonical names are English, PascalCase where appropriate (e.g., AlbumArtist).
var canonicalMap = map[string]string{
// title
"title": "Title",
"titre": "Title",
"song": "Title",
// artist (main)
"artist": "Artist",
"artiste": "Artist",
"artists": "Artist",
// album
"album": "Album",
// album artist
"albumartist": "AlbumArtist",
"album artist": "AlbumArtist",
"album_artist": "AlbumArtist",
"artistesdelalbum": "AlbumArtist",
"artistealbum": "AlbumArtist",
// composer / author
"composer": "Composer",
"auteur": "Composer",
// track
"track": "Track",
"tracknumber": "Track",
"trackno": "Track",
"piste": "Track",
// disc
"disc": "Disc",
"discnumber": "Disc",
"disque": "Disc",
// genre
"genre": "Genre",
// year/date
"year": "Year",
"annee": "Year",
"année": "Year",
"date": "Date",
// comment
"comment": "Comment",
"commentaire": "Comment",
// lyrics
"lyrics": "Lyrics",
}
// ffmpegKey maps canonical names to ffmpeg metadata keys.
// FFmpeg expects lower-case keys; AlbumArtist becomes album_artist, Year maps to date.
var ffmpegKey = map[string]string{
"Title": "title",
"Artist": "artist",
"Album": "album",
"AlbumArtist": "album_artist",
"Composer": "composer",
"Track": "track",
"TrackNumber": "track",
"Disc": "disc",
"DiscNumber": "disc",
"Genre": "genre",
"Date": "date",
"Year": "date", // prefer date for ffmpeg
"Comment": "comment",
"Lyrics": "lyrics",
}
// ffmpegMultiKey maps multi-valued canonical names to ffmpeg/Vorbis keys.
// For FLAC/Vorbis, use uppercase ARTISTS/ALBUMARTISTS for proper multi-value support.
var ffmpegMultiKey = map[string]string{
"Artists": "ARTISTS",
"AlbumArtists": "ALBUMARTISTS",
}
// allowedCanonicals is the set of canonical tags we accept for writes.
var allowedCanonicals = func() map[string]struct{} {
m := make(map[string]struct{}, len(ffmpegKey)+len(ffmpegMultiKey))
for k := range ffmpegKey {
m[k] = struct{}{}
}
for k := range ffmpegMultiKey {
m[k] = struct{}{}
}
return m
}()
// normalizeKey reduces a user-provided key to a lookup token: lowercased, trimmed
// and with common separators removed.
func normalizeKey(s string) string {
s = strings.TrimSpace(s)
s = strings.ToLower(s)
// normalize separators so that "Album Artist", "album-artist", "album_artist" match
s = strings.ReplaceAll(s, "_", " ")
s = strings.ReplaceAll(s, "-", " ")
s = strings.Join(strings.Fields(s), " ") // collapse multiple spaces
return s
}
// normalizeAndMapToFFmpeg takes arbitrary incoming tags, applies key normalization
// and synonym mapping, filters to allowed canonical keys, and returns a map of
// ffmpeg-ready metadata keys with their values (empty values are dropped).
func normalizeAndMapToFFmpeg(in map[string]string) map[string]string {
out := map[string]string{}
for k, v := range in {
if strings.TrimSpace(v) == "" {
continue
}
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
// If user already provided a canonical name, accept it
// (e.g., Title, Artist, AlbumArtist)
c2 := strings.TrimSpace(k)
if _, allowed := allowedCanonicals[c2]; allowed {
canonical = c2
} else {
// unrecognized key: skip
continue
}
}
ffk, ok := ffmpegKey[canonical]
if !ok {
continue
}
out[ffk] = v
}
return out
}
// normalizeMultiToFFmpeg maps multi-valued canonical keys to ffmpeg keys, preserving arrays.
// Uses ffmpegMultiKey for proper Vorbis comment names (ARTISTS, ALBUMARTISTS).
func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string {
out := map[string][]string{}
for k, arr := range in {
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
// if already canonical, keep as-is
if _, allowed := allowedCanonicals[k]; allowed {
canonical = k
} else {
continue
}
}
// First check if this is a multi-valued tag
ffk, ok := ffmpegMultiKey[canonical]
if !ok {
// Fall back to regular ffmpeg key
ffk, ok = ffmpegKey[canonical]
if !ok {
continue
}
}
vals := []string{}
for _, v := range arr {
v = strings.TrimSpace(v)
if v == "" {
continue
}
vals = append(vals, v)
}
if len(vals) > 0 {
out[ffk] = vals
}
}
return out
}
// mapClearCanonicalsToFFmpeg converts a list of canonical names to ffmpeg keys
// intended to be cleared by setting them to empty value.
func mapClearCanonicalsToFFmpeg(keys []string) map[string]struct{} {
out := map[string]struct{}{}
for _, k := range keys {
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
// if already canonical and allowed, keep
if _, allowed := allowedCanonicals[k]; allowed {
canonical = k
} else {
continue
}
}
if ffk, ok := ffmpegKey[canonical]; ok {
out[ffk] = struct{}{}
}
}
return out
}
// mapClearsToID3Frames converts canonical clear keys to ID3v2 frame IDs
func mapClearsToID3Frames(keys []string) []string {
out := []string{}
for _, k := range keys {
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
if _, allowed := allowedCanonicals[k]; allowed {
canonical = k
} else {
continue
}
}
switch canonical {
case "Title":
out = append(out, "TIT2")
case "Artist", "Artists":
out = append(out, "TPE1")
case "Album":
out = append(out, "TALB")
case "AlbumArtist", "AlbumArtists":
out = append(out, "TPE2")
case "Composer":
out = append(out, "TCOM")
case "Track", "TrackNumber":
out = append(out, "TRCK")
case "Disc", "DiscNumber":
out = append(out, "TPOS")
case "Genre":
out = append(out, "TCON")
case "Date", "Year":
// Prefer TDRC; TYER used historically
out = append(out, "TDRC", "TYER")
case "Comment":
out = append(out, "COMM")
case "Lyrics":
out = append(out, "USLT")
}
}
return out
}
func mapClearsToTXXX(keys []string) []string {
out := []string{}
for _, k := range keys {
token := normalizeKey(k)
switch token {
case "artists":
out = append(out, "Artists", "ARTISTS")
case "albumartists":
out = append(out, "AlbumArtists", "ALBUMARTISTS")
}
}
return out
}

View File

@@ -2,6 +2,7 @@ package fbhttp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -10,9 +11,11 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
"github.com/shirou/gopsutil/v4/disk"
"github.com/spf13/afero"
@@ -54,6 +57,13 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
file.Content = ""
}
// If metadata requested, attempt to read audio tags and attach them
if r.URL.Query().Get("metadata") == "1" {
if err := file.ReadAudioTags(); err != nil {
log.Printf("warning: failed reading audio tags for %s: %v", file.Path, err)
}
}
return renderJSON(w, r, file)
})
@@ -201,14 +211,20 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
return http.StatusForbidden, nil
}
// Only check parent relationship when a destination was provided.
// Some actions (like metadata) don't provide a destination and calling
// filepath.Rel with an empty dst returns an error "can't make relative to ...".
if dst != "" {
err = checkParent(src, dst)
if err != nil {
return http.StatusBadRequest, err
}
}
override := r.URL.Query().Get("override") == "true"
rename := r.URL.Query().Get("rename") == "true"
if !override && !rename {
// Only check destination existence when a destination was provided.
if dst != "" && !override && !rename {
if _, err = d.user.Fs.Stat(dst); err == nil {
return http.StatusConflict, nil
}
@@ -222,6 +238,89 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
return http.StatusForbidden, nil
}
// Special-case metadata action because it needs the request body
if action == "metadata" {
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
var tags map[string]string
multi := map[string][]string{}
var clear []string
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusBadRequest, err
}
// Accept both plain tag maps and objects with __clear__ array
var raw map[string]any
if err := json.Unmarshal(body, &raw); err == nil && raw != nil {
// extract string-valued entries into tags
tags = map[string]string{}
for k, v := range raw {
if k == "__clear__" {
// parse array of strings
if arr, ok := v.([]any); ok {
for _, itm := range arr {
if s, ok := itm.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
clear = append(clear, s)
}
}
}
}
continue
}
if sv, ok := v.(string); ok {
tags[k] = sv
continue
}
if arr, ok := v.([]any); ok {
vals := []string{}
for _, itm := range arr {
if s, ok := itm.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
vals = append(vals, s)
}
}
}
if len(vals) > 0 {
multi[k] = vals
}
}
}
} else {
// fallback to simple map
tags = map[string]string{}
if err := json.Unmarshal(body, &tags); err != nil {
return http.StatusBadRequest, err
}
}
// If the source is a directory, apply changes to all audio files within.
// Otherwise, apply to the single file.
err = d.RunHook(func() error {
fi, statErr := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: src,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
if statErr != nil {
return statErr
}
if fi.IsDir {
return applyMetadataToDir(r.Context(), d, src, tags, multi, clear)
}
return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear)
}, action, src, dst, d.user)
return errToStatus(err), err
}
err = d.RunHook(func() error {
return patchAction(r.Context(), action, src, dst, d, fileCache)
}, action, src, dst, d.user)
@@ -230,6 +329,257 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
})
}
// applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating
// a temporary file and replacing the original. This requires that the
// underlying filesystem exposes a real path (see FileInfo.RealPath()).
// For FLAC files with multi-valued tags (ARTISTS, ALBUMARTISTS), it uses
// metaflac directly since ffmpeg doesn't support true multi-valued Vorbis comments.
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string, multi map[string][]string, clear []string) error {
fi, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: src,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
if err != nil {
return err
}
real := fi.RealPath()
// If RealPath returns the same virtual path, we cannot run ffmpeg on it.
if real == "" || real == fi.Path {
return fmt.Errorf("unable to obtain underlying real file path for %s: %w", fi.Path, fberrors.ErrInvalidRequestParams)
}
// Ensure ffmpeg is available
if _, err := exec.LookPath("ffmpeg"); err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
dir := filepath.Dir(real)
// Create a unique temporary filename in the same directory and keep the
// same extension as the original so ffmpeg can infer the output format.
ext := filepath.Ext(real)
tmp := filepath.Join(dir, fmt.Sprintf(".metadata_tmp_%d%s", time.Now().UnixNano(), ext))
// Ensure the temp file is removed on error
defer func() {
if _, statErr := os.Stat(tmp); statErr == nil {
_ = os.Remove(tmp)
}
}()
// Build ffmpeg args to preserve existing metadata and override only the
// provided non-empty fields. We explicitly map input global metadata (0)
// to output, and avoid `-map_metadata -1` which would clear everything.
args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"}
// For MP3, prefer ID3v2.4 to support multi-valued tags properly
isMP3 := strings.EqualFold(ext, ".mp3")
// Treat M4A/MP4 similarly for metadata handling specifics
isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4")
// FLAC supports true multi-valued Vorbis comments via metaflac
isFLAC := strings.EqualFold(ext, ".flac")
if isMP3 {
args = append(args, "-id3v2_version", "4")
}
// For FLAC files with multi-valued tags, use metaflac which properly supports
// multiple values for the same tag (Vorbis comments).
if isFLAC && len(multi) > 0 {
if _, err := exec.LookPath("metaflac"); err == nil {
// Use metaflac for multi-valued tags
if err := applyMultiValuedTagsWithMetaflac(ctx, real, tags, multi, clear); err != nil {
log.Printf("metaflac failed, falling back to ffmpeg: %v", err)
// Fall through to ffmpeg
} else {
return nil
}
}
}
// If MP3 and there are clears, perform ID3v2 in-place removal first.
// This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts).
didID3Clear := false
if isMP3 && len(clear) > 0 {
frames := mapClearsToID3Frames(clear)
txxx := mapClearsToTXXX(clear)
removeNumeric := false
for _, c := range clear {
tok := normalizeKey(c)
if tok == "artists" || tok == "albumartists" {
removeNumeric = true
break
}
}
if err := clearID3v2Frames(real, frames, txxx, removeNumeric); err == nil {
didID3Clear = true
}
// If ID3 clear failed, we'll still try ffmpeg below; but we won't add ffmpeg clears for MP3.
}
// Normalize incoming keys and map to ffmpeg keys
norm := normalizeAndMapToFFmpeg(tags)
changes := 0
setKeys := map[string]struct{}{}
for k, v := range norm {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
changes++
setKeys[k] = struct{}{}
}
// Handle multi-valued tags (e.g., Artists, AlbumArtists)
if len(multi) > 0 {
mm := normalizeMultiToFFmpeg(multi)
for ffk, vals := range mm {
// MP4/M4A expects a single value; join for compatibility
if isMP4 && (ffk == "artist" || ffk == "album_artist") {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, joined))
changes++
setKeys[ffk] = struct{}{}
}
continue
}
// MP3 (ID3): prefer writing consolidated artist/album_artist rather than TXXX ARTISTS
if isMP3 {
if ffk == "ARTISTS" {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", "artist", joined))
changes++
setKeys["artist"] = struct{}{}
}
continue
}
if ffk == "ALBUMARTISTS" {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", "album_artist", joined))
changes++
setKeys["album_artist"] = struct{}{}
}
continue
}
}
// Default: add repeated metadata entries for formats that support it (e.g., Vorbis)
for _, v := range vals {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v))
changes++
}
setKeys[ffk] = struct{}{}
}
}
// Map cleared canonical keys to ffmpeg keys and set to empty value.
// For MP3, we skip ffmpeg-based clears because ID3v2 in-place removal is preferred.
if len(clear) > 0 && !isMP3 {
for ffk := range mapClearCanonicalsToFFmpeg(clear) {
// Avoid clearing a key we are explicitly setting in this operation,
// which could result in an empty extra value (e.g., trailing NUL).
if _, isSet := setKeys[ffk]; isSet {
continue
}
args = append(args, "-metadata", fmt.Sprintf("%s=", ffk))
changes++
}
}
// If no ffmpeg changes are needed (e.g., only clears on MP3), we can return.
if changes == 0 {
// If we performed ID3 clear or there was simply nothing to change, exit early.
if didID3Clear || len(clear) == 0 {
if isMP3 {
// Best-effort cleanup of numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}
// Otherwise continue and let ffmpeg run (non-MP3 clears already added above).
}
args = append(args, tmp)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
// Capture combined output to provide actionable errors when ffmpeg fails
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg error: %w: %s", err, strings.TrimSpace(string(out)))
}
// replace original
if err := os.Rename(tmp, real); err != nil {
return err
}
if isMP3 {
// Post-write cleanup for numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}
// applyMetadataToDir iterates all immediate files in a directory and applies
// metadata changes to each supported audio file.
func applyMetadataToDir(ctx context.Context, d *data, dir string, tags map[string]string, multi map[string][]string, clear []string) error {
// List directory entries
f, err := d.user.Fs.Open(dir)
if err != nil {
return err
}
defer f.Close()
names, err := f.Readdirnames(0)
if err != nil {
return err
}
// Supported audio extensions for metadata updates
isAudio := func(ext string) bool {
ext = strings.ToLower(ext)
switch ext {
case ".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".wav":
return true
default:
return false
}
}
processed := 0
failed := 0
for _, name := range names {
p := filepath.Join(dir, name)
info, err := d.user.Fs.Stat(p)
if err != nil {
// skip entries we can't stat
log.Printf("metadata: skip %s: %v", p, err)
continue
}
if info.IsDir() {
continue
}
if !isAudio(filepath.Ext(name)) {
continue
}
if err := applyMetadataWithFFmpeg(ctx, d, p, tags, multi, clear); err != nil {
failed++
log.Printf("metadata: failed for %s: %v", p, err)
continue
}
processed++
}
if processed == 0 {
return fmt.Errorf("no audio files found in album: %w", fberrors.ErrInvalidRequestParams)
}
if failed > 0 {
// Do not fail entire album operation; return success and rely on logs
// to indicate which files failed. This avoids a 500 when some files
// are temporarily locked or have unsupported formats.
return nil
}
return nil
}
func checkParent(src, dst string) error {
rel, err := filepath.Rel(src, dst)
if err != nil {
@@ -380,3 +730,94 @@ var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (
Used: usage.Used,
})
})
// applyMultiValuedTagsWithMetaflac uses metaflac to write tags to FLAC files.
// Unlike ffmpeg, metaflac properly supports multiple values for the same tag.
func applyMultiValuedTagsWithMetaflac(ctx context.Context, filepath string, tags map[string]string, multi map[string][]string, clear []string) error {
// Normalize tags and multi to proper Vorbis comment names
norm := normalizeAndMapToFFmpeg(tags)
mm := normalizeMultiToFFmpeg(multi)
// Build metaflac arguments
// First, remove tags that we're going to set (to avoid duplicates)
removeArgs := []string{}
for k := range norm {
upperK := strings.ToUpper(k)
removeArgs = append(removeArgs, "--remove-tag="+upperK)
// Also handle ALBUM_ARTIST vs "ALBUM ARTIST" variants
if upperK == "ALBUM_ARTIST" {
removeArgs = append(removeArgs, "--remove-tag=ALBUM ARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUMARTIST")
}
}
for k := range mm {
removeArgs = append(removeArgs, "--remove-tag="+k)
}
// Also remove cleared tags
for _, c := range clear {
token := normalizeKey(c)
canonical, ok := canonicalMap[token]
if !ok {
if _, allowed := allowedCanonicals[c]; allowed {
canonical = c
} else {
continue
}
}
// Check multi-valued tags first
if ffk, ok := ffmpegMultiKey[canonical]; ok {
removeArgs = append(removeArgs, "--remove-tag="+ffk)
}
// Also check regular tags
if ffk, ok := ffmpegKey[canonical]; ok {
upperK := strings.ToUpper(ffk)
removeArgs = append(removeArgs, "--remove-tag="+upperK)
// Handle all variants of album artist
if canonical == "AlbumArtist" {
removeArgs = append(removeArgs, "--remove-tag=ALBUM ARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUMARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUM_ARTIST")
}
// Handle artist variants (lowercase/uppercase)
if canonical == "Artist" {
removeArgs = append(removeArgs, "--remove-tag=artist")
removeArgs = append(removeArgs, "--remove-tag=ARTIST")
}
}
}
// Execute removal first if needed
if len(removeArgs) > 0 {
removeArgs = append(removeArgs, filepath)
cmd := exec.CommandContext(ctx, "metaflac", removeArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("metaflac remove failed: %w: %s", err, string(out))
}
}
// Now set the new tags
setArgs := []string{}
for k, v := range norm {
// Use uppercase for Vorbis comments (standard convention)
setArgs = append(setArgs, fmt.Sprintf("--set-tag=%s=%s", strings.ToUpper(k), v))
}
// Add multi-valued tags (each value as a separate --set-tag)
for k, vals := range mm {
for _, v := range vals {
setArgs = append(setArgs, fmt.Sprintf("--set-tag=%s=%s", k, v))
}
}
// If nothing to set, we're done (removal was already executed above)
if len(setArgs) == 0 {
return nil
}
setArgs = append(setArgs, filepath)
cmd := exec.CommandContext(ctx, "metaflac", setArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("metaflac set failed: %w: %s", err, string(out))
}
return nil
}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "filebrowser",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

75
start-dev.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
export PATH="$HOME/.local/go/bin:$PATH"
echo "Project root: $PROJECT_ROOT"
# Stop any existing dev servers first
if [ -x "$PROJECT_ROOT/stop-dev.sh" ]; then
echo "Stopping existing dev servers..."
"$PROJECT_ROOT/stop-dev.sh" || true
fi
cd "$PROJECT_ROOT"
echo "Starting backend..."
if [ -f filebrowser.log ]; then
mv filebrowser.log filebrowser.log.$(date +%s)
fi
nohup go run main.go > filebrowser.log 2>&1 &
backend_pid=$!
echo $backend_pid > filebrowser.pid
echo "Backend pid: $backend_pid"
echo "Waiting for backend to listen on 127.0.0.1:8080..."
for i in $(seq 1 15); do
if ss -ltn | grep -q "127.0.0.1:8080"; then
echo "Backend listening."
break
fi
sleep 1
done
echo "Starting frontend (Vite)..."
if [ ! -d "$PROJECT_ROOT/frontend" ]; then
echo "ERROR: frontend directory not found: $PROJECT_ROOT/frontend"
exit 1
fi
cd "$PROJECT_ROOT/frontend"
if [ ! -d node_modules ]; then
if command -v pnpm >/dev/null 2>&1; then
pnpm install --no-audit --no-fund
else
npm install --no-audit --no-fund
fi
fi
if [ -f "$PROJECT_ROOT/frontend-dev.log" ]; then
mv "$PROJECT_ROOT/frontend-dev.log" "$PROJECT_ROOT/frontend-dev.log.$(date +%s)"
fi
if command -v pnpm >/dev/null 2>&1; then
nohup pnpm run dev > "$PROJECT_ROOT/frontend-dev.log" 2>&1 &
frontend_pid=$!
else
nohup npm run dev > "$PROJECT_ROOT/frontend-dev.log" 2>&1 &
frontend_pid=$!
fi
echo $frontend_pid > "$PROJECT_ROOT/frontend.pid"
echo "Frontend pid: $frontend_pid"
echo "Waiting for frontend on 5173..."
for i in $(seq 1 10); do
if ss -ltn | grep -q ":5173"; then
echo "Frontend listening."
break
fi
sleep 1
done
echo "Started. Backend logs: $PROJECT_ROOT/filebrowser.log"
echo "Frontend logs: $PROJECT_ROOT/frontend-dev.log"
echo "Open http://localhost:5173/ to use the dev UI"

51
stop-dev.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
echo "Stopping dev servers (project root: $PROJECT_ROOT)"
# Kill backend from pid file
if [ -f "$PROJECT_ROOT/filebrowser.pid" ]; then
pid=$(cat "$PROJECT_ROOT/filebrowser.pid")
if kill -0 "$pid" 2>/dev/null; then
echo "Killing backend pid $pid"
kill "$pid" || true
sleep 1
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" || true
fi
fi
rm -f "$PROJECT_ROOT/filebrowser.pid"
fi
# Kill frontend from pid file
if [ -f "$PROJECT_ROOT/frontend.pid" ]; then
pid=$(cat "$PROJECT_ROOT/frontend.pid")
if kill -0 "$pid" 2>/dev/null; then
echo "Killing frontend pid $pid"
kill "$pid" || true
sleep 1
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" || true
fi
fi
rm -f "$PROJECT_ROOT/frontend.pid"
fi
# Also attempt to free ports 8080 and 5173 by killing processes listening on them (best-effort)
pids=$(ss -ltnp | grep -E '127\.0\.0\.1:8080|:5173' | grep -oP 'pid=\K[0-9]+' | sort -u || true)
for p in $pids; do
if [ -n "$p" ]; then
if kill -0 "$p" 2>/dev/null; then
echo "Killing leftover pid $p"
kill "$p" || true
sleep 1
if kill -0 "$p" 2>/dev/null; then
kill -9 "$p" || true
fi
fi
fi
done
echo "Stopped."