Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d76695bf | |||
| 8993c8031a | |||
| 41ecbc5f73 | |||
| b59d30784f | |||
| b91b7c431c | |||
|
|
9c62bc990b | ||
|
|
b7e69dc2a3 | ||
|
|
4692ac2748 | ||
|
|
85c64667c1 | ||
|
|
a58f930d58 | ||
|
|
69b54f1ae8 | ||
|
|
ef70de2676 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -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
86
Dockerfile.full
Normal 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" ]
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
221
files/tags.go
Normal 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
7
files/write_mp3.go
Normal 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
7924
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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" }),
|
||||
|
||||
834
frontend/src/components/prompts/ModifyMetadata.vue
Normal file
834
frontend/src/components/prompts/ModifyMetadata.vue
Normal 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>
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
241
frontend/src/utils/metadata.ts
Normal file
241
frontend/src/utils/metadata.ts
Normal 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;
|
||||
}
|
||||
@@ -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
2
go.mod
@@ -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
464
go.sum
@@ -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=
|
||||
32
http/data.go
32
http/data.go
@@ -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
117
http/id3_clear.go
Normal 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
257
http/metadata.go
Normal 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
|
||||
}
|
||||
443
http/resource.go
443
http/resource.go
@@ -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
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "filebrowser",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
75
start-dev.sh
Executable file
75
start-dev.sh
Executable 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
51
stop-dev.sh
Executable 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."
|
||||
Reference in New Issue
Block a user