Compare commits

...
Author SHA1 Message Date
Kevin B. 90af0ba1cd convert quota and access prompts to composition api + ts (#3367) 2026-11-13 13:37:54 -06:00
Kevin B. 654b8a792b Merge branch 'dev/v2.2.0' into feat/info-prompt-and-metadata 2026-11-11 21:28:37 -05:00
Kevin B. a843758864 remove extension list 2026-11-11 14:26:25 -05:00
Kevin B. 8b724a6baa Merge branch 'dev/v2.2.0' into feat/info-prompt-and-metadata 2026-11-11 10:55:08 -05:00
Kevin B. ec3df65081 update translations 2026-11-10 13:12:43 -05:00
Kevin B. f1d06997a1 Add media info tab, video tags and some cleanup
- The info prompt now uses tabs for the main sections.
  - There's a new section (now tab) "Media Info" that shows the file media metadata
    like album, lyrics, subtitles, etc.
  - The "Access rules" tab, shows the access prompt embedded.
  - Created a component for the tabs cuz is being used in 3 places already.
  - Also moved the info prompt to composition + TS.
- Moved `media.js` to typescript, and share vs source is resolved in one
  place to avoid them from drift later.
- Cleaned up the frontend metadata cache, which was getting messy. It is now a single cache
  for directories and single items (metadata, lyrics, subs).
- If a video has a embedded artwork it will be used for media session or the video poster and will fallback to the
  thumbnail/preview url otherwise.

In backend:
- Read embedded tags and cover art from mp4/m4v/mov videos (at least, this is what go-media supports I think).
- Cache tag reads by path + modtime + size.
- Embedded cover art size limit is now 8 MB (was 5 MB), I had some audios that had artwork bigger than that and didn't loaded.
2026-11-10 12:52:19 -05:00
52 changed files with 1974 additions and 1641 deletions

No files matched your search

+26 -17
View File
@@ -52,7 +52,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
}
}
@@ -380,7 +380,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt, nil)
if err != nil {
logger.Debugf("failed to extract video metadata for file: %s (%s): %v", info.RealPath, info.Name, err)
} else {
@@ -471,23 +471,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
// go-media fast path: tags, lyrics availability, and artwork. Unsupported or
// failed reads fall through to the existing dhowden/tag code below.
if info, ok := gomedia.Audio(realPath, getArt, func() (gomedia.AudioInfo, bool) { return legacyAudioInfo(realPath, getArt) }); ok {
item.Metadata = &iteminfo.MediaMetadata{
Title: info.Tags.Title,
Artist: info.Tags.Artist,
Album: info.Tags.Album,
Year: info.Tags.Year,
Genre: info.Tags.Genre,
Track: info.Tags.Track,
HasLyrics: info.HasLyrics,
}
item.Metadata = &iteminfo.MediaMetadata{HasLyrics: info.HasLyrics}
applyMediaTags(item.Metadata, info, realPath)
if getDuration {
audioDuration(ctx, item, realPath, ffmpegService)
}
if n := len(info.Artwork); n > 0 && n <= 5*1024*1024 {
item.Metadata.AlbumArt = info.Artwork
} else if n > 5*1024*1024 {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
}
return nil
}
@@ -707,7 +695,7 @@ func legacyLyrics(realPath string) (gomedia.LyricsResult, bool) {
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, ffmpegService *ffmpeg.FFmpegService) error {
// go-media fast path. Works without ffmpeg installed.
probeService := ffmpegService
if probeService == nil {
@@ -759,12 +747,33 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
if info.FormatName != "" {
meta.Container = info.FormatName
}
if ok {
if tags, tagsOK := gomedia.VideoTags(realPath, getArt); tagsOK {
applyMediaTags(meta, tags, realPath)
}
}
if meta.Duration > 0 || meta.VideoCodec != "" || meta.AudioCodec != "" || meta.Container != "" {
item.Metadata = meta
}
return nil
}
// applyMediaTags copies go-media tags and embedded cover art into meta. Covers
// over 8MB are skipped.
func applyMediaTags(meta *iteminfo.MediaMetadata, info gomedia.AudioInfo, realPath string) {
meta.Title = info.Tags.Title
meta.Artist = info.Tags.Artist
meta.Album = info.Tags.Album
meta.Year = info.Tags.Year
meta.Genre = info.Tags.Genre
meta.Track = info.Tags.Track
if n := len(info.Artwork); n > 0 && n <= 8*1024*1024 {
meta.AlbumArt = info.Artwork
} else if n > 8*1024*1024 {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
}
}
// DeleteFiles deletes the file or directory at indexPath. boundIndexPath limits
// symlink resolution to the caller's scope ("" or "/" = source root only). The
// entry itself is removed: a symlink leaf is deleted, never followed, and an
+56 -23
View File
@@ -38,6 +38,11 @@ var (
// containers. Keying on modtime+size means an edited file is re-probed.
probeCache = cache.NewCache[ProbeResult](24 * time.Hour)
// tagsCache caches the embedded part of a tag read by file identity, like probeCache.
// A listing reads tags for every media child on each uncached request. Sidecar lyrics
// are not cached: adding an .lrc next to a file does not change its modtime.
tagsCache = cache.NewCache[AudioInfo](24 * time.Hour)
// probeGroup coalesces concurrent probes of the same file so a burst of
// requests for one container reads it once.
probeGroup singleflight.Group
@@ -133,16 +138,26 @@ func logUnsupported(op, path, reason string) {
// openFile opens path for go-media, which reads through io.ReaderAt.
func openFile(path string) (*os.File, int64, error) {
f, err := os.Open(path)
f, info, err := openFileInfo(path)
if err != nil {
return nil, 0, err
}
return f, info.Size(), nil
}
// openFileInfo is openFile for callers that also need the modtime (cache keys),
// so they do not stat the file a second time.
func openFileInfo(path string) (*os.File, os.FileInfo, error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, err
return nil, nil, err
}
return f, info.Size(), nil
return f, info, nil
}
// ffprobeFormatNames maps go-media containers to the format_name strings ffprobe
@@ -179,18 +194,14 @@ func probeFast(path string) (ProbeResult, bool) {
return ProbeResult{}, false
}
f, size, err := openFile(path)
f, info, err := openFileInfo(path)
if err != nil {
logFallback("probe", path, err)
return ProbeResult{}, false
}
defer f.Close()
info, err := f.Stat()
if err != nil {
logFallback("probe", path, err)
return ProbeResult{}, false
}
size := info.Size()
cacheKey := probeCacheKey(path, info.ModTime().UnixNano(), size)
if res, ok := probeCache.Get(cacheKey); ok {
return res, true
@@ -311,38 +322,60 @@ type AudioInfo struct {
// is true. A missing cover is not an error.
// legacy is the dhowden tag read, run only in shadow mode.
func Audio(path string, withArt bool, legacy Legacy[AudioInfo]) (AudioInfo, bool) {
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt) })
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt, true) })
}
func audioFast(path string, withArt bool) (AudioInfo, bool) {
func audioFast(path string, withArt, withSidecar bool) (AudioInfo, bool) {
if !Enabled() {
return AudioInfo{}, false
}
f, size, err := openFile(path)
f, st, err := openFileInfo(path)
if err != nil {
logFallback("tags", path, err)
return AudioInfo{}, false
}
defer f.Close()
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
if err != nil {
logFallback("tags", path, err)
return AudioInfo{}, false
size := st.Size()
cacheKey := tagsCacheKey(path, st.ModTime().UnixNano(), size)
var info AudioInfo
cached := false
if !withArt {
info, cached = tagsCache.Get(cacheKey)
}
info := AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
if !info.HasLyrics {
if !cached {
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
if err != nil {
logFallback("tags", path, err)
return AudioInfo{}, false
}
info = AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
stored := info
stored.Tags.Raw = nil
tagsCache.Set(cacheKey, stored)
if withArt {
info.Artwork = audio.Artwork
}
}
if withSidecar && !info.HasLyrics {
// Sidecar lyrics are only checked when the file has no embedded lyrics.
_, _, info.HasLyrics = media.LyricsSidecar(path)
}
if withArt {
info.Artwork = audio.Artwork
}
return info, true
}
// tagsCacheKey identifies a file version for the tag cache: path plus modtime and size.
func tagsCacheKey(path string, modTimeNanos, size int64) string {
return fmt.Sprintf("tags:%s:%d:%d", path, modTimeNanos, size)
}
// VideoTags reads embedded tags and cover from a video container
func VideoTags(path string, withArt bool) (AudioInfo, bool) {
return timed("tags", path, nil, nil, func() (AudioInfo, bool) { return audioFast(path, withArt, false) })
}
// Lyrics returns lyrics from a sidecar file, or else from embedded tags. The
// format is "embedded" for tags or the sidecar extension. An empty result with
// ok=true means go-media checked and found no lyrics.
+1
View File
@@ -38,6 +38,7 @@ const EXTRA_ICONS = [
'create_new_folder',
'do_not_disturb_on',
'done',
'equalizer',
'error_outline',
'file',
'file_copy',
+4 -4
View File
@@ -1,6 +1,6 @@
import { notify } from "@/notify/index.ts";
import { getApiPath, getParentDir } from "@/utils/url.js";
import { invalidateDirMetadataCache, invalidateDirMetadataItem } from "@/utils/cache/metadataCache.ts";
import { getApiPath } from "@/utils/url.js";
import { invalidateDirMetadataItem, markDirMetadataItemChanged } from "@/utils/cache/metadataCache.ts";
import { fetchURL } from "./utils.ts";
/**
@@ -35,7 +35,7 @@ export async function createArchive(opts) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
invalidateDirMetadataCache({ source: toSource || fromSource, path: getParentDir(destination) });
markDirMetadataItemChanged({ source: toSource || fromSource }, destination);
if (deleteAfter) {
for (const p of paths) {
invalidateDirMetadataItem({ source: fromSource }, p);
@@ -81,7 +81,7 @@ export async function unarchive(opts) {
body: JSON.stringify(body),
});
if (!preview) {
invalidateDirMetadataCache({ source: toSource || fromSource, path: destination });
invalidateDirMetadataItem({ source: toSource || fromSource }, destination);
if (deleteAfter) {
invalidateDirMetadataItem({ source: fromSource }, path);
}
+2 -2
View File
@@ -8,12 +8,12 @@
// - /api/tools/ -> tools.js
// - /api/office/ -> office.js
// - /api/wopi/ -> wopi.js
// - /api/media/ -> media.js
// - /api/media/ -> media.ts
// - /public/api/* -> public functions in respective files (e.g., resourcesApi.fetchFilesPublic)
import * as accessApi from "./access.js";
import * as authApi from "./auth.js";
import * as mediaApi from "./media.js";
import * as mediaApi from "./media.ts";
import * as officeApi from "./office.js";
import * as resourcesApi from "./resources.js";
import * as settingsApi from "./settings.js";
-247
View File
@@ -1,247 +0,0 @@
import { notify } from "@/notify/index.ts";
import { state } from "@/store/index.ts";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils.ts";
import {
getDirMetadataEntry,
setCachedDirMetadata,
setDirMetadataEntryPromise,
stemOf,
takePendingDirChanges,
} from "@/utils/cache/metadataCache.ts";
// GET /api/media/subtitles or /public/api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const hash = state.shareInfo?.hash || null
const baseParams = {
path,
name: subtitleName,
...(embedded && { embedded: 'true' }),
}
let res
if (hash) {
const apiPath = getPublicApiPath('media/subtitles', {
hash,
...baseParams,
})
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
res = await fetchURL(apiPath, {
headers: { 'X-SHARE-PASSWORD': encodeURIComponent(sharePassword) },
})
} else {
const apiPath = getApiPath('media/subtitles', {
source,
...baseParams,
})
res = await fetchURL(apiPath)
}
const content = await res.text()
return content
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
// GET /api/media/lyrics
export async function getLyrics(source, path) {
const apiPath = getApiPath('media/lyrics', {
source: source,
path: path,
});
const res = await fetchURL(apiPath);
const data = await res.json();
return { lyrics: data.lyrics || "", format: data.format || "" };
}
async function publicResponseError(response) {
const error = new Error(response.statusText);
try {
const data = await response.json();
if (data?.message) {
error.message = data.message;
}
} catch (_e) {
// keep the status text
}
/** @type {any} */ (error).status = response.status;
return error;
}
// GET /public/api/media/lyrics
export async function getLyricsPublic(path, hash, password = "") {
const params = {
path,
hash,
};
const apiPath = getPublicApiPath("media/lyrics", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
});
if (!response.ok) {
throw await publicResponseError(response);
}
const data = await response.json();
return { lyrics: data.lyrics || "", format: data.format || "" };
}
// GET /api/media/metadata — directory or file with metadata; optional albumArt for embedded cover extraction.
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
export async function fetchDirectoryMediaMetadata(source, path, albumArt = false) {
const apiPath = getApiPath("media/metadata", {
source,
path,
...(albumArt ? { albumArt: "true" } : {}),
});
const res = await fetchURL(apiPath);
const data = await res.json();
return adjustedData(data);
}
// GET /public/api/media/metadata
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "", albumArt = false) {
const params = {
path,
hash,
...(albumArt ? { albumArt: "true" } : {}),
};
const apiPath = getPublicApiPath("media/metadata", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
});
if (!response.ok) {
throw await publicResponseError(response);
}
const data = await response.json();
return adjustedData(data);
}
async function fetchWholeDirectoryMetadata({ isShare, source, hash, path, albumArt }, password) {
const payload = isShare
? await fetchDirectoryMediaMetadataPublic(path, hash, password, albumArt)
: await fetchDirectoryMediaMetadata(source, path, albumArt);
return new Map(
(payload?.items || []).filter((i) => i.metadata).map((i) => [i.name, i.metadata])
);
}
async function fetchItemMetadata({ isShare, source, hash, path, albumArt }, password, name) {
const base = !path || path === "/" ? "" : path.replace(/\/+$/, "");
const itemPath = `${base}/${name}`;
const payload = isShare
? await fetchDirectoryMediaMetadataPublic(itemPath, hash, password, albumArt)
: await fetchDirectoryMediaMetadata(source, itemPath, albumArt);
return payload && payload.type !== "directory" && payload.metadata ? payload.metadata : null;
}
// Applies the per-item changes recorded since the folder was cached, fetching only the changed items.
function resolvePendingChanges(entry, { removed, dirty, stems }, cacheOpts, password) {
const base = entry.promise;
const promise = (async () => {
const map = new Map(await base);
for (const name of removed) {
map.delete(name);
}
const toRefresh = new Set(dirty);
if (stems.size) {
for (const name of map.keys()) {
if (stems.has(stemOf(name))) {
toRefresh.add(name);
}
}
}
// returns the names that failed, others are applied
const refresh = async (names) => {
const failed = [];
await Promise.all(
names.map(async (name) => {
try {
const metadata = await fetchItemMetadata(cacheOpts, password, name);
if (metadata) {
map.set(name, metadata);
} else {
map.delete(name);
}
} catch (_e) {
failed.push(name);
}
})
);
return failed;
};
const failed = await refresh([...toRefresh]);
if (failed.length && (await refresh(failed)).length) {
return fetchWholeDirectoryMetadata(cacheOpts, password);
}
return map;
})();
setDirMetadataEntryPromise(entry, promise);
return promise;
}
/**
* Fetches media metadata, and caches it by directory, shared for all the callers to avoid unnecessary requests.
* Items changed (deleted, uploaded, moved) are patched individually instead of refetch the whole dir
* @param {string} path - directory path
* @param {{isShare?: boolean, source?: string, hash?: string, password?: string, albumArt?: boolean}} opts
* @returns {Promise<Map<string, object>>}
*/
export async function getDirectoryMetadataMap(path, opts = {}) {
const { isShare = false, source, hash, password = "", albumArt = false } = opts;
const cacheOpts = { isShare, source, hash, path, albumArt };
const entry = albumArt ? null : getDirMetadataEntry(cacheOpts);
if (entry) {
const pending = takePendingDirChanges(entry);
return pending ? resolvePendingChanges(entry, pending, cacheOpts, password) : entry.promise;
}
const promise = fetchWholeDirectoryMetadata(cacheOpts, password);
if (!albumArt) {
setCachedDirMetadata(cacheOpts, promise);
}
return promise;
}
// GET /api/media/stream — audio/video bytes via viewToken (range-based, not download-metered).
export function getStreamURL(source, path, viewToken) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
if (!viewToken) {
throw new Error('view token required')
}
try {
const params = {
source: source,
file: path,
viewToken: viewToken,
sessionId: state.sessionId,
}
const apiPath = getApiPath('media/stream', params)
return window.origin + apiPath
} catch (err) {
notify.showError(err.message || 'Error getting stream URL')
throw err
}
}
// GET /public/api/media/stream
export function getStreamURLPublic(share, files, viewToken) {
if (!viewToken) {
throw new Error('view token required')
}
const fileArray = Array.isArray(files) ? files : [files]
const params = {
file: fileArray,
hash: share.hash,
viewToken: viewToken,
sessionId: state.sessionId,
}
const apiPath = getPublicApiPath('media/stream', params)
return window.origin + apiPath
}
+120
View File
@@ -0,0 +1,120 @@
import { notify } from "@/notify/index.ts";
import { getters, state } from "@/store/index.ts";
import type { MediaMetadata, SubtitleTrack } from "@/store/types.ts";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL, type ListingResponse } from "./utils.ts";
import { cachedDirectory, cachedItem, type DirMetadataMap, type MetadataScope } from "@/utils/cache/metadataCache.ts";
/** GET /api/media/metadata: a directory with the metadata of its items, or a single file */
export type MediaResource = ListingResponse & {
metadata?: MediaMetadata;
subtitles?: SubtitleTrack[];
hasPreview?: boolean;
};
export interface Lyrics {
lyrics: string;
format: string;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "";
}
// Inside a share the requests go to the public API, otherwise to the source.
// Decided here so callers don't branch, and it is the scope of what gets cached.
function mediaScope(source?: string): MetadataScope {
const hash = getters.shareHash();
return hash ? { isShare: true, hash } : { source };
}
// GET /api/media/{endpoint} or /public/api/media/{endpoint}
function fetchMedia(endpoint: string, scope: MetadataScope, params: Record<string, string>): Promise<Response> {
if (scope.isShare) {
const password = localStorage.getItem(`sharepass:${scope.hash}`) || "";
return fetchURL(getPublicApiPath(`media/${endpoint}`, { hash: scope.hash, ...params }), {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password) },
});
}
return fetchURL(getApiPath(`media/${endpoint}`, { source: scope.source, ...params }));
}
export function getSubtitleContent(source: string, path: string, subtitleName: string, embedded = false): Promise<string> {
const scope = mediaScope(source);
return cachedItem(scope, path, `subtitle:${subtitleName}:${embedded}`, async () => {
try {
const res = await fetchMedia("subtitles", scope, {
path,
name: subtitleName,
...(embedded && { embedded: "true" }),
});
return await res.text();
} catch (err) {
notify.showError(errorMessage(err) || `Error fetching subtitle ${subtitleName}`);
throw err;
}
});
}
export function getLyrics(source: string, path: string): Promise<Lyrics> {
const scope = mediaScope(source);
return cachedItem(scope, path, "lyrics", async () => {
const res = await fetchMedia("lyrics", scope, { path });
const data = await res.json();
return { lyrics: data.lyrics || "", format: data.format || "" };
});
}
// albumArt adds the embedded cover of audio files.
async function fetchMetadata(scope: MetadataScope, path: string, albumArt = false): Promise<MediaResource> {
const res = await fetchMedia("metadata", scope, { path, ...(albumArt && { albumArt: "true" }) });
return adjustedData(await res.json());
}
/** Metadata of a single file */
export function getItemMediaMetadata(
path: string,
{ source, albumArt = false }: { source?: string; albumArt?: boolean } = {},
): Promise<MediaResource> {
const scope = mediaScope(source);
const fetcher = () => fetchMetadata(scope, path, albumArt);
return albumArt ? fetcher() : cachedItem(scope, path, "metadata", fetcher);
}
/**
* Metadata of the items of a directory by name, cached by dir and shared for all callers.
* Items changed are patched individually instead of refetching the whole dir again.
*/
export function getDirectoryMetadataMap(path: string, { source }: { source?: string } = {}): Promise<DirMetadataMap> {
const scope = mediaScope(source);
const base = !path || path === "/" ? "" : path.replace(/\/+$/, "");
return cachedDirectory(
scope,
path,
async () => {
const payload = await fetchMetadata(scope, path);
const withMetadata = (payload.items || []).filter((i) => i.metadata);
return new Map(withMetadata.map((i) => [i.name, i.metadata as object]));
},
async (name) => {
const payload = await fetchMetadata(scope, `${base}/${name}`);
return payload.type !== "directory" && payload.metadata ? payload.metadata : null;
},
);
}
// GET /api/media/stream or /public/api/media/stream — audio/video bytes via viewToken (range-based, not download-metered).
export function getStreamURL(source: string, path: string, viewToken: string): string {
const scope = mediaScope(source);
if (!scope.isShare && !source) {
throw new Error("no source provided");
}
if (!viewToken) {
throw new Error("view token required");
}
const params = { file: path, viewToken: viewToken, sessionId: state.sessionId };
const apiPath = scope.isShare
? getPublicApiPath("media/stream", { hash: scope.hash, ...params })
: getApiPath("media/stream", { source, ...params });
return window.origin + apiPath;
}
+4 -8
View File
@@ -7,14 +7,13 @@ import {
notifyDownloadError,
} from "@/utils/appNotifications.js"
import { renew } from "@/utils/auth.js"
import { getApiPath, getPublicApiPath, getParentDir } from '@/utils/url.js'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { adjustedData, fetchURL } from "./utils.ts"
import { rememberViewToken } from "./viewToken.js"
import { isMediaFile } from "@/utils/mediaFile.js"
import { getObjectProperty } from "@/utils/object.js"
import { getStreamURL, getStreamURLPublic } from "./media.js"
import { getStreamURL } from "./media.ts"
import {
invalidateDirMetadataCache,
invalidateDirMetadataItem,
markDirMetadataItemChanged,
removeDirMetadataItem
@@ -1026,7 +1025,7 @@ export async function moveCopy(
removeDirMetadataItem({ source: item.fromSource }, item.from)
}
if (rename) {
invalidateDirMetadataCache({ source: item.toSource, path: getParentDir(item.to) })
invalidateDirMetadataItem({ source: item.toSource }, item.to)
} else {
markDirMetadataItemChanged({ source: item.toSource }, item.isDir ? `${item.to}/` : item.to)
}
@@ -1147,9 +1146,6 @@ export function getViewURL(source, path, viewToken, shareInfo = null, allowDownl
if (viewToken) {
if (isMediaFile(typeHint)) {
if (shareInfo) {
return getStreamURLPublic(shareInfo, [path], viewToken)
}
return getStreamURL(source, path, viewToken)
}
if (shareInfo) {
@@ -1665,7 +1661,7 @@ export async function moveCopyPublic(
removeDirMetadataItem({ isShare: true, hash }, item.from)
}
if (rename) {
invalidateDirMetadataCache({ isShare: true, hash, path: getParentDir(item.to) })
invalidateDirMetadataItem({ isShare: true, hash }, item.to)
} else {
markDirMetadataItemChanged({ isShare: true, hash }, item.isDir ? `${item.to}/` : item.to)
}
-1
View File
@@ -19,7 +19,6 @@ vi.mock("@/store", () => ({
vi.mock("@/api/media", () => ({
getStreamURL: (_source, path, viewToken) =>
`http://localhost/api/media/stream?file=${encodeURIComponent(path)}&viewToken=${viewToken}`,
getStreamURLPublic: () => "http://localhost/public/stream",
}));
import { getViewURL, getOpenFileURL } from "@/api/resources.js";
+5 -1
View File
@@ -495,8 +495,12 @@ function itemsForPrompt(): MenuItem[] {
}
function showInfoPrompt() {
const item =
isPreview.value && !props.items?.length && (showOverflow.value || !firstSelected.value)
? state.req
: firstSelected.value;
mutations.closeHovers();
mutations.showPrompt({ name: "info", props: { item: firstSelected.value } });
mutations.showPrompt({ name: "info", props: { item } });
}
function navToItem() {
+15 -88
View File
@@ -1,26 +1,7 @@
<template>
<div class="audio-side-panel">
<div class="panel-tabs">
<div class="tab-container">
<input type="radio" id="tab-queue" v-model="activeTab" value="queue" hidden />
<label for="tab-queue" class="tab-btn" :class="{ active: activeTab === 'queue' }" :title="$t('player.QueuePlayback')">
<i class="material-symbols">queue_music</i>
<span>{{ $t('player.queue') }}</span>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span v-if="queueCount > 0">({{ queueCount }})</span>
</label>
<input type="radio" id="tab-lyrics" v-model="activeTab" value="lyrics" hidden />
<label for="tab-lyrics" class="tab-btn" :class="{ active: activeTab === 'lyrics' }" :title="$t('player.lyrics')">
<i class="material-symbols">lyrics</i>
<span>{{ $t('player.lyrics') }}</span>
</label>
<input type="radio" id="tab-visualizer" v-model="activeTab" value="visualizer" hidden />
<label for="tab-visualizer" class="tab-btn" :class="{ active: activeTab === 'visualizer' }" :title="$t('player.visualizer.title')">
<i class="material-symbols">equalizer</i>
<span>{{ $t('player.visualizer.title') }}</span>
</label>
<div class="tab-indicator" :style="indicatorStyle"></div>
</div>
<SlidingTabs v-model="activeTab" :tabs="panelTabs" />
</div>
<div class="panel-content">
@@ -90,6 +71,7 @@
</template>
<script>
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import { mutations, state } from "@/store";
@@ -119,7 +101,7 @@ const FREQ_LABELS = [
export default {
name: "AudioPanel",
components: { PlaybackQueue, FloatingActionButton },
components: { SlidingTabs, PlaybackQueue, FloatingActionButton },
props: {
lyrics: { type: Array, default: () => [] },
lyricsMeta: { type: String, default: '' },
@@ -183,12 +165,18 @@ export default {
visualizerSettingsLabel() {
return this.$t('player.visualizer.settings');
},
// tabs in the panel
indicatorStyle() {
const tabCount = 3;
const width = 100 / tabCount;
const tabIndex = ['queue', 'lyrics', 'visualizer'].indexOf(this.activeTab);
return { width: `${width}%`, left: `${tabIndex * width}%` };
panelTabs() {
return [
{
id: 'queue',
icon: 'queue_music',
label: this.$t('player.queue'),
title: this.$t('player.QueuePlayback'),
suffix: this.queueCount > 0 ? `(${this.queueCount})` : undefined,
},
{ id: 'lyrics', icon: 'lyrics', label: this.$t('player.lyrics') },
{ id: 'visualizer', icon: 'equalizer', label: this.$t('player.visualizer.title') },
];
},
},
watch: {
@@ -788,62 +776,6 @@ export default {
border-bottom: 1px solid var(--divider);
}
/* Container for the sliding indicator */
.tab-container {
position: relative;
display: flex;
gap: 0.5em;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
padding: 0.5em;
border: none;
background: transparent;
color: var(--textSecondary);
border-radius: var(--borderRadius);
cursor: pointer;
font-size: 0.9rem;
transition: color 0.2s ease, transform 0.2s ease;
position: relative;
z-index: 1;
user-select: none;
width: 100%;
min-width: 0;
white-space: nowrap;
}
.tab-btn > i,
.tab-btn > span + span {
flex-shrink: 0;
}
.tab-btn > span:first-of-type {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-btn.active {
color: white;
}
.tab-indicator {
position: absolute;
top: 0; bottom: 0;
left: 0;
background: var(--primaryColor);
border-radius: var(--borderRadius);
z-index: 0;
transition: left 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
}
.panel-content {
flex: 1;
overflow: hidden;
@@ -974,11 +906,6 @@ export default {
cursor: default;
}
.tab-btn:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
.tab-visualizer {
position: relative;
display: flex;
+33 -6
View File
@@ -87,7 +87,7 @@ import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import { sortedItems } from "@/utils/sort.ts";
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
type ItemInfo = Omit<FileListItem, "metadata"> & { metadata?: MediaMetadata; isDir?: boolean; hasDuration?: boolean };
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
export interface ListEntry {
name: string;
@@ -98,7 +98,7 @@ export interface ListEntry {
size?: number;
modified?: string;
created?: string;
metadata?: unknown;
metadata?: MediaMetadata;
isDirectory?: boolean;
originalItem?: ItemInfo;
}
@@ -330,7 +330,8 @@ onMounted(async () => {
if (currentSource.value !== state.req.source || props.browsePath) {
void withLoading(() => resourcesApi.fetchFiles(sourceToUse, pathToUse).then(fillOptions));
} else {
fillOptions(initialReq);
const pending = fillOptions(initialReq);
if (pending) void withLoading(() => pending);
}
}
attachScrollListener();
@@ -417,7 +418,22 @@ function resetToShare(newHash: string) {
void withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(fillOptions));
}
function fillOptions(req: { source?: string; items?: FileListItem[] }) {
function fillOptions(req: { source?: string; type?: string; items?: FileListItem[] }): Promise<unknown> | undefined {
// if the path points to a file browse its parent and highlight the item
if (req.type && req.type !== "directory" && path.value !== "/") {
const filePath = path.value;
const parent = `${url.removeLastDir(filePath)}/`;
path.value = parent
const showParent = (parentReq: Parameters<typeof fillOptions>[0]) => {
const pending = fillOptions(parentReq);
selectEntry(filePath);
return pending;
};
if (!props.browseSource && (props.browseShare || getters.isShare())) {
return resourcesApi.fetchFilesPublic(parent, props.browseShare || getters.shareHash()).then(showParent);
}
return resourcesApi.fetchFiles(source.value ?? currentSource.value, parent).then(showParent);
}
// Sets the current path and resets the current items.
// Use path (the path we're browsing) instead of req.path (which may be relative)
current.value = path.value;
@@ -462,6 +478,16 @@ function fillOptions(req: { source?: string; items?: FileListItem[] }) {
type: "directory",
});
}
return undefined;
}
function selectEntry(itemPath: string) {
const entry = items.value.find((item) => item.path === itemPath);
if (!entry || entry.type === "directory") return;
selected.value = entry.path;
selectedSource.value = entry.source;
selectedType.value = entry.type;
emit("update:selected", { path: entry.path, source: entry.source, type: entry.type, isValid: true });
}
function sortEntries(entries: ListEntry[]) {
@@ -625,8 +651,9 @@ function isContextItem(item: ListEntry) {
}
function attachScrollListener() {
const el = root.value?.closest<HTMLElement>(".floating-window > .card-content");
if (!el) return;
const prompt = root.value?.closest<HTMLElement>(".floating-window > .card-content");
if (!prompt || !root.value) return;
const el = findScrollParents(root.value).find((node) => node !== prompt && prompt.contains(node)) ?? prompt;
el.addEventListener("scroll", handleScroll, { passive: true });
scrollContainer = el;
}
+360 -317
View File
@@ -1,23 +1,28 @@
<template>
<div class="card-content">
<div class="card-content" :class="{ 'embedded-content': embedded }">
<div v-if="loading" class="loading-spinner-wrapper">
<LoadingSpinner size="medium" />
</div>
<template v-else>
<!-- Warning banner for missing path -->
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
<div v-if="!pathExists && baseEntries.length > 0 && !isEditingPath" class="warning-banner">
<i class="material-symbols-outlined">warning</i>
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
<button type="button" class="button button--flat button--blue" @click="startPathReassignment">
{{ $t("messages.reassignPath") }}
</button>
</div>
<div v-if="isEditingPath">
<file-list @update:selected="updateTempPath" :browseSource="sourceName" :showFiles="true"></file-list>
<div v-if="isEditingPath" :class="{ 'embedded-list': embedded }">
<FileList
@update:selected="updateTempPath"
:browseSource="sourceName"
:browsePath="embedded ? currentPath : null"
:hideDestinationSource="true"
:showFiles="true"
></FileList>
</div>
<div v-else>
<PathPickerButton
v-if="!embedded"
class="path-picker"
v-model:path="currentPath"
v-model:source="currentSource"
@@ -27,7 +32,7 @@
:placeholder="$t('sidebar.chooseSource')"
@navigate="onPathPickerNavigate"
/>
<div class="settings-items">
<div v-if="!embedded" class="settings-items">
<ActivityViewerButton class="item" :href="activityViewerHref" />
</div>
<!-- Default behavior banner -->
@@ -38,9 +43,7 @@
}}
<HelpTooltipIcon :text="$t('access.defaultBehaviorDescription')" />
</div>
</div>
<!-- Add Form -->
<div class="form-flex-group">
<ExpandDropdown
v-model="addType"
@@ -70,12 +73,12 @@
</button>
</div>
<!-- Cascade Delete Toggle -->
<div v-if="entries.length > 0" class="cascade-toggle-section">
<ToggleSwitch v-model="cascadeDelete"
<div v-if="entries.length > 0" class="cascade-toggle-section settings-items">
<ToggleSwitch class="item" v-model="cascadeDelete"
:name="$t('access.cascadeDelete')"
:description="$t('access.cascadeDeleteDescription')" />
</div>
<settings-table
<SettingsTable
v-if="entries.length > 0"
:columns="columns"
:items="tableRows"
@@ -87,11 +90,11 @@
<i class="material-symbols">delete</i>
</button>
</template>
</settings-table>
</SettingsTable>
</div>
</template>
</div>
<div class="card-actions">
<div class="card-actions" :class="{ 'embedded-actions': embedded }">
<template v-if="isEditingPath">
<button type="button" class="button button--flat button--grey" @click="cancelPathChange" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
{{ $t("general.cancel") }}
@@ -101,7 +104,7 @@
</button>
</template>
<template v-else>
<button type="button" class="button button--flat button--grey" @click="cancelChanges" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
<button v-if="!embedded" type="button" class="button button--flat button--grey" @click="cancelChanges" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button type="button" class="button button--flat" :disabled="!dirty || saving" @click="saveChanges" :aria-label="$t('general.save')" :title="$t('general.save')">
@@ -111,12 +114,15 @@
</div>
</template>
<script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { notify } from "@/notify";
import { mutations } from "@/store";
import { accessApi } from "@/api";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import FileList from "../files/FileList.vue";
import type { SelectionPayload } from "../files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
@@ -126,309 +132,326 @@ import SettingsTable from "@/components/settings/Table.vue";
import EntityPickerButton from "@/components/settings/EntityPickerButton.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { eventBus } from "@/store/eventBus";
import type { AccessRule, AccessRuleCategory } from "@/store/types.ts";
export default {
name: "access",
components: {
HelpTooltipIcon,
FileList,
ToggleSwitch,
LoadingSpinner,
PathPickerButton,
ExpandDropdown,
ActivityViewerButton,
SettingsTable,
EntityPickerButton,
},
props: {
promptId: { type: [String, Number], default: null },
sourceName: { type: String, required: true },
path: { type: String, required: true, default: "/" }
},
data() {
return {
loading: false,
isEditingPath: false,
isReassigningPath: false,
tempPath: this.path,
currentPath: this.path,
currentSource: this.sourceName,
tempSource: this.sourceName,
originalPath: this.path,
rule: { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } },
sourceDenyDefault: false,
pathExists: true,
addType: "user",
addListType: "deny",
cascadeDelete: false,
/** Staged rule edits; nothing is applied until Save. */
pendingAdds: [],
pendingDeletes: [],
saving: false
};
},
computed: {
addTypeOptions() {
return [
{ value: "user", label: this.$t("general.user") },
{ value: "group", label: this.$t("general.group") },
{ value: "all", label: this.$t("access.all") },
];
},
addListTypeOptions() {
return [
{ value: "deny", label: this.$t("access.deny") },
{ value: "allow", label: this.$t("access.allow") },
];
},
/** Entries persisted on the server (before staged edits). */
baseEntries() {
/** @type {{allow: boolean, type: "user" | "group" | "all", name: string}[]} */
const entries = [];
if (this.rule.denyAll) {
entries.push({ allow: false, type: "all", name: this.$t("access.all") });
}
for (const name of this.rule.deny?.users || []) {
entries.push({ allow: false, type: "user", name });
}
for (const name of this.rule.deny?.groups || []) {
entries.push({ allow: false, type: "group", name });
}
for (const name of this.rule.allow?.users || []) {
entries.push({ allow: true, type: "user", name });
}
for (const name of this.rule.allow?.groups || []) {
entries.push({ allow: true, type: "group", name });
}
return entries;
},
/** What the rule will look like once Save applies the staged edits. */
entries() {
const deleted = new Set(this.pendingDeletes.map(this.opKey));
const base = this.baseEntries.filter(e => !deleted.has(this.opKey({
allow: e.allow,
ruleCategory: e.type,
value: e.type === "all" ? "" : e.name,
})));
const staged = this.pendingAdds.map(op => ({
allow: op.allow,
type: op.ruleCategory,
name: op.ruleCategory === "all" ? this.$t("access.all") : op.value,
}));
return [...base, ...staged];
},
dirty() {
return this.pendingAdds.length > 0 || this.pendingDeletes.length > 0;
},
columns() {
return [
{ key: "allowDeny", label: this.$t("access.allowDeny"), sortable: true },
{ key: "userGroup", label: this.$t("access.userGroup"), sortable: true },
{ key: "name", label: this.$t("general.name"), sortable: true },
{ key: "edit", label: this.$t("general.edit"), narrow: true, align: "right" },
];
},
/** Names already present on the selected allow/deny list for the category (excluded from the picker). */
existingNames() {
return this.entries
.filter((e) => e.type === this.addType && e.allow === (this.addListType === "allow"))
.map((e) => e.name);
},
tableRows() {
return this.entries.map((entry) => ({
id: `${entry.type}-${entry.name}-${entry.allow ? "allow" : "deny"}`,
allowDeny: entry.allow ? this.$t("access.allow") : this.$t("access.deny"),
userGroup: entry.type === "user"
? this.$t("general.user")
: (entry.type === "group" ? this.$t("general.group") : this.$t("access.all")),
name: entry.name,
entry,
}));
},
activityViewerHref() {
return activityViewerPresets.access(this.currentSource, this.currentPath);
},
},
async mounted() {
await this.fetchRule();
},
watch: {
sourceName(newSourceName) {
this.currentSource = newSourceName;
this.tempSource = newSourceName;
this.resetPending();
this.fetchRule();
},
path(newPath) {
this.currentPath = newPath;
this.tempPath = newPath;
this.isEditingPath = false;
this.resetPending();
this.fetchRule();
}
},
methods: {
async onPathPickerNavigate() {
this.resetPending();
await this.fetchRule();
eventBus.emit("accessRulesChanged");
},
/**
* @param {{path: string, source: string}} pathOrData
*/
updateTempPath(pathOrData) {
if (pathOrData?.path) {
this.tempPath = pathOrData.path;
this.tempSource = pathOrData.source;
}
},
async confirmPathChange() {
if (this.isReassigningPath) {
// Reassigning path - call API to update
try {
await accessApi.updatePath(this.currentSource, this.originalPath, this.tempPath);
notify.showSuccessToast(this.$t("messages.pathReassigned"));
this.originalPath = this.tempPath;
this.currentPath = this.tempPath;
this.currentSource = this.tempSource;
this.pathExists = true;
this.isEditingPath = false;
this.isReassigningPath = false;
await this.fetchRule();
// Emit event to refresh access rules list
eventBus.emit('accessRulesChanged');
} catch (e) {
notify.showError(this.$t("messages.pathReassignFailed"));
console.error(e);
}
} else {
// Just viewing a different path
this.currentPath = this.tempPath;
this.currentSource = this.tempSource;
this.isEditingPath = false;
this.resetPending();
await this.fetchRule();
}
},
cancelPathChange() {
this.isEditingPath = false;
this.isReassigningPath = false;
},
startPathReassignment() {
this.isReassigningPath = true;
this.tempPath = this.currentPath;
this.isEditingPath = true;
},
async fetchRule() {
this.loading = true;
try {
const response = await accessApi.get(this.currentSource, this.currentPath);
// Handle new API response structure - now sourceDenyDefault is part of the rule
this.rule = response;
this.sourceDenyDefault = response.sourceDenyDefault || false;
this.pathExists = response.pathExists !== false;
} catch (_e) {
this.rule = { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } };
this.sourceDenyDefault = false;
this.pathExists = true;
} finally {
this.loading = false;
}
},
resetPending() {
this.pendingAdds = [];
this.pendingDeletes = [];
},
/** Stable key for matching entries to staged ops. */
opKey(op) {
return `${op.allow ? "allow" : "deny"}|${op.ruleCategory}|${op.value}`;
},
/**
* Stages a delete. Pending adds are simply un-staged; persisted
* entries get a delete op applied on Save.
* @param {{allow: boolean, type: string, name: string}} entry
*/
deleteAccess(entry) {
const op = {
allow: entry.allow,
ruleCategory: entry.type,
value: entry.type === 'all' ? '' : entry.name,
cascade: this.cascadeDelete && entry.type !== 'all'
};
const pendingIdx = this.pendingAdds.findIndex(a => this.opKey(a) === this.opKey(op));
if (pendingIdx !== -1) {
this.pendingAdds.splice(pendingIdx, 1);
return;
}
this.pendingDeletes.push(op);
},
/**
* Stages rule entries. `names` are picked users/groups; for the 'all'
* category an empty list stages the single deny-all entry.
* @param {string[]} names
*/
addEntries(names) {
const values = this.addType === 'all' ? [''] : names;
for (const value of values) {
const op = {
allow: this.addListType === 'allow' && this.addType !== 'all',
ruleCategory: this.addType,
value
};
// Re-adding a staged-for-delete entry just cancels the delete.
const delIdx = this.pendingDeletes.findIndex(d => this.opKey(d) === this.opKey(op));
if (delIdx !== -1) {
this.pendingDeletes.splice(delIdx, 1);
continue;
}
if (this.pendingAdds.some(a => this.opKey(a) === this.opKey(op))) {
continue;
}
// Same name on the opposite list (allow vs deny) is allowed by the backend.
this.pendingAdds.push(op);
}
},
/** Applies staged deletes then adds; keeps unapplied ops on failure. */
async saveChanges() {
if (this.saving) return;
this.saving = true;
try {
for (const op of [...this.pendingDeletes]) {
await accessApi.del(this.currentSource, this.currentPath, {
allow: op.allow,
ruleCategory: op.ruleCategory,
value: op.value,
cascade: op.cascade
});
this.pendingDeletes.splice(this.pendingDeletes.indexOf(op), 1);
}
for (const op of [...this.pendingAdds]) {
await accessApi.add(this.currentSource, this.currentPath, {
allow: op.allow,
ruleCategory: op.ruleCategory,
value: op.value
});
this.pendingAdds.splice(this.pendingAdds.indexOf(op), 1);
}
} catch (e) {
notify.showError(e);
console.error(e);
}
await this.fetchRule();
// Emit event to refresh access rules list
eventBus.emit('accessRulesChanged');
this.saving = false;
if (!this.dirty) {
mutations.closeTopPrompt(this.promptId ?? undefined);
}
},
cancelChanges() {
this.pendingAdds = [];
this.pendingDeletes = [];
mutations.closeTopPrompt(this.promptId ?? undefined);
},
type AccessListType = "allow" | "deny";
interface AccessEntry {
allow: boolean;
type: AccessRuleCategory;
name: string;
}
interface AccessOp {
allow: boolean;
ruleCategory: AccessRuleCategory;
value: string;
cascade?: boolean;
}
interface TableColumn {
key: string;
label: string;
sortable?: boolean;
narrow?: boolean;
align?: "left" | "center" | "right";
}
const props = withDefaults(
defineProps<{
promptId?: number | null;
sourceName: string;
path?: string;
embedded?: boolean;
}>(),
{
promptId: null,
path: "/",
embedded: false,
}
};
);
const { t } = useI18n();
function emptyRule(): AccessRule {
return {
denyAll: false,
deny: { users: [], groups: [] },
allow: { users: [], groups: [] },
sourceDenyDefault: false,
pathExists: true,
};
}
const loading = ref(false);
const isEditingPath = ref(false);
const isReassigningPath = ref(false);
const tempPath = ref(props.path);
const currentPath = ref(props.path);
const currentSource = ref(props.sourceName);
const tempSource = ref(props.sourceName);
const originalPath = ref(props.path);
const rule = ref<AccessRule>(emptyRule());
const addType = ref<AccessRuleCategory>("user");
const addListType = ref<AccessListType>("deny");
const cascadeDelete = ref(false);
const pendingAdds = ref<AccessOp[]>([]);
const pendingDeletes = ref<AccessOp[]>([]);
const saving = ref(false);
const addTypeOptions = computed<{ value: AccessRuleCategory; label: string }[]>(() => [
{ value: "user", label: t("general.user") },
{ value: "group", label: t("general.group") },
{ value: "all", label: t("access.all") },
]);
const addListTypeOptions = computed<{ value: AccessListType; label: string }[]>(() => [
{ value: "deny", label: t("access.deny") },
{ value: "allow", label: t("access.allow") },
]);
/** Entries persisted on the server (before staged edits). */
const baseEntries = computed(() => {
const entries: AccessEntry[] = [];
if (rule.value.denyAll) {
entries.push({ allow: false, type: "all", name: t("access.all") });
}
for (const name of rule.value.deny.users) {
entries.push({ allow: false, type: "user", name });
}
for (const name of rule.value.deny.groups) {
entries.push({ allow: false, type: "group", name });
}
for (const name of rule.value.allow.users) {
entries.push({ allow: true, type: "user", name });
}
for (const name of rule.value.allow.groups) {
entries.push({ allow: true, type: "group", name });
}
return entries;
});
/** What the rule will look like once Save applies the staged edits. */
const entries = computed(() => {
const deleted = new Set(pendingDeletes.value.map(opKey));
const base = baseEntries.value.filter(e => !deleted.has(opKey({
allow: e.allow,
ruleCategory: e.type,
value: e.type === "all" ? "" : e.name,
})));
const staged = pendingAdds.value.map((op): AccessEntry => ({
allow: op.allow,
type: op.ruleCategory,
name: op.ruleCategory === "all" ? t("access.all") : op.value,
}));
return [...base, ...staged];
});
const sourceDenyDefault = computed(() => rule.value.sourceDenyDefault);
const pathExists = computed(() => rule.value.pathExists);
const dirty = computed(() => pendingAdds.value.length > 0 || pendingDeletes.value.length > 0);
const columns = computed<TableColumn[]>(() => [
{ key: "allowDeny", label: t("access.allowDeny"), sortable: true },
{ key: "userGroup", label: t("access.userGroup"), sortable: true },
{ key: "name", label: t("general.name"), sortable: true },
{ key: "edit", label: t("general.edit"), narrow: true, align: "right" },
]);
/** Names already present on the selected allow/deny list for the category (excluded from the picker). */
const existingNames = computed(() =>
entries.value
.filter((e) => e.type === addType.value && e.allow === (addListType.value === "allow"))
.map((e) => e.name)
);
const tableRows = computed(() =>
entries.value.map((entry) => ({
id: `${entry.type}-${entry.name}-${entry.allow ? "allow" : "deny"}`,
allowDeny: entry.allow ? t("access.allow") : t("access.deny"),
userGroup: entry.type === "user"
? t("general.user")
: (entry.type === "group" ? t("general.group") : t("access.all")),
name: entry.name,
entry,
}))
);
const activityViewerHref = computed(() => activityViewerPresets.access(currentSource.value, currentPath.value));
onMounted(fetchRule);
watch(() => props.sourceName, (newSourceName) => {
currentSource.value = newSourceName;
tempSource.value = newSourceName;
resetPending();
fetchRule();
});
watch(() => props.path, (newPath) => {
currentPath.value = newPath;
tempPath.value = newPath;
isEditingPath.value = false;
resetPending();
fetchRule();
});
async function onPathPickerNavigate() {
resetPending();
await fetchRule();
eventBus.emit("accessRulesChanged");
}
function updateTempPath(payload: SelectionPayload) {
if (payload.path) {
tempPath.value = payload.path;
tempSource.value = payload.source ?? tempSource.value;
}
}
async function confirmPathChange() {
if (isReassigningPath.value) {
// Reassigning path - call API to update
try {
await accessApi.updatePath(currentSource.value, originalPath.value, tempPath.value);
notify.showSuccessToast(t("messages.pathReassigned"));
originalPath.value = tempPath.value;
currentPath.value = tempPath.value;
currentSource.value = tempSource.value;
isEditingPath.value = false;
isReassigningPath.value = false;
await fetchRule();
// Emit event to refresh access rules list
eventBus.emit("accessRulesChanged");
} catch (e) {
notify.showError(t("messages.pathReassignFailed"));
console.error(e);
}
} else {
// Just viewing a different path
currentPath.value = tempPath.value;
currentSource.value = tempSource.value;
isEditingPath.value = false;
resetPending();
await fetchRule();
}
}
function cancelPathChange() {
isEditingPath.value = false;
isReassigningPath.value = false;
}
function startPathReassignment() {
isReassigningPath.value = true;
tempPath.value = currentPath.value;
isEditingPath.value = true;
}
async function fetchRule() {
loading.value = true;
try {
const response: AccessRule = await accessApi.get(currentSource.value, currentPath.value);
// Handle new API response structure - now sourceDenyDefault is part of the rule
rule.value = response;
} catch {
rule.value = emptyRule();
} finally {
loading.value = false;
}
}
function resetPending() {
pendingAdds.value = [];
pendingDeletes.value = [];
}
/** Stable key for matching entries to staged ops. */
function opKey(op: Pick<AccessOp, "allow" | "ruleCategory" | "value">) {
return `${op.allow ? "allow" : "deny"}|${op.ruleCategory}|${op.value}`;
}
/**
* Stages a delete. Pending adds are simply un-staged; persisted
* entries get a delete op applied on Save.
*/
function deleteAccess(entry: AccessEntry) {
const op: AccessOp = {
allow: entry.allow,
ruleCategory: entry.type,
value: entry.type === "all" ? "" : entry.name,
cascade: cascadeDelete.value && entry.type !== "all"
};
const pendingIdx = pendingAdds.value.findIndex(a => opKey(a) === opKey(op));
if (pendingIdx !== -1) {
pendingAdds.value.splice(pendingIdx, 1);
return;
}
pendingDeletes.value.push(op);
}
/**
* Stages rule entries. `names` are picked users/groups; for the 'all'
* category an empty list stages the single deny-all entry.
*/
function addEntries(names: string[]) {
const values = addType.value === "all" ? [""] : names;
for (const value of values) {
const op: AccessOp = {
allow: addListType.value === "allow" && addType.value !== "all",
ruleCategory: addType.value,
value
};
// Re-adding a staged-for-delete entry just cancels the delete.
const delIdx = pendingDeletes.value.findIndex(d => opKey(d) === opKey(op));
if (delIdx !== -1) {
pendingDeletes.value.splice(delIdx, 1);
continue;
}
if (pendingAdds.value.some(a => opKey(a) === opKey(op))) {
continue;
}
// Same name on the opposite list (allow vs deny) is allowed by the backend.
pendingAdds.value.push(op);
}
}
/** Applies staged deletes then adds; keeps unapplied ops on failure. */
async function saveChanges() {
if (saving.value) return;
saving.value = true;
let failed = false;
try {
for (const op of [...pendingDeletes.value]) {
await accessApi.del(currentSource.value, currentPath.value, {
allow: op.allow,
ruleCategory: op.ruleCategory,
value: op.value,
cascade: op.cascade
});
pendingDeletes.value.splice(pendingDeletes.value.indexOf(op), 1);
}
for (const op of [...pendingAdds.value]) {
await accessApi.add(currentSource.value, currentPath.value, {
allow: op.allow,
ruleCategory: op.ruleCategory,
value: op.value
});
pendingAdds.value.splice(pendingAdds.value.indexOf(op), 1);
}
} catch (e) {
failed = true;
notify.showError(e);
console.error(e);
}
await fetchRule();
// Emit event to refresh access rules list
eventBus.emit("accessRulesChanged");
saving.value = false;
if (!failed && props.embedded) {
notify.showSuccessToast(t("settings.settingsUpdated"));
}
if (!dirty.value && !props.embedded) {
mutations.closeTopPrompt(props.promptId ?? undefined);
}
}
function cancelChanges() {
resetPending();
mutations.closeTopPrompt(props.promptId ?? undefined);
}
</script>
<style scoped>
@@ -467,6 +490,26 @@ export default {
margin-bottom: 1em;
}
.embedded-content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.embedded-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.embedded-actions {
display: flex;
justify-content: flex-end;
gap: 0.25em;
}
.loading-spinner-wrapper {
display: flex;
align-items: center;
+323 -323
View File
@@ -1,18 +1,13 @@
<template>
<div class="card-content info-content">
<div class="settings-items">
<ActivityViewerButton
v-if="source && !isShare"
:href="activityViewerHref"
/>
<div class="card-content no-buttons prompt-panel info-content">
<div class="info-tabs">
<SlidingTabs :model-value="activeTab" :tabs="tabs" @update:model-value="activeTab = $event as InfoTab" />
</div>
<div class="info-grid">
<!-- Basic Information Section -->
<div class="info-section">
<h3 class="section-title">{{ $t("prompts.basicInfo") }}</h3>
<div class="info-grid" :class="{ 'info-grid--fill': activeTab === 'access' }">
<div class="info-section" v-if="activeTab === 'basic'">
<div class="info-item">
<strong>{{ $t("prompts.displayName") }}</strong>
<span role="group" aria-label="info display name">{{ displayName }}</span>
<span role="group" aria-label="info display name">{{ item.name }}</span>
</div>
<div class="info-item">
<strong>{{ $t("general.size") }}</strong>
@@ -20,7 +15,7 @@
</div>
<div class="info-item">
<strong>{{ $t("general.type") }}</strong>
<span role="group" aria-label="info type">{{ type }}</span>
<span role="group" aria-label="info type">{{ item.type }}</span>
</div>
<div class="info-item" v-if="humanTime">
<strong>{{ $t("files.lastModified") }}</strong>
@@ -38,23 +33,54 @@
<strong>{{ $t("general.path") }}</strong>
<span role="group" aria-label="info path" class="break-word">{{ filePath }}</span>
</div>
<div class="info-item" v-if="hidden !== undefined">
<div class="info-item" v-if="item.hidden !== undefined">
<strong>{{ $t("prompts.hidden") }}</strong>
<span role="group" aria-label="info hidden">{{ hidden ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span role="group" aria-label="info hidden">{{ item.hidden ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="info-item" v-if="hasPreview !== undefined">
<div class="info-item" v-if="item.hasPreview !== undefined">
<strong>{{ $t("prompts.hasPreview") }}</strong>
<span role="group" aria-label="info has preview">{{ hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span role="group" aria-label="info has preview">{{ item.hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="settings-items" v-if="source && !isShare">
<ActivityViewerButton :href="activityViewerHref" />
</div>
</div>
<!-- Storage quota (admin, folders) -->
<div v-if="showQuotaSection" class="info-section">
<h3 class="section-title">{{ $t("quotas.title") }}</h3>
<!-- Media metadata -->
<div class="info-section" v-else-if="activeTab === 'media'">
<div class="info-item" v-for="info in mediaInfo" :key="info.key">
<strong>{{ info.label }}</strong>
<span>{{ info.value }}</span>
</div>
<!-- Lyrics -->
<div class="info-item" v-if="hasLyrics">
<strong>{{ $t("player.lyrics") }}</strong>
<span>
<a class="info-toggle" role="button" tabindex="0" @click="toggleLyrics" @keydown.enter="toggleLyrics">
{{ showLyrics ? $t("general.hide") : $t("general.show") }}
</a>
<span v-if="showLyrics" class="info-multiline">{{ lyricsText }}</span>
</span>
</div>
<!-- Subtitles -->
<div class="info-item" v-for="(track, idx) in subtitleTracks" :key="track.name">
<strong>{{ $t("prompts.subtitle", { n: idx + 1 }) }}</strong>
<span>
{{ subtitleLabel(track) }}
<a class="info-toggle" role="button" tabindex="0" @click="toggleSubtitle(track)" @keydown.enter="toggleSubtitle(track)">
{{ subtitleContent[track.name] !== undefined ? $t("general.hide") : $t("general.show") }}
</a>
<span v-if="subtitleContent[track.name] !== undefined" class="info-multiline">{{ subtitleContent[track.name] }}</span>
</span>
</div>
<div class="info-item" v-if="!showMediaContent">
<span>{{ $t("general.none") }}</span>
</div>
</div>
<div v-else-if="activeTab === 'quota'" class="info-section">
<div class="settings-items">
<SettingsButton
class="info-manage-link"
:name="$t('quotas.title')"
:name="t('quotas.title')"
:description="$t('quotas.openDescription')"
@click="openQuotaPrompt"
/>
@@ -65,7 +91,6 @@
</div>
<div v-if="quotaEnabled" class="info-quota-usage">
<QuotaFolderBar
class="info-quota-bar"
:source="source"
:path="filePath"
:enabled="true"
@@ -74,43 +99,13 @@
/>
</div>
</div>
<!-- Access rules (admin) -->
<div v-if="showAccessSection" class="info-section settings-items">
<h3 class="section-title">{{ $t("access.rules") }}</h3>
<SettingsButton
class="info-manage-link"
:name="$t('access.accessManagement')"
:description="$t('access.manageDescription')"
@click="openAccessPrompt"
/>
<div class="info-item">
<strong>{{ $t("access.hasRules") }}</strong>
<span>{{ hasAccessRules ? $t("general.yes") : $t("general.no") }}</span>
</div>
<div v-if="accessRuleEntries.length" class="access-rules-list">
<div
v-for="entry in accessRuleEntries"
:key="accessEntryKey(entry)"
class="info-item access-rule-entry"
>
<strong>{{ entry.allow ? $t("access.allow") : $t("access.deny") }}</strong>
<span>{{ accessEntryLabel(entry) }}</span>
</div>
</div>
</div>
<!-- Additional Information Section -->
<div class="info-section" v-if="additionalInfo.length > 0">
<h3 class="section-title">{{ $t("prompts.additionalInfo") }}</h3>
<div class="info-section" v-else-if="activeTab === 'additional'">
<div class="info-item" v-for="info in additionalInfo" :key="info.key">
<strong>{{ info.label }}</strong>
<span>{{ info.value }}</span>
</div>
</div>
<!-- Hash Generator Section -->
<div class="info-section" v-if="!dir">
<h3 class="section-title">{{ $t("prompts.checksums") }}</h3>
<div class="info-section" v-else-if="activeTab === 'checksums'">
<div class="hash-generator">
<div class="hash-select">
<label for="hash-algo">{{ $t("prompts.hashAlgorithm") }}</label>
@@ -128,7 +123,6 @@
</button>
</div>
</div>
<div class="hash-result">
<label for="hash-result">{{ $t("prompts.hashValue") }}</label>
<div class="form-flex-group">
@@ -137,7 +131,7 @@
<button
type="button"
class="button form-button flat-left"
@click="copyToClipboard"
@click="copyHash"
:disabled="!hashResult"
:title="$t('buttons.copyToClipboard')"
:aria-label="$t('buttons.copyToClipboard')"
@@ -148,271 +142,293 @@
</div>
</div>
</div>
<div v-if="accessVisited" v-show="activeTab === 'access'" class="info-section">
<Access embedded :source-name="source" :path="filePath" />
</div>
</div>
</div>
</template>
<script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { copyToClipboard } from "@/utils/clipboard";
import { resourcesApi, quotasApi, accessApi } from "@/api";
import { formatDuration } from "@/utils/files";
import { resourcesApi, quotasApi, mediaApi } from "@/api";
import { getters, mutations, state } from "@/store";
import type { FileListItem, QuotaSnapshot, SubtitleTrack } from "@/store/types.ts";
import { notify } from "@/notify";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import SettingsButton from "@/components/settings/SettingsButton.vue";
import QuotaFolderBar from "@/components/prompts/QuotaFolderBar.vue";
import Access from "@/components/prompts/Access.vue";
export default {
name: "info",
components: {
ExpandDropdown,
ActivityViewerButton,
SettingsButton,
QuotaFolderBar,
},
props: {
promptId: {
type: [String, Number],
default: null,
},
item: {
type: Object,
required: true,
},
},
data() {
return {
selectedHashAlgo: "md5",
hashResult: "",
generatingHash: false,
quotaSnapshot: {},
quotaExists: false,
accessRule: {
denyAll: false,
deny: { users: [], groups: [] },
allow: { users: [], groups: [] },
},
};
},
async mounted() {
if (this.showQuotaSection) {
await this.loadQuota();
type InfoTab = "basic" | "media" | "quota" | "access" | "additional" | "checksums";
type HashAlgo = "md5" | "sha1" | "sha256" | "sha512";
interface InfoItem extends FileListItem {
subtitles?: SubtitleTrack[];
token?: string;
hash?: string;
onlyOfficeId?: string;
}
interface InfoRow {
key: string;
label: string;
value: string | number;
}
const props = defineProps<{
item: InfoItem;
promptId?: string | number | null;
}>();
const { t } = useI18n();
const hashAlgoOptions: { value: HashAlgo; label: string }[] = [
{ value: "md5", label: "MD5" },
{ value: "sha1", label: "SHA1" },
{ value: "sha256", label: "SHA256" },
{ value: "sha512", label: "SHA512" },
];
const activeTab = ref<InfoTab>("basic");
const accessVisited = ref(false);
const selectedHashAlgo = ref<HashAlgo>("md5");
const hashResult = ref("");
const generatingHash = ref(false);
const quotaSnapshot = ref<QuotaSnapshot>({});
const quotaExists = ref(false);
const lyricsText = ref("");
const lyricsLoading = ref(false);
const showLyrics = ref(false);
// Subtitle content by track name
const subtitleContent = ref<Record<string, string>>({});
// Listing items carry no subtitles, they're fetched when the tab opens
const mediaDetails = ref<InfoItem | null>(null);
let mediaRequested = false;
const isShare = getters.isShare();
const source = computed(() => props.item.source || "");
const filePath = computed(() => props.item.path || "");
const isDir = computed(() => props.item.type === "directory");
const isAudio = computed(() => !!props.item.type?.startsWith("audio"));
const isVideo = computed(() => !!props.item.type?.startsWith("video"));
const isAdmin = computed(() => getters.isAdmin());
const humanSize = computed(() => getHumanReadableFilesize(props.item.size || 0));
const humanTime = computed(() => formatDate(props.item.modified));
const modTime = computed(() => relativeDate(props.item.modified));
const humanCreatedTime = computed(() => formatDate(props.item.created));
const createdTime = computed(() => relativeDate(props.item.created));
const activityViewerHref = computed(() => activityViewerPresets.files(source.value, filePath.value));
const additionalInfo = computed<InfoRow[]>(() => {
const { token, hash, onlyOfficeId } = props.item;
const rows: [string, string, string | undefined][] = [
["token", t("prompts.token"), token],
["hash", t("general.hash"), hash],
["onlyOfficeId", t("prompts.onlyOfficeId"), onlyOfficeId],
];
return rows.flatMap(([key, label, value]) => (value ? [{ key, label, value }] : []));
});
// the fresh response wins over the (older) listing metadata
const mediaMetadata = computed(() => mediaDetails.value?.metadata || props.item.metadata);
const hasLyrics = computed(() => isAudio.value && !!mediaMetadata.value?.hasLyrics);
const subtitleTracks = computed<SubtitleTrack[]>(() =>
isVideo.value ? props.item.subtitles || mediaDetails.value?.subtitles || [] : []
);
const mediaInfo = computed<InfoRow[]>(() => {
const meta = mediaMetadata.value;
if (!meta || !(isAudio.value || isVideo.value)) return [];
const rows: [string, string, string | number | undefined][] = [
["title", t("prompts.mediaTitle"), meta.title],
["artist", t("prompts.artist"), meta.artist],
["album", t("prompts.album"), meta.album],
["year", t("prompts.year"), meta.year],
["genre", t("prompts.genre"), meta.genre],
["track", t("prompts.track"), meta.track],
["duration", t("files.duration"), meta.duration ? formatDuration(meta.duration) : ""],
["container", t("prompts.container"), meta.container],
["videoCodec", t("prompts.videoCodec"), meta.videoCodec],
["audioCodec", t("prompts.audioCodec"), meta.audioCodec],
];
return rows.flatMap(([key, label, value]) => (value ? [{ key, label, value }] : []));
});
const showMediaContent = computed(
() => mediaInfo.value.length > 0 || hasLyrics.value || subtitleTracks.value.length > 0
);
const showQuotaSection = computed(
() => isAdmin.value && !isShare && isDir.value && !!source.value && !!filePath.value
);
const showAccessSection = computed(() => isAdmin.value && !isShare && !!source.value && !!filePath.value);
const quotaLimitBytes = computed(() => quotaSnapshot.value.limitBytes || 0);
const quotaEnabled = computed(() => quotaExists.value && quotaLimitBytes.value > 0);
const tabs = computed(() => {
const list: { id: InfoTab; label: string }[] = [{ id: "basic", label: t("prompts.basicInfo") }];
if (isAudio.value || isVideo.value) list.push({ id: "media", label: t("prompts.mediaInfo") });
if (showQuotaSection.value) list.push({ id: "quota", label: t("quotas.title") });
if (showAccessSection.value) list.push({ id: "access", label: t("access.rules") });
if (additionalInfo.value.length > 0) list.push({ id: "additional", label: t("prompts.additionalInfo") });
if (!isDir.value) list.push({ id: "checksums", label: t("prompts.checksums") });
return list;
});
watch(activeTab, (tab) => {
if (tab === "access") accessVisited.value = true;
if (tab === "media" && !mediaRequested) {
mediaRequested = true;
void loadMediaDetails();
}
});
onMounted(async () => {
if (showQuotaSection.value) await loadQuota();
});
function formatDate(date?: string) {
return date ? formatTimestamp(date, state.user.locale) : "";
}
function relativeDate(date?: string) {
return date ? fromNow(date, state.user.locale) : "";
}
async function loadMediaDetails() {
const { item } = props;
if (!(isAudio.value || isVideo.value)) return;
if (item.metadata && (!isVideo.value || item.subtitles)) return;
try {
const data = (await mediaApi.getItemMediaMetadata(filePath.value, { source: source.value })) as InfoItem;
if (data && data.type !== "directory") {
mediaDetails.value = data;
}
if (this.showAccessSection) {
await this.loadAccessRules();
} catch (err) {
console.warn("Failed to fetch media details:", err);
}
}
// Raw lyrics (sidecar or embedded) are fetched on first show
async function toggleLyrics() {
if (lyricsLoading.value) return;
if (showLyrics.value) {
showLyrics.value = false;
return;
}
if (!lyricsText.value) {
lyricsLoading.value = true;
try {
const { lyrics } = await mediaApi.getLyrics(source.value, filePath.value);
lyricsText.value = lyrics;
} catch (err) {
console.warn("Failed to fetch lyrics:", err);
} finally {
lyricsLoading.value = false;
}
},
computed: {
hashAlgoOptions() {
return [
{ value: "md5", label: "MD5" },
{ value: "sha1", label: "SHA1" },
{ value: "sha256", label: "SHA256" },
{ value: "sha512", label: "SHA512" },
];
},
humanSize() {
return getHumanReadableFilesize(this.item?.size || 0);
},
humanTime() {
if (!this.item?.modified) return "";
return formatTimestamp(this.item.modified, state.user.locale);
},
modTime() {
if (!this.item?.modified) return "";
return fromNow(this.item.modified, state.user.locale);
},
humanCreatedTime() {
if (!this.item?.created) return "";
return formatTimestamp(this.item.created, state.user.locale);
},
createdTime() {
if (!this.item?.created) return "";
return fromNow(this.item.created, state.user.locale);
},
name() {
return this.item?.name || "";
},
type() {
return this.item?.type || "";
},
displayName() {
return this.item?.name || "";
},
dir() {
return this.item?.type === "directory";
},
source() {
return this.item?.source || "";
},
isShare() {
return getters.isShare();
},
filePath() {
return this.item?.path || "";
},
activityViewerHref() {
return activityViewerPresets.files(this.source, this.filePath);
},
hidden() {
return this.item?.hidden;
},
hasPreview() {
return this.item?.hasPreview;
},
additionalInfo() {
const info = [];
}
showLyrics.value = !!lyricsText.value;
}
if (this.item?.token) {
info.push({ key: "token", label: this.$t("prompts.token"), value: this.item.token });
}
if (this.item?.hash) {
info.push({ key: "hash", label: this.$t("general.hash"), value: this.item.hash });
}
if (this.item?.onlyOfficeId) {
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: this.item.onlyOfficeId });
}
function subtitleLabel(track: SubtitleTrack) {
const extras = [track.language, track.codec].filter(Boolean).join(", ");
return track.embedded ? extras : [track.name, extras && `(${extras})`].filter(Boolean).join(" ");
}
return info;
},
isAdmin() {
return getters.isAdmin();
},
showQuotaSection() {
return this.isAdmin && !this.isShare && this.dir && this.source && this.filePath;
},
showAccessSection() {
return this.isAdmin && !this.isShare && this.source && this.filePath;
},
quotaEnabled() {
return this.quotaExists && (this.quotaSnapshot.limitBytes || 0) > 0;
},
quotaLimitBytes() {
return this.quotaSnapshot.limitBytes || 0;
},
accessRuleEntries() {
/** @type {{allow: boolean, type: "user" | "group" | "all", name: string}[]} */
const entries = [];
if (this.accessRule.denyAll) {
entries.push({ allow: false, type: "all", name: this.$t("access.all") });
}
for (const name of this.accessRule.deny?.users || []) {
entries.push({ allow: false, type: "user", name });
}
for (const name of this.accessRule.deny?.groups || []) {
entries.push({ allow: false, type: "group", name });
}
for (const name of this.accessRule.allow?.users || []) {
entries.push({ allow: true, type: "user", name });
}
for (const name of this.accessRule.allow?.groups || []) {
entries.push({ allow: true, type: "group", name });
}
return entries;
},
hasAccessRules() {
return this.accessRuleEntries.length > 0;
},
},
methods: {
async loadQuota() {
try {
const raw = await quotasApi.get(this.source, this.filePath);
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data) return;
this.quotaSnapshot = {
usedBytes: data.usedBytes,
reservedBytes: data.reservedBytes,
measurementStatus: data.measurementStatus,
limitBytes: data.limitBytes,
};
if ((data.limitBytes || 0) > 0) {
this.quotaExists = true;
}
} catch {
// no quota or preview unavailable
}
},
async loadAccessRules() {
try {
const response = await accessApi.get(this.source, this.filePath);
this.accessRule = response;
} catch {
this.accessRule = {
denyAll: false,
deny: { users: [], groups: [] },
allow: { users: [], groups: [] },
};
}
},
openQuotaPrompt() {
mutations.showPrompt({
name: "Quota",
props: {
item: this.item,
source: this.source,
path: this.filePath,
},
});
},
openAccessPrompt() {
mutations.showPrompt({
name: "access",
props: {
sourceName: this.source,
path: this.filePath,
},
});
},
accessEntryKey(entry) {
return `${entry.type}-${entry.name}-${entry.allow}`;
},
accessEntryLabel(entry) {
if (entry.type === "user") {
return `${this.$t("general.user")}: ${entry.name}`;
}
if (entry.type === "group") {
return `${this.$t("general.group")}: ${entry.name}`;
}
return entry.name;
},
async generateHash() {
if (this.generatingHash || !this.item) return;
// Raw subtitle content are fetched on first show
async function toggleSubtitle(track: SubtitleTrack) {
if (track.name in subtitleContent.value) {
delete subtitleContent.value[track.name];
return;
}
try {
const content: string = await mediaApi.getSubtitleContent(source.value, filePath.value, track.name, track.embedded);
if (content) {
subtitleContent.value[track.name] = content;
}
} catch (err) {
console.warn("Failed to fetch subtitle:", err);
}
}
this.hashResult = "";
this.generatingHash = true;
async function loadQuota() {
try {
const raw = await quotasApi.get(source.value, filePath.value);
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data) return;
const { usedBytes, reservedBytes, measurementStatus, limitBytes } = data;
quotaSnapshot.value = { usedBytes, reservedBytes, measurementStatus, limitBytes };
quotaExists.value = (limitBytes || 0) > 0;
} catch {
// no quota or preview unavailable
}
}
try {
const source = this.item.source;
const path = this.item.path;
function openQuotaPrompt() {
mutations.showPrompt({
name: "Quota",
props: { item: props.item, source: source.value, path: filePath.value },
});
}
const hash = await resourcesApi.checksum(source, path, this.selectedHashAlgo);
this.hashResult = hash;
} catch (err) {
this.hashResult = this.$t("prompts.errorGeneratingHash");
const errorMessage = err instanceof Error ? err.message : "Error generating hash";
notify.showError(errorMessage);
} finally {
this.generatingHash = false;
}
},
async copyToClipboard() {
if (!this.hashResult) return;
await copyToClipboard(this.hashResult);
},
},
};
async function generateHash() {
if (generatingHash.value) return;
hashResult.value = "";
generatingHash.value = true;
try {
hashResult.value = (await resourcesApi.checksum(source.value, filePath.value, selectedHashAlgo.value)) as string;
} catch (err) {
hashResult.value = t("prompts.errorGeneratingHash");
notify.showError(err instanceof Error ? err.message : "Error generating hash");
} finally {
generatingHash.value = false;
}
}
async function copyHash() {
if (hashResult.value) await copyToClipboard(hashResult.value);
}
</script>
<style scoped>
.info-content {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.info-tabs {
flex-shrink: 0;
padding: 0 0.5em 0.5em;
border-bottom: 1px solid var(--divider);
}
.info-toggle {
color: var(--primaryColor);
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
}
.info-toggle:hover {
text-decoration: underline;
}
.info-multiline {
display: block;
margin-top: 0.5em;
max-height: 16em;
overflow: auto;
white-space: pre-wrap;
user-select: text;
}
.info-manage-link {
@@ -423,27 +439,18 @@ export default {
padding: 0.5em;
}
.access-rules-list {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.access-rule-entry strong {
min-width: 72px;
}
.info-description {
margin-bottom: 1.5em;
color: var(--textSecondary);
line-height: 1.5;
text-align: center;
}
.info-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
gap: 1.5em;
flex: 1;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.info-grid--fill {
grid-template-rows: minmax(0, 1fr);
}
.info-section {
@@ -452,14 +459,8 @@ export default {
gap: 0.5em;
}
.section-title {
font-size: 0.95em;
font-weight: 600;
color: var(--textPrimary);
margin: 0 0 0.75em;
padding-bottom: 0.5em;
padding-top: 0.5em;
border-bottom: 1px solid var(--divider);
.info-grid--fill > .info-section {
min-height: 0;
}
.info-item {
@@ -515,10 +516,9 @@ export default {
font-family: monospace;
}
/* Responsive adjustments */
@media (width <= 768px) {
.info-grid {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.info-item strong {
min-width: 100px;
@@ -73,7 +73,6 @@
</div>
</div>
</div>
<!-- Empty state -->
<div v-else class="empty">
<i class="material-symbols">queue_music</i>
@@ -82,33 +81,7 @@
</div>
<div class="card-actions">
<div class="mode-buttons">
<div class="mode-indicator" :style="modeIndicatorStyle"></div>
<input
type="radio"
id="mode-sequential"
name="playback-mode"
value="sequential"
:checked="playbackMode === 'sequential'"
@change="setMode('sequential')"
hidden
/>
<label for="mode-sequential" class="mode-btn" :class="{ active: playbackMode === 'sequential' }" :title="$t('player.PlayAllOncePlayback')">
<i class="material-symbols">playlist_play</i>
</label>
<input
type="radio"
id="mode-shuffle"
name="playback-mode"
value="shuffle"
:checked="playbackMode === 'shuffle'"
@change="setMode('shuffle')"
hidden
/>
<label for="mode-shuffle" class="mode-btn" :class="{ active: playbackMode === 'shuffle' }" :title="$t('player.ShuffleAllPlayback')">
<i class="material-symbols">shuffle</i>
</label>
</div>
<SlidingTabs icon-only :tabs="modeTabs" :model-value="playbackMode" @update:model-value="setMode" />
<button
type="button"
class="repeat-one-btn"
@@ -140,6 +113,7 @@ import {
formatArtist
} from '@/utils/playbackQueue.js';
import Icon from "@/components/files/Icon.vue";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import { resourcesApi } from "@/api";
import { globalVars } from "@/utils/constants";
import { formatDuration, getTypeFromMime, removeExtension } from "@/utils/files.js";
@@ -192,14 +166,10 @@ const loopIcon = computed(() => getLoopIcon(loop.value));
const loopLabel = computed(() => getLoopLabel(loop.value, t));
const isPlaying = computed(() => state.playbackQueue.isPlaying || false);
const isPromptVisible = computed(() => state.prompts.some(prompt => prompt.name === 'PlaybackQueue'));
const modeIndicatorStyle = computed(() => {
const modes = ['sequential', 'shuffle'];
const index = modes.indexOf(playbackMode.value);
if (index === -1) return {};
const width = `${100 / modes.length}%`;
const left = `${index * (100 / modes.length)}%`;
return { left, width };
});
const modeTabs = computed(() => [
{ id: 'sequential', icon: 'playlist_play', title: t('player.PlayAllOncePlayback') },
{ id: 'shuffle', icon: 'shuffle', title: t('player.ShuffleAllPlayback') },
]);
// Only the rendered rows are formatted, with their position in the queue
const visibleQueue = computed(() =>
@@ -302,7 +272,7 @@ function getThumbnailUrl(item: QueueItem) {
return resourcesApi.getPreviewURL(source, path, item.modified);
}
function setMode(mode: 'sequential' | 'shuffle') {
function setMode(mode: string) {
if (mode === playbackMode.value) return;
const listing = state.navigation.listing || state.req?.parentDirItems || [];
cyclePlaybackModes(playbackMode.value, {
@@ -549,7 +519,6 @@ function updatePromptTitle() {
color: white;
}
.mode-btn i,
.repeat-one-btn i {
font-size: 1.5rem;
}
@@ -665,35 +634,12 @@ function updatePromptTitle() {
user-select: none;
}
.mode-buttons {
display: flex;
position: relative;
background: var(--surfaceSecondary);
border-radius: 2em;
overflow: hidden;
padding: 0;
flex-shrink: 0;
}
.mode-indicator {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--primaryColor);
border-radius: inherit;
transition: left 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
z-index: 0;
}
.mode-btn,
.repeat-one-btn {
background: transparent;
border: none;
border-radius: 0;
border-radius: 2em;
padding: 0.35rem 0.75rem;
color: var(--textSecondary);
cursor: pointer;
font-size: inherit;
transition: background 0.2s, color 0.2s, transform 0.2s;
@@ -703,28 +649,6 @@ function updatePromptTitle() {
justify-content: center;
}
.mode-btn {
flex: 1;
border-radius: 0;
color: var(--textSecondary);
z-index: 1;
position: relative;
}
.mode-btn.active {
color: white;
}
.mode-btn:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
.repeat-one-btn {
border-radius: 2em;
color: var(--textSecondary);
}
.repeat-one-btn.active {
background: var(--primaryColor);
color: white;
+184 -147
View File
@@ -44,15 +44,41 @@
</div>
</div>
<div class="card-actions">
<button type="button" class="button button--flat button--grey" @click="close">{{ $t("general.cancel") }}</button>
<button v-if="quotaExists" type="button" class="button button--flat button--red" @click="removeQuota">
<button
type="button"
class="button button--flat button--grey"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
@click="close"
>
{{ $t("general.cancel") }}
</button>
<button
v-if="quotaExists"
type="button"
class="button button--flat button--red"
:aria-label="$t('general.remove')"
:title="$t('general.remove')"
@click="removeQuota"
>
{{ $t("general.remove") }}
</button>
<button type="button" class="button button--flat button--blue" @click="save">{{ $t("general.save") }}</button>
<button
type="button"
class="button button--flat button--blue"
:disabled="!dirty"
:aria-label="$t('general.save')"
:title="$t('general.save')"
@click="save"
>
{{ $t("general.save") }}
</button>
</div>
</template>
<script>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { mutations, state } from "@/store";
import { quotasApi } from "@/api";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -62,153 +88,164 @@ import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue
import QuotaCustomLimitInput from "@/components/settings/QuotaCustomLimitInput.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { notify } from "@/notify";
import {
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits";
import { bytesFromCustomAmount, customAmountFromBytes } from "@/utils/quotaUnits";
import type { FileListItem, FolderQuota, QuotaMeter, QuotaSnapshot, QuotaUnit } from "@/store/types.ts";
export default {
name: "Quota",
components: { ToggleSwitch, ExpandDropdown, QuotaFolderBar, ActivityViewerButton, QuotaCustomLimitInput },
props: {
promptId: { type: [String, Number], default: null },
item: { type: Object, required: true },
source: { type: String, required: true },
path: { type: String, default: "" },
},
data() {
return {
enabled: false,
customAmount: 10,
customUnit: "gb",
meter: "index_size",
quotaExists: false,
snapshot: {},
};
},
computed: {
indexingDisabled() {
return Boolean(state.sources.info?.[this.source]?.indexingDisabled);
},
meterOptions() {
const opts = [
{ value: "index_size", label: this.$t("quotas.meterIndexScope") },
{ value: "accounted", label: this.$t("quotas.meterAccounted") },
];
if (this.indexingDisabled) {
return opts.filter((o) => o.value === "accounted");
type QuotaResponse = FolderQuota | FolderQuota[];
interface QuotaBody {
limitBytes: number;
meter: QuotaMeter;
}
const props = withDefaults(
defineProps<{
promptId?: number | null;
item: Pick<FileListItem, "path">;
source: string;
path?: string;
}>(),
{
promptId: null,
path: "",
}
);
const { t } = useI18n();
const enabled = ref(false);
const customAmount = ref(10);
const customUnit = ref<QuotaUnit>("gb");
const meter = ref<QuotaMeter>("index_size");
const quotaExists = ref(false);
const snapshot = ref<QuotaSnapshot>({});
// Limit and meter as loaded, to tell whether there is something to save
const saved = ref<QuotaBody | null>(null);
const indexingDisabled = computed(() => Boolean(state.sources.info?.[props.source]?.indexingDisabled));
const meterOptions = computed(() => {
const opts: { value: QuotaMeter; label: string }[] = [
{ value: "index_size", label: t("quotas.meterIndexScope") },
{ value: "accounted", label: t("quotas.meterAccounted") },
];
if (indexingDisabled.value) {
return opts.filter((o) => o.value === "accounted");
}
return opts;
});
const measurementHelp = computed(() =>
meter.value === "accounted" ? t("quotas.meterAccountedHint") : t("quotas.meterIndexHint")
);
const displayPath = computed(() => props.path || props.item.path || "/");
const limitBytes = computed(() => {
if (!enabled.value) return 0;
return bytesFromCustomAmount(customAmount.value, customUnit.value);
});
const dirty = computed(() => {
if (!enabled.value) return quotaExists.value;
if (!saved.value) return true;
return limitBytes.value !== saved.value.limitBytes || meter.value !== saved.value.meter;
});
const activityViewerHref = computed(() => activityViewerPresets.quotas(props.source, displayPath.value));
onMounted(async () => {
if (indexingDisabled.value) {
meter.value = "accounted";
}
await load();
});
function close() {
mutations.closeTopPrompt();
}
// The quotas endpoints answer with a list
function firstQuota(raw: QuotaResponse) {
return Array.isArray(raw) ? raw[0] : raw;
}
function errorMessage(err: unknown) {
return (err instanceof Error && err.message) || t("prompts.operationFailed");
}
function applySnapshot(data?: QuotaSnapshot) {
if (!data) return;
snapshot.value = {
usedBytes: data.usedBytes,
reservedBytes: data.reservedBytes,
measurementStatus: data.measurementStatus,
};
}
async function load() {
try {
const data = firstQuota((await quotasApi.get(props.source, displayPath.value)) as QuotaResponse);
if (!data) return;
applySnapshot(data);
if ((data.limitBytes || 0) > 0) {
quotaExists.value = true;
enabled.value = true;
meter.value = data.configuredMeter || data.meter || "index_size";
if (indexingDisabled.value) {
meter.value = "accounted";
}
return opts;
},
measurementHelp() {
if (this.meter === "accounted") {
return this.$t("quotas.meterAccountedHint");
}
return this.$t("quotas.meterIndexHint");
},
displayPath() {
return this.path || this.item?.path || "/";
},
limitBytes() {
if (!this.enabled) return 0;
return bytesFromCustomAmount(this.customAmount, this.customUnit);
},
activityViewerHref() {
return activityViewerPresets.quotas(this.source, this.displayPath);
},
},
async mounted() {
if (this.indexingDisabled) {
this.meter = "accounted";
syncLimitFromBytes(data.limitBytes);
saved.value = { limitBytes: bytesFromCustomAmount(customAmount.value, customUnit.value), meter: meter.value };
}
await this.load();
},
methods: {
close() {
mutations.closeTopPrompt();
},
applySnapshot(data) {
if (!data) return;
this.snapshot = {
...this.snapshot,
usedBytes: data.usedBytes,
reservedBytes: data.reservedBytes,
measurementStatus: data.measurementStatus,
meter: data.effectiveMeter || data.meter,
configuredMeter: data.configuredMeter,
effectiveMeter: data.effectiveMeter,
limitBytes: data.limitBytes,
};
},
async load() {
try {
const raw = await quotasApi.get(this.source, this.displayPath);
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data) return;
this.applySnapshot(data);
if ((data.limitBytes || 0) > 0) {
this.quotaExists = true;
this.enabled = true;
this.meter = data.configuredMeter || data.meter || "index_size";
if (this.indexingDisabled) {
this.meter = "accounted";
}
this.syncLimitFromBytes(data.limitBytes);
}
} catch {
// no quota yet
} catch {
// no quota yet
}
}
function syncLimitFromBytes(bytes: number) {
const { amount, unit } = customAmountFromBytes(bytes);
customAmount.value = amount;
customUnit.value = unit;
}
async function save() {
try {
if (!enabled.value) {
if (quotaExists.value) {
await quotasApi.remove(props.source, displayPath.value);
}
},
syncLimitFromBytes(bytes) {
const { amount, unit } = customAmountFromBytes(bytes);
this.customAmount = amount;
this.customUnit = unit;
},
async save() {
try {
if (!this.enabled) {
if (this.quotaExists) {
await quotasApi.remove(this.source, this.displayPath);
}
notify.showSuccess(this.$t("general.saved"));
this.close();
return;
}
const meter = this.indexingDisabled ? "accounted" : this.meter;
const body = {
limitBytes: this.limitBytes,
meter,
};
if (this.quotaExists) {
const updated = await quotasApi.update(this.source, this.displayPath, body);
this.applySnapshot(updated);
} else {
const created = await quotasApi.create({
source: this.source,
path: this.displayPath,
...body,
});
this.quotaExists = true;
this.applySnapshot(created);
}
notify.showSuccess(this.$t("general.saved"));
this.close();
} catch (/** @type {any} */ err) {
notify.showError(err.message || this.$t("prompts.operationFailed"));
}
},
async removeQuota() {
if (!this.quotaExists) return;
try {
await quotasApi.remove(this.source, this.displayPath);
notify.showSuccess(this.$t("general.removed"));
this.close();
} catch (/** @type {any} */ err) {
notify.showError(err.message || this.$t("prompts.operationFailed"));
}
},
},
};
notify.showSuccess(t("general.saved"));
close();
return;
}
const body: QuotaBody = {
limitBytes: limitBytes.value,
meter: indexingDisabled.value ? "accounted" : meter.value,
};
if (quotaExists.value) {
const updated = await quotasApi.update(props.source, displayPath.value, body);
applySnapshot(firstQuota(updated as QuotaResponse));
} else {
const created = await quotasApi.create({
source: props.source,
path: displayPath.value,
...body,
});
quotaExists.value = true;
applySnapshot(firstQuota(created as QuotaResponse));
}
notify.showSuccess(t("general.saved"));
close();
} catch (err) {
notify.showError(errorMessage(err));
}
}
async function removeQuota() {
if (!quotaExists.value) return;
try {
await quotasApi.remove(props.source, displayPath.value);
notify.showSuccess(t("general.removed"));
close();
} catch (err) {
notify.showError(errorMessage(err));
}
}
</script>
<style scoped>
@@ -27,43 +27,45 @@
</div>
</template>
<script>
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import ProgressBar from "@/components/ProgressBar.vue";
import type { QuotaSnapshot } from "@/store/types.ts";
export default {
name: "QuotaFolderBar",
components: { ProgressBar },
props: {
promptId: { type: [String, Number], default: null },
source: { type: String, required: true },
path: { type: String, required: true },
enabled: { type: Boolean, default: false },
limitBytes: { type: Number, default: 0 },
snapshot: { type: Object, default: () => ({}) },
},
computed: {
barVal() {
if (!this.enabled) return 0;
return this.snapshot.usedBytes || 0;
},
barMax() {
if (!this.enabled) return 1;
return this.limitBytes > 0 ? this.limitBytes : 1;
},
barValText() {
if (!this.enabled) return this.$t("general.disabled");
return null;
},
barStatus() {
if (!this.enabled) return "disk";
const used = (this.snapshot.usedBytes || 0) + (this.snapshot.reservedBytes || 0);
const max = this.barMax;
if (used / max >= 0.95) return "error";
if (used / max >= 0.8) return "warning";
return "normal";
},
},
};
type BarStatus = "disk" | "error" | "warning" | "normal";
const props = withDefaults(
defineProps<{
source: string;
path: string;
enabled?: boolean;
limitBytes?: number;
snapshot?: QuotaSnapshot;
}>(),
{
enabled: false,
limitBytes: 0,
snapshot: () => ({}),
}
);
const { t } = useI18n();
const barVal = computed(() => (props.enabled ? props.snapshot.usedBytes || 0 : 0));
const barMax = computed(() => {
if (!props.enabled) return 1;
return props.limitBytes > 0 ? props.limitBytes : 1;
});
const barValText = computed(() => (props.enabled ? null : t("general.disabled")));
const barStatus = computed<BarStatus>(() => {
if (!props.enabled) return "disk";
const used = (props.snapshot.usedBytes || 0) + (props.snapshot.reservedBytes || 0);
const max = barMax.value;
if (used / max >= 0.95) return "error";
if (used / max >= 0.8) return "warning";
return "normal";
});
</script>
<style scoped>
@@ -0,0 +1,140 @@
<template>
<div
class="sliding-tabs"
:class="{ 'sliding-tabs--icon-only': iconOnly }"
:role="iconOnly ? 'radiogroup' : 'tablist'"
:style="{ '--tab-count': tabs.length, '--tab-index': activeIndex }"
>
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="sliding-tab"
:class="{ active: tab.id === modelValue }"
:role="iconOnly ? 'radio' : 'tab'"
:aria-selected="iconOnly ? undefined : tab.id === modelValue"
:aria-checked="iconOnly ? tab.id === modelValue : undefined"
:aria-label="tab.label ? undefined : tab.title"
:title="tab.title ?? tab.label"
@click="emit('update:modelValue', tab.id)"
>
<i v-if="tab.icon" class="material-symbols">{{ tab.icon }}</i>
<span v-if="tab.label" class="sliding-tab-label">{{ tab.label }}</span>
<span v-if="tab.suffix" class="sliding-tab-suffix">{{ tab.suffix }}</span>
</button>
<div v-if="activeIndex >= 0" class="sliding-tabs-indicator" aria-hidden="true"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
export interface SlidingTab {
id: string;
/** Text of the tab. iconOnly tabs omit it and use only title */
label?: string;
icon?: string;
suffix?: string;
title?: string;
}
const props = withDefaults(
defineProps<{
tabs: SlidingTab[];
modelValue: string;
iconOnly?: boolean;
}>(),
{ iconOnly: false }
);
const emit = defineEmits<{ "update:modelValue": [id: string] }>();
const activeIndex = computed(() => props.tabs.findIndex((tab) => tab.id === props.modelValue));
</script>
<style scoped>
.sliding-tabs {
--tab-gap: 0.5em;
--tab-count: 1; /* set inline, from the tabs */
--tab-index: 0; /* set inline, from the active tab */
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: var(--tab-gap);
}
.sliding-tabs--icon-only {
--tab-gap: 0px;
background: var(--surfaceSecondary);
border-radius: 2em;
overflow: hidden;
}
.sliding-tab {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
min-width: 0;
padding: 0.5em;
border: none;
border-radius: var(--borderRadius);
background: transparent;
color: var(--textSecondary);
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
user-select: none;
transition: color 0.2s ease, transform 0.2s ease;
}
.sliding-tab > i,
.sliding-tab-suffix {
flex-shrink: 0;
}
.sliding-tabs--icon-only .sliding-tab {
padding: 0.35rem 0.75rem;
border-radius: 0;
}
.sliding-tabs--icon-only .sliding-tab > i {
font-size: 1.5rem;
}
.sliding-tab.active {
color: white;
}
.sliding-tab:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
.sliding-tab-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.sliding-tabs-indicator {
position: absolute;
inset: 0 auto 0 0;
z-index: 0;
width: calc((100% - (var(--tab-count) - 1) * var(--tab-gap)) / var(--tab-count));
transform: translateX(calc(var(--tab-index) * (100% + var(--tab-gap))));
border-radius: inherit;
background: var(--primaryColor);
pointer-events: none;
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.sliding-tabs:not(.sliding-tabs--icon-only) .sliding-tabs-indicator {
border-radius: var(--borderRadius);
}
</style>
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "يجب أن تكون كلمة المرور الجديدة مختلفة عن كلمة المرور الحالية"
},
"prompts": {
"mediaInfo": "معلومات الوسائط",
"mediaTitle": "العنوان",
"subtitle": "الترجمة {n}",
"artist": "الفنان",
"album": "الألبوم",
"year": "السنة",
"genre": "النوع",
"track": "المقطع",
"container": "الحاوية",
"videoCodec": "ترميز الفيديو",
"audioCodec": "ترميز الصوت",
"noTemplate": "قالبك فارغ!",
"newFileTemplate": "قالب الملف الجديد",
"newFileTemplateMessage": "أضف أسماء ملفات جديدة هنا، سيتم استخدامها كقالب لإنشاء ملفات جديدة في قائمة السياق!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "كلمة المرور الخاصة بك مطلوبة لإنشاء رمز المصادقة."
},
"general": {
"show": "إظهار",
"hide": "إخفاء",
"import": "استيراد",
"export": "تصدير",
"name": "الاسم",
@@ -676,7 +689,6 @@
"allow": "السماح",
"allowDeny": "السماح/الرفض",
"userGroup": "المستخدم/المجموعة",
"accessManagement": "إدارة الوصول",
"totalDenied": "مرفوض",
"totalAllowed": "المسموح به",
"all": "رفض الكل",
@@ -684,8 +696,6 @@
"defaultBehavior": "الإجراء الافتراضي{suffix}",
"cascadeDelete": "الحذف التكراري",
"cascadeDeleteDescription": "عند التمكين، سيؤدي حذف قاعدة وصول إلى إزالتها أيضًا من جميع الدلائل الفرعية. يتيح لك ذلك إزالة مستخدم أو مجموعة بالكامل من شجرة الدليل بأكملها.",
"hasRules": "تم تكوين قواعد الوصول",
"manageDescription": "قواعد الوصول المفتوح لهذا المسار.",
"newGroup": "مجموعة جديدة",
"editGroup": "تعديل المجموعة",
"groupName": "اسم المجموعة",
+13 -3
View File
@@ -9,6 +9,8 @@
"noCreateOptions": "Няма налични опции за създаване"
},
"general": {
"show": "Показване",
"hide": "Скриване",
"import": "Импортиране",
"export": "Експортиране",
"to": "до{suffix}",
@@ -413,6 +415,17 @@
"shareDefaultsDescription": "Конфигурирайте опциите по подразбиране за нови споделени ресурси и наложете правилата за потребители, които не са администратори."
},
"prompts": {
"mediaInfo": "Информация за медията",
"mediaTitle": "Заглавие",
"subtitle": "Субтитри {n}",
"artist": "Изпълнител",
"album": "Албум",
"year": "Година",
"genre": "Жанр",
"track": "Песен",
"container": "Контейнер",
"videoCodec": "Видео кодек",
"audioCodec": "Аудио кодек",
"noTemplate": "Вашият шаблон е празен!",
"newFileTemplate": "Шаблон за нов файл",
"newFileTemplateMessage": "Добавете нови имена на файлове тук, те ще се използват като шаблон за създаване на нови файлове в контекстното меню!",
@@ -789,13 +802,10 @@
"allow": "Разрешаване",
"allowDeny": "Разрешаване/Забрана",
"userGroup": "Потребител/Група",
"accessManagement": "Управление на достъпа",
"defaultBehavior": "Действие по подразбиране{suffix}",
"defaultBehaviorDescription": "Това показва какво се случва, когато потребител се опита да получи достъп до път, където няма специфични правила за достъп. 'Разрешаване' означава, че потребителите могат да имат достъп по подразбиране, 'Забрана' означава, че потребителите са блокирани по подразбиране, освен ако не са изрично разрешени.",
"cascadeDelete": "Рекурсивно изтриване",
"cascadeDeleteDescription": "Когато е включено, изтриването на правило за достъп ще го премахне и от всички подпапки. Това ви позволява напълно да премахнете потребител или група от цялото дърво на папките.",
"hasRules": "Конфигурирани правила за достъп",
"manageDescription": "Правила за свободен достъп за този маршрут.",
"newGroup": "Нова група",
"editGroup": "Редактиране на група",
"groupName": "Име на групата",
+13 -3
View File
@@ -227,6 +227,17 @@
"shareDefaultsDescription": "Nakonfigurujte výchozí nastavení sdílených složek pro nové sdílené složky a vynuťte dodržování zásad u uživatelů bez oprávnění správce."
},
"prompts": {
"mediaInfo": "Informace o médiu",
"mediaTitle": "Název",
"subtitle": "Titulky {n}",
"artist": "Interpret",
"album": "Album",
"year": "Rok",
"genre": "Žánr",
"track": "Stopa",
"container": "Kontejner",
"videoCodec": "Video kodek",
"audioCodec": "Audio kodek",
"noTemplate": "Vaše šablona je prázdná!",
"newFileTemplate": "Šablona nového souboru",
"newFileTemplateMessage": "Zde přidejte nové názvy souborů, budou použity jako šablona pro vytváření nových souborů v kontextové nabídce!",
@@ -604,6 +615,8 @@
"orange": "oranžová"
},
"general": {
"show": "Zobrazit",
"hide": "Skrýt",
"import": "Importovat",
"export": "Exportovat",
"name": "Název",
@@ -788,14 +801,11 @@
"allow": "Povolit",
"allowDeny": "Povolit/odmítnout",
"userGroup": "Uživatel/skupina",
"accessManagement": "Správa přístupu",
"all": "Odmítnout vše",
"defaultBehaviorDescription": "To ukazuje, co se stane, když se uživatel pokusí získat přístup k cestě, kde neexistují žádná specifická pravidla přístupu. \"Povolit\" znamená, že uživatelé mají ve výchozím nastavení přístup, \"Odmítnout\" znamená, že uživatelé jsou ve výchozím nastavení blokováni, pokud není výslovně povoleno.",
"defaultBehavior": "Výchozí akce{suffix}",
"cascadeDelete": "Rekurzivní mazání",
"cascadeDeleteDescription": "Pokud je tato možnost povolena, odstraní se pravidlo přístupu také ze všech podadresářů. To umožňuje zcela odstranit uživatele nebo skupinu z celého adresářového stromu.",
"hasRules": "Nastavená pravidla přístupu",
"manageDescription": "Pravidla otevřeného přístupu pro tuto trasu.",
"newGroup": "Nová skupina",
"editGroup": "Upravit skupinu",
"groupName": "Název skupiny",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Das neue Passwort muss sich von Ihrem aktuellen Passwort unterscheiden."
},
"prompts": {
"mediaInfo": "Medieninformationen",
"mediaTitle": "Titel",
"subtitle": "Untertitel {n}",
"artist": "Künstler",
"album": "Album",
"year": "Jahr",
"genre": "Genre",
"track": "Titel",
"container": "Container",
"videoCodec": "Video-Codec",
"audioCodec": "Audio-Codec",
"noTemplate": "Ihre Vorlage ist leer!",
"newFileTemplate": "Vorlage für neue Dateien",
"newFileTemplateMessage": "Fügen Sie hier neue Dateinamen hinzu, diese werden als Vorlage für die Erstellung neuer Dateien im Kontextmenü verwendet!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Ihr Passwort ist erforderlich, um einen Authentifizierungscode zu generieren."
},
"general": {
"show": "Anzeigen",
"hide": "Ausblenden",
"import": "Importieren",
"export": "Exportieren",
"name": "Name",
@@ -676,7 +689,6 @@
"allow": "Zulassen",
"allowDeny": "Zulassen/Verweigern",
"userGroup": "Benutzer/Gruppe",
"accessManagement": "Zugangsverwaltung",
"totalDenied": "Verweigert",
"totalAllowed": "Erlaubt",
"all": "Alle ablehnen",
@@ -684,8 +696,6 @@
"defaultBehavior": "Standard-Aktion{suffix}",
"cascadeDelete": "Rekursives Löschen",
"cascadeDeleteDescription": "Wenn diese Funktion aktiviert ist, wird beim Löschen einer Zugriffsregel diese auch aus allen Unterverzeichnissen entfernt. Auf diese Weise können Sie einen Benutzer oder eine Gruppe vollständig aus dem gesamten Verzeichnisbaum entfernen.",
"hasRules": "Zugriffsregeln konfiguriert",
"manageDescription": "Zugangsregeln für diesen Pfad.",
"newGroup": "Neue Gruppe",
"editGroup": "Gruppe bearbeiten",
"groupName": "Gruppenname",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον τρέχοντα κωδικό πρόσβασής σας"
},
"prompts": {
"mediaInfo": "Πληροφορίες πολυμέσων",
"mediaTitle": "Τίτλος",
"subtitle": "Υπότιτλος {n}",
"artist": "Καλλιτέχνης",
"album": "Άλμπουμ",
"year": "Έτος",
"genre": "Είδος",
"track": "Κομμάτι",
"container": "Περιέκτης",
"videoCodec": "Κωδικοποιητής βίντεο",
"audioCodec": "Κωδικοποιητής ήχου",
"noTemplate": "Το πρότυπό σας είναι κενό!",
"newFileTemplate": "Πρότυπο νέου αρχείου",
"newFileTemplateMessage": "Προσθέστε νέα ονόματα αρχείων εδώ, θα χρησιμοποιηθούν ως πρότυπο για τη δημιουργία νέων αρχείων στο μενού περιβάλλοντος!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Ο κωδικός πρόσβασής σας απαιτείται για τη δημιουργία ενός κωδικού ελέγχου ταυτότητας."
},
"general": {
"show": "Εμφάνιση",
"hide": "Απόκρυψη",
"import": "Εισαγωγή",
"export": "Εξαγωγή",
"name": "Όνομα",
@@ -676,7 +689,6 @@
"allow": "Επιτρέψτε το",
"allowDeny": "Επιτρέψτε/απορρίψτε",
"userGroup": "Χρήστης/Ομάδα",
"accessManagement": "Διαχείριση πρόσβασης",
"totalDenied": "Απορρίπτεται",
"totalAllowed": "Επιτρεπόμενο",
"all": "Άρνηση όλων",
@@ -684,8 +696,6 @@
"defaultBehavior": "Προεπιλεγμένη ενέργεια{suffix}",
"cascadeDelete": "Αναδρομική διαγραφή",
"cascadeDeleteDescription": "Όταν είναι ενεργοποιημένη, η διαγραφή ενός κανόνα πρόσβασης θα τον αφαιρέσει επίσης από όλους τους υποκαταλόγους. Αυτό σας επιτρέπει να αφαιρέσετε πλήρως έναν χρήστη ή μια ομάδα από ολόκληρο το δέντρο καταλόγων.",
"hasRules": "Οι κανόνες πρόσβασης έχουν ρυθμιστεί",
"manageDescription": "Κανόνες ανοικτής πρόσβασης για αυτή τη διαδρομή.",
"newGroup": "Νέα ομάδα",
"editGroup": "Επεξεργασία ομάδας",
"groupName": "Όνομα ομάδας",
+13 -3
View File
@@ -9,6 +9,8 @@
"noCreateOptions": "No create options are available"
},
"general": {
"show": "Show",
"hide": "Hide",
"import": "Import",
"export": "Export",
"to": "to{suffix}",
@@ -433,6 +435,17 @@
"deleteFailed": "Failed to delete share"
},
"prompts": {
"mediaInfo": "Media Info",
"mediaTitle": "Title",
"subtitle": "Subtitle {n}",
"artist": "Artist",
"album": "Album",
"year": "Year",
"genre": "Genre",
"track": "Track",
"container": "Container",
"videoCodec": "Video Codec",
"audioCodec": "Audio Codec",
"noTemplate": "Your template is empty!",
"newFileTemplate": "New file template",
"newFileTemplateMessage": "Add new filenames here, they will be used as template for new file creation in the context menu!",
@@ -862,15 +875,12 @@
"refineSearch": "Showing first {count} matches. Refine your search to see more.",
"all": "Deny All",
"rules": "Access rules",
"hasRules": "Access rules configured",
"manageDescription": "Open access rules for this path.",
"deny": "Deny",
"totalDenied": "Denied",
"totalAllowed": "Allowed",
"allow": "Allow",
"allowDeny": "Allow/Deny",
"userGroup": "User/Group",
"accessManagement": "Access Management",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "This shows what happens when a user tries to access a path where no specific access rules exist. 'Allow' means users can access by default, 'Deny' means users are blocked by default unless explicitly allowed.",
"cascadeDelete": "Recursive Delete",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "La nueva contraseña debe ser diferente de tu contraseña actual"
},
"prompts": {
"mediaInfo": "Información multimedia",
"mediaTitle": "Título",
"subtitle": "Subtítulo {n}",
"artist": "Artista",
"album": "Álbum",
"year": "Año",
"genre": "Género",
"track": "Pista",
"container": "Contenedor",
"videoCodec": "Códec de vídeo",
"audioCodec": "Códec de audio",
"noTemplate": "¡Tu plantilla está vacía!",
"newFileTemplate": "Plantilla para nuevos archivos",
"newFileTemplateMessage": "¡Añade nuevos nombres de archivo aquí, se usarán como plantilla para crear nuevos archivos en el menú contextual!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Su contraseña es necesaria para generar un código autenticador."
},
"general": {
"show": "Mostrar",
"hide": "Ocultar",
"import": "Importar",
"export": "Exportar",
"name": "Nombre{suffix}",
@@ -676,7 +689,6 @@
"allow": "Permitir",
"allowDeny": "Permitir/Bloquear",
"userGroup": "Usuario/Grupo",
"accessManagement": "Gestión de accesos",
"totalDenied": "Bloqueados",
"totalAllowed": "Permitidos",
"all": "Bloquear todo",
@@ -684,8 +696,6 @@
"defaultBehavior": "Regla por defecto {suffix}",
"cascadeDelete": "Eliminación en cascada",
"cascadeDeleteDescription": "Si eliminas una regla de acceso con esta opción, también la eliminará para todo el contenido (archivos y carpetas), lo cual nos permite eliminar por completo un usuario o grupo de todo el árbol de directorios",
"hasRules": "Normas de acceso configuradas",
"manageDescription": "Abrir las reglas de acceso de esta ruta.",
"newGroup": "Nuevo grupo",
"editGroup": "Editar grupo",
"groupName": "Nombre del grupo",
+13 -3
View File
@@ -9,6 +9,8 @@
"noCreateOptions": "Ez dago sortzeko aukerarik erabilgarri"
},
"general": {
"show": "Erakutsi",
"hide": "Ezkutatu",
"import": "Inportatu",
"export": "Esportatu",
"to": "-{suffix}",
@@ -433,6 +435,17 @@
"deleteFailed": "Ezin izan da partekatzea ezabatu"
},
"prompts": {
"mediaInfo": "Media informazioa",
"mediaTitle": "Izenburua",
"subtitle": "Azpititulua {n}",
"artist": "Artista",
"album": "Albuma",
"year": "Urtea",
"genre": "Generoa",
"track": "Pista",
"container": "Edukiontzia",
"videoCodec": "Bideo kodeka",
"audioCodec": "Audio kodeka",
"noTemplate": "Txantiloia hutsik dago!",
"newFileTemplate": "Fitxategi berrien txantiloia",
"newFileTemplateMessage": "Gehitu fitxategi-izen berriak hemen; laster-menuan fitxategi berriak sortzeko txantiloi gisa erabiliko dira!",
@@ -822,15 +835,12 @@
"refineSearch": "Lehen {count} emaitzak erakusten. Zehaztu bilaketa gehiago ikusteko.",
"all": "Ukatu denak",
"rules": "Sarbide-arauak",
"hasRules": "Sarbide-arauak konfiguratuta",
"manageDescription": "Ireki bide honen sarbide-arauak.",
"deny": "Ukatu",
"totalDenied": "Ukatuak",
"totalAllowed": "Baimenduak",
"allow": "Baimendu",
"allowDeny": "Baimendu/Ukatu",
"userGroup": "Erabiltzailea/Taldea",
"accessManagement": "Sarbideen kudeaketa",
"defaultBehavior": "Ekintza lehenetsia{suffix}",
"defaultBehaviorDescription": "Hemen ikusten da zer gertatzen den erabiltzaile bat sarbide-arau zehatzik ez duen bide batera sartzen saiatzen denean. 'Baimendu' aukerak esan nahi du erabiltzaileak berez sar daitezkeela; 'Ukatu' aukerak, berriz, erabiltzaileak berez blokeatuta daudela, esplizituki baimendu ezean.",
"cascadeDelete": "Ezabatze errekurtsiboa",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Le nouveau mot de passe doit être différent de votre mot de passe actuel"
},
"prompts": {
"mediaInfo": "Infos média",
"mediaTitle": "Titre",
"subtitle": "Sous-titre {n}",
"artist": "Artiste",
"album": "Album",
"year": "Année",
"genre": "Genre",
"track": "Piste",
"container": "Conteneur",
"videoCodec": "Codec vidéo",
"audioCodec": "Codec audio",
"noTemplate": "Votre modèle est vide !",
"newFileTemplate": "Modèle de nouveau fichier",
"newFileTemplateMessage": "Ajoutez ici de nouveaux noms de fichiers, ils seront utilisés comme modèle pour la création de nouveaux fichiers dans le menu contextuel !",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Votre mot de passe est nécessaire pour générer un code d'authentification."
},
"general": {
"show": "Afficher",
"hide": "Masquer",
"import": "Importer",
"export": "Exporter",
"name": "Nom",
@@ -676,7 +689,6 @@
"allow": "Autoriser",
"allowDeny": "Autoriser/refuser",
"userGroup": "Utilisateur/Groupe",
"accessManagement": "Gestion de l'accès",
"totalDenied": "Refusé",
"totalAllowed": "Autorisé",
"all": "Refuser tout",
@@ -684,8 +696,6 @@
"defaultBehavior": "Action par défaut{suffix}",
"cascadeDelete": "Suppression récursive",
"cascadeDeleteDescription": "Lorsque cette option est activée, la suppression d'une règle d'accès s'applique également à tous les sous-répertoires. Cela vous permet de supprimer complètement un utilisateur ou un groupe de toute l'arborescence des répertoires.",
"hasRules": "Règles d'accès configurées",
"manageDescription": "Règles d'accès libre pour ce chemin.",
"newGroup": "Nouveau groupe",
"editGroup": "Modifier le groupe",
"groupName": "Nom du groupe",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "הסיסמה החדשה חייבת להיות שונה מהסיסמה הנוכחית שלך"
},
"prompts": {
"mediaInfo": "מידע מדיה",
"mediaTitle": "כותרת",
"subtitle": "כתובית {n}",
"artist": "אמן",
"album": "אלבום",
"year": "שנה",
"genre": "ז'אנר",
"track": "רצועה",
"container": "מכל",
"videoCodec": "קודק וידאו",
"audioCodec": "קודק אודיו",
"noTemplate": "התבנית שלך ריקה!",
"newFileTemplate": "תבנית קובץ חדש",
"newFileTemplateMessage": "הוסף שמות קבצים חדשים כאן, הם ישמשו כתבנית ליצירת קבצים חדשים בתפריט ההקשר!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "יש להזין את הסיסמה שלך כדי ליצור קוד אימות."
},
"general": {
"show": "הצג",
"hide": "הסתר",
"import": "ייבוא",
"export": "ייצוא",
"name": "שם",
@@ -676,7 +689,6 @@
"allow": "אפשר",
"allowDeny": "אפשר/דחה",
"userGroup": "משתמש/קבוצה",
"accessManagement": "ניהול גישה",
"totalDenied": "נדחה",
"totalAllowed": "מותר",
"all": "דחה הכל",
@@ -684,8 +696,6 @@
"defaultBehavior": "פעולה ברירת מחדל{suffix}",
"cascadeDelete": "מחיקה רקורסיבית",
"cascadeDeleteDescription": "כאשר אפשרות זו מופעלת, מחיקת כלל גישה תסיר אותו גם מכל הספריות המשנה. כך תוכל להסיר משתמש או קבוצה לחלוטין מכל עץ הספריות.",
"hasRules": "כללי הגישה הוגדרו",
"manageDescription": "כללי הגישה הפתוחה לנתיב זה.",
"newGroup": "קבוצה חדשה",
"editGroup": "עריכת קבוצה",
"groupName": "שם הקבוצה",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Az új jelszónak különböznie kell a jelenlegi jelszótól"
},
"prompts": {
"mediaInfo": "Média információ",
"mediaTitle": "Cím",
"subtitle": "{n}. felirat",
"artist": "Előadó",
"album": "Album",
"year": "Év",
"genre": "Műfaj",
"track": "Szám",
"container": "Konténer",
"videoCodec": "Videó kodek",
"audioCodec": "Audió kodek",
"noTemplate": "A sablon üres!",
"newFileTemplate": "Új fájl sablon",
"newFileTemplateMessage": "Adjon hozzá új fájlneveket itt, ezek sablonként szolgálnak az új fájlok létrehozásához a helyi menüben!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "A hitelesítési kód generálásához a jelszóra van szükség."
},
"general": {
"show": "Mutat",
"hide": "Elrejt",
"import": "Importálás",
"export": "Exportálás",
"name": "Név",
@@ -676,7 +689,6 @@
"allow": "Engedélyezze a",
"allowDeny": "Engedélyezés/Tiltás",
"userGroup": "Felhasználó/csoport",
"accessManagement": "Hozzáférés-kezelés",
"totalDenied": "Elutasítva",
"totalAllowed": "Engedélyezett",
"all": "Mindent megtagadni",
@@ -684,8 +696,6 @@
"defaultBehavior": "Alapértelmezett cselekvés{suffix}",
"cascadeDelete": "Rekurzív törlés",
"cascadeDeleteDescription": "Ha engedélyezve van, egy hozzáférési szabály törlése az összes alkönyvtárból is eltávolítja azt. Ez lehetővé teszi egy felhasználó vagy csoport teljes eltávolítását a teljes könyvtárfából.",
"hasRules": "A hozzáférési szabályok beállítása",
"manageDescription": "A hozzáférésre vonatkozó szabályok ezen az útvonalon.",
"newGroup": "Új csoport",
"editGroup": "Csoport szerkesztése",
"groupName": "Csoport neve",
+13 -3
View File
@@ -9,6 +9,8 @@
"noCreateOptions": "Non sono disponibili opzioni di creazione"
},
"general": {
"show": "Mostra",
"hide": "Nascondi",
"import": "Importa",
"export": "Esporta",
"divider": "Divisore{suffix}",
@@ -413,6 +415,17 @@
"shareDefaultsDescription": "Configurare le opzioni predefinite per le nuove condivisioni e applicare le politiche agli utenti non amministratori."
},
"prompts": {
"mediaInfo": "Info multimediali",
"mediaTitle": "Titolo",
"subtitle": "Sottotitolo {n}",
"artist": "Artista",
"album": "Album",
"year": "Anno",
"genre": "Genere",
"track": "Traccia",
"container": "Contenitore",
"videoCodec": "Codec video",
"audioCodec": "Codec audio",
"noTemplate": "Il tuo modello è vuoto!",
"newFileTemplate": "Modello nuovo file",
"newFileTemplateMessage": "Aggiungi qui nuovi nomi di file, verranno utilizzati come modello per la creazione di nuovi file nel menu contestuale!",
@@ -778,13 +791,10 @@
"allow": "Consenti",
"allowDeny": "Consenti/Nega",
"userGroup": "Utente/Gruppo",
"accessManagement": "Gestione Accessi",
"defaultBehavior": "Azione predefinita{suffix}",
"defaultBehaviorDescription": "Cosa succede quando non esistono regole specifiche. 'Consenti' permette l'accesso, 'Nega' blocca tutto tranne gli esplicitamente autorizzati.",
"cascadeDelete": "Eliminazione Ricorsiva",
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola rimuoverà anche quelle nelle sottodirectory.",
"hasRules": "Regole di accesso configurate",
"manageDescription": "Regole di libero accesso per questo percorso.",
"newGroup": "Nuovo gruppo",
"editGroup": "Modifica gruppo",
"groupName": "Nome del gruppo",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "新しいパスワードは、現在のパスワードとは異なるものにしてください"
},
"prompts": {
"mediaInfo": "メディア情報",
"mediaTitle": "タイトル",
"subtitle": "字幕 {n}",
"artist": "アーティスト",
"album": "アルバム",
"year": "年",
"genre": "ジャンル",
"track": "トラック",
"container": "コンテナ",
"videoCodec": "ビデオコーデック",
"audioCodec": "オーディオコーデック",
"noTemplate": "テンプレートが空です!",
"newFileTemplate": "新規ファイルテンプレート",
"newFileTemplateMessage": "新しいファイル名をここに追加すると、コンテキストメニューでの新規ファイル作成のテンプレートとして使用されます!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "認証コードを生成するにはパスワードが必要です。"
},
"general": {
"show": "表示",
"hide": "非表示",
"import": "インポート",
"export": "エクスポート",
"name": "名前",
@@ -676,7 +689,6 @@
"allow": "許可",
"allowDeny": "許可/拒否",
"userGroup": "ユーザー/グループ",
"accessManagement": "アクセス管理",
"totalDenied": "拒否済み",
"totalAllowed": "許可済み",
"all": "すべて拒否",
@@ -684,8 +696,6 @@
"defaultBehavior": "デフォルト動作{suffix}",
"cascadeDelete": "再帰的削除",
"cascadeDeleteDescription": "有効な場合、アクセスルールを削除すると、すべてのサブディレクトリからも削除される。これにより、ディレクトリツリー全体からユーザーまたはグループを 完全に削除することができる。",
"hasRules": "アクセスルールが設定されました",
"manageDescription": "この経路に関するオープンアクセスに関する規則。",
"newGroup": "新規グループ",
"editGroup": "グループを編集",
"groupName": "グループ名",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "새 비밀번호는 현재 비밀번호와 달라야 합니다."
},
"prompts": {
"mediaInfo": "미디어 정보",
"mediaTitle": "제목",
"subtitle": "자막 {n}",
"artist": "아티스트",
"album": "앨범",
"year": "연도",
"genre": "장르",
"track": "트랙",
"container": "컨테이너",
"videoCodec": "비디오 코덱",
"audioCodec": "오디오 코덱",
"noTemplate": "템플릿이 비어 있습니다!",
"newFileTemplate": "새 파일 템플릿",
"newFileTemplateMessage": "여기에 새 파일 이름을 추가하면 컨텍스트 메뉴에서 새 파일 생성 템플릿으로 사용됩니다!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "인증 코드를 생성하려면 비밀번호가 필요합니다."
},
"general": {
"show": "표시",
"hide": "숨기기",
"import": "가져오기",
"export": "내보내기",
"name": "이름",
@@ -676,7 +689,6 @@
"allow": "허용",
"allowDeny": "허용/거부",
"userGroup": "사용자/그룹",
"accessManagement": "액세스 관리",
"totalDenied": "거부됨",
"totalAllowed": "허용됨",
"all": "모두 거부",
@@ -684,8 +696,6 @@
"defaultBehavior": "기본 작업{suffix}",
"cascadeDelete": "재귀적 삭제",
"cascadeDeleteDescription": "이 기능을 활성화하면 액세스 규칙을 삭제하면 모든 하위 디렉터리에서도 해당 규칙이 제거됩니다. 이렇게 하면 전체 디렉터리 트리에서 사용자 또는 그룹을 완전히 제거할 수 있습니다.",
"hasRules": "액세스 규칙이 구성되었습니다.",
"manageDescription": "이 경로에 적용되는 오픈 액세스 규칙.",
"newGroup": "새 그룹",
"editGroup": "그룹 편집",
"groupName": "그룹 이름",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Het nieuwe wachtwoord moet anders zijn dan je huidige wachtwoord"
},
"prompts": {
"mediaInfo": "Media-info",
"mediaTitle": "Titel",
"subtitle": "Ondertitel {n}",
"artist": "Artiest",
"album": "Album",
"year": "Jaar",
"genre": "Genre",
"track": "Nummer",
"container": "Container",
"videoCodec": "Videocodec",
"audioCodec": "Audiocodec",
"noTemplate": "Uw sjabloon is leeg!",
"newFileTemplate": "Sjabloon voor nieuw bestand",
"newFileTemplateMessage": "Voeg hier nieuwe bestandsnamen toe, ze worden gebruikt als sjabloon voor het aanmaken van nieuwe bestanden in het contextmenu!",
@@ -604,6 +615,8 @@
"accountOptions": "Accountinstellingen"
},
"general": {
"show": "Tonen",
"hide": "Verbergen",
"import": "Importeren",
"export": "Exporteren",
"name": "Naam",
@@ -788,14 +801,11 @@
"allow": "Sta toe.",
"allowDeny": "Toestaan/weigeren",
"userGroup": "Gebruiker/Groep",
"accessManagement": "Toegangsbeheer",
"all": "Alles weigeren",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
"defaultBehavior": "Standaardactie{suffix}",
"cascadeDelete": "Recursief verwijderen",
"cascadeDeleteDescription": "Indien ingeschakeld, zal het verwijderen van een toegangsregel deze ook verwijderen uit alle submappen. Hierdoor kun je een gebruiker of groep volledig verwijderen uit de hele mappenstructuur.",
"hasRules": "Toegangsregels geconfigureerd",
"manageDescription": "Regels voor open toegang voor dit pad.",
"newGroup": "Nieuwe groep",
"editGroup": "Groep bewerken",
"groupName": "Groepsnaam",
+13 -3
View File
@@ -9,6 +9,8 @@
"noCreateOptions": "Er zijn geen opties beschikbaar"
},
"general": {
"show": "Tonen",
"hide": "Verbergen",
"import": "Importeren",
"export": "Exporteren",
"to": "naar{suffix}",
@@ -433,6 +435,17 @@
"deleteFailed": "Share verwijderen is mislukt"
},
"prompts": {
"mediaInfo": "Media-info",
"mediaTitle": "Titel",
"subtitle": "Ondertitel {n}",
"artist": "Artiest",
"album": "Album",
"year": "Jaar",
"genre": "Genre",
"track": "Nummer",
"container": "Container",
"videoCodec": "Videocodec",
"audioCodec": "Audiocodec",
"noTemplate": "Uw sjabloon is leeg!",
"newFileTemplate": "Sjabloon voor nieuw bestand",
"newFileTemplateMessage": "Voeg hier nieuwe bestandsnamen toe, ze worden gebruikt als sjabloon voor het aanmaken van nieuwe bestanden in het contextmenu!",
@@ -809,15 +822,12 @@
"groupsOidcNote": "Leden van OIDC-gesynchroniseerde groepen worden bij elke aanmelding van die gebruikers opnieuw ingesteld vanuit de identiteitsprovider.",
"all": "Alles weigeren",
"rules": "Toegangsregels",
"hasRules": "Toegangsregels geconfigureerd",
"manageDescription": "Regels inzake open toegang voor dit traject.",
"deny": "Weigeren",
"totalDenied": "Geweigerd",
"totalAllowed": "Toegestaan",
"allow": "Toestaan",
"allowDeny": "Toestaan/weigeren",
"userGroup": "Gebruiker/Groep",
"accessManagement": "Toegangsbeheer",
"defaultBehavior": "Standaardactie{suffix}",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
"cascadeDelete": "Recursief verwijderen",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Nowe hasło musi różnić się od obecnego hasła"
},
"prompts": {
"mediaInfo": "Informacje o mediach",
"mediaTitle": "Tytuł",
"subtitle": "Napisy {n}",
"artist": "Wykonawca",
"album": "Album",
"year": "Rok",
"genre": "Gatunek",
"track": "Ścieżka",
"container": "Kontener",
"videoCodec": "Kodek wideo",
"audioCodec": "Kodek audio",
"noTemplate": "Twój szablon jest pusty!",
"newFileTemplate": "Szablon nowego pliku",
"newFileTemplateMessage": "Dodaj tutaj nowe nazwy plików, będą one używane jako szablon do tworzenia nowych plików w menu kontekstowym!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Hasło jest wymagane do wygenerowania kodu uwierzytelniającego."
},
"general": {
"show": "Pokaż",
"hide": "Ukryj",
"import": "Importuj",
"export": "Eksportuj",
"name": "Nazwa",
@@ -676,7 +689,6 @@
"allow": "Zezwalaj",
"allowDeny": "Zezwól/Odmów",
"userGroup": "Użytkownik/Grupa",
"accessManagement": "Zarządzanie dostępem",
"totalDenied": "Odmowa",
"totalAllowed": "Dozwolone",
"all": "Odmów wszystkim",
@@ -684,8 +696,6 @@
"defaultBehavior": "Działanie domyślne{suffix}",
"cascadeDelete": "Usuwanie rekursywne",
"cascadeDeleteDescription": "Po włączeniu tej opcji usunięcie reguły dostępu spowoduje również usunięcie jej ze wszystkich podkatalogów. Pozwala to na całkowite usunięcie użytkownika lub grupy z całego drzewa katalogów.",
"hasRules": "Skonfigurowane reguły dostępu",
"manageDescription": "Zasady otwartego dostępu dla tej ścieżki.",
"newGroup": "Nowa grupa",
"editGroup": "Edytuj grupę",
"groupName": "Nazwa grupy",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "A nova senha deve ser diferente da sua senha atual"
},
"prompts": {
"mediaInfo": "Informação do meio",
"mediaTitle": "Título",
"subtitle": "Legenda {n}",
"artist": "Artista",
"album": "Álbum",
"year": "Ano",
"genre": "Género",
"track": "Faixa",
"container": "Contentor",
"videoCodec": "Codec de vídeo",
"audioCodec": "Codec de áudio",
"noTemplate": "Seu modelo está vazio!",
"newFileTemplate": "Modelo de novo arquivo",
"newFileTemplateMessage": "Adicione novos nomes de arquivos aqui, eles serão usados como modelo para criar novos arquivos no menu de contexto!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Sua senha é necessária para gerar um código de autenticação."
},
"general": {
"show": "Mostrar",
"hide": "Ocultar",
"import": "Importar",
"export": "Exportar",
"name": "Nome",
@@ -676,7 +689,6 @@
"allow": "Permitir",
"allowDeny": "Permitir/Negar",
"userGroup": "Usuário/Grupo",
"accessManagement": "Gerenciamento de acesso",
"totalDenied": "Negado",
"totalAllowed": "Permitido",
"all": "Negar tudo",
@@ -684,8 +696,6 @@
"defaultBehavior": "Ação padrão{suffix}",
"cascadeDelete": "Exclusão recursiva",
"cascadeDeleteDescription": "Quando ativada, a exclusão de uma regra de acesso também a removerá de todos os subdiretórios. Isso permite remover completamente um usuário ou grupo de toda a árvore de diretórios.",
"hasRules": "Regras de acesso configuradas",
"manageDescription": "Regras de acesso aberto para este caminho.",
"newGroup": "Novo grupo",
"editGroup": "Editar grupo",
"groupName": "Nome do grupo",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "A nova palavra-passe tem de ser diferente da sua palavra-passe atual"
},
"prompts": {
"mediaInfo": "Informação do meio",
"mediaTitle": "Título",
"subtitle": "Legenda {n}",
"artist": "Artista",
"album": "Álbum",
"year": "Ano",
"genre": "Género",
"track": "Faixa",
"container": "Contentor",
"videoCodec": "Codec de vídeo",
"audioCodec": "Codec de áudio",
"noTemplate": "O seu modelo está vazio!",
"newFileTemplate": "Modelo de novo ficheiro",
"newFileTemplateMessage": "Adicione novos nomes de ficheiros aqui, eles serão usados como modelo para criar novos ficheiros no menu de contexto!",
@@ -604,6 +615,8 @@
"accountOptions": "Opções da conta"
},
"general": {
"show": "Mostrar",
"hide": "Ocultar",
"import": "Importar",
"export": "Exportar",
"name": "Nome",
@@ -788,14 +801,11 @@
"allow": "Permitir",
"allowDeny": "Permitir/Negar",
"userGroup": "Utilizador/Grupo",
"accessManagement": "Gestão do acesso",
"all": "Recusar tudo",
"defaultBehaviorDescription": "Isto mostra o que acontece quando um utilizador tenta aceder a um caminho onde não existem regras de acesso específicas. \"Permitir\" significa que os utilizadores podem aceder por predefinição, \"Negar\" significa que os utilizadores são bloqueados por predefinição, a menos que sejam explicitamente autorizados.",
"defaultBehavior": "Ação por defeito{suffix}",
"cascadeDelete": "Eliminação recursiva",
"cascadeDeleteDescription": "Quando activada, a eliminação de uma regra de acesso também a removerá de todos os subdirectórios. Isto permite-lhe remover completamente um utilizador ou grupo de toda a árvore de diretórios.",
"hasRules": "Regras de acesso configuradas",
"manageDescription": "Regras de acesso livre para este percurso.",
"newGroup": "Novo grupo",
"editGroup": "Editar grupo",
"groupName": "Nome do grupo",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Noua parolă trebuie să fie diferită de parola actuală"
},
"prompts": {
"mediaInfo": "Informații media",
"mediaTitle": "Titlu",
"subtitle": "Subtitrare {n}",
"artist": "Artist",
"album": "Album",
"year": "An",
"genre": "Gen",
"track": "Piesă",
"container": "Container",
"videoCodec": "Codec video",
"audioCodec": "Codec audio",
"noTemplate": "Șablonul tău este gol!",
"newFileTemplate": "Șablon fișier nou",
"newFileTemplateMessage": "Adăugați nume de fișiere noi aici, acestea vor fi folosite ca șablon pentru crearea de fișiere noi în meniul contextual!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Parola dvs. este necesară pentru a genera un cod de autentificare."
},
"general": {
"show": "Afișează",
"hide": "Ascunde",
"import": "Import",
"export": "Export",
"name": "Nume și prenume",
@@ -676,7 +689,6 @@
"allow": "Permiteți",
"allowDeny": "Permiteți/Negați",
"userGroup": "Utilizator/grup",
"accessManagement": "Gestionarea accesului",
"totalDenied": "Refuzat",
"totalAllowed": "Permise",
"all": "Refuzați toate",
@@ -684,8 +696,6 @@
"defaultBehavior": "Acțiune implicită{suffix}",
"cascadeDelete": "Ștergere recursivă",
"cascadeDeleteDescription": "Atunci când este activată, ștergerea unei reguli de acces o va elimina și din toate subdirectoarele. Acest lucru vă permite să eliminați complet un utilizator sau un grup din întregul arbore de directoare.",
"hasRules": "Reguli de acces configurate",
"manageDescription": "Reguli de acces liber pentru această cale.",
"newGroup": "Grup nou",
"editGroup": "Editează grupul",
"groupName": "Numele grupului",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Новый пароль должен отличаться от вашего текущего пароля"
},
"prompts": {
"mediaInfo": "Информация о медиа",
"mediaTitle": "Название",
"subtitle": "Субтитры {n}",
"artist": "Исполнитель",
"album": "Альбом",
"year": "Год",
"genre": "Жанр",
"track": "Дорожка",
"container": "Контейнер",
"videoCodec": "Видеокодек",
"audioCodec": "Аудиокодек",
"noTemplate": "Ваш шаблон пуст!",
"newFileTemplate": "Шаблон нового файла",
"newFileTemplateMessage": "Добавьте новые имена файлов здесь, они будут использоваться как шаблон для создания новых файлов в контекстном меню!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Ваш пароль необходим для генерации кода аутентификатора."
},
"general": {
"show": "Показать",
"hide": "Скрыть",
"import": "Импорт",
"export": "Экспорт",
"name": "Имя",
@@ -676,7 +689,6 @@
"allow": "Разрешить",
"allowDeny": "Разрешить/запретить",
"userGroup": "Пользователь/группа",
"accessManagement": "Управление доступом",
"totalDenied": "Отказано",
"totalAllowed": "Разрешено",
"all": "Запретить все",
@@ -684,8 +696,6 @@
"defaultBehavior": "Действие по умолчанию{suffix}",
"cascadeDelete": "Рекурсивное удаление",
"cascadeDeleteDescription": "Если эта функция включена, удаление правила доступа также удалит его из всех подкаталогов. Это позволяет полностью удалить пользователя или группу из всего дерева каталогов.",
"hasRules": "Настроены правила доступа",
"manageDescription": "Правила открытого доступа для данного маршрута.",
"newGroup": "Новая группа",
"editGroup": "Редактировать группу",
"groupName": "Название группы",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Nové heslo sa nesmie zhodovať s vaším súčasným heslom"
},
"prompts": {
"mediaInfo": "Informácie o médiu",
"mediaTitle": "Názov",
"subtitle": "Titulky {n}",
"artist": "Interpret",
"album": "Album",
"year": "Rok",
"genre": "Žáner",
"track": "Stopa",
"container": "Kontajner",
"videoCodec": "Video kodek",
"audioCodec": "Audio kodek",
"noTemplate": "Vaša šablóna je prázdna!",
"newFileTemplate": "Šablóna nového súboru",
"newFileTemplateMessage": "Tu pridajte nové názvy súborov, budú sa používať ako šablóna na vytváranie nových súborov v kontextovej ponuke!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Na vygenerovanie autentifikačného kódu je potrebné vaše heslo."
},
"general": {
"show": "Zobraziť",
"hide": "Skryť",
"import": "Importovať",
"export": "Exportovať",
"name": "Názov",
@@ -676,7 +689,6 @@
"allow": "Povoľte",
"allowDeny": "Povoliť/odmietnuť",
"userGroup": "Používateľ/skupina",
"accessManagement": "Správa prístupu",
"totalDenied": "Odmietnuté",
"totalAllowed": "Povolené",
"all": "Odmietnuť všetko",
@@ -684,8 +696,6 @@
"defaultBehavior": "Predvolená akcia{suffix}",
"cascadeDelete": "Rekurzívne vymazanie",
"cascadeDeleteDescription": "Ak je táto možnosť povolená, odstránením pravidla prístupu sa odstráni aj zo všetkých podadresárov. To umožňuje úplne odstrániť používateľa alebo skupinu z celého stromu adresárov.",
"hasRules": "Nastavené pravidlá prístupu",
"manageDescription": "Pravidlá otvoreného prístupu pre túto trasu.",
"newGroup": "Nová skupina",
"editGroup": "Upraviť skupinu",
"groupName": "Názov skupiny",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Det nya lösenordet måste skilja sig från ditt nuvarande lösenord"
},
"prompts": {
"mediaInfo": "Mediainformation",
"mediaTitle": "Titel",
"subtitle": "Undertext {n}",
"artist": "Artist",
"album": "Album",
"year": "År",
"genre": "Genre",
"track": "Spår",
"container": "Behållare",
"videoCodec": "Videokodek",
"audioCodec": "Ljudkodek",
"noTemplate": "Din mall är tom!",
"newFileTemplate": "Mall för ny fil",
"newFileTemplateMessage": "Lägg till nya filnamn här, de kommer att användas som mall för att skapa nya filer i snabbmenyn!",
@@ -604,6 +615,8 @@
"accountOptions": "Kontoalternativ"
},
"general": {
"show": "Visa",
"hide": "Dölj",
"import": "Importera",
"export": "Exportera",
"name": "Namn",
@@ -788,14 +801,11 @@
"allow": "Tillåt",
"allowDeny": "Tillåt/avslå",
"userGroup": "Användare/Grupp",
"accessManagement": "Hantering av åtkomst",
"all": "Avvisa alla",
"defaultBehaviorDescription": "Detta visar vad som händer när en användare försöker komma åt en sökväg där det inte finns några specifika åtkomstregler. \"Allow\" innebär att användare kan komma åt som standard, \"Deny\" innebär att användare blockeras som standard om de inte uttryckligen tillåts.",
"defaultBehavior": "Standardåtgärd{suffix}",
"cascadeDelete": "Rekursiv radering",
"cascadeDeleteDescription": "Om funktionen är aktiverad tas en åtkomstregel även bort från alla underkataloger. På så sätt kan du helt ta bort en användare eller grupp från hela katalogträdet.",
"hasRules": "Konfigurerade åtkomstregler",
"manageDescription": "Regler för fri åtkomst för denna väg.",
"newGroup": "Ny grupp",
"editGroup": "Redigera grupp",
"groupName": "Gruppnamn",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Yeni şifreniz, mevcut şifrenizden farklı olmalıdır"
},
"prompts": {
"mediaInfo": "Medya Bilgisi",
"mediaTitle": "Başlık",
"subtitle": "Altyazı {n}",
"artist": "Sanatçı",
"album": "Albüm",
"year": "Yıl",
"genre": "Tür",
"track": "Parça",
"container": "Kapsayıcı",
"videoCodec": "Video Kodeği",
"audioCodec": "Ses Kodeği",
"noTemplate": "Şablonunuz boş!",
"newFileTemplate": "Yeni dosya şablonu",
"newFileTemplateMessage": "Buraya yeni dosya adları ekleyin, bunlar bağlam menüsünde yeni dosya oluşturmak için şablon olarak kullanılacaktır!",
@@ -494,6 +505,8 @@
"passwordRequiredForGeneration": "Kimlik doğrulayıcı kodu oluşturmak için parolanız gereklidir."
},
"general": {
"show": "Göster",
"hide": "Gizle",
"import": "İçe Aktar",
"export": "Dışa Aktar",
"name": "İsim{suffix}",
@@ -676,7 +689,6 @@
"allow": "İzin ver",
"allowDeny": "İzin Ver / Reddet",
"userGroup": "Kullanıcı/Grup",
"accessManagement": "Erişim Yönetimi",
"totalDenied": "Reddedildi",
"totalAllowed": "İzin verildi",
"all": "Tümünü Reddet",
@@ -684,8 +696,6 @@
"defaultBehavior": "Varsayılan eylem{suffix}",
"cascadeDelete": "Yinelemeli Silme",
"cascadeDeleteDescription": "Etkinleştirildiğinde, bir erişim kuralı silindiğinde tüm alt dizinlerden de kaldırılır. Bu, bir kullanıcıyı veya grubu tüm dizin ağacından tamamen kaldırmanıza olanak tanır.",
"hasRules": "Erişim kuralları yapılandırıldı",
"manageDescription": "Bu yol için açık erişim kuralları.",
"newGroup": "Yeni grup",
"editGroup": "Grubu düzenle",
"groupName": "Grup adı",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "Новий пароль повинен відрізнятися від вашого поточного пароля"
},
"prompts": {
"mediaInfo": "Інформація про медіа",
"mediaTitle": "Назва",
"subtitle": "Субтитри {n}",
"artist": "Виконавець",
"album": "Альбом",
"year": "Рік",
"genre": "Жанр",
"track": "Доріжка",
"container": "Контейнер",
"videoCodec": "Відеокодек",
"audioCodec": "Аудіокодек",
"noTemplate": "Ваш шаблон порожній!",
"newFileTemplate": "Шаблон нового файлу",
"newFileTemplateMessage": "Додайте нові імена файлів тут, вони будуть використовуватися як шаблон для створення нових файлів у контекстному меню!",
@@ -604,6 +615,8 @@
"accountOptions": "Параметри облікового запису"
},
"general": {
"show": "Показати",
"hide": "Приховати",
"import": "Імпорт",
"export": "Експорт",
"name": "Ім'я",
@@ -788,14 +801,11 @@
"allow": "Дозвольте",
"allowDeny": "Дозволити/Заборонити",
"userGroup": "Користувач/група",
"accessManagement": "Керування доступом",
"all": "Заперечувати все.",
"defaultBehaviorDescription": "Тут показано, що відбувається, коли користувач намагається отримати доступ до шляху, для якого не існує певних правил доступу. \"Дозволити\" означає, що користувачі можуть отримати доступ за замовчуванням, \"Заборонити\" означає, що користувачі за замовчуванням заблоковані, якщо тільки це не дозволено явно.",
"defaultBehavior": "Дія за замовчуванням{suffix}",
"cascadeDelete": "Рекурсивне видалення",
"cascadeDeleteDescription": "Якщо увімкнено, видалення правила доступу також видалить його з усіх підкаталогів. Це дозволяє повністю видалити користувача або групу з усього дерева каталогів.",
"hasRules": "Налаштовано правила доступу",
"manageDescription": "Правила відкритого доступу для цього шляху.",
"newGroup": "Нова група",
"editGroup": "Редагувати групу",
"groupName": "Назва групи",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "新密码必须与您当前的密码不同"
},
"prompts": {
"mediaInfo": "媒体信息",
"mediaTitle": "标题",
"subtitle": "字幕 {n}",
"artist": "艺术家",
"album": "专辑",
"year": "年份",
"genre": "流派",
"track": "音轨",
"container": "容器",
"videoCodec": "视频编解码器",
"audioCodec": "音频编解码器",
"noTemplate": "您的模板为空!",
"newFileTemplate": "新建文件模板",
"newFileTemplateMessage": "在此处添加新文件名,它们将用作上下文菜单中新建文件的模板!",
@@ -604,6 +615,8 @@
"accountOptions": "账户选项"
},
"general": {
"show": "显示",
"hide": "隐藏",
"import": "导入",
"export": "导出",
"name": "名称",
@@ -788,14 +801,11 @@
"allow": "允许",
"allowDeny": "允许/拒绝",
"userGroup": "用户/组",
"accessManagement": "访问管理",
"all": "全部拒绝",
"defaultBehaviorDescription": "这显示了当用户试图访问没有特定访问规则的路径时会发生什么。允许 \"表示默认情况下用户可以访问,\"拒绝 \"表示除非明确允许,否则默认情况下用户将被阻止。",
"defaultBehavior": "默认操作{suffix}",
"cascadeDelete": "递归删除",
"cascadeDeleteDescription": "启用后,删除访问规则也会从所有子目录中删除该规则。这样就可以从整个目录树中完全删除用户或组。",
"hasRules": "已配置访问规则",
"manageDescription": "此路径的开放访问规则。",
"newGroup": "新建群组",
"editGroup": "编辑群组",
"groupName": "群组名称",
+13 -3
View File
@@ -157,6 +157,17 @@
"requirePasswordChangeMustDiffer": "新密碼必須與您目前的密碼不同"
},
"prompts": {
"mediaInfo": "媒體資訊",
"mediaTitle": "標題",
"subtitle": "字幕 {n}",
"artist": "藝術家",
"album": "專輯",
"year": "年份",
"genre": "類型",
"track": "音軌",
"container": "容器",
"videoCodec": "視訊編解碼器",
"audioCodec": "音訊編解碼器",
"noTemplate": "您的模板為空!",
"newFileTemplate": "新建檔案模板",
"newFileTemplateMessage": "在此處添加新檔案名稱,它們將用作上下文選單中新建檔案的模板!",
@@ -604,6 +615,8 @@
"orange": "橘色"
},
"general": {
"show": "顯示",
"hide": "隱藏",
"import": "匯入",
"export": "匯出",
"name": "名稱{suffix}",
@@ -788,14 +801,11 @@
"allow": "允許",
"allowDeny": "允許/拒絕",
"userGroup": "使用者/群組",
"accessManagement": "存取管理",
"all": "全部拒絕",
"defaultBehaviorDescription": "這顯示使用者嘗試存取沒有特定存取規則的路徑時會發生的情況。「允許」表示使用者預設可以存取,「拒絕」表示使用者預設會被阻擋,除非明確允許。",
"defaultBehavior": "預設動作{suffix}",
"cascadeDelete": "遞迴刪除",
"cascadeDeleteDescription": "啟用時,刪除存取規則也會從所有子目錄中移除該規則。這可讓您從整個目錄樹中完全移除使用者或群組。",
"hasRules": "已設定存取規則",
"manageDescription": "此路徑的開放存取規則。",
"newGroup": "新增群組",
"editGroup": "編輯群組",
"groupName": "群組名稱",
+5 -6
View File
@@ -29,6 +29,7 @@ import { rawList, withEntry, withoutEntry } from "@/utils/listing/selection.ts";
import type {
DisplayPreference,
FileListItem,
MediaMetadata,
Prompt,
ReqObject,
RouteObject,
@@ -995,14 +996,12 @@ export const mutations = {
state.req = value;
setActiveViewGrantScope(value?.source ?? "");
},
patchListingMetadata: (items: FileListItem[] | undefined, metadataMap: Map<string, unknown> | undefined, mergeForPath: string | null = null) => {
patchListingMetadata: (items: FileListItem[] | undefined, metadataMap: Map<string, MediaMetadata> | undefined, mergeForPath: string | null = null) => {
if (!items?.length || !metadataMap?.size) {
return;
}
const resolveMetadata = (item: FileListItem, metadata: unknown) =>
item.path === mergeForPath
? { ...(metadata as Record<string, unknown>), ...(item.metadata as Record<string, unknown>) }
: metadata;
const resolveMetadata = (item: FileListItem, metadata: MediaMetadata | undefined) =>
item.path === mergeForPath ? { ...metadata, ...item.metadata } : metadata;
// state.req.items is markRaw so editing an item would not re-render its row.
// For that, we swap the changed items for copies and replace the array
if (items === state.req?.items) {
@@ -1044,7 +1043,7 @@ export const mutations = {
}
navigationSortCache = null;
},
patchRequestMetadata: (metadataItems: Array<{ name: string; metadata: unknown }> | undefined) => {
patchRequestMetadata: (metadataItems: Array<{ name: string; metadata: MediaMetadata }> | undefined) => {
if (!state.req?.items || !metadataItems?.length) {
return;
}
+52 -5
View File
@@ -15,7 +15,18 @@ export interface FileListItem {
isShared?: boolean;
pinned?: boolean;
hidden?: boolean;
metadata?: unknown;
metadata?: MediaMetadata;
}
/** from backend utils.SubtitleTrack. */
export interface SubtitleTrack {
name: string;
language?: string;
srclang?: string;
title?: string;
index?: number;
codec?: string;
embedded: boolean;
}
/** from backend iteminfo.MediaMetadata. */
@@ -34,10 +45,46 @@ export interface MediaMetadata {
hasLyrics?: boolean;
}
export type QuotaMeter = "index_size" | "index_scope" | "accounted";
export type QuotaUnit = "mb" | "gb";
/** from backend web.folderQuotaResponse */
export interface QuotaSnapshot {
limitBytes?: number;
usedBytes?: number;
reservedBytes?: number;
meter?: QuotaMeter;
configuredMeter?: QuotaMeter;
effectiveMeter?: QuotaMeter;
measurementStatus?: "ready" | "accounted_fallback";
}
export interface FolderQuota extends QuotaSnapshot {
source: string;
path: string;
limitBytes: number;
}
export type AccessRuleCategory = "user" | "group" | "all";
/** from backend access.FrontendRuleSet */
export interface AccessRuleSet {
users: string[];
groups: string[];
}
/** from backend access.FrontendAccessRule */
export interface AccessRule {
denyAll?: boolean;
deny: AccessRuleSet;
allow: AccessRuleSet;
sourceDenyDefault: boolean;
pathExists: boolean;
}
/** List items have their position in `req.items` (by mutations.replaceRequest) */
export interface fileListItemIndex extends Omit<FileListItem, "metadata"> {
export interface fileListItemIndex extends FileListItem {
index: number;
metadata?: MediaMetadata;
}
export interface ReqObject {
@@ -60,13 +107,13 @@ export interface ReqObject {
modified?: string;
created?: string;
hasPreview?: boolean;
subtitles?: unknown[];
subtitles?: SubtitleTrack[];
viewToken?: string;
parentDirItems?: FileListItem[];
onlyOfficeId?: string;
hasUpdate?: boolean;
isDir?: boolean;
metadata?: unknown;
metadata?: MediaMetadata;
// Directory listing properties
listing?: FileListItem[];
+197 -154
View File
@@ -1,58 +1,49 @@
import { state } from "@/store/index.ts";
/** Which source (or share) a folder belongs to */
export interface DirMetadataScope {
/** Which source (or share) a path belongs to */
export interface MetadataScope {
isShare?: boolean;
source?: string;
hash?: string;
}
export interface DirMetadataKey extends DirMetadataScope {
path: string;
}
/** metadata by file name */
export type DirMetadataMap = Map<string, object>;
/** Per-item changes recorded since a folder was cached */
export interface PendingDirChanges {
/** deleted or moved away */
removed: Set<string>;
/** uploaded, copied or moved */
dirty: Set<string>;
/** names without extension */
stems: Set<string>;
}
type Change = "removed" | "changed";
export interface DirMetadataEntry extends PendingDirChanges {
key: string;
promise: Promise<DirMetadataMap>;
size: number;
interface Entry {
scope: string;
/** normalized path of the folder or file */
path: string;
promise: Promise<unknown>;
time: number;
settled: boolean;
pendingSince: number;
/** items in a folder entry, they count against MAX_FOLDER_ITEMS */
size: number;
/** folder entries only: items deleted or uploaded since it was fetched */
changes?: Map<string, Change>;
}
const TTL_MS = 300000;
const PENDING_REUSE_MS = 120000;
const MAX_TOTAL_ITEMS = 5000;
const TTL_MS = 600000;
const MAX_FILE_ENTRIES = 30;
const MAX_FOLDER_ITEMS = 5000;
const MAX_PENDING_CHANGES = 200;
// least recently used first
const cacheMap = new Map<string, DirMetadataEntry>();
const DIR = "dir";
// folders and single files, least recently used first
const cache = new Map<string, Entry>();
let cacheUser: string | null = null;
// Entries are not keyed by user, the cache is dropped whenever the signed-in user logs out.
// Entries are not keyed by user, the cache is dropped whenever the signed-in user changes,
// one account's metadata is never served to the other.
function cache(): Map<string, DirMetadataEntry> {
function checkUser(): void {
const user = state.user?.username || "";
if (user !== cacheUser) {
cacheMap.clear();
cache.clear();
cacheUser = user;
}
return cacheMap;
}
function scopePart({ isShare, source, hash }: DirMetadataScope): string {
function scopePart({ isShare, source, hash }: MetadataScope): string {
return isShare ? `share:${hash}` : `src:${source}`;
}
@@ -61,8 +52,8 @@ function normalizePath(path: string | undefined): string {
return collapsed.length > 1 ? collapsed.replace(/\/$/, "") : "/";
}
function getCacheKey({ isShare, source, hash, path }: DirMetadataKey): string {
return `${scopePart({ isShare, source, hash })}|${normalizePath(path)}`;
function keyOf(scope: MetadataScope, path: string, kind: string): string {
return `${scopePart(scope)}|${normalizePath(path)}|${kind}`;
}
function splitItemPath(itemPath: string | undefined): { dir: string; name: string; isDir: boolean } {
@@ -77,189 +68,241 @@ function splitItemPath(itemPath: string | undefined): { dir: string; name: strin
}
/** file name without its last extension, so "song.mp3" and "song.lrc" match */
export function stemOf(name: string): string {
function stemOf(name: string): string {
const dot = name.lastIndexOf(".");
return (dot > 0 ? name.slice(0, dot) : name).toLowerCase();
}
function trimToLimit(): void {
const entries = cache();
let total = 0;
for (const entry of entries.values()) {
total += entry.size;
function trim(): void {
let files = 0;
let folderItems = 0;
for (const { changes, size } of cache.values()) {
if (changes) {
folderItems += size;
} else {
files++;
}
}
for (const [key, entry] of entries) {
if (total <= MAX_TOTAL_ITEMS) {
for (const [key, entry] of cache) {
if (files <= MAX_FILE_ENTRIES && folderItems <= MAX_FOLDER_ITEMS) {
break;
}
total -= entry.size;
entries.delete(key);
if (entry.changes ? folderItems > MAX_FOLDER_ITEMS : files > MAX_FILE_ENTRIES) {
cache.delete(key);
if (entry.changes) {
folderItems -= entry.size;
} else {
files--;
}
}
}
}
/** @param startsCache true for folder fetc. the TTL then counts from when its result arrived */
function watchEntry(entry: DirMetadataEntry, startsCache = false): void {
/** @param fresh true for a new fetch, the TTL then counts from when its result arrived */
function track(key: string, entry: Entry, fresh: boolean): void {
const { promise } = entry;
entry.settled = false;
entry.pendingSince = Date.now();
promise.then(
(map) => {
if (entry.promise !== promise) {
(payload) => {
if (cache.get(key) !== entry || entry.promise !== promise) {
return;
}
entry.settled = true;
if (cache().get(entry.key) === entry) {
if (startsCache) {
entry.time = Date.now();
}
entry.size = map.size;
if (map.size === 0) {
cache().delete(entry.key);
} else {
trimToLimit();
}
// An empty folder answer is a valid answer, dropping it made every consumer
// (listing, preview, next/previous) request the whole folder again. It counts as one item.
if (!entry.changes && !payload) {
cache.delete(key);
return;
}
if (fresh) {
entry.time = Date.now();
}
entry.size = payload instanceof Map ? Math.max(1, payload.size) : 1;
trim();
},
() => {
if (entry.promise !== promise) {
return;
}
entry.settled = true;
if (cache().get(entry.key) === entry) {
cache().delete(entry.key);
if (cache.get(key) === entry && entry.promise === promise) {
cache.delete(key);
}
},
);
}
/** Looking an entry up counts as using it, so the folders you keep returning to are removed last */
export function getDirMetadataEntry(opts: DirMetadataKey): DirMetadataEntry | null {
const entries = cache();
const key = getCacheKey(opts);
const entry = entries.get(key);
/** Looking an entry up counts as using it, so the ones you keep returning to are removed last */
function lookup(key: string): Entry | null {
const entry = cache.get(key);
if (!entry) {
return null;
}
entries.delete(key);
cache.delete(key);
if (Date.now() - entry.time > TTL_MS) {
return null;
}
if (!entry.settled && Date.now() - entry.pendingSince > PENDING_REUSE_MS) {
return null;
}
entries.set(key, entry);
cache.set(key, entry);
return entry;
}
/** Stores a pending or resolved promise for this folder. */
export function setCachedDirMetadata(opts: DirMetadataKey, promise: Promise<DirMetadataMap>): void {
const entries = cache();
const key = getCacheKey(opts);
entries.delete(key);
const entry: DirMetadataEntry = { key, promise, size: 0, time: Date.now(), settled: false, pendingSince: Date.now(), removed: new Set(), dirty: new Set(), stems: new Set() };
entries.set(key, entry);
watchEntry(entry, true);
function add(key: string, scope: MetadataScope, path: string, promise: Promise<unknown>, changes?: Map<string, Change>): Entry {
const entry: Entry = { scope: scopePart(scope), path: normalizePath(path), promise, time: Date.now(), size: 0, changes };
cache.set(key, entry);
track(key, entry, true);
return entry;
}
/**
* Sends the per-item changes to the caller and starts clean.
* @returns the changes, or null when are none
* Runs once per file and kind within the TTL, so the info prompt, the preview and repeated opens share one request. Failed or empty results are not kept.
* Entries go when the file, or a sidecar with its name, changes.
*/
export function takePendingDirChanges(entry: DirMetadataEntry): PendingDirChanges | null {
if (entry.removed.size + entry.dirty.size + entry.stems.size === 0) {
return null;
export function cachedItem<T>(scope: MetadataScope, path: string, kind: string, fetcher: () => Promise<T>): Promise<T> {
checkUser();
const key = keyOf(scope, path, kind);
const hit = lookup(key);
return (hit ?? add(key, scope, path, fetcher())).promise as Promise<T>;
}
// Applies the per-item changes recorded since the folder was cached, fetching only the changed items.
async function applyChanges(
base: Promise<DirMetadataMap>,
changes: Map<string, Change>,
fetchAll: () => Promise<DirMetadataMap>,
fetchOne: (name: string) => Promise<object | null>,
): Promise<DirMetadataMap> {
const map = new Map(await base);
const toRefresh = new Set<string>();
for (const [name, change] of changes) {
if (change === "removed") {
map.delete(name);
} else {
toRefresh.add(name);
}
}
const pending = { removed: entry.removed, dirty: entry.dirty, stems: entry.stems };
entry.removed = new Set();
entry.dirty = new Set();
entry.stems = new Set();
return pending;
const stems = new Set([...changes.keys()].map(stemOf));
for (const name of map.keys()) {
if (stems.has(stemOf(name))) {
toRefresh.add(name);
}
}
// returns the names that failed, others are applied
const refresh = async (names: string[]): Promise<string[]> => {
const failed: string[] = [];
await Promise.all(
names.map(async (name) => {
try {
const metadata = await fetchOne(name);
if (metadata) {
map.set(name, metadata);
} else {
map.delete(name);
}
} catch {
failed.push(name);
}
}),
);
return failed;
};
const failed = await refresh([...toRefresh]);
if (failed.length && (await refresh(failed)).length) {
return fetchAll();
}
return map;
}
/** Replaces the promise of an existing entry (after pending changes were applied). */
export function setDirMetadataEntryPromise(entry: DirMetadataEntry, promise: Promise<DirMetadataMap>): void {
entry.promise = promise;
watchEntry(entry);
/**
* Metadata of a folder's items by name, cached and shared for all the callers to avoid unnecessary
* requests. Items changed (deleted, uploaded, moved) are patched with `fetchOne` instead of
* refetching the whole folder with `fetchAll`.
*/
export function cachedDirectory(
scope: MetadataScope,
path: string,
fetchAll: () => Promise<DirMetadataMap>,
fetchOne: (name: string) => Promise<object | null>,
): Promise<DirMetadataMap> {
checkUser();
const key = keyOf(scope, path, DIR);
const entry = lookup(key) ?? add(key, scope, path, fetchAll(), new Map());
const { changes } = entry;
if (changes?.size) {
entry.changes = new Map();
entry.promise = applyChanges(entry.promise as Promise<DirMetadataMap>, changes, fetchAll, fetchOne);
track(key, entry, false);
}
return entry.promise as Promise<DirMetadataMap>;
}
function dropSubtree(scope: DirMetadataScope, itemPath: string): void {
const entries = cache();
const prefix = `${scopePart(scope)}|`;
function drop(match: (entry: Entry) => boolean): void {
checkUser();
for (const [key, entry] of cache) {
if (match(entry)) {
cache.delete(key);
}
}
}
/** an item and everything cached below it, for folders and files alike */
function dropSubtree(scope: MetadataScope, itemPath: string): void {
const scopeKey = scopePart(scope);
const root = normalizePath(itemPath);
for (const key of [...entries.keys()]) {
if (!key.startsWith(prefix)) {
continue;
}
const pathPart = key.slice(prefix.length);
if (pathPart === root || pathPart.startsWith(`${root}/`)) {
entries.delete(key);
}
}
const prefix = root === "/" ? "/" : `${root}/`;
drop((entry) => entry.scope === scopeKey && (entry.path === root || entry.path.startsWith(prefix)));
}
function recordItemChange(scope: DirMetadataScope, itemPath: string, kind: "removed" | "changed"): void {
/** cached entries go when the file or a sidecar sharing its stem changes */
function dropFilesByStem(scope: MetadataScope, dir: string, stem: string): void {
const scopeKey = scopePart(scope);
const dirPath = normalizePath(dir);
drop((entry) => {
if (entry.changes || entry.scope !== scopeKey) {
return false;
}
const parts = splitItemPath(entry.path);
const entryStem = stemOf(parts.name);
// "movie.en.srt" (stem "movie.en") changes the subtitles of "movie.mkv" (stem "movie")
return parts.dir === dirPath && (entryStem === stem || stem.startsWith(`${entryStem}.`));
});
}
function recordChange(scope: MetadataScope, itemPath: string, change: Change): void {
const { dir, name, isDir } = splitItemPath(itemPath);
if (!name) {
return;
}
dropSubtree(scope, itemPath);
if (kind === "changed" && isDir) {
if (change === "changed" && isDir) {
return; // folders have no media metadata of their own
}
const key = getCacheKey({ ...scope, path: dir });
const entry = cache().get(key);
if (!entry) {
dropFilesByStem(scope, dir, stemOf(name));
const key = keyOf(scope, dir, DIR);
const changes = cache.get(key)?.changes;
if (!changes) {
return;
}
if (kind === "removed") {
entry.removed.add(name);
entry.dirty.delete(name);
} else {
entry.dirty.add(name);
entry.removed.delete(name);
}
entry.stems.add(stemOf(name));
if (entry.removed.size + entry.dirty.size > MAX_PENDING_CHANGES) {
cache().delete(key);
changes.set(name, change);
if (changes.size > MAX_PENDING_CHANGES) {
cache.delete(key);
}
}
/** An item was deleted or moved: drop only its entry */
export function removeDirMetadataItem(scope: DirMetadataScope, itemPath: string): void {
recordItemChange(scope, itemPath, "removed");
/** An item was deleted or moved away: drop only its entry */
export function removeDirMetadataItem(scope: MetadataScope, itemPath: string): void {
recordChange(scope, itemPath, "removed");
}
/** An item was uploaded, copied or moved: refresh only its entry */
export function markDirMetadataItemChanged(scope: DirMetadataScope, itemPath: string): void {
recordItemChange(scope, itemPath, "changed");
export function markDirMetadataItemChanged(scope: MetadataScope, itemPath: string): void {
recordChange(scope, itemPath, "changed");
}
/** drops its folder's listing and everything cached below the item */
export function invalidateDirMetadataItem(scope: DirMetadataScope, itemPath: string): void {
export function invalidateDirMetadataItem(scope: MetadataScope, itemPath: string): void {
const { dir, name } = splitItemPath(itemPath);
dropSubtree(scope, itemPath);
cache().delete(getCacheKey({ ...scope, path: splitItemPath(itemPath).dir }));
if (name) {
dropFilesByStem(scope, dir, stemOf(name));
}
cache.delete(keyOf(scope, dir, DIR));
}
/**
* Invalidates cached dir metadata so the next call gets fresh data: one folder with `path`, a whole
* source or share without it, everything with no options.
*/
export function invalidateDirMetadataCache(opts: DirMetadataScope & { path?: string } = {}): void {
const entries = cache();
const { source, hash, path } = opts;
const isShare = opts.isShare ?? (hash !== undefined && source === undefined);
if (path !== undefined) {
entries.delete(getCacheKey({ isShare, source, hash, path }));
return;
}
if (source === undefined && hash === undefined) {
entries.clear();
return;
}
const prefix = `${scopePart({ isShare, source, hash })}|`;
for (const key of [...entries.keys()]) {
if (key.startsWith(prefix)) {
entries.delete(key);
}
}
/** Drops everything cached, so the next calls get fresh data */
export function invalidateDirMetadataCache(): void {
cache.clear();
}
+2 -1
View File
@@ -114,7 +114,6 @@
"markdown",
"memory",
"menu_book",
"metadata",
"monitoring",
"more_horiz",
"more_vert",
@@ -130,12 +129,14 @@
"palette",
"passkey",
"pause",
"paused",
"person",
"photo",
"php",
"picture_as_pdf",
"play_arrow",
"play_circle",
"playing",
"playlist_play",
"progress_activity",
"push_pin",
+4 -7
View File
@@ -308,13 +308,10 @@ export default {
? getters.isShare() && state.shareInfo?.hash === currentHash && state.req?.path === path
: !getters.isShare() && state.req?.source === currentSource && state.req?.path === path;
try {
const metaMap = isShare
? await mediaApi.getDirectoryMetadataMap(state.shareInfo.subPath, {
isShare: true,
hash: state.shareInfo.hash,
password: this.sharePassword,
})
: await mediaApi.getDirectoryMetadataMap(path, { source: currentSource });
const metaMap = await mediaApi.getDirectoryMetadataMap(
isShare ? state.shareInfo.subPath : path,
{ source: currentSource },
);
if (!isCurrentListing()) {
return;
}
+10 -37
View File
@@ -381,14 +381,7 @@ export default {
async attachDirMediaMetadata(listing, dirPath) {
if (!listing?.length) return;
try {
const isShare = getters.isShare();
const metaMap = isShare
? await mediaApi.getDirectoryMetadataMap(dirPath, {
isShare: true,
hash: state.shareInfo.hash,
password: localStorage.getItem(`sharepass:${state.shareInfo.hash}`) || '',
})
: await mediaApi.getDirectoryMetadataMap(dirPath, { source: state.req.source });
const metaMap = await mediaApi.getDirectoryMetadataMap(dirPath, { source: state.req.source });
if (this.listing !== listing) return;
mutations.patchListingMetadata(listing, metaMap, state.req.path);
mutations.setupNavigation({
@@ -498,15 +491,8 @@ export default {
this.lyricsFetchedForPath = state.req.path;
if (state.req.metadata?.hasLyrics) {
try {
if (getters.isShare()) {
const hash = state.shareInfo.hash;
const password = localStorage.getItem(`sharepass:${hash}`) || "";
const { lyrics: raw, format } = await mediaApi.getLyricsPublic(state.req.path, hash, password);
this.lyrics = parseLyrics(raw, format);
} else {
const { lyrics: raw, format } = await mediaApi.getLyrics(state.req.source, state.req.path);
this.lyrics = parseLyrics(raw, format);
}
const { lyrics: raw, format } = await mediaApi.getLyrics(state.req.source, state.req.path);
this.lyrics = parseLyrics(raw, format);
} catch (err) {
console.warn("Failed to fetch lyrics:", err);
this.lyrics = [];
@@ -533,27 +519,14 @@ export default {
return;
}
const req = state.req;
if (!getters.isShare() && !getters.isLoggedIn()) {
return;
}
try {
let enriched;
if (getters.isShare()) {
const pwd =
localStorage.getItem(`sharepass:${state.shareInfo.hash}`) || "";
enriched = await mediaApi.fetchDirectoryMediaMetadataPublic(
req.path,
state.shareInfo.hash,
pwd,
true,
);
} else {
if (!getters.isLoggedIn()) {
return;
}
enriched = await mediaApi.fetchDirectoryMediaMetadata(
req.source,
req.path,
true,
);
}
const enriched = await mediaApi.getItemMediaMetadata(req.path, {
source: req.source,
albumArt: true,
});
if (state.req.path !== expectedPath) {
return;
}
+34 -46
View File
@@ -24,7 +24,7 @@
<div class="audio-left-column">
<!-- Album art with a generic icon if no image/metadata -->
<div class="album-art-container"
:class="{ 'no-artwork': !albumArtUrl }"
:class="{ 'no-artwork': !artworkUrl }"
:style="{
width: `${displayArtSize}em`,
maxWidth: '100%',
@@ -33,7 +33,7 @@
@mouseenter="onAlbumArtHover"
@mouseleave="onAlbumArtLeave"
@wheel="onAlbumArtScroll">
<img class="no-select album-art" v-if="albumArtUrl" :src="albumArtUrl" :alt="metadata.album || 'Album art'" />
<img class="no-select album-art" v-if="artworkUrl" :src="artworkUrl" :alt="metadata.album || 'Album art'" />
<div v-else class="album-art-fallback">
<i class="material-symbols">music_note</i>
</div>
@@ -138,9 +138,8 @@
<video
ref="videoElement"
:type="req.type"
preload="none"
:src="nativeVideoSrc"
:poster="posterUrl"
:poster="artworkUrl"
:autoplay="videoElementAutoplay"
@play="handlePlay"
@error="onVideoPlaybackError"
@@ -799,7 +798,8 @@ export default {
&& getters.previewPerms().video
);
},
posterUrl() {
artworkUrl() {
if (this.albumArtUrl) return this.albumArtUrl;
if (!this.scrubPreviewEnabled) return undefined;
return getters.isShare()
? getPreviewURLPublic(this.req.path, 'original')
@@ -996,7 +996,7 @@ export default {
playsinline: true,
clickToPlay: false, // we manage this ourselves with the gestures, plyr has a issue where this doesn't work in mobile.
resetOnEnd: false,
preload: this.previewType === 'video' ? 'none' : 'metadata',
preload: 'none',
fullscreen: {
enabled: true,
fallback: true,
@@ -1023,12 +1023,10 @@ export default {
// Record the mounted file before any async prepare step so route-leave
// detection works while Plyr is still waiting on subtitles/metadata.
this.mountedPreviewKey = resolvePipMediaKey(this.req?.source, this.req?.path);
this.loadMediaMetadata();
if (this.previewType === 'video' || this.previewType === 'audio') {
await this.preparePlyrMount();
}
if (this.previewType === "audio") {
this.loadAudioMetadata();
}
document.addEventListener('keydown', this.handleKeydown);
this.pagehideHandler = (event) => {
if (event.persisted) return;
@@ -1086,8 +1084,8 @@ export default {
const fallbackUrl = fallbackIcon.includes('?')
? `${fallbackIcon}&t=${timestamp}`
: `${fallbackIcon}?t=${timestamp}`;
// Video uses the same thumbnail as the poster; audio uses embedded album art (if any).
const artworkSrc = this.previewType === 'video' ? this.posterUrl : this.albumArtUrl;
// Video and audio uses the embedded artwork (if any), otherwise will fallback to the thumbnail/preview url.
const artworkSrc = this.artworkUrl;
const metadata = {
title: this.metadata?.title || this.fileName,
artist: this.metadata?.artist || globalVars.name || "Filebrowser Quantum",
@@ -1247,6 +1245,7 @@ export default {
return;
}
this.plyrTeardownDone = true;
this.cleanupAlbumArt();
if (this.scrubPreviewCleanup) {
this.scrubPreviewCleanup();
@@ -1310,7 +1309,6 @@ export default {
this.clearRewindForwardFeedback();
this.cleanupAudioVisualizer();
this.clearMediaSession();
this.cleanupAlbumArt();
this.player.off();
if (leavingInPip && media) {
registerPipSession(media, {
@@ -1646,39 +1644,25 @@ export default {
this.albumArtSize = Math.max(10, Math.min(50, newSize));
},
// Load metadata from the backend response
loadAudioMetadata() {
if (this.previewType !== "audio") return;
// Check if metadata is already provided by the backend
if (this.req.metadata) {
this.metadata = {
title: this.req.metadata.title || this.fileName, // Fallback to filename
artist: this.req.metadata.artist || null,
album: this.req.metadata.album || null,
year: this.req.metadata.year || null
};
// Handle base64 encoded album art
if (this.req.metadata.albumArt) {
try {
const byteCharacters = atob(this.req.metadata.albumArt);
const byteArray = Uint8Array.from(byteCharacters, (c) => c.charCodeAt(0));
const blob = new Blob([byteArray], { type: 'image/jpeg' });
this.albumArtUrl = URL.createObjectURL(blob);
} catch (error) {
console.error("Failed to decode album art:", error);
this.albumArtUrl = null;
}
}
} else {
this.metadata = {
title: this.fileName,
artist: null,
album: null,
year: null,
};
loadMediaMetadata() {
const meta = this.req.metadata;
this.metadata = {
title: meta?.title || this.fileName, // Fallback to filename
artist: meta?.artist || null,
album: meta?.album || null,
year: meta?.year || null,
};
if (!meta?.albumArt) return;
// Base64 encoded art
try {
const bytes = Uint8Array.from(atob(meta.albumArt), (c) => c.charCodeAt(0));
this.albumArtUrl = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' }));
} catch (error) {
console.error("Failed to decode album art:", error);
this.albumArtUrl = null;
}
},
cleanupAlbumArt() {
if (this.previewType !== "audio") return;
if (typeof this.albumArtUrl === 'string' && this.albumArtUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.albumArtUrl);
}
@@ -2898,12 +2882,9 @@ export default {
return !!el.closest(
'.plyr__controls, .plyr__control, .plyr__menu__container, .plyr__menu, ' +
'[data-plyr="seek"], .plyr__progress, [data-plyr="volume"], .plyr__volume, ' +
'.audio-side-panel .tab-btn, ' +
'.audio-side-panel .sliding-tab, ' +
'.audio-side-panel .fab-button, ' +
'.audio-side-panel .lyric-line, ' +
'.audio-side-panel input[type="radio"], ' +
'.audio-side-panel label[for^="tab-"], ' +
'.audio-side-panel .mode-btn, ' +
'.audio-side-panel .repeat-one-btn, ' +
'.audio-side-panel .queue-item, ' +
'.audio-side-panel .clear-queue-btn, ' +
@@ -4151,6 +4132,13 @@ export default {
margin: 0;
}
/* this is just a workaround, the own plyr poster was hiding the video poster and showing black
when it didn't loaded (somehow happens); but summarizing, this a bug of plyr, it also loads the poster image 2 times
cuz it sets the URL on the html video tags and in their own */
.plyr__poster {
display: none !important;
}
/* Scrub preview popup (mounted on Plyr container / fullscreen root while scrubbing) */
.fb-scrub-preview {
position: fixed;