feat: adding metadata button

This commit is contained in:
vcadoux
2026-01-27 09:14:11 +01:00
parent ef70de2676
commit 69b54f1ae8
10 changed files with 8260 additions and 0 deletions

4
.gitignore vendored
View File

@@ -37,3 +37,7 @@ build/
default.nix
Dockerfile.dev
filebrowser.log
filebrowser.pid
frontend-dev.log
frontend.pid

BIN
SWAG.mp3 Normal file

Binary file not shown.

7924
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -188,6 +188,21 @@ 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 {
return await res.json();
} catch (e) {
return null;
}
}
export async function updateMetadata(url: string, content: any) {
// Update metadata for a resource. Backend must support PATCH with action=metadata.
return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
}
export function getDownloadURL(file: ResourceItem, inline: any) {
const params = {
...(inline && { inline: "true" }),

View File

@@ -0,0 +1,151 @@
<template>
<div class="card floating metadata-card">
<div class="card-title">
<h2>{{ $t('metadata') }}</h2>
</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-for="field in fields" :key="field" class="metadata-field">
<p class="metadata-title"><strong>{{ fieldLabels[field] || field }}</strong></p>
<p class="metadata-current">{{ displayCurrent(field) }}</p>
<div class="metadata-edit">
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
<button
class="button button--flat"
@click.prevent="applyField(field)"
>
{{ $t('buttons.apply') }}
</button>
</div>
</div>
</template>
</div>
<div class="card-action">
<button
id="focus-prompt"
type="submit"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
>
{{ $t('buttons.ok') }}
</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";
export default {
name: "modifyMetadata",
inject: ["$showError"],
data() {
return {
fields: ["title", "artist", "album", "track", "genre", "date", "comment"],
fieldLabels: {
title: this.$t ? this.$t('prompts.title') : 'title',
artist: this.$t ? this.$t('prompts.artist') : 'artist',
album: this.$t ? this.$t('prompts.album') : 'album',
track: this.$t ? this.$t('prompts.track') : 'track',
genre: this.$t ? this.$t('prompts.genre') : 'genre',
date: this.$t ? this.$t('prompts.date') : 'date',
comment: this.$t ? this.$t('prompts.comment') : 'comment',
},
placeholders: {},
metadataList: [],
newValues: {},
};
},
computed: {
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
async mountedFetch() {
// Initialize newValues
this.fields.forEach((f) => (this.newValues[f] = ""));
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) {
// If fetching metadata fails, just keep empty list
this.metadataList = [];
}
},
displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return "";
const vals = this.metadataList.map((m) => m[field] ?? "");
const allEqual = vals.every((v) => v === vals[0]);
if (allEqual) return vals[0];
return this.$t ? this.$t('prompts.multipleValues') : '(multiple values)';
},
async applyField(field) {
if (!this.req) return;
const value = this.newValues[field];
if (value === undefined || value === null || value === "") return;
const files = this.selected.map((i) => this.req.items[i].url);
try {
await Promise.all(files.map((u) => updateMetadata(u, { [field]: value })));
// trigger a refresh of listing
const fileStore = useFileStore();
fileStore.reload = true;
this.closeHovers();
} catch (e) {
this.$showError(e);
}
},
},
mounted() {
this.mountedFetch();
},
};
</script>
<style scoped>
.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;
}
</style>

View File

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

View File

@@ -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"].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;
});

6
package-lock.json generated Normal file
View File

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

75
start-dev.sh Executable file
View File

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

51
stop-dev.sh Executable file
View File

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