mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Merge remote-tracking branch 'upstream/develop' into feature/custom-tf-model-127
This commit is contained in:
commit
e55536e581
96 changed files with 1228 additions and 435 deletions
|
|
@ -1,5 +1,5 @@
|
|||
# Ubuntu 24.10 (Oracular Oriole)
|
||||
FROM photoprism/develop:250407-oracular
|
||||
FROM photoprism/develop:250412-oracular
|
||||
|
||||
## Alternative Environments:
|
||||
# FROM photoprism/develop:armv7 # ARMv7 (32bit)
|
||||
|
|
|
|||
6
Makefile
6
Makefile
|
|
@ -399,9 +399,11 @@ docker-build:
|
|||
$(DOCKER_COMPOSE) build --pull
|
||||
docker-nvidia: docker-nvidia-up
|
||||
docker-nvidia-up:
|
||||
docker compose --profile=vision -f compose.nvidia.yaml up
|
||||
docker compose --profile=qdrant -f compose.nvidia.yaml up
|
||||
docker-nvidia-down:
|
||||
docker compose --profile=qdrant -f compose.nvidia.yaml down --remove-orphans
|
||||
docker-nvidia-build:
|
||||
docker compose --profile=vision -f compose.nvidia.yaml build
|
||||
docker compose --profile=qdrant -f compose.nvidia.yaml build
|
||||
docker-intel: docker-intel-up
|
||||
docker-intel-up:
|
||||
docker compose -f compose.intel.yaml up
|
||||
|
|
|
|||
|
|
@ -146,27 +146,14 @@ services:
|
|||
extends:
|
||||
file: ./compose.yaml
|
||||
service: mariadb
|
||||
photoprism-vision:
|
||||
profiles: ["all", "vision"]
|
||||
environment:
|
||||
TF_CPP_MIN_LOG_LEVEL: 2
|
||||
NVIDIA_VISIBLE_DEVICES: "all"
|
||||
NVIDIA_DRIVER_CAPABILITIES: "all"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: "nvidia"
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
extends:
|
||||
file: ./compose.yaml
|
||||
service: photoprism-vision
|
||||
qdrant:
|
||||
profiles: ["all", "vision"]
|
||||
extends:
|
||||
file: ./compose.yaml
|
||||
service: qdrant
|
||||
photoprism-vision:
|
||||
extends:
|
||||
file: ./compose.yaml
|
||||
service: photoprism-vision
|
||||
traefik:
|
||||
extends:
|
||||
file: ./compose.yaml
|
||||
|
|
@ -198,9 +185,3 @@ volumes:
|
|||
driver: local
|
||||
mariadb:
|
||||
driver: local
|
||||
|
||||
## Create shared "photoprism-develop" network for connecting with services in other compose.yaml files
|
||||
networks:
|
||||
default:
|
||||
name: photoprism
|
||||
driver: bridge
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ services:
|
|||
## Web UI: https://qdrant.localssl.dev/dashboard
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
profiles: ["all", "vision"]
|
||||
profiles: ["all", "qdrant"]
|
||||
links:
|
||||
- "traefik:localssl.dev"
|
||||
- "traefik:app.localssl.dev"
|
||||
|
|
@ -199,6 +199,8 @@ services:
|
|||
## See: https://github.com/photoprism/photoprism-vision
|
||||
photoprism-vision:
|
||||
image: photoprism/vision:latest
|
||||
entrypoint: [ "/app/venv/bin/flask" ]
|
||||
command: [ "--app", "app", "run", "--debug", "--host", "0.0.0.0" ]
|
||||
profiles: ["all", "vision"]
|
||||
stop_grace_period: 5s
|
||||
working_dir: "/app"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
|
|||
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
|
||||
PROG="photoprism" \
|
||||
S6_KEEP_ENV=1 \
|
||||
S6_VERBOSITY=0 \
|
||||
S6_LOGGING=0
|
||||
|
||||
# Copy scripts and package sources config.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
|
|||
TF_ENABLE_ONEDNN_OPTS=1 \
|
||||
MALLOC_ARENA_MAX=4 \
|
||||
PROG="photoprism" \
|
||||
S6_KEEP_ENV=1 \
|
||||
S6_KEEP_ENV=0 \
|
||||
S6_VERBOSITY=0 \
|
||||
S6_LOGGING=0
|
||||
|
||||
# Copy scripts and package sources config.
|
||||
|
|
@ -73,6 +74,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
|
|||
/photoprism/storage/config \
|
||||
/photoprism/storage/cache && \
|
||||
/scripts/install-s6.sh && \
|
||||
ln -sf /scripts/services/photoprism /etc/s6-overlay/s6-rc.d/photoprism && \
|
||||
touch /etc/s6-overlay/s6-rc.d/user/contents.d/photoprism && \
|
||||
/scripts/cleanup.sh
|
||||
|
||||
# Set default working directory.
|
||||
|
|
@ -83,4 +86,3 @@ EXPOSE 2342 2442 2443
|
|||
|
||||
# Set default entrypoint and command.
|
||||
ENTRYPOINT ["/init"]
|
||||
CMD ["/scripts/cmd.sh", "tail", "-f", "/dev/null"]
|
||||
|
|
@ -39,6 +39,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
|
|||
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
|
||||
PROG="photoprism" \
|
||||
S6_KEEP_ENV=1 \
|
||||
S6_VERBOSITY=0 \
|
||||
S6_LOGGING=0
|
||||
|
||||
# Copy scripts and package sources config.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
|
|||
TF_ENABLE_ONEDNN_OPTS=1 \
|
||||
MALLOC_ARENA_MAX=4 \
|
||||
PROG="photoprism" \
|
||||
S6_KEEP_ENV=1 \
|
||||
S6_KEEP_ENV=0 \
|
||||
S6_VERBOSITY=0 \
|
||||
S6_LOGGING=0
|
||||
|
||||
# Copy scripts and package sources config.
|
||||
|
|
@ -74,6 +75,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
|
|||
/photoprism/storage/config \
|
||||
/photoprism/storage/cache && \
|
||||
/scripts/install-s6.sh && \
|
||||
ln -sf /scripts/services/photoprism /etc/s6-overlay/s6-rc.d/photoprism && \
|
||||
touch /etc/s6-overlay/s6-rc.d/user/contents.d/photoprism && \
|
||||
/scripts/cleanup.sh
|
||||
|
||||
# Set default working directory.
|
||||
|
|
@ -84,4 +87,3 @@ EXPOSE 2342 2442 2443
|
|||
|
||||
# Set default entrypoint and command.
|
||||
ENTRYPOINT ["/init"]
|
||||
CMD ["/scripts/cmd.sh", "tail", "-f", "/dev/null"]
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
|
|||
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
|
||||
PROG="photoprism" \
|
||||
S6_KEEP_ENV=1 \
|
||||
S6_VERBOSITY=0 \
|
||||
S6_LOGGING=0
|
||||
|
||||
# Copy scripts and package sources config.
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
|
|||
PHOTOPRISM_AUTO_INDEX="300" \
|
||||
PHOTOPRISM_AUTO_IMPORT="-1" \
|
||||
PHOTOPRISM_INIT="https" \
|
||||
S6_KEEP_ENV=1 \
|
||||
S6_KEEP_ENV=0 \
|
||||
S6_VERBOSITY=0 \
|
||||
S6_LOGGING=0
|
||||
|
||||
# Copy dist files, scripts, and debian backports sources list.
|
||||
|
|
@ -132,6 +133,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
|
|||
/photoprism/storage/config \
|
||||
/photoprism/storage/cache && \
|
||||
/scripts/install-s6.sh && \
|
||||
ln -sf /scripts/services/photoprism /etc/s6-overlay/s6-rc.d/photoprism && \
|
||||
touch /etc/s6-overlay/s6-rc.d/user/contents.d/photoprism && \
|
||||
/scripts/cleanup.sh
|
||||
|
||||
# Set default working directory.
|
||||
|
|
@ -142,4 +145,3 @@ EXPOSE 2342 2443
|
|||
|
||||
# Set default entrypoint and command.
|
||||
ENTRYPOINT ["/init"]
|
||||
CMD ["/scripts/cmd.sh", "/opt/photoprism/bin/photoprism", "start"]
|
||||
|
|
|
|||
|
|
@ -101,6 +101,3 @@ EXPOSE 2342 2443
|
|||
|
||||
# Copy app files.
|
||||
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism
|
||||
|
||||
# Start app.
|
||||
CMD ["/scripts/cmd.sh", "/opt/photoprism/bin/photoprism", "start"]
|
||||
|
|
@ -101,6 +101,3 @@ EXPOSE 2342 2443
|
|||
|
||||
# Copy app files.
|
||||
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism
|
||||
|
||||
# Start app.
|
||||
CMD ["/scripts/cmd.sh", "/opt/photoprism/bin/photoprism", "start"]
|
||||
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
|
|
@ -2833,9 +2833,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
|
||||
"integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==",
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz",
|
||||
"integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
|
|
@ -3959,9 +3959,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
|
||||
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
|
|
@ -8384,9 +8384,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/flow-remove-types": {
|
||||
"version": "2.266.1",
|
||||
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.266.1.tgz",
|
||||
"integrity": "sha512-dKaAhawO6bcBm5q/7FnX3gAAVZninLcDaCGIqbXIXfaLRTubnVzWgGDPXIPt72BH8uOwg6m3GifVPbn/W4+rcg==",
|
||||
"version": "2.267.0",
|
||||
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.267.0.tgz",
|
||||
"integrity": "sha512-NCXvOE6Z0N75pUkO5LnCgt+7kK78MRiB5d2xYHu87JYJ2Gl7Vz/JlwKTVwOclPUzDdTwznXY2sLpY+EMKzPqFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hermes-parser": "0.25.1",
|
||||
|
|
@ -9429,9 +9429,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.0.tgz",
|
||||
"integrity": "sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==",
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
|
||||
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "^1.1.1",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
<div id="photoprism" :class="['theme-' + themeName]">
|
||||
<p-loading-bar height="4"></p-loading-bar>
|
||||
|
||||
<p-notify></p-notify>
|
||||
|
||||
<v-app :class="appClass">
|
||||
<p-navigation></p-navigation>
|
||||
|
||||
|
|
@ -13,6 +11,7 @@
|
|||
</v-app>
|
||||
|
||||
<p-dialogs></p-dialogs>
|
||||
<p-notify></p-notify>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ export class View {
|
|||
document.addEventListener("focusin", (ev) => {
|
||||
console.log("%cdocument.focusin", "color: #B2EBF2;", ev.target);
|
||||
});
|
||||
document.addEventListener("focusout", (ev) => {
|
||||
console.log("%cdocument.focusout", "color: #B2EBF2;", ev.target);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
v-bind="props"
|
||||
density="comfortable"
|
||||
:icon="buttonIcon"
|
||||
:tabindex="tabindex"
|
||||
class="action-menu__btn"
|
||||
:class="buttonClass"
|
||||
></v-btn>
|
||||
|
|
@ -49,6 +50,10 @@ export default {
|
|||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
tabindex: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
|
|
|
|||
|
|
@ -194,11 +194,9 @@ export default {
|
|||
attach: document.body,
|
||||
},
|
||||
VOverlay: {
|
||||
scrim: true,
|
||||
transition: false,
|
||||
openDelay: 0,
|
||||
closeDelay: 0,
|
||||
attach: document.body,
|
||||
},
|
||||
VExpansionPanel: {
|
||||
tile: true,
|
||||
|
|
|
|||
|
|
@ -319,7 +319,11 @@ export default {
|
|||
}
|
||||
},
|
||||
focusContent(ev) {
|
||||
if (this.$refs.content && this.$refs.content instanceof HTMLElement) {
|
||||
if (
|
||||
this.$refs.content &&
|
||||
this.$refs.content instanceof HTMLElement &&
|
||||
document.activeElement !== this.$refs.content
|
||||
) {
|
||||
this.$refs.content.focus();
|
||||
|
||||
if (this.debug && ev) {
|
||||
|
|
@ -940,7 +944,7 @@ export default {
|
|||
}
|
||||
|
||||
// Focus content element.
|
||||
this.$refs.content.focus();
|
||||
this.focusContent();
|
||||
|
||||
// Create PhotoSwipe instance.
|
||||
let lightbox = new Lightbox(options);
|
||||
|
|
@ -1501,7 +1505,7 @@ export default {
|
|||
}
|
||||
|
||||
// Ensure that content is focused.
|
||||
this.$refs.content.focus();
|
||||
this.focusContent();
|
||||
},
|
||||
// Called when the user clicks on the PhotoSwipe lightbox background,
|
||||
// see https://photoswipe.com/click-and-tap-actions.
|
||||
|
|
@ -2190,7 +2194,7 @@ export default {
|
|||
// Resize and focus content element.
|
||||
this.$nextTick(() => {
|
||||
this.resize(true);
|
||||
this.$refs.content.focus();
|
||||
this.focusContent();
|
||||
});
|
||||
},
|
||||
// Hides the lightbox sidebar, if visible.
|
||||
|
|
@ -2206,7 +2210,7 @@ export default {
|
|||
// Resize and focus content element.
|
||||
this.$nextTick(() => {
|
||||
this.resize(true);
|
||||
this.$refs.content.focus();
|
||||
this.focusContent();
|
||||
});
|
||||
},
|
||||
toggleControls() {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
<v-list-item class="px-3" :elevation="0" :ripple="false" @click.stop.prevent="goHome">
|
||||
<template #prepend>
|
||||
<div class="v-avatar bg-transparent nav-logo">
|
||||
<a :href="siteUrl" @click.stop.prevent="goHome">
|
||||
<a :href="siteUrl" tabindex="-1" @click.stop.prevent="goHome">
|
||||
<img :src="appIcon" :alt="appName" :class="{ 'animate-hue': indexing }" />
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -77,6 +77,7 @@
|
|||
icon
|
||||
variant="text"
|
||||
:elevation="0"
|
||||
tabindex="-1"
|
||||
class="nav-minimize hidden-sm-and-down"
|
||||
:ripple="false"
|
||||
:title="$gettext('Minimize')"
|
||||
|
|
@ -99,7 +100,7 @@
|
|||
color="primary"
|
||||
open-strategy="single"
|
||||
:density="$vuetify.display.smAndDown ? 'compact' : 'default'"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<v-list-item v-if="isMini && !isRestricted" class="nav-expand" @click.stop="toggleIsMini()">
|
||||
<v-icon :icon="rtl ? 'mdi-chevron-left' : 'mdi-chevron-right'" class="ma-auto"></v-icon>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,36 @@
|
|||
<template>
|
||||
<div v-if="visible" id="p-notify" tabindex="-1">
|
||||
<v-snackbar
|
||||
:model-value="snackbar"
|
||||
:class="'p-notify--' + message.color"
|
||||
class="p-notify clickable"
|
||||
@click.stop.prevent="showNext"
|
||||
@update:model-value="onSnackbar"
|
||||
>
|
||||
<v-icon
|
||||
v-if="message.icon"
|
||||
:icon="'mdi-' + message.icon"
|
||||
:color="message.color"
|
||||
class="p-notify_icon"
|
||||
start
|
||||
></v-icon>
|
||||
{{ message.text }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
:color="'on-' + message.color"
|
||||
variant="text"
|
||||
class="p-notify__close"
|
||||
@click.stop.prevent="showNext"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
<teleport to="body">
|
||||
<div v-if="visible" id="p-notify" tabindex="-1">
|
||||
<div
|
||||
:class="'p-notify--' + message.color"
|
||||
class="v-snackbar v-snackbar--bottom v-snackbar--center p-notify"
|
||||
role="alert"
|
||||
@click.stop.prevent="showNext"
|
||||
>
|
||||
<div class="v-snackbar__wrapper rounded-pill v-snackbar--variant-flat">
|
||||
<span class="v-snackbar__underlay"></span>
|
||||
<div class="v-snackbar__content">
|
||||
<i
|
||||
v-if="message.icon"
|
||||
:class="['text-' + message.color, 'mdi-' + message.icon]"
|
||||
class="mdi v-icon notranslate p-notify__icon"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<div class="p-notify__text">
|
||||
{{ message.text }}
|
||||
</div>
|
||||
<i
|
||||
:class="'text-on-' + message.color"
|
||||
class="mdi-close mdi v-icon notranslate p-notify__close"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
<script>
|
||||
let focusElement = null;
|
||||
|
||||
export default {
|
||||
name: "PNotify",
|
||||
data() {
|
||||
|
|
@ -164,22 +165,12 @@ export default {
|
|||
this.message.delay = this.defaultDelay;
|
||||
}
|
||||
|
||||
if (!focusElement) {
|
||||
focusElement = document.activeElement;
|
||||
}
|
||||
|
||||
if (!this.snackbar) {
|
||||
this.snackbar = true;
|
||||
}
|
||||
|
||||
this.visible = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (focusElement && typeof focusElement.focus === "function" && document.activeElement !== focusElement) {
|
||||
focusElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.lastText = "";
|
||||
this.showNext();
|
||||
|
|
@ -188,15 +179,6 @@ export default {
|
|||
this.lastText = "";
|
||||
this.visible = false;
|
||||
this.message.text = "";
|
||||
|
||||
// Return focus to the previously active element, if any.
|
||||
if (focusElement) {
|
||||
if (typeof focusElement.focus === "function" && document.activeElement !== focusElement) {
|
||||
focusElement.focus();
|
||||
}
|
||||
|
||||
focusElement = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -122,13 +122,17 @@ export default {
|
|||
onLoad() {
|
||||
this.loading = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
if (document.activeElement !== this.$refs.input) {
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
onLoaded() {
|
||||
this.loading = false;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
if (document.activeElement !== this.$refs.input) {
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
reset() {
|
||||
|
|
|
|||
|
|
@ -209,7 +209,10 @@ export default {
|
|||
}
|
||||
|
||||
if (ev.target && ev.target instanceof HTMLElement && this.$refs.content?.$el instanceof HTMLElement) {
|
||||
if (!ev.target.closest(".p-photo-edit-dialog") || ev.target?.disabled) {
|
||||
if (
|
||||
document.activeElement !== this.$refs.content.$el &&
|
||||
(!ev.target.closest(".p-photo-edit-dialog") || ev.target?.disabled)
|
||||
) {
|
||||
this.$refs.content?.$el.focus();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
<v-text-field
|
||||
:model-value="filter.q"
|
||||
:density="density"
|
||||
tabindex="1"
|
||||
hide-details
|
||||
clearable
|
||||
single-line
|
||||
|
|
@ -62,16 +63,24 @@
|
|||
group
|
||||
class="ms-1"
|
||||
>
|
||||
<v-btn value="cards" icon="mdi-view-column" class="ps-1 action-view-cards" @click="setView('cards')"></v-btn>
|
||||
<v-btn
|
||||
value="cards"
|
||||
tabindex="2"
|
||||
icon="mdi-view-column"
|
||||
class="ps-1 action-view-cards"
|
||||
@click="setView('cards')"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
v-if="listView"
|
||||
value="list"
|
||||
tabindex="2"
|
||||
icon="mdi-view-list"
|
||||
class="action-view-list"
|
||||
@click="setView('list')"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
value="mosaic"
|
||||
tabindex="2"
|
||||
icon="mdi-view-comfy"
|
||||
class="pe-1 action-view-mosaic"
|
||||
@click="setView('mosaic')"
|
||||
|
|
@ -82,12 +91,18 @@
|
|||
v-if="canDelete && context === 'archive' && config.count.archived > 0"
|
||||
:title="$gettext('Delete All')"
|
||||
icon="mdi-delete-sweep"
|
||||
tabindex="3"
|
||||
class="action-delete-all ms-1"
|
||||
@click.stop="deleteAll"
|
||||
>
|
||||
</v-btn>
|
||||
|
||||
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
|
||||
<p-action-menu
|
||||
v-if="$vuetify.display.mdAndUp"
|
||||
:items="menuActions"
|
||||
:tabindex="3"
|
||||
button-class="ms-1"
|
||||
></p-action-menu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-spacer></v-spacer>
|
||||
|
|
@ -110,6 +125,7 @@
|
|||
:model-value="filter.country"
|
||||
:label="$gettext('Country')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="4"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
@ -131,6 +147,7 @@
|
|||
:model-value="filter.camera"
|
||||
:label="$gettext('Camera')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="5"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
@ -151,6 +168,7 @@
|
|||
id="viewSelect"
|
||||
:model-value="settings.view"
|
||||
:label="$gettext('View')"
|
||||
tabindex="6"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
@ -171,6 +189,7 @@
|
|||
:model-value="filter.order"
|
||||
:label="$gettext('Sort Order')"
|
||||
:menu-props="{ maxHeight: 400 }"
|
||||
tabindex="7"
|
||||
single-line
|
||||
variant="solo-filled"
|
||||
:density="density"
|
||||
|
|
@ -190,6 +209,7 @@
|
|||
:model-value="filter.year"
|
||||
:label="$gettext('Year')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="8"
|
||||
single-line
|
||||
variant="solo-filled"
|
||||
:density="density"
|
||||
|
|
@ -209,6 +229,7 @@
|
|||
:model-value="filter.month"
|
||||
:label="$gettext('Month')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="9"
|
||||
single-line
|
||||
variant="solo-filled"
|
||||
:density="density"
|
||||
|
|
@ -242,6 +263,7 @@
|
|||
:model-value="filter.color"
|
||||
:label="$gettext('Color')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="10"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
@ -262,6 +284,7 @@
|
|||
:model-value="filter.label"
|
||||
:label="$gettext('Category')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="11"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
|
|||
|
|
@ -220,7 +220,10 @@ export default {
|
|||
}
|
||||
|
||||
if (ev.target && ev.target instanceof HTMLElement && this.$refs.form?.$el instanceof HTMLElement) {
|
||||
if (!ev.target.closest(".p-upload-dialog") || ev.target?.disabled) {
|
||||
if (
|
||||
document.activeElement !== this.$refs.form.$el &&
|
||||
(!ev.target.closest(".p-upload-dialog") || ev.target?.disabled)
|
||||
) {
|
||||
this.$refs.form?.$el.focus();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,75 @@
|
|||
/* Notifications */
|
||||
|
||||
.v-snackbar .p-notify {
|
||||
#p-notify {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
margin-top: auto;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
z-index: 2500;
|
||||
}
|
||||
|
||||
.v-snackbar {
|
||||
margin: 16px;
|
||||
#p-notify .v-snackbar {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.v-snackbar__wrapper {
|
||||
#p-notify .v-snackbar .v-snackbar__wrapper {
|
||||
position: relative;
|
||||
min-width: 320px;
|
||||
cursor: pointer;
|
||||
min-width: 280px;
|
||||
max-width: 440px;
|
||||
color: rgba(var(--v-theme-on-surface), 1);
|
||||
background-color: rgba(var(--v-theme-surface), 0.32);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 1px -1px var(--v-shadow-key-umbra-opacity, #0003), 0 1px 1px 0 var(--v-shadow-key-penumbra-opacity, #00000024), 0 1px 3px 0 var(--v-shadow-key-ambient-opacity, #0000001f) !important;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.v-snackbar__wrapper .p-notify__close {
|
||||
#p-notify .v-snackbar__wrapper .v-snackbar__content {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
justify-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#p-notify .v-snackbar__wrapper .v-snackbar__content .p-notify__icon {
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
font-size: 22px;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
#p-notify .v-snackbar__wrapper .v-snackbar__content .p-notify__text {
|
||||
flex-grow: 1;
|
||||
margin: 2px 8px;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#p-notify .v-snackbar__wrapper .v-snackbar__content .p-notify__close {
|
||||
opacity: 0.8;
|
||||
font-size: 24px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v-snackbar__wrapper .v-snackbar__underlay {
|
||||
|
|
@ -32,17 +84,6 @@
|
|||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.v-snackbar__wrapper .v-snackbar__content {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
justify-items: center;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.v-snackbar.p-notify--success .v-snackbar__wrapper {
|
||||
background-color: rgba(var(--v-theme-success), 0.36) !important;
|
||||
color: rgba(var(--v-theme-on-success), 1) !important;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
<v-text-field
|
||||
:model-value="filter.q"
|
||||
:density="density"
|
||||
tabindex="1"
|
||||
hide-details
|
||||
clearable
|
||||
overflow
|
||||
|
|
@ -48,12 +49,13 @@
|
|||
<v-btn
|
||||
v-if="canManage && staticFilter.type === 'album'"
|
||||
:title="$gettext('Add Album')"
|
||||
tabindex="2"
|
||||
icon="mdi-plus"
|
||||
class="action-add ms-1"
|
||||
@click.prevent="create()"
|
||||
></v-btn>
|
||||
|
||||
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
|
||||
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" :tabindex="3" button-class="ms-1"></p-action-menu>
|
||||
</v-toolbar>
|
||||
|
||||
<div class="toolbar-expansion-panel">
|
||||
|
|
@ -67,6 +69,7 @@
|
|||
:label="$gettext('Year')"
|
||||
:disabled="context === 'state'"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="4"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
@ -87,6 +90,7 @@
|
|||
:model-value="filter.category"
|
||||
:label="$gettext('Category')"
|
||||
:menu-props="{ maxHeight: 346 }"
|
||||
tabindex="5"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
@ -107,6 +111,7 @@
|
|||
:model-value="filter.order"
|
||||
:label="$gettext('Sort Order')"
|
||||
:menu-props="{ maxHeight: 400 }"
|
||||
tabindex="6"
|
||||
single-line
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
overflow
|
||||
single-line
|
||||
rounded
|
||||
tabindex="1"
|
||||
variant="solo-filled"
|
||||
:density="density"
|
||||
validate-on="invalid-input"
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
<v-btn
|
||||
v-if="!filter.all"
|
||||
icon="mdi-eye"
|
||||
tabindex="2"
|
||||
:title="$gettext('Show more')"
|
||||
class="action-show-all ms-1"
|
||||
@click.stop="showAll"
|
||||
|
|
@ -53,12 +55,18 @@
|
|||
<v-btn
|
||||
v-else
|
||||
icon="mdi-eye-off"
|
||||
tabindex="2"
|
||||
:title="$gettext('Show less')"
|
||||
class="action-show-important ms-1"
|
||||
@click.stop="showImportant"
|
||||
>
|
||||
</v-btn>
|
||||
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
|
||||
<p-action-menu
|
||||
v-if="$vuetify.display.mdAndUp"
|
||||
:items="menuActions"
|
||||
:tabindex="3"
|
||||
button-class="ms-1"
|
||||
></p-action-menu>
|
||||
</v-toolbar>
|
||||
</v-form>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
</router-link>
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" class="action-reload" @click.stop="refresh"> </v-btn>
|
||||
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="1" class="action-reload" @click.stop="refresh"> </v-btn>
|
||||
</v-toolbar>
|
||||
</v-form>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
overflow
|
||||
single-line
|
||||
rounded
|
||||
tabindex="1"
|
||||
variant="solo-filled"
|
||||
:density="density"
|
||||
validate-on="invalid-input"
|
||||
|
|
@ -45,12 +46,13 @@
|
|||
<v-btn
|
||||
v-if="!isPublic"
|
||||
:title="$gettext('Delete All')"
|
||||
tabindex="2"
|
||||
icon="mdi-delete-sweep"
|
||||
class="action-delete action-delete-all ms-1"
|
||||
@click.stop="onDelete"
|
||||
>
|
||||
</v-btn>
|
||||
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
|
||||
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" :tabindex="3" button-class="ms-1"></p-action-menu>
|
||||
</v-toolbar>
|
||||
</v-form>
|
||||
<div v-if="loading" class="p-page__loading">
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
<v-toolbar density="compact" class="page-toolbar" color="secondary-light">
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" class="action-reload" @click.stop="refresh"> </v-btn>
|
||||
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="2" class="action-reload" @click.stop="refresh"> </v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="!filter.hidden"
|
||||
:title="$gettext('Show hidden')"
|
||||
tabindex="3"
|
||||
icon="mdi-eye"
|
||||
class="action-show-hidden"
|
||||
@click.stop="onShowHidden"
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
tabindex="3"
|
||||
:title="$gettext('Exclude hidden')"
|
||||
icon="mdi-eye-off"
|
||||
class="action-exclude-hidden"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<v-text-field
|
||||
v-if="canSearch"
|
||||
:model-value="filter.q"
|
||||
tabindex="1"
|
||||
hide-details
|
||||
clearable
|
||||
single-line
|
||||
|
|
@ -32,12 +33,13 @@
|
|||
"
|
||||
></v-text-field>
|
||||
|
||||
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" class="action-reload" @click.stop="refresh"></v-btn>
|
||||
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="2" class="action-reload" @click.stop="refresh"></v-btn>
|
||||
|
||||
<template v-if="canManage">
|
||||
<v-btn
|
||||
v-if="!filter.hidden"
|
||||
:title="$gettext('Show hidden')"
|
||||
tabindex="3"
|
||||
icon="mdi-eye"
|
||||
class="action-show-hidden"
|
||||
@click.stop="onShowHidden"
|
||||
|
|
@ -46,6 +48,7 @@
|
|||
<v-btn
|
||||
v-else
|
||||
:title="$gettext('Exclude hidden')"
|
||||
tabindex="3"
|
||||
icon="mdi-eye-off"
|
||||
class="action-exclude-hidden"
|
||||
@click.stop="onExcludeHidden()"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
<v-text-field
|
||||
v-model.lazy.trim="filter.q"
|
||||
:placeholder="$gettext('Search')"
|
||||
tabindex="1"
|
||||
density="compact"
|
||||
flat
|
||||
single-line
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
v-model="settings.ui.theme"
|
||||
:disabled="busy"
|
||||
:items="themes"
|
||||
tabindex="2"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
:label="$gettext('Theme')"
|
||||
|
|
@ -33,6 +34,7 @@
|
|||
v-model="settings.ui.language"
|
||||
:disabled="busy"
|
||||
:items="languages"
|
||||
tabindex="2"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
:label="$gettext('Language')"
|
||||
|
|
@ -47,6 +49,7 @@
|
|||
<v-select
|
||||
v-model="settings.ui.timeZone"
|
||||
:disabled="busy"
|
||||
tabindex="2"
|
||||
item-value="ID"
|
||||
item-title="Name"
|
||||
:items="options.TimeZones($gettext('Default'))"
|
||||
|
|
@ -62,6 +65,7 @@
|
|||
v-model="settings.ui.startPage"
|
||||
:disabled="busy"
|
||||
:items="options.StartPages(settings.features)"
|
||||
tabindex="2"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
:label="$gettext('Start Page')"
|
||||
|
|
@ -82,6 +86,7 @@
|
|||
<v-checkbox
|
||||
v-model="settings.features.people"
|
||||
:disabled="busy"
|
||||
tabindex="2"
|
||||
class="ma-0 pa-0 input-people"
|
||||
density="compact"
|
||||
:label="$gettext('People')"
|
||||
|
|
@ -97,6 +102,7 @@
|
|||
<v-checkbox
|
||||
v-model="settings.features.calendar"
|
||||
:disabled="busy"
|
||||
tabindex="2"
|
||||
class="ma-0 pa-0 input-calendar"
|
||||
density="compact"
|
||||
:label="$gettext('Calendar')"
|
||||
|
|
@ -112,6 +118,7 @@
|
|||
<v-checkbox
|
||||
v-model="settings.features.moments"
|
||||
:disabled="busy"
|
||||
tabindex="2"
|
||||
class="ma-0 pa-0 input-moments"
|
||||
density="compact"
|
||||
:label="$gettext('Moments')"
|
||||
|
|
@ -127,6 +134,7 @@
|
|||
<v-checkbox
|
||||
v-model="settings.features.labels"
|
||||
:disabled="busy"
|
||||
tabindex="2"
|
||||
class="ma-0 pa-0 input-labels"
|
||||
density="compact"
|
||||
:label="$gettext('Labels')"
|
||||
|
|
@ -141,6 +149,7 @@
|
|||
<v-checkbox
|
||||
v-model="settings.features.private"
|
||||
:disabled="busy"
|
||||
tabindex="2"
|
||||
class="ma-0 pa-0 input-private"
|
||||
density="compact"
|
||||
:label="$gettext('Private')"
|
||||
|
|
@ -160,6 +169,7 @@
|
|||
:disabled="busy || config.readonly || isDemo"
|
||||
class="ma-0 pa-0 input-upload"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Upload')"
|
||||
:hint="$gettext('Add files to your library via Web Upload.')"
|
||||
prepend-icon="mdi-cloud-upload"
|
||||
|
|
@ -175,6 +185,7 @@
|
|||
:disabled="busy || isDemo"
|
||||
class="ma-0 pa-0 input-download"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Download')"
|
||||
:hint="$gettext('Download single files and zip archives.')"
|
||||
prepend-icon="mdi-download"
|
||||
|
|
@ -190,6 +201,7 @@
|
|||
:disabled="busy || config.readonly || isDemo"
|
||||
class="ma-0 pa-0 input-import"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Import')"
|
||||
:hint="$gettext('Imported files will be sorted by date and given a unique name.')"
|
||||
prepend-icon="mdi-folder-plus"
|
||||
|
|
@ -205,6 +217,7 @@
|
|||
:disabled="busy"
|
||||
class="ma-0 pa-0 input-share"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Share')"
|
||||
:hint="$gettext('Upload to WebDAV and share links with friends.')"
|
||||
prepend-icon="mdi-share-variant"
|
||||
|
|
@ -220,6 +233,7 @@
|
|||
:disabled="busy || isDemo"
|
||||
class="ma-0 pa-0 input-edit"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Edit')"
|
||||
:hint="$gettext('Change photo titles, locations, and other metadata.')"
|
||||
prepend-icon="mdi-pencil"
|
||||
|
|
@ -235,6 +249,7 @@
|
|||
:disabled="busy || isDemo"
|
||||
class="ma-0 pa-0 input-archive"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Archive')"
|
||||
:hint="$gettext('Hide photos that have been moved to archive.')"
|
||||
prepend-icon="mdi-package-down"
|
||||
|
|
@ -250,6 +265,7 @@
|
|||
:disabled="busy"
|
||||
class="ma-0 pa-0 input-delete"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Delete')"
|
||||
:hint="$gettext('Permanently remove files to free up storage.')"
|
||||
prepend-icon="mdi-delete"
|
||||
|
|
@ -264,6 +280,7 @@
|
|||
:disabled="busy"
|
||||
class="ma-0 pa-0 input-services"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Services')"
|
||||
:hint="$gettext('Share your pictures with other apps and services.')"
|
||||
prepend-icon="mdi-sync"
|
||||
|
|
@ -279,6 +296,7 @@
|
|||
:disabled="busy || isDemo"
|
||||
class="ma-0 pa-0 input-library"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Library')"
|
||||
:hint="$gettext('Index and import files through the user interface.')"
|
||||
prepend-icon="mdi-film"
|
||||
|
|
@ -294,6 +312,7 @@
|
|||
:disabled="busy"
|
||||
class="ma-0 pa-0 input-files"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Originals')"
|
||||
:hint="$gettext('Browse indexed files and folders in Library.')"
|
||||
prepend-icon="mdi-file-tree"
|
||||
|
|
@ -309,6 +328,7 @@
|
|||
:disabled="busy"
|
||||
class="ma-0 pa-0 input-logs"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Logs')"
|
||||
:hint="$gettext('Show server logs in Library.')"
|
||||
prepend-icon="mdi-playlist-check"
|
||||
|
|
@ -324,6 +344,7 @@
|
|||
:disabled="busy || isDemo"
|
||||
class="ma-0 pa-0 input-account"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Account')"
|
||||
:hint="$gettext('Change personal profile and security settings.')"
|
||||
prepend-icon="mdi-shield-account-variant"
|
||||
|
|
@ -339,6 +360,7 @@
|
|||
:disabled="busy || isDemo"
|
||||
class="ma-0 pa-0 input-places"
|
||||
density="compact"
|
||||
tabindex="2"
|
||||
:label="$gettext('Places')"
|
||||
:hint="$gettext('Search and display photos on a map.')"
|
||||
prepend-icon="mdi-map-marker"
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -14,8 +14,8 @@ require (
|
|||
github.com/esimov/pigo v1.4.6
|
||||
github.com/gin-contrib/gzip v1.2.3
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang/geo v0.0.0-20250410091149-9ff723126794
|
||||
github.com/google/open-location-code/go v0.0.0-20250404115940-e760de81d3c4
|
||||
github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7
|
||||
github.com/google/open-location-code/go v0.0.0-20250412170143-cff6d0b3fec9
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/gosimple/slug v1.15.0
|
||||
github.com/jinzhu/gorm v1.9.16
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -182,8 +182,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
|
|||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20250410091149-9ff723126794 h1:sYWK0P7YCim7xkB7IqMX6k4YtrcqLVSdCZqmMnqhBAI=
|
||||
github.com/golang/geo v0.0.0-20250410091149-9ff723126794/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
|
||||
github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7 h1:HNykSFq2QowNxC/zZc1IEbRuj30sMiY4aCSLb4EK/zA=
|
||||
github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
|
|
@ -209,8 +209,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
|
|||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/open-location-code/go v0.0.0-20250404115940-e760de81d3c4 h1:iwgo0bYcHhpWlsTZr3jIG7kiB1vrh4rk1wRC3j6CF2I=
|
||||
github.com/google/open-location-code/go v0.0.0-20250404115940-e760de81d3c4/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
|
||||
github.com/google/open-location-code/go v0.0.0-20250412170143-cff6d0b3fec9 h1:B43DA5D33P7i20Y0ZK0WfY+W0ZRf/frierrFj/nNwLQ=
|
||||
github.com/google/open-location-code/go v0.0.0-20250412170143-cff6d0b3fec9/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ func (m Embedding) Magnitude() float64 {
|
|||
return m.Dist(NullEmbedding)
|
||||
}
|
||||
|
||||
// JSON returns the face embedding as JSON bytes.
|
||||
// JSON returns the face embedding as JSON-encoded bytes.
|
||||
func (m Embedding) JSON() []byte {
|
||||
var noResult = []byte("")
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ func (embeddings Embeddings) Dist(other Embedding) (dist float64) {
|
|||
return dist
|
||||
}
|
||||
|
||||
// JSON returns the embeddings as JSON bytes.
|
||||
// JSON returns the embeddings as JSON-encoded bytes.
|
||||
func (embeddings Embeddings) JSON() []byte {
|
||||
var noResult = []byte("")
|
||||
|
||||
|
|
|
|||
|
|
@ -7,59 +7,18 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/api/download"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// NewApiRequest returns a new Vision API request with the specified file payload and scheme.
|
||||
func NewApiRequest(images Files, fileScheme string) (*ApiRequest, error) {
|
||||
imageUrls := make(Files, len(images))
|
||||
|
||||
if fileScheme == scheme.Https && !strings.HasPrefix(DownloadUrl, "https://") {
|
||||
log.Tracef("vision: file request scheme changed from https to data because https is not configured")
|
||||
fileScheme = scheme.Data
|
||||
}
|
||||
|
||||
for i := range images {
|
||||
switch fileScheme {
|
||||
case scheme.Https:
|
||||
if id, err := download.Register(images[i]); err != nil {
|
||||
return nil, fmt.Errorf("%s (create download url)", err)
|
||||
} else {
|
||||
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, id)
|
||||
}
|
||||
case scheme.Data:
|
||||
if file, err := os.Open(images[i]); err != nil {
|
||||
return nil, fmt.Errorf("%s (create data url)", err)
|
||||
} else {
|
||||
imageUrls[i] = media.DataUrl(file)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid file scheme %s", clean.Log(fileScheme))
|
||||
}
|
||||
}
|
||||
|
||||
return &ApiRequest{
|
||||
Id: rnd.UUID(),
|
||||
Model: "",
|
||||
Images: imageUrls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PerformApiRequest performs a Vision API request and returns the result.
|
||||
func PerformApiRequest(apiRequest *ApiRequest, uri, method, key string) (apiResponse *ApiResponse, err error) {
|
||||
if apiRequest == nil {
|
||||
return apiResponse, errors.New("api request is nil")
|
||||
}
|
||||
|
||||
data, jsonErr := apiRequest.MarshalJSON()
|
||||
data, jsonErr := apiRequest.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
return apiResponse, jsonErr
|
||||
|
|
@ -88,15 +47,19 @@ func PerformApiRequest(apiRequest *ApiRequest, uri, method, key string) (apiResp
|
|||
return apiResponse, clientErr
|
||||
}
|
||||
|
||||
apiResponse = &ApiResponse{}
|
||||
|
||||
// Unmarshal response and add labels, if returned.
|
||||
if apiJson, apiErr := io.ReadAll(clientResp.Body); apiErr != nil {
|
||||
return apiResponse, apiErr
|
||||
} else if apiErr = json.Unmarshal(apiJson, apiResponse); apiErr != nil {
|
||||
return apiResponse, apiErr
|
||||
} else if clientResp.StatusCode >= 300 {
|
||||
log.Debugf("vision: %s (status code %d)", apiJson, clientResp.StatusCode)
|
||||
// Parse and return response, or an error if the request failed.
|
||||
switch apiRequest.GetResponseFormat() {
|
||||
case ApiFormatVision:
|
||||
apiResponse = &ApiResponse{}
|
||||
if apiJson, apiErr := io.ReadAll(clientResp.Body); apiErr != nil {
|
||||
return apiResponse, apiErr
|
||||
} else if apiErr = json.Unmarshal(apiJson, apiResponse); apiErr != nil {
|
||||
return apiResponse, apiErr
|
||||
} else if clientResp.StatusCode >= 300 {
|
||||
log.Debugf("vision: %s (status code %d)", apiJson, clientResp.StatusCode)
|
||||
}
|
||||
default:
|
||||
return apiResponse, fmt.Errorf("unsupported response format %s", clean.Log(apiRequest.responseFormat))
|
||||
}
|
||||
|
||||
return apiResponse, nil
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ func TestNewApiRequest(t *testing.T) {
|
|||
|
||||
t.Run("Data", func(t *testing.T) {
|
||||
thumbnails := Files{examplesPath + "/chameleon_lime.jpg"}
|
||||
result, err := NewApiRequest(thumbnails, scheme.Data)
|
||||
result, err := NewApiRequestImages(thumbnails, scheme.Data)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
// t.Logf("request: %#v", result)
|
||||
|
||||
if result != nil {
|
||||
json, jsonErr := result.MarshalJSON()
|
||||
json, jsonErr := result.JSON()
|
||||
assert.NoError(t, jsonErr)
|
||||
assert.NotEmpty(t, json)
|
||||
// t.Logf("json: %s", json)
|
||||
|
|
@ -30,13 +30,13 @@ func TestNewApiRequest(t *testing.T) {
|
|||
})
|
||||
t.Run("Https", func(t *testing.T) {
|
||||
thumbnails := Files{examplesPath + "/chameleon_lime.jpg"}
|
||||
result, err := NewApiRequest(thumbnails, scheme.Https)
|
||||
result, err := NewApiRequestImages(thumbnails, scheme.Https)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
// t.Logf("request: %#v", result)
|
||||
if result != nil {
|
||||
json, jsonErr := result.MarshalJSON()
|
||||
json, jsonErr := result.JSON()
|
||||
assert.NoError(t, jsonErr)
|
||||
assert.NotEmpty(t, json)
|
||||
t.Logf("json: %s", json)
|
||||
|
|
|
|||
9
internal/ai/vision/api_format.go
Normal file
9
internal/ai/vision/api_format.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package vision
|
||||
|
||||
type ApiFormat = string
|
||||
|
||||
const (
|
||||
ApiFormatUrl ApiFormat = "url"
|
||||
ApiFormatImages ApiFormat = "images"
|
||||
ApiFormatVision ApiFormat = "vision"
|
||||
)
|
||||
|
|
@ -2,7 +2,18 @@ package vision
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/api/download"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
|
|
@ -10,10 +21,104 @@ type Files = []string
|
|||
|
||||
// ApiRequest represents a Vision API service request.
|
||||
type ApiRequest struct {
|
||||
Id string `form:"id" yaml:"Id,omitempty" json:"id,omitempty"`
|
||||
Model string `form:"model" yaml:"Model,omitempty" json:"model,omitempty"`
|
||||
Url string `form:"url" yaml:"Url,omitempty" json:"url,omitempty"`
|
||||
Images Files `form:"images" yaml:"Images,omitempty" json:"images,omitempty"`
|
||||
Id string `form:"id" yaml:"Id,omitempty" json:"id,omitempty"`
|
||||
Model string `form:"model" yaml:"Model,omitempty" json:"model,omitempty"`
|
||||
Url string `form:"url" yaml:"Url,omitempty" json:"url,omitempty"`
|
||||
Images Files `form:"images" yaml:"Images,omitempty" json:"images,omitempty"`
|
||||
responseFormat ApiFormat `form:"-"`
|
||||
}
|
||||
|
||||
// NewApiRequest returns a new service API request with the specified format and payload.
|
||||
func NewApiRequest(requestFormat ApiFormat, files Files, fileScheme scheme.Type) (result *ApiRequest, err error) {
|
||||
if len(files) == 0 {
|
||||
return result, errors.New("missing files")
|
||||
}
|
||||
|
||||
switch requestFormat {
|
||||
case ApiFormatUrl:
|
||||
return NewApiRequestUrl(files[0], fileScheme)
|
||||
case ApiFormatImages, ApiFormatVision:
|
||||
return NewApiRequestImages(files, fileScheme)
|
||||
default:
|
||||
return result, errors.New("invalid request format")
|
||||
}
|
||||
}
|
||||
|
||||
// NewApiRequestUrl returns a new Vision API request with the specified image Url as payload.
|
||||
func NewApiRequestUrl(fileName string, fileScheme scheme.Type) (result *ApiRequest, err error) {
|
||||
var imgUrl string
|
||||
|
||||
switch fileScheme {
|
||||
case scheme.Https:
|
||||
// Return if no thumbnail filenames were given.
|
||||
if !fs.FileExistsNotEmpty(fileName) {
|
||||
return result, errors.New("invalid image file name")
|
||||
}
|
||||
|
||||
// Generate a random token for the remote service to download the file.
|
||||
fileUuid := rnd.UUID()
|
||||
|
||||
if err = download.Register(fileUuid, fileName); err != nil {
|
||||
return result, fmt.Errorf("%s (create download url)", err)
|
||||
}
|
||||
|
||||
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
|
||||
case scheme.Data:
|
||||
var u *url.URL
|
||||
if u, err = url.Parse(fileName); err != nil {
|
||||
return result, fmt.Errorf("%s (invalid image url)", err)
|
||||
} else if !slices.Contains(scheme.HttpsHttp, u.Scheme) {
|
||||
return nil, fmt.Errorf("unsupported image url scheme %s", clean.Log(u.Scheme))
|
||||
} else {
|
||||
imgUrl = u.String()
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported file scheme %s", clean.Log(fileScheme))
|
||||
}
|
||||
|
||||
return &ApiRequest{
|
||||
Id: rnd.UUID(),
|
||||
Model: "",
|
||||
Url: imgUrl,
|
||||
responseFormat: ApiFormatVision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewApiRequestImages returns a new Vision API request with the specified images as payload.
|
||||
func NewApiRequestImages(images Files, fileScheme scheme.Type) (*ApiRequest, error) {
|
||||
imageUrls := make(Files, len(images))
|
||||
|
||||
if fileScheme == scheme.Https && !strings.HasPrefix(DownloadUrl, "https://") {
|
||||
log.Tracef("vision: file request scheme changed from https to data because https is not configured")
|
||||
fileScheme = scheme.Data
|
||||
}
|
||||
|
||||
for i := range images {
|
||||
switch fileScheme {
|
||||
case scheme.Https:
|
||||
fileUuid := rnd.UUID()
|
||||
if err := download.Register(fileUuid, images[i]); err != nil {
|
||||
return nil, fmt.Errorf("%s (create download url)", err)
|
||||
} else {
|
||||
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
|
||||
}
|
||||
case scheme.Data:
|
||||
if file, err := os.Open(images[i]); err != nil {
|
||||
return nil, fmt.Errorf("%s (create data url)", err)
|
||||
} else {
|
||||
imageUrls[i] = media.DataUrl(file)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported file scheme %s", clean.Log(fileScheme))
|
||||
}
|
||||
}
|
||||
|
||||
return &ApiRequest{
|
||||
Id: rnd.UUID(),
|
||||
Model: "",
|
||||
Images: imageUrls,
|
||||
responseFormat: ApiFormatVision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetId returns the request ID string and generates a random ID if none was set.
|
||||
|
|
@ -25,7 +130,16 @@ func (r *ApiRequest) GetId() string {
|
|||
return r.Id
|
||||
}
|
||||
|
||||
// MarshalJSON returns request as JSON.
|
||||
func (r *ApiRequest) MarshalJSON() ([]byte, error) {
|
||||
// GetResponseFormat returns the expected response format type.
|
||||
func (r *ApiRequest) GetResponseFormat() ApiFormat {
|
||||
if r.responseFormat == "" {
|
||||
return ApiFormatVision
|
||||
}
|
||||
|
||||
return r.responseFormat
|
||||
}
|
||||
|
||||
// JSON returns the request data as JSON-encoded bytes.
|
||||
func (r *ApiRequest) JSON() ([]byte, error) {
|
||||
return json.Marshal(*r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,9 @@ package vision
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/api/download"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Caption returns generated captions for the specified images.
|
||||
|
|
@ -23,56 +15,23 @@ func Caption(imgName string, src media.Src) (result CaptionResult, err error) {
|
|||
} else if model := Config.Model(ModelTypeCaption); model != nil {
|
||||
// Use remote service API if a server endpoint has been configured.
|
||||
if uri, method := model.Endpoint(); uri != "" && method != "" {
|
||||
var imgUrl string
|
||||
var apiRequest *ApiRequest
|
||||
var apiResponse *ApiResponse
|
||||
|
||||
switch src {
|
||||
case media.SrcLocal:
|
||||
// Return if no thumbnail filenames were given.
|
||||
if !fs.FileExistsNotEmpty(imgName) {
|
||||
return result, errors.New("invalid image file name")
|
||||
}
|
||||
|
||||
/* TODO: Add support for data URLs to the service.
|
||||
if file, fileErr := os.Open(imgName); fileErr != nil {
|
||||
return result, fmt.Errorf("%s (open image file)", err)
|
||||
} else {
|
||||
imgUrl = media.DataUrl(file)
|
||||
} */
|
||||
|
||||
dlId, dlErr := download.Register(imgName)
|
||||
|
||||
if dlErr != nil {
|
||||
return result, fmt.Errorf("%s (create download url)", err)
|
||||
}
|
||||
|
||||
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, dlId)
|
||||
case media.SrcRemote:
|
||||
var u *url.URL
|
||||
if u, err = url.Parse(imgName); err != nil {
|
||||
return result, fmt.Errorf("%s (invalid image url)", err)
|
||||
} else if !slices.Contains(scheme.HttpsHttp, u.Scheme) {
|
||||
return result, fmt.Errorf("unsupported image url scheme %s", clean.Log(u.Scheme))
|
||||
} else {
|
||||
imgUrl = u.String()
|
||||
}
|
||||
default:
|
||||
return result, fmt.Errorf("unsupported media source type %s", clean.Log(src))
|
||||
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), Files{imgName}, model.EndpointFileScheme()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
apiRequest := &ApiRequest{
|
||||
Id: rnd.UUID(),
|
||||
Model: model.Name,
|
||||
Url: imgUrl,
|
||||
if model.Name != "" {
|
||||
apiRequest.Model = model.Name
|
||||
}
|
||||
|
||||
/* if json, _ := apiRequest.MarshalJSON(); len(json) > 0 {
|
||||
/* if json, _ := apiRequest.JSON(); len(json) > 0 {
|
||||
log.Debugf("request: %s", json)
|
||||
} */
|
||||
|
||||
apiResponse, apiErr := PerformApiRequest(apiRequest, uri, method, model.EndpointKey())
|
||||
|
||||
if apiErr != nil {
|
||||
return result, apiErr
|
||||
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
|
||||
return result, err
|
||||
} else if apiResponse.Result.Caption == nil {
|
||||
return result, errors.New("invalid caption model response")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
package vision
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
var (
|
||||
AssetsPath = fs.Abs("../../../assets")
|
||||
FaceNetModelPath = fs.Abs("../../../assets/facenet")
|
||||
NsfwModelPath = fs.Abs("../../../assets/nsfw")
|
||||
CachePath = fs.Abs("../../../storage/cache")
|
||||
ServiceUri = ""
|
||||
ServiceKey = ""
|
||||
ServiceTimeout = time.Minute
|
||||
DownloadUrl = ""
|
||||
DefaultResolution = 224
|
||||
AssetsPath = fs.Abs("../../../assets")
|
||||
FaceNetModelPath = fs.Abs("../../../assets/facenet")
|
||||
NsfwModelPath = fs.Abs("../../../assets/nsfw")
|
||||
CachePath = fs.Abs("../../../storage/cache")
|
||||
DownloadUrl = ""
|
||||
ServiceUri = ""
|
||||
ServiceKey = ""
|
||||
ServiceTimeout = time.Minute
|
||||
ServiceMethod = http.MethodPost
|
||||
ServiceFileScheme = scheme.Data
|
||||
ServiceRequestFormat = ApiFormatVision
|
||||
ServiceResponseFormat = ApiFormatVision
|
||||
DefaultResolution = 224
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
"github.com/photoprism/photoprism/internal/ai/face"
|
||||
"github.com/photoprism/photoprism/internal/thumb/crop"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
// Faces detects faces in the specified image and generates embeddings from them.
|
||||
|
|
@ -30,7 +29,11 @@ func Faces(fileName string, minSize int, cacheCrop bool, expected int) (result f
|
|||
}
|
||||
|
||||
if uri, method := model.Endpoint(); uri != "" && method != "" {
|
||||
faceCrops := make([]string, len(result))
|
||||
var faceCrops []string
|
||||
var apiRequest *ApiRequest
|
||||
var apiResponse *ApiResponse
|
||||
|
||||
faceCrops = make([]string, len(result))
|
||||
|
||||
for i, f := range result {
|
||||
if f.Area.Col == 0 && f.Area.Row == 0 {
|
||||
|
|
@ -46,20 +49,16 @@ func Faces(fileName string, minSize int, cacheCrop bool, expected int) (result f
|
|||
}
|
||||
}
|
||||
|
||||
apiRequest, apiRequestErr := NewApiRequest(faceCrops, scheme.Data)
|
||||
|
||||
if apiRequestErr != nil {
|
||||
return result, apiRequestErr
|
||||
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), faceCrops, model.EndpointFileScheme()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if model.Name != "" {
|
||||
apiRequest.Model = model.Name
|
||||
}
|
||||
|
||||
apiResponse, apiErr := PerformApiRequest(apiRequest, uri, method, model.EndpointKey())
|
||||
|
||||
if apiErr != nil {
|
||||
return result, apiErr
|
||||
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
for i := range result {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
// Labels finds matching labels for the specified image.
|
||||
|
|
@ -24,20 +23,19 @@ func Labels(images Files, src media.Src) (result classify.Labels, err error) {
|
|||
} else if model := Config.Model(ModelTypeLabels); model != nil {
|
||||
// Use remote service API if a server endpoint has been configured.
|
||||
if uri, method := model.Endpoint(); uri != "" && method != "" {
|
||||
apiRequest, apiRequestErr := NewApiRequest(images, scheme.Data)
|
||||
var apiRequest *ApiRequest
|
||||
var apiResponse *ApiResponse
|
||||
|
||||
if apiRequestErr != nil {
|
||||
return result, apiRequestErr
|
||||
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), images, model.EndpointFileScheme()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if model.Name != "" {
|
||||
apiRequest.Model = model.Name
|
||||
}
|
||||
|
||||
apiResponse, apiErr := PerformApiRequest(apiRequest, uri, method, model.EndpointKey())
|
||||
|
||||
if apiErr != nil {
|
||||
return result, apiErr
|
||||
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
for _, label := range apiResponse.Result.Labels {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package vision
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
|
|
@ -11,6 +10,7 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/ai/nsfw"
|
||||
"github.com/photoprism/photoprism/internal/ai/tensorflow"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
var modelMutex = sync.Mutex{}
|
||||
|
|
@ -22,11 +22,9 @@ type Model struct {
|
|||
Version string `yaml:"Version,omitempty" json:"version,omitempty"`
|
||||
Resolution int `yaml:"Resolution,omitempty" json:"resolution,omitempty"`
|
||||
Meta *tensorflow.ModelInfo `yaml:"Meta,omitempty" json:"meta,omitempty"`
|
||||
Uri string `yaml:"Uri,omitempty" json:"-"`
|
||||
Key string `yaml:"Key,omitempty" json:"-"`
|
||||
Method string `yaml:"Method,omitempty" json:"-"`
|
||||
Service Service `yaml:"Service,omitempty" json:"Service,omitempty"`
|
||||
Path string `yaml:"Path,omitempty" json:"-"`
|
||||
Disabled bool `yaml:"Disabled,omitempty" json:"-"`
|
||||
Disabled bool `yaml:"Disabled,omitempty" json:"disabled,omitempty"`
|
||||
classifyModel *classify.Model
|
||||
faceModel *face.Model
|
||||
nsfwModel *nsfw.Model
|
||||
|
|
@ -37,32 +35,51 @@ type Models []*Model
|
|||
|
||||
// Endpoint returns the remote service request method and endpoint URL, if any.
|
||||
func (m *Model) Endpoint() (uri, method string) {
|
||||
if m.Uri == "" && ServiceUri == "" || m.Type == "" {
|
||||
if uri, method = m.Service.Endpoint(); uri != "" && method != "" {
|
||||
return uri, method
|
||||
} else if ServiceUri == "" {
|
||||
return "", ""
|
||||
} else if serviceType := clean.TypeLowerUnderscore(m.Type); serviceType == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if m.Method != "" {
|
||||
method = m.Method
|
||||
} else {
|
||||
method = http.MethodPost
|
||||
}
|
||||
|
||||
if m.Uri != "" {
|
||||
return m.Uri, method
|
||||
} else {
|
||||
return fmt.Sprintf("%s/%s", ServiceUri, clean.TypeLowerUnderscore(m.Type)), method
|
||||
return fmt.Sprintf("%s/%s", ServiceUri, serviceType), ServiceMethod
|
||||
}
|
||||
}
|
||||
|
||||
// EndpointKey returns the access token belonging to the remote service endpoint, if any.
|
||||
func (m *Model) EndpointKey() string {
|
||||
if m.Key != "" {
|
||||
return m.Key
|
||||
} else if ServiceKey != "" {
|
||||
func (m *Model) EndpointKey() (key string) {
|
||||
if key = m.Service.EndpointKey(); key != "" {
|
||||
return key
|
||||
} else {
|
||||
return ServiceKey
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
// EndpointFileScheme returns the endpoint API request file scheme type.
|
||||
func (m *Model) EndpointFileScheme() (fileScheme scheme.Type) {
|
||||
if fileScheme = m.Service.EndpointFileScheme(); fileScheme != "" {
|
||||
return fileScheme
|
||||
}
|
||||
|
||||
return ServiceFileScheme
|
||||
}
|
||||
|
||||
// EndpointRequestFormat returns the endpoint API request format.
|
||||
func (m *Model) EndpointRequestFormat() (format ApiFormat) {
|
||||
if format = m.Service.EndpointRequestFormat(); format != "" {
|
||||
return format
|
||||
}
|
||||
|
||||
return ServiceRequestFormat
|
||||
}
|
||||
|
||||
// EndpointResponseFormat returns the endpoint API response format.
|
||||
func (m *Model) EndpointResponseFormat() (format ApiFormat) {
|
||||
if format = m.Service.EndpointResponseFormat(); format != "" {
|
||||
return format
|
||||
}
|
||||
|
||||
return ServiceResponseFormat
|
||||
}
|
||||
|
||||
// ClassifyModel returns the matching classify model instance, if any.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package vision
|
||||
|
||||
import "github.com/photoprism/photoprism/internal/ai/tensorflow"
|
||||
import (
|
||||
"github.com/photoprism/photoprism/internal/ai/tensorflow"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
// Default computer vision model configuration.
|
||||
var (
|
||||
|
|
@ -76,7 +79,12 @@ var (
|
|||
CaptionModel = &Model{
|
||||
Type: ModelTypeCaption,
|
||||
Resolution: 224,
|
||||
Uri: "http://photoprism-vision:5000/api/v1/vision/caption",
|
||||
Service: Service{
|
||||
Uri: "http://photoprism-vision:5000/api/v1/vision/caption",
|
||||
FileScheme: scheme.Https,
|
||||
RequestFormat: ApiFormatUrl,
|
||||
ResponseFormat: ApiFormatVision,
|
||||
},
|
||||
}
|
||||
DefaultModels = Models{NasnetModel, NsfwModel, FacenetModel, CaptionModel}
|
||||
DefaultThresholds = Thresholds{Confidence: 10}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/ai/nsfw"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
// Nsfw checks the specified images for inappropriate content.
|
||||
|
|
@ -25,20 +24,19 @@ func Nsfw(images Files, src media.Src) (result []nsfw.Result, err error) {
|
|||
} else if model := Config.Model(ModelTypeNsfw); model != nil {
|
||||
// Use remote service API if a server endpoint has been configured.
|
||||
if uri, method := model.Endpoint(); uri != "" && method != "" {
|
||||
apiRequest, apiRequestErr := NewApiRequest(images, scheme.Data)
|
||||
var apiRequest *ApiRequest
|
||||
var apiResponse *ApiResponse
|
||||
|
||||
if apiRequestErr != nil {
|
||||
return result, apiRequestErr
|
||||
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), images, model.EndpointFileScheme()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if model.Name != "" {
|
||||
apiRequest.Model = model.Name
|
||||
}
|
||||
|
||||
apiResponse, apiErr := PerformApiRequest(apiRequest, uri, method, model.EndpointKey())
|
||||
|
||||
if apiErr != nil {
|
||||
return result, apiErr
|
||||
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result = apiResponse.Result.Nsfw
|
||||
|
|
|
|||
73
internal/ai/vision/service.go
Normal file
73
internal/ai/vision/service.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package vision
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
)
|
||||
|
||||
// Service represents a remote computer vision service configuration.
|
||||
type Service struct {
|
||||
Uri string `yaml:"Uri,omitempty" json:"uri"`
|
||||
Method string `yaml:"Method,omitempty" json:"method"`
|
||||
Key string `yaml:"Key,omitempty" json:"-"`
|
||||
FileScheme string `yaml:"FileScheme,omitempty" json:"fileScheme,omitempty"`
|
||||
RequestFormat ApiFormat `yaml:"RequestFormat,omitempty" json:"requestFormat,omitempty"`
|
||||
ResponseFormat ApiFormat `yaml:"ResponseFormat,omitempty" json:"responseFormat,omitempty"`
|
||||
Disabled bool `yaml:"Disabled,omitempty" json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// Endpoint returns the remote service request method and endpoint URL, if any.
|
||||
func (m *Service) Endpoint() (uri, method string) {
|
||||
if m.Disabled || m.Uri == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if m.Method != "" {
|
||||
method = m.Method
|
||||
} else {
|
||||
method = ServiceMethod
|
||||
}
|
||||
|
||||
return m.Uri, method
|
||||
}
|
||||
|
||||
// EndpointKey returns the access token belonging to the remote service endpoint, if any.
|
||||
func (m *Service) EndpointKey() string {
|
||||
if m.Disabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
return m.Key
|
||||
}
|
||||
|
||||
// EndpointFileScheme returns the endpoint API file scheme type.
|
||||
func (m *Service) EndpointFileScheme() scheme.Type {
|
||||
if m.Disabled {
|
||||
return ""
|
||||
} else if m.FileScheme == "" {
|
||||
return ServiceFileScheme
|
||||
}
|
||||
|
||||
return m.FileScheme
|
||||
}
|
||||
|
||||
// EndpointRequestFormat returns the endpoint API request format.
|
||||
func (m *Service) EndpointRequestFormat() ApiFormat {
|
||||
if m.Disabled {
|
||||
return ""
|
||||
} else if m.RequestFormat == "" {
|
||||
return ApiFormatVision
|
||||
}
|
||||
|
||||
return m.RequestFormat
|
||||
}
|
||||
|
||||
// EndpointResponseFormat returns the endpoint API response format.
|
||||
func (m *Service) EndpointResponseFormat() ApiFormat {
|
||||
if m.Disabled {
|
||||
return ""
|
||||
} else if m.ResponseFormat == "" {
|
||||
return ApiFormatVision
|
||||
}
|
||||
|
||||
return m.ResponseFormat
|
||||
}
|
||||
6
internal/ai/vision/testdata/vision.yml
vendored
6
internal/ai/vision/testdata/vision.yml
vendored
|
|
@ -17,6 +17,10 @@ Models:
|
|||
- serve
|
||||
- Type: caption
|
||||
Resolution: 224
|
||||
Uri: http://photoprism-vision:5000/api/v1/vision/caption
|
||||
Service:
|
||||
Uri: http://photoprism-vision:5000/api/v1/vision/caption
|
||||
FileScheme: https
|
||||
RequestFormat: url
|
||||
ResponseFormat: vision
|
||||
Thresholds:
|
||||
Confidence: 10
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/api/download"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
)
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ func TestMain(m *testing.M) {
|
|||
log = logrus.StandardLogger()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
event.AuditLog = log
|
||||
download.AllowedPaths = append(download.AllowedPaths, AssetsPath)
|
||||
|
||||
// Set test config values.
|
||||
DownloadUrl = "https://app.localssl.dev/api/v1/dl"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import (
|
|||
gc "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
var cache = gc.New(time.Minute*15, 5*time.Minute)
|
||||
var expires = time.Minute * 15
|
||||
var cache = gc.New(expires, 5*time.Minute)
|
||||
|
||||
// Flush resets the download cache.
|
||||
func Flush() {
|
||||
|
|
|
|||
24
internal/api/download/download_test.go
Normal file
24
internal/api/download/download_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Init test logger.
|
||||
log = logrus.StandardLogger()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
event.AuditLog = log
|
||||
AllowedPaths = append(AllowedPaths, fs.Abs("./testdata"))
|
||||
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
// Find returns the fileName for the given download id or an error if the id is invalid.
|
||||
func Find(uniqueId string) (fileName string, err error) {
|
||||
|
||||
if uniqueId == "" || !rnd.IsUUID(uniqueId) {
|
||||
return fileName, fmt.Errorf("id has an invalid format")
|
||||
}
|
||||
|
|
|
|||
28
internal/api/download/paths.go
Normal file
28
internal/api/download/paths.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
var AllowedPaths []string
|
||||
|
||||
// Deny checks if the filename may not be registered for download.
|
||||
func Deny(fileName string) bool {
|
||||
if len(AllowedPaths) == 0 || fileName == "" {
|
||||
return true
|
||||
} else if fileName = fs.Abs(fileName); strings.HasPrefix(fileName, "/etc") ||
|
||||
strings.HasPrefix(filepath.Base(fileName), ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, dir := range AllowedPaths {
|
||||
if dir != "" && strings.HasPrefix(fileName, dir+"/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,22 +1,33 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Register makes the specified file available for download with the
|
||||
// returned id until the cache expires, or the server is restarted.
|
||||
func Register(fileName string) (string, error) {
|
||||
if !fs.FileExists(fileName) {
|
||||
return "", fmt.Errorf("%s does not exists", clean.Log(fileName))
|
||||
// Register generated an event to make the specified file available
|
||||
// for download until the cache expires, or the server is restarted.
|
||||
func Register(fileUuid, fileName string) error {
|
||||
if !rnd.IsUUID(fileUuid) {
|
||||
event.AuditWarn([]string{"api", "download", "create temporary token for %s", authn.Failed}, fileName)
|
||||
return errors.New("invalid file uuid")
|
||||
}
|
||||
|
||||
uniqueId := rnd.UUID()
|
||||
cache.SetDefault(uniqueId, fileName)
|
||||
if fileName = fs.Abs(fileName); !fs.FileExists(fileName) {
|
||||
event.AuditWarn([]string{"api", "download", "create temporary token for %s", authn.Failed}, fileName)
|
||||
return errors.New("file not found")
|
||||
} else if Deny(fileName) {
|
||||
event.AuditErr([]string{"api", "download", "create temporary token for %s", authn.Denied}, fileName)
|
||||
return errors.New("forbidden file path")
|
||||
}
|
||||
|
||||
return uniqueId, nil
|
||||
event.AuditInfo([]string{"api", "download", "create temporary token for %s", authn.Succeeded}, fileName)
|
||||
|
||||
cache.SetDefault(fileUuid, fileName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,27 +11,36 @@ import (
|
|||
|
||||
func TestRegister(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
// Generate a random token for the remote service to download the file.
|
||||
fileUuid := rnd.UUID()
|
||||
fileName := fs.Abs("./testdata/image.jpg")
|
||||
uniqueId, err := Register(fileName)
|
||||
err := Register(fileUuid, fileName)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, rnd.IsUUID(uniqueId))
|
||||
assert.True(t, rnd.IsUUID(fileUuid))
|
||||
|
||||
findName, findErr := Find(uniqueId)
|
||||
findName, findErr := Find(fileUuid)
|
||||
|
||||
assert.NoError(t, findErr)
|
||||
assert.Equal(t, fileName, findName)
|
||||
|
||||
Flush()
|
||||
|
||||
findName, findErr = Find(uniqueId)
|
||||
findName, findErr = Find(fileUuid)
|
||||
|
||||
assert.Error(t, findErr)
|
||||
assert.Equal(t, "", findName)
|
||||
})
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
// Generate a random token for the remote service to download the file.
|
||||
fileUuid := rnd.UUID()
|
||||
fileName := fs.Abs("./testdata/invalid.jpg")
|
||||
uniqueId, err := Register(fileName)
|
||||
err := Register(fileUuid, fileName)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", uniqueId)
|
||||
assert.True(t, rnd.IsUUID(fileUuid))
|
||||
|
||||
findName, findErr := Find(fileUuid)
|
||||
|
||||
assert.Error(t, findErr)
|
||||
assert.Equal(t, "", findName)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1602,7 +1602,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/dl/{hash}": {
|
||||
"/api/v1/dl/{file}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
|
|
@ -1616,8 +1616,8 @@
|
|||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File Hash",
|
||||
"name": "hash",
|
||||
"description": "file hash or unique download id",
|
||||
"name": "file",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
|
|
@ -4911,7 +4911,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/vision/faces": {
|
||||
"/api/v1/vision/face": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -4919,8 +4919,8 @@
|
|||
"tags": [
|
||||
"Vision"
|
||||
],
|
||||
"summary": "returns the positions and embeddings of detected faces",
|
||||
"operationId": "PostVisionFaces",
|
||||
"summary": "returns the embeddings of a face image",
|
||||
"operationId": "PostVisionFace",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "list of image file urls",
|
||||
|
|
@ -5018,6 +5018,95 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/vision/nsfw": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Vision"
|
||||
],
|
||||
"summary": "checks the specified images for inappropriate content",
|
||||
"operationId": "PostVisionNsfw",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "list of image file urls",
|
||||
"name": "images",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/vision.ApiRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/vision.ApiResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/webhook/{channel}": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Webhook"
|
||||
],
|
||||
"summary": "listens for webhook events and checks their authorization",
|
||||
"operationId": "Webhook",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "webhook event data",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/hooks.Payload"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/zip": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -6568,6 +6657,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"event.Data": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"form.Album": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -7041,6 +7134,20 @@
|
|||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"hooks.Payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/event.Data"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -7058,6 +7165,26 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"nsfw.Result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drawing": {
|
||||
"type": "number"
|
||||
},
|
||||
"hentai": {
|
||||
"type": "number"
|
||||
},
|
||||
"neutral": {
|
||||
"type": "number"
|
||||
},
|
||||
"porn": {
|
||||
"type": "number"
|
||||
},
|
||||
"sexy": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.Album": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -7652,11 +7779,8 @@
|
|||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"videos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -7686,10 +7810,16 @@
|
|||
"caption": {
|
||||
"$ref": "#/definitions/vision.CaptionResult"
|
||||
},
|
||||
"faces": {
|
||||
"embeddings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
|
|
@ -7697,6 +7827,12 @@
|
|||
"items": {
|
||||
"$ref": "#/definitions/vision.LabelResult"
|
||||
}
|
||||
},
|
||||
"nsfw": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/nsfw.Result"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -7706,6 +7842,9 @@
|
|||
"confidence": {
|
||||
"type": "number"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -7714,8 +7853,11 @@
|
|||
"vision.LabelResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number"
|
||||
|
|
@ -7723,6 +7865,12 @@
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"topicality": {
|
||||
"type": "number"
|
||||
}
|
||||
|
|
@ -7737,10 +7885,28 @@
|
|||
"resolution": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/vision.ModelType"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vision.ModelType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"labels",
|
||||
"nsfw",
|
||||
"face",
|
||||
"caption"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ModelTypeLabels",
|
||||
"ModelTypeNsfw",
|
||||
"ModelTypeFace",
|
||||
"ModelTypeCaption"
|
||||
]
|
||||
}
|
||||
},
|
||||
"externalDocs": {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import (
|
|||
// @Router /api/v1/vision/caption [post]
|
||||
func PostVisionCaption(router *gin.RouterGroup) {
|
||||
router.POST("/vision/caption", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
||||
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||
|
||||
// Abort if permission is not granted.
|
||||
if s.Abort(c) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import (
|
|||
// @Router /api/v1/vision/face [post]
|
||||
func PostVisionFace(router *gin.RouterGroup) {
|
||||
router.POST("/vision/face", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
||||
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||
|
||||
// Abort if permission is not granted.
|
||||
if s.Abort(c) {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ func TestPostVisionFace(t *testing.T) {
|
|||
fs.Abs("./testdata/face_160x160.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -65,13 +65,13 @@ func TestPostVisionFace(t *testing.T) {
|
|||
fs.Abs("./testdata/london_160x160.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -101,13 +101,13 @@ func TestPostVisionFace(t *testing.T) {
|
|||
fs.Abs("./testdata/face_320x320.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -142,13 +142,13 @@ func TestPostVisionFace(t *testing.T) {
|
|||
|
||||
files := vision.Files{}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import (
|
|||
// @Router /api/v1/vision/labels [post]
|
||||
func PostVisionLabels(router *gin.RouterGroup) {
|
||||
router.POST("/vision/labels", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
||||
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||
|
||||
// Abort if permission is not granted.
|
||||
if s.Abort(c) {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ func TestPostVisionLabels(t *testing.T) {
|
|||
fs.Abs("./testdata/cat_224x224.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -59,13 +59,13 @@ func TestPostVisionLabels(t *testing.T) {
|
|||
fs.Abs("./testdata/green_224x224.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -93,13 +93,13 @@ func TestPostVisionLabels(t *testing.T) {
|
|||
|
||||
files := vision.Files{}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import (
|
|||
// @Router /api/v1/vision/nsfw [post]
|
||||
func PostVisionNsfw(router *gin.RouterGroup) {
|
||||
router.POST("/vision/nsfw", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
||||
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||
|
||||
// Abort if permission is not granted.
|
||||
if s.Abort(c) {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ func TestPostVisionNsfw(t *testing.T) {
|
|||
fs.Abs("./testdata/nsfw_224x224.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -74,13 +74,13 @@ func TestPostVisionNsfw(t *testing.T) {
|
|||
fs.Abs("./testdata/green_224x224.jpg"),
|
||||
}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -108,13 +108,13 @@ func TestPostVisionNsfw(t *testing.T) {
|
|||
|
||||
files := vision.Files{}
|
||||
|
||||
req, err := vision.NewApiRequest(files, scheme.Data)
|
||||
req, err := vision.NewApiRequestImages(files, scheme.Data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonReq, jsonErr := req.MarshalJSON()
|
||||
jsonReq, jsonErr := req.JSON()
|
||||
|
||||
if jsonErr != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const (
|
|||
AccessPrivate Permission = "access_private"
|
||||
AccessOwn Permission = "access_own"
|
||||
AccessAll Permission = "access_all"
|
||||
Use Permission = "use"
|
||||
ActionUse Permission = "use"
|
||||
ActionSearch Permission = "search"
|
||||
ActionView Permission = "view"
|
||||
ActionUpload Permission = "upload"
|
||||
|
|
@ -31,6 +31,7 @@ const (
|
|||
ActionDelete Permission = "delete"
|
||||
ActionRate Permission = "rate"
|
||||
ActionReact Permission = "react"
|
||||
ActionPublish Permission = "publish"
|
||||
ActionSubscribe Permission = "subscribe"
|
||||
ActionManage Permission = "manage"
|
||||
ActionManageOwn Permission = "manage_own"
|
||||
|
|
@ -58,7 +59,9 @@ const (
|
|||
ResourceUsers Resource = "users"
|
||||
ResourceSessions Resource = "sessions"
|
||||
ResourceLogs Resource = "logs"
|
||||
ResourceApi Resource = "api"
|
||||
ResourceWebDAV Resource = "webdav"
|
||||
ResourceWebhooks Resource = "webhooks"
|
||||
ResourceMetrics Resource = "metrics"
|
||||
ResourceVision Resource = "vision"
|
||||
ResourceFeedback Resource = "feedback"
|
||||
|
|
@ -86,4 +89,5 @@ const (
|
|||
ChannelSubjects Resource = "subjects"
|
||||
ChannelPeople Resource = "people"
|
||||
ChannelSync Resource = "sync"
|
||||
ChannelInstance Resource = "instance"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ var (
|
|||
AccessOwn: true,
|
||||
AccessShared: true,
|
||||
AccessLibrary: true,
|
||||
ActionUse: true,
|
||||
ActionView: true,
|
||||
ActionCreate: true,
|
||||
ActionUpdate: true,
|
||||
|
|
@ -20,6 +21,7 @@ var (
|
|||
ActionRate: true,
|
||||
ActionReact: true,
|
||||
ActionManage: true,
|
||||
ActionPublish: true,
|
||||
ActionSubscribe: true,
|
||||
}
|
||||
GrantUploadAccess = Grant{
|
||||
|
|
@ -42,10 +44,12 @@ var (
|
|||
GrantAll = Grant{
|
||||
AccessAll: true,
|
||||
AccessOwn: true,
|
||||
ActionUse: true,
|
||||
ActionView: true,
|
||||
ActionCreate: true,
|
||||
ActionUpdate: true,
|
||||
ActionDelete: true,
|
||||
ActionPublish: true,
|
||||
ActionSubscribe: true,
|
||||
}
|
||||
GrantManageOwn = Grant{
|
||||
|
|
@ -122,9 +126,13 @@ var (
|
|||
AccessAll: true,
|
||||
ActionSubscribe: true,
|
||||
}
|
||||
GrantUse = Grant{
|
||||
Use: true,
|
||||
ActionCreate: true,
|
||||
GrantPublishOwn = Grant{
|
||||
AccessOwn: true,
|
||||
ActionPublish: true,
|
||||
}
|
||||
GrantUseOwn = Grant{
|
||||
AccessOwn: true,
|
||||
ActionUse: true,
|
||||
}
|
||||
GrantNone = Grant{}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ var ResourceNames = []Resource{
|
|||
ResourceUsers,
|
||||
ResourceSessions,
|
||||
ResourceLogs,
|
||||
ResourceApi,
|
||||
ResourceWebDAV,
|
||||
ResourceWebhooks,
|
||||
ResourceMetrics,
|
||||
ResourceVision,
|
||||
ResourceFeedback,
|
||||
|
|
|
|||
|
|
@ -87,17 +87,25 @@ var Rules = ACL{
|
|||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceApi: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantPublishOwn,
|
||||
},
|
||||
ResourceWebDAV: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceWebhooks: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantPublishOwn,
|
||||
},
|
||||
ResourceMetrics: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantViewAll,
|
||||
},
|
||||
ResourceVision: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantUse,
|
||||
RoleClient: GrantUseOwn,
|
||||
},
|
||||
ResourceFeedback: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ var VisionRunCommand = &cli.Command{
|
|||
&cli.StringFlag{
|
||||
Name: "models",
|
||||
Aliases: []string{"m"},
|
||||
// TODO: Add captions to the list once the service can be used from the CLI.
|
||||
Usage: "model types (labels, nsfw)",
|
||||
Usage: "model types (labels, nsfw, caption)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import (
|
|||
|
||||
"github.com/photoprism/photoprism/internal/ai/face"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/api/download"
|
||||
"github.com/photoprism/photoprism/internal/config/customize"
|
||||
"github.com/photoprism/photoprism/internal/config/ttl"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
|
|
@ -289,6 +290,13 @@ func (c *Config) Propagate() {
|
|||
vision.ServiceKey = c.VisionKey()
|
||||
vision.DownloadUrl = c.DownloadUrl()
|
||||
|
||||
// Set allowed path in download package.
|
||||
download.AllowedPaths = []string{
|
||||
c.SidecarPath(),
|
||||
c.OriginalsPath(),
|
||||
c.ThumbCachePath(),
|
||||
}
|
||||
|
||||
// Set cache expiration defaults.
|
||||
ttl.CacheDefault = c.HttpCacheMaxAge()
|
||||
ttl.CacheVideo = c.HttpVideoMaxAge()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
|
||||
// AlbumsSettings represents album defaults and preferences.
|
||||
type AlbumsSettings struct {
|
||||
Order AlbumsOrder `json:"order" yaml:"Order"`
|
||||
Download DownloadSettings `json:"download" yaml:"Download"`
|
||||
Order AlbumsOrder `json:"order" yaml:"Order"`
|
||||
}
|
||||
|
||||
// AlbumsOrder represents default album sort orders.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ const (
|
|||
type Settings struct {
|
||||
UI UISettings `json:"ui" yaml:"UI"`
|
||||
Search SearchSettings `json:"search" yaml:"Search"`
|
||||
Albums AlbumsSettings `json:"albums" yaml:"Albums"`
|
||||
Maps MapsSettings `json:"maps" yaml:"Maps"`
|
||||
Features FeatureSettings `json:"features" yaml:"Features"`
|
||||
Import ImportSettings `json:"import" yaml:"Import"`
|
||||
|
|
@ -27,6 +26,7 @@ type Settings struct {
|
|||
Stack StackSettings `json:"stack" yaml:"Stack"`
|
||||
Share ShareSettings `json:"share" yaml:"Share"`
|
||||
Download DownloadSettings `json:"download" yaml:"Download"`
|
||||
Albums AlbumsSettings `json:"albums" yaml:"Albums"`
|
||||
Templates TemplateSettings `json:"templates" yaml:"Templates"`
|
||||
}
|
||||
|
||||
|
|
|
|||
26
internal/config/customize/testdata/settings.yml
vendored
26
internal/config/customize/testdata/settings.yml
vendored
|
|
@ -10,19 +10,6 @@ Search:
|
|||
ListView: true
|
||||
ShowTitles: true
|
||||
ShowCaptions: true
|
||||
Albums:
|
||||
Order:
|
||||
Album: oldest
|
||||
Folder: added
|
||||
Moment: oldest
|
||||
State: newest
|
||||
Month: oldest
|
||||
Download:
|
||||
Name: share
|
||||
Disabled: false
|
||||
Originals: true
|
||||
MediaRaw: false
|
||||
MediaSidecar: false
|
||||
Maps:
|
||||
Animate: 0
|
||||
Style: ""
|
||||
|
|
@ -75,5 +62,18 @@ Download:
|
|||
Originals: true
|
||||
MediaRaw: false
|
||||
MediaSidecar: false
|
||||
Albums:
|
||||
Download:
|
||||
Name: share
|
||||
Disabled: false
|
||||
Originals: true
|
||||
MediaRaw: false
|
||||
MediaSidecar: false
|
||||
Order:
|
||||
Album: oldest
|
||||
Folder: added
|
||||
Moment: oldest
|
||||
State: newest
|
||||
Month: oldest
|
||||
Templates:
|
||||
Default: index.gohtml
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ var SessionFixtures = SessionMap{
|
|||
RefID: "sessjr0ge18d",
|
||||
SessTimeout: 0,
|
||||
SessExpires: unix.Now() + unix.Day,
|
||||
AuthScope: clean.Scope("metrics photos albums videos"),
|
||||
AuthScope: clean.Scope("metrics photos albums videos api webhooks"),
|
||||
AuthProvider: authn.ProviderAccessToken.String(),
|
||||
AuthMethod: authn.MethodDefault.String(),
|
||||
GrantType: authn.GrantPassword.String(),
|
||||
|
|
@ -229,7 +229,7 @@ var SessionFixtures = SessionMap{
|
|||
RefID: "sessgh6123yt",
|
||||
SessTimeout: 0,
|
||||
SessExpires: unix.Now() + unix.Week,
|
||||
AuthScope: clean.Scope("statistics"),
|
||||
AuthScope: clean.Scope("statistics api webhooks"),
|
||||
AuthProvider: authn.ProviderClient.String(),
|
||||
AuthMethod: authn.MethodOAuth2.String(),
|
||||
GrantType: authn.GrantCLI.String(),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ func Start(ctx context.Context, conf *config.Config) {
|
|||
conf.BaseUri("/health"),
|
||||
conf.BaseUri(config.ApiUri + "/t"),
|
||||
conf.BaseUri(config.ApiUri + "/folders/t"),
|
||||
conf.BaseUri(config.ApiUri + "/dl"),
|
||||
conf.BaseUri(config.ApiUri + "/zip"),
|
||||
conf.BaseUri(config.ApiUri + "/albums"),
|
||||
conf.BaseUri(config.ApiUri + "/labels"),
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ func (w *Vision) Start(q string, models []string, force bool) (err error) {
|
|||
if (entity.SrcPriority[caption.Source] > entity.SrcPriority[m.CaptionSrc]) || !m.HasCaption() {
|
||||
m.SetCaption(caption.Text, caption.Source)
|
||||
changed = true
|
||||
log.Infof("vision: changed caption of %s to %t", photoName, clean.Log(m.PhotoCaption))
|
||||
log.Infof("vision: changed caption of %s to %s", photoName, clean.Log(m.PhotoCaption))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ func TestContentType(t *testing.T) {
|
|||
result := ContentType("invalid")
|
||||
assert.Equal(t, "invalid", result)
|
||||
})
|
||||
t.Run("Json", func(t *testing.T) {
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
result := ContentType("text/json")
|
||||
assert.Equal(t, "application/json; charset=utf-8", result)
|
||||
})
|
||||
|
|
|
|||
17
pkg/fs/fs.go
17
pkg/fs/fs.go
|
|
@ -89,6 +89,23 @@ func FileExistsNotEmpty(fileName string) bool {
|
|||
return err == nil && !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
// FileSize returns the size of a file in bytes or -1 in case of an error.
|
||||
func FileSize(fileName string) int64 {
|
||||
if fileName == "" {
|
||||
return -1
|
||||
}
|
||||
|
||||
info, err := os.Stat(fileName)
|
||||
|
||||
if err != nil || info == nil {
|
||||
return -1
|
||||
} else if info.IsDir() {
|
||||
return -1
|
||||
}
|
||||
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// PathExists tests if a path exists, and is a directory or symlink.
|
||||
func PathExists(path string) bool {
|
||||
if path == "" {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ func TestFileExistsNotEmpty(t *testing.T) {
|
|||
assert.False(t, FileExistsNotEmpty(""))
|
||||
}
|
||||
|
||||
func TestFileSize(t *testing.T) {
|
||||
assert.Equal(t, 10990, int(FileSize("./testdata/test.jpg")))
|
||||
assert.Equal(t, 10990, int(FileSize("./testdata/test.jpg")))
|
||||
assert.Equal(t, 0, int(FileSize("./testdata/empty.jpg")))
|
||||
assert.Equal(t, -1, int(FileSize("./foo.jpg")))
|
||||
assert.Equal(t, -1, int(FileSize("")))
|
||||
}
|
||||
|
||||
func TestPathExists(t *testing.T) {
|
||||
assert.True(t, PathExists("./testdata"))
|
||||
assert.False(t, PathExists("./testdata/test.jpg"))
|
||||
|
|
|
|||
|
|
@ -5,19 +5,46 @@ import (
|
|||
"io"
|
||||
)
|
||||
|
||||
// EncodeBase64 returns the base64 encoding of bin.
|
||||
func EncodeBase64(bin []byte) string {
|
||||
// EncodeBase64String returns the base64 encoding of bin.
|
||||
func EncodeBase64String(bin []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(bin)
|
||||
}
|
||||
|
||||
// EncodedLenBase64 returns the length in bytes of the base64 encoding of an input buffer of length n.
|
||||
func EncodedLenBase64(decodedBytes int) int {
|
||||
return base64.StdEncoding.EncodedLen(decodedBytes)
|
||||
}
|
||||
|
||||
// DecodeBase64String returns the bytes represented by the base64 string s.
|
||||
// If the input is malformed, it returns the partially decoded data and
|
||||
// [CorruptInputError]. Newline characters (\r and \n) are ignored.
|
||||
func DecodeBase64String(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// ReadBase64 returns a new reader that decodes base64 and returns binary data.
|
||||
func ReadBase64(stream io.Reader) io.Reader {
|
||||
return base64.NewDecoder(base64.StdEncoding, stream)
|
||||
}
|
||||
|
||||
// DecodeBase64 returns the bytes represented by the base64 string s.
|
||||
// If the input is malformed, it returns the partially decoded data and
|
||||
// [CorruptInputError]. Newline characters (\r and \n) are ignored.
|
||||
func DecodeBase64(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
// EncodeBase64Bytes encodes src, writing EncodedLenBase64 bytes to dst.
|
||||
//
|
||||
// The encoding pads the output to a multiple of 4 bytes,
|
||||
// so Encode is not appropriate for use on individual blocks
|
||||
// of a large data stream.
|
||||
func EncodeBase64Bytes(dst, src []byte) {
|
||||
base64.StdEncoding.Encode(dst, src)
|
||||
}
|
||||
|
||||
// DecodedLenBase64 returns the maximum length in bytes of the decoded data
|
||||
// corresponding to n bytes of base64-encoded data.
|
||||
func DecodedLenBase64(encodedBytes int) int {
|
||||
return base64.StdEncoding.DecodedLen(encodedBytes)
|
||||
}
|
||||
|
||||
// DecodeBase64Bytes decodes src, writing at most DecodedLenBase64 bytes to dst.
|
||||
// If src contains invalid base64 data, it returns the number of bytes successfully
|
||||
// written. New line characters (\r and \n) are ignored.
|
||||
func DecodeBase64Bytes(dst, src []byte) (n int, err error) {
|
||||
return base64.StdEncoding.Decode(dst, src)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,76 @@
|
|||
package media
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBase64(t *testing.T) {
|
||||
t.Run("Gopher", func(t *testing.T) {
|
||||
data, err := DecodeBase64(gopher)
|
||||
t.Run("DecodeString", func(t *testing.T) {
|
||||
data, err := DecodeBase64String(gopher)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, gopher, EncodeBase64(data))
|
||||
|
||||
if mime := mimetype.Detect(data); mime == nil {
|
||||
t.Fatal("mimetype image/png expected")
|
||||
} else {
|
||||
assert.Equal(t, "image/png", mime.String())
|
||||
}
|
||||
})
|
||||
t.Run("DecodeString", func(t *testing.T) {
|
||||
data, err := DecodeBase64String(gopher)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, gopher, EncodeBase64String(data))
|
||||
})
|
||||
t.Run("Read", func(t *testing.T) {
|
||||
reader := ReadBase64(strings.NewReader(gopher))
|
||||
|
||||
if data, err := io.ReadAll(reader); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if decodeData, decodeErr := DecodeBase64String(gopher); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
} else {
|
||||
assert.Equal(t, data, decodeData)
|
||||
assert.Equal(t, EncodeBase64String(data), gopher)
|
||||
}
|
||||
})
|
||||
t.Run("DecodeBytes", func(t *testing.T) {
|
||||
encoded := []byte(gopher)
|
||||
encodedLen := len(encoded)
|
||||
decodedLen := DecodedLenBase64(encodedLen)
|
||||
binary := make([]byte, decodedLen)
|
||||
|
||||
if n, err := DecodeBase64Bytes(binary, encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
assert.GreaterOrEqual(t, decodedLen, n)
|
||||
}
|
||||
})
|
||||
t.Run("EncodeBytes", func(t *testing.T) {
|
||||
encoded := []byte(gopher)
|
||||
encodedLen := len(encoded)
|
||||
decodedLen := DecodedLenBase64(encodedLen)
|
||||
binary := make([]byte, decodedLen)
|
||||
|
||||
if n, err := DecodeBase64Bytes(binary, encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
binary = binary[:n]
|
||||
assert.GreaterOrEqual(t, decodedLen, n)
|
||||
}
|
||||
|
||||
binaryEncodedLen := EncodedLenBase64(len(binary))
|
||||
binaryEncoded := make([]byte, binaryEncodedLen)
|
||||
|
||||
EncodeBase64Bytes(binaryEncoded, binary)
|
||||
assert.Equal(t, encoded, binaryEncoded)
|
||||
assert.Equal(t, gopher, string(binaryEncoded))
|
||||
|
||||
data, err := DecodeBase64String(string(binaryEncoded))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, gopher, EncodeBase64String(data))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func DataUrl(r io.Reader) string {
|
|||
}
|
||||
|
||||
// Generate data URL.
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, EncodeBase64(data))
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, EncodeBase64String(data))
|
||||
}
|
||||
|
||||
// ReadUrl reads binary data from a regular file path,
|
||||
|
|
@ -85,7 +85,7 @@ func ReadUrl(fileUrl string, schemes []string) (data []byte, err error) {
|
|||
if _, binaryData, found := strings.Cut(u.Opaque, ";base64,"); !found || len(binaryData) == 0 {
|
||||
return data, fmt.Errorf("invalid %s url", u.Scheme)
|
||||
} else {
|
||||
return DecodeBase64(binaryData)
|
||||
return DecodeBase64String(binaryData)
|
||||
}
|
||||
case scheme.File:
|
||||
if data, err = os.ReadFile(fileUrl); err != nil {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func TestReadUrl(t *testing.T) {
|
|||
if data, err := ReadUrl(dataUrl, []string{"https", "data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
expected, _ := DecodeBase64(gopher)
|
||||
expected, _ := DecodeBase64String(gopher)
|
||||
assert.Equal(t, expected, data)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const (
|
|||
ContentType = "Content-Type"
|
||||
ContentDisposition = "Content-Disposition"
|
||||
ContentEncoding = "Content-Encoding"
|
||||
ContentLength = "Content-Length"
|
||||
ContentRange = "Content-Range"
|
||||
Location = "Location"
|
||||
Origin = "Origin"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ func TestContent(t *testing.T) {
|
|||
assert.Equal(t, "Content-Type", ContentType)
|
||||
assert.Equal(t, "Content-Disposition", ContentDisposition)
|
||||
assert.Equal(t, "Content-Encoding", ContentEncoding)
|
||||
assert.Equal(t, "Content-Length", ContentLength)
|
||||
assert.Equal(t, "Content-Range", ContentRange)
|
||||
assert.Equal(t, "Location", Location)
|
||||
assert.Equal(t, "Origin", Origin)
|
||||
|
|
|
|||
8
pkg/media/http/header/webhook.go
Normal file
8
pkg/media/http/header/webhook.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package header
|
||||
|
||||
const (
|
||||
WebhookID string = "webhook-id"
|
||||
WebhookSignature string = "webhook-signature"
|
||||
WebhookTimestamp string = "webhook-timestamp"
|
||||
WebhookSecretPrefix string = "whsec_"
|
||||
)
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
package scheme
|
||||
|
||||
// Type represents a URL scheme type.
|
||||
type Type = string
|
||||
|
||||
const (
|
||||
File = "file"
|
||||
Data = "data"
|
||||
Http = "http"
|
||||
Https = "https"
|
||||
HttpUnix = Http + "+" + Unix
|
||||
Websocket = "wss"
|
||||
Unix = "unix"
|
||||
Unixgram = "unixgram"
|
||||
Unixpacket = "unixpacket"
|
||||
File Type = "file"
|
||||
Data Type = "data"
|
||||
Http Type = "http"
|
||||
Https Type = "https"
|
||||
Websocket Type = "wss"
|
||||
Unix Type = "unix"
|
||||
HttpUnix Type = "http+unix"
|
||||
Unixgram Type = "unixgram"
|
||||
Unixpacket Type = "unixpacket"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
75
scripts/dist/install-nats.sh
vendored
Executable file
75
scripts/dist/install-nats.sh
vendored
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Installs the nats-server binary, a cloud-native messaging system, on Linux.
|
||||
# bash <(curl -s https://raw.githubusercontent.com/photoprism/photoprism/develop/scripts/dist/install-nats.sh)
|
||||
|
||||
set -e
|
||||
|
||||
# Show usage information if first argument is --help.
|
||||
if [[ ${1} == "--help" ]]; then
|
||||
echo "Usage: ${0##*/} [destdir] [version]" 1>&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# You can provide a custom installation directory as the first argument.
|
||||
DESTDIR=$(realpath "${1:-/usr/local}")
|
||||
|
||||
# Determine target architecture.
|
||||
if [[ $PHOTOPRISM_ARCH ]]; then
|
||||
SYSTEM_ARCH=$PHOTOPRISM_ARCH
|
||||
else
|
||||
SYSTEM_ARCH=$(uname -m)
|
||||
fi
|
||||
|
||||
DESTARCH=${BUILD_ARCH:-$SYSTEM_ARCH}
|
||||
|
||||
case $DESTARCH in
|
||||
amd64 | AMD64 | x86_64 | x86-64)
|
||||
DESTARCH=amd64
|
||||
;;
|
||||
|
||||
arm64 | ARM64 | aarch64)
|
||||
DESTARCH=arm64
|
||||
;;
|
||||
|
||||
arm | ARM | aarch | armv7l | armhf)
|
||||
DESTARCH=arm7
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unsupported Machine Architecture: \"$DESTARCH\"" 1>&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
. /etc/os-release
|
||||
|
||||
# Abort if not executed as root.
|
||||
if [[ $(id -u) != "0" ]] && [[ $DESTDIR == "/usr" || $DESTDIR == "/usr/local" ]]; then
|
||||
echo "Error: Run ${0##*/} as root to install in a system directory!" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing NATS for ${DESTARCH^^}..."
|
||||
|
||||
# Alternatively, users can specify a custom version to install as the second argument.
|
||||
GITHUB_LATEST=$(curl --silent "https://api.github.com/repos/nats-io/nats-server/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
VERSION=${2:-$GITHUB_LATEST}
|
||||
ARCHIVE="nats-server-${VERSION}-linux-${DESTARCH}.tar.gz"
|
||||
GITHUB_URL="https://github.com/nats-io/nats-server/releases/download/${VERSION}/${ARCHIVE}"
|
||||
|
||||
echo "------------------------------------------------"
|
||||
echo "VERSION : ${VERSION}"
|
||||
echo "LATEST : ${GITHUB_LATEST}"
|
||||
echo "DOWNLOAD: ${GITHUB_URL}"
|
||||
echo "DESTDIR : ${DESTDIR}"
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# Adjust the installation path because the archive does not contain a bin directory.
|
||||
DESTDIR="${DESTDIR}/bin"
|
||||
|
||||
echo "Extracting the nats-server binary in \"${ARCHIVE}\" to \"${DESTDIR}\"..."
|
||||
mkdir -p "${DESTDIR}"
|
||||
curl -fsSL "${GITHUB_URL}" | tar --overwrite --mode=755 -xz -C "${DESTDIR}" --strip-components=1 --wildcards --no-anchored "nats-server"
|
||||
|
||||
echo "Done."
|
||||
71
scripts/dist/install-qdrant.sh
vendored
Executable file
71
scripts/dist/install-qdrant.sh
vendored
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Installs the qdrant binary, a vector search engine, on Linux.
|
||||
# bash <(curl -s https://raw.githubusercontent.com/photoprism/photoprism/develop/scripts/dist/install-qdrant.sh)
|
||||
|
||||
set -e
|
||||
|
||||
# Show usage information if first argument is --help.
|
||||
if [[ ${1} == "--help" ]]; then
|
||||
echo "Usage: ${0##*/} [destdir] [version]" 1>&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# You can provide a custom installation directory as the first argument.
|
||||
DESTDIR=$(realpath "${1:-/usr/local}")
|
||||
|
||||
# Determine target architecture.
|
||||
if [[ $PHOTOPRISM_ARCH ]]; then
|
||||
SYSTEM_ARCH=$PHOTOPRISM_ARCH
|
||||
else
|
||||
SYSTEM_ARCH=$(uname -m)
|
||||
fi
|
||||
|
||||
DESTARCH=${BUILD_ARCH:-$SYSTEM_ARCH}
|
||||
|
||||
case $DESTARCH in
|
||||
amd64 | AMD64 | x86_64 | x86-64)
|
||||
DESTARCH=x86_64
|
||||
;;
|
||||
|
||||
arm64 | ARM64 | aarch64)
|
||||
DESTARCH=aarch64
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unsupported Machine Architecture: \"$DESTARCH\"" 1>&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
. /etc/os-release
|
||||
|
||||
# Abort if not executed as root.
|
||||
if [[ $(id -u) != "0" ]] && [[ $DESTDIR == "/usr" || $DESTDIR == "/usr/local" ]]; then
|
||||
echo "Error: Run ${0##*/} as root to install in a system directory!" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing Qdrant for ${DESTARCH^^}..."
|
||||
|
||||
# Alternatively, users can specify a custom version to install as the second argument.
|
||||
GITHUB_LATEST=$(curl --silent "https://api.github.com/repos/qdrant/qdrant/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
VERSION=${2:-$GITHUB_LATEST}
|
||||
ARCHIVE="qdrant-${DESTARCH}-unknown-linux-musl.tar.gz"
|
||||
GITHUB_URL="https://github.com/qdrant/qdrant/releases/download/${VERSION}/${ARCHIVE}"
|
||||
|
||||
echo "------------------------------------------------"
|
||||
echo "VERSION : ${VERSION}"
|
||||
echo "LATEST : ${GITHUB_LATEST}"
|
||||
echo "DOWNLOAD: ${GITHUB_URL}"
|
||||
echo "DESTDIR : ${DESTDIR}"
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# Adjust the installation path because the archive does not contain a bin directory.
|
||||
DESTDIR="${DESTDIR}/bin"
|
||||
|
||||
echo "Extracting the qdrant binary in \"${ARCHIVE}\" to \"${DESTDIR}\"..."
|
||||
mkdir -p "${DESTDIR}"
|
||||
curl -fsSL "${GITHUB_URL}" | tar --overwrite --mode=755 -xz -C "${DESTDIR}" --wildcards --no-anchored "qdrant"
|
||||
|
||||
echo "Done."
|
||||
26
scripts/dist/install-s6.sh
vendored
26
scripts/dist/install-s6.sh
vendored
|
|
@ -58,27 +58,29 @@ set -eu
|
|||
|
||||
S6_OVERLAY_LATEST=$(curl --silent "https://api.github.com/repos/just-containers/s6-overlay/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
S6_OVERLAY_VERSION=${1:-$S6_OVERLAY_LATEST}
|
||||
S6_ARCH_URL="https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz"
|
||||
S6_NOARCH_URL="https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz"
|
||||
ARCHIVE_NOARCH="s6-overlay-noarch.tar.xz"
|
||||
ARCHIVE_BINARY="s6-overlay-${S6_OVERLAY_ARCH}.tar.xz"
|
||||
S6_NOARCH_URL="https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/${ARCHIVE_NOARCH}"
|
||||
S6_BINARY_URL="https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/${ARCHIVE_BINARY}"
|
||||
|
||||
echo "Installing S6 Overlay..."
|
||||
echo "Installing S6 Overlay for ${S6_OVERLAY_ARCH^^}..."
|
||||
|
||||
echo "------------------------------------------------"
|
||||
echo "VERSION : ${S6_OVERLAY_VERSION}"
|
||||
echo "LATEST : ${S6_OVERLAY_LATEST}"
|
||||
echo "DESTDIR : ${S6_OVERLAY_DESTDIR}"
|
||||
echo "BINARY URL: ${S6_ARCH_URL}"
|
||||
echo "NOARCH URL: ${S6_NOARCH_URL}"
|
||||
echo "VERSION: ${S6_OVERLAY_VERSION}"
|
||||
echo "LATEST : ${S6_OVERLAY_LATEST}"
|
||||
echo "NOARCH : ${ARCHIVE_NOARCH}"
|
||||
echo "BINARY : ${ARCHIVE_BINARY}"
|
||||
echo "DESTDIR: ${S6_OVERLAY_DESTDIR}"
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# Create the destination directory if it does not already exist.
|
||||
mkdir -p "${S6_OVERLAY_DESTDIR}"
|
||||
|
||||
# Download and install the s6-overlay release from GitHub.
|
||||
echo "Extracting \"$S6_ARCH_URL\" to \"$S6_OVERLAY_DESTDIR\"."
|
||||
curl -fsSL "$S6_ARCH_URL" | tar -C "${S6_OVERLAY_DESTDIR}" -Jxp
|
||||
|
||||
echo "Extracting \"$S6_NOARCH_URL\" to \"$S6_OVERLAY_DESTDIR\"."
|
||||
echo "Extracting \"$S6_NOARCH_URL\" to \"$S6_OVERLAY_DESTDIR\"..."
|
||||
curl -fsSL "$S6_NOARCH_URL" | tar -C "${S6_OVERLAY_DESTDIR}" -Jxp
|
||||
|
||||
echo "Extracting \"$S6_BINARY_URL\" to \"$S6_OVERLAY_DESTDIR\"..."
|
||||
curl -fsSL "$S6_BINARY_URL" | tar -C "${S6_OVERLAY_DESTDIR}" -Jxp
|
||||
|
||||
echo "Done."
|
||||
|
|
|
|||
0
scripts/dist/services/photoprism/dependencies.d/base
vendored
Normal file
0
scripts/dist/services/photoprism/dependencies.d/base
vendored
Normal file
3
scripts/dist/services/photoprism/run
vendored
Normal file
3
scripts/dist/services/photoprism/run
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#!/command/execlineb -P
|
||||
with-contenv
|
||||
/scripts/cmd.sh /opt/photoprism/bin/photoprism start
|
||||
1
scripts/dist/services/photoprism/type
vendored
Normal file
1
scripts/dist/services/photoprism/type
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
Loading…
Add table
Add a link
Reference in a new issue