Merge remote-tracking branch 'upstream/develop' into feature/custom-tf-model-127

This commit is contained in:
raystlin 2025-04-13 15:27:50 +00:00
commit e55536e581
96 changed files with 1228 additions and 435 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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.

View file

@ -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"]

View file

@ -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.

View file

@ -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"]

View file

@ -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.

View file

@ -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"]

View file

@ -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"]

View file

@ -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"]

View file

@ -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",

View file

@ -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>

View file

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

View file

@ -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: "",

View file

@ -194,11 +194,9 @@ export default {
attach: document.body,
},
VOverlay: {
scrim: true,
transition: false,
openDelay: 0,
closeDelay: 0,
attach: document.body,
},
VExpansionPanel: {
tile: true,

View file

@ -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() {

View file

@ -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>

View file

@ -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;
}
}
},
},

View file

@ -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() {

View file

@ -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();
}
}

View file

@ -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"

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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"

View file

@ -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()"

View file

@ -31,6 +31,7 @@
<v-text-field
v-model.lazy.trim="filter.q"
:placeholder="$gettext('Search')"
tabindex="1"
density="compact"
flat
single-line

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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("")

View file

@ -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("")

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,9 @@
package vision
type ApiFormat = string
const (
ApiFormatUrl ApiFormat = "url"
ApiFormatImages ApiFormat = "images"
ApiFormatVision ApiFormat = "vision"
)

View file

@ -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)
}

View file

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

View file

@ -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
)

View file

@ -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 {

View file

@ -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 {

View file

@ -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.

View file

@ -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}

View file

@ -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

View 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
}

View file

@ -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

View file

@ -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"

View file

@ -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() {

View 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)
}

View file

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

View 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
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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": {

View file

@ -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) {

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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)

View file

@ -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"
)

View file

@ -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{}
)

View file

@ -22,7 +22,9 @@ var ResourceNames = []Resource{
ResourceUsers,
ResourceSessions,
ResourceLogs,
ResourceApi,
ResourceWebDAV,
ResourceWebhooks,
ResourceMetrics,
ResourceVision,
ResourceFeedback,

View file

@ -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,

View file

@ -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",

View file

@ -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()

View file

@ -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.

View file

@ -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"`
}

View file

@ -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

View file

@ -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(),

View file

@ -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"),

View file

@ -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))
}
}
}

View file

@ -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)
})

View file

@ -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 == "" {

View file

@ -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"))

View file

@ -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)
}

View file

@ -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))
})
}

View file

@ -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 {

View file

@ -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)
}
})

View file

@ -9,6 +9,7 @@ const (
ContentType = "Content-Type"
ContentDisposition = "Content-Disposition"
ContentEncoding = "Content-Encoding"
ContentLength = "Content-Length"
ContentRange = "Content-Range"
Location = "Location"
Origin = "Origin"

View file

@ -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)

View file

@ -0,0 +1,8 @@
package header
const (
WebhookID string = "webhook-id"
WebhookSignature string = "webhook-signature"
WebhookTimestamp string = "webhook-timestamp"
WebhookSecretPrefix string = "whsec_"
)

View file

@ -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
View 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
View 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."

View file

@ -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."

View file

3
scripts/dist/services/photoprism/run vendored Normal file
View 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
View file

@ -0,0 +1 @@
longrun