Add video player #17

Still need to index metadata. Work in progress.

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-13 15:36:42 +02:00
parent e634fd97a7
commit bd3426ae51
30 changed files with 597 additions and 42 deletions

View file

@ -4163,6 +4163,11 @@
"entities": "^1.1.1"
}
},
"dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"domain-browser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
@ -6265,6 +6270,22 @@
}
}
},
"global": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
"integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
"requires": {
"min-document": "^2.19.0",
"process": "~0.5.1"
},
"dependencies": {
"process": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
"integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
}
}
},
"global-modules": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
@ -6453,6 +6474,22 @@
"resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ=="
},
"hls.js": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.13.2.tgz",
"integrity": "sha512-sIg2t4uGpWQLzuK1Iid9614WOKqxj4OYg+EbFbhhTDCsxpENBN+Du3yBFnoi+a83DuOOHdiQd1ydnti9loSGXw==",
"requires": {
"eventemitter3": "3.1.0",
"url-toolkit": "^2.1.6"
},
"dependencies": {
"eventemitter3": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz",
"integrity": "sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA=="
}
}
},
"hmac-drbg": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@ -8120,6 +8157,14 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
},
"mediaelement": {
"version": "4.2.16",
"resolved": "https://registry.npmjs.org/mediaelement/-/mediaelement-4.2.16.tgz",
"integrity": "sha512-5GinxsRpVA36w6tAD6nTqVSiZ0LzIhqUrzD8wzOAtZPPM7NOwOBtz6Oa85VemS+3Jvoo38jM1RvNqwKYJBBxtQ==",
"requires": {
"global": "^4.3.1"
}
},
"mem": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz",
@ -8223,6 +8268,14 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
"integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
},
"min-document": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
"requires": {
"dom-walk": "^0.1.0"
}
},
"mini-css-extract-plugin": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz",
@ -9252,9 +9305,9 @@
"integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs="
},
"postcss": {
"version": "7.0.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.29.tgz",
"integrity": "sha512-ba0ApvR3LxGvRMMiUa9n0WR4HjzcYm7tS+ht4/2Nd0NLtHpPIH77fuB9Xh1/yJVz9O/E/95Y/dn8ygWsyffXtw==",
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.30.tgz",
"integrity": "sha512-nu/0m+NtIzoubO+xdAlwZl/u5S5vi/y6BCsoL8D+8IxsD3XvBS8X4YEADNIVXKVuQvduiucnRv+vPIqj56EGMQ==",
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
@ -12532,6 +12585,11 @@
"schema-utils": "^1.0.0"
}
},
"url-toolkit": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.1.6.tgz",
"integrity": "sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw=="
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View file

@ -61,6 +61,7 @@
"eventsource-polyfill": "^0.9.6",
"file-loader": "^3.0.1",
"friendly-errors-webpack-plugin": "^1.7.0",
"hls.js": "^0.13.2",
"html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.19.1",
"i18n-iso-countries": "^5.3.0",
@ -75,6 +76,7 @@
"luxon": "^1.24.1",
"mapbox-gl": "^1.10.0",
"material-design-icons-iconfont": "^5.0.1",
"mediaelement": "^4.2.16",
"mini-css-extract-plugin": "^0.7.0",
"minimist": ">=1.2.5",
"mocha": "^6.2.3",
@ -83,7 +85,7 @@
"ora": "^3.4.0",
"photoswipe": "^4.1.3",
"pluralize": "^8.0.0",
"postcss": "^7.0.29",
"postcss": "^7.0.30",
"postcss-browser-reporter": "^0.6.0",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",

View file

@ -21,12 +21,16 @@ import VueLuxon from "vue-luxon";
import VueFilters from "vue2-filters";
import VueFullscreen from "vue-fullscreen";
import VueInfiniteScroll from "vue-infinite-scroll";
import Hls from "hls.js";
// Initialize helpers
const viewer = new Viewer();
const clipboard = new Clipboard(window.localStorage, "photo_clipboard");
const isPublic = config.get("public");
// HTTP Live Streaming (video support)
window.Hls = Hls;
// Assign helpers to VueJS prototype
Vue.prototype.$event = Event;
Vue.prototype.$notify = Notify;

View file

@ -1,15 +1,16 @@
import PNotify from "./p-notify.vue";
import PNavigation from "./p-navigation.vue";
import PLoadingBar from "./p-loading-bar.vue";
import PPhotoSearch from "./p-photo-search.vue";
import PVideoPlayer from "./p-video-player.vue";
import PPhotoViewer from "./p-photo-viewer.vue";
import PPhotoCards from "./p-photo-cards.vue";
import PPhotoMosaic from "./p-photo-mosaic.vue";
import PPhotoList from "./p-photo-list.vue";
import PPhotoSearch from "./p-photo-search.vue";
import PPhotoClipboard from "./p-photo-clipboard.vue";
import PLabelClipboard from "./p-label-clipboard.vue";
import PAlbumClipboard from "./p-album-clipboard.vue";
import PAlbumToolbar from "./p-album-toolbar.vue";
import PPhotoViewer from "./p-photo-viewer.vue";
import PScrollTop from "./p-scroll-top.vue";
const components = {};
@ -18,6 +19,7 @@ components.install = (Vue) => {
Vue.component("p-notify", PNotify);
Vue.component("p-navigation", PNavigation);
Vue.component("p-loading-bar", PLoadingBar);
Vue.component("p-video-player", PVideoPlayer);
Vue.component("p-photo-viewer", PPhotoViewer);
Vue.component("p-photo-cards", PPhotoCards);
Vue.component("p-photo-mosaic", PPhotoMosaic);

View file

@ -269,6 +269,7 @@
@confirm="upload.dialog = false"></p-upload-dialog>
<p-photo-edit-dialog :show="edit.dialog" :selection="edit.selection" :index="edit.index" :album="edit.album"
@close="edit.dialog = false"></p-photo-edit-dialog>
<p-video-dialog ref="video" :play="video.play" :album="video.album"></p-video-dialog>
</div>
</template>
@ -288,16 +289,21 @@
config: this.$config.values,
page: this.$config.page,
upload: {
dialog: false,
subscription: null,
dialog: false,
},
edit: {
dialog: false,
subscription: null,
dialog: false,
album: null,
selection: [],
index: 0,
},
video: {
subscription: null,
album: null,
play: null,
},
};
},
computed: {
@ -334,6 +340,7 @@
},
created() {
this.upload.subscription = Event.subscribe("dialog.upload", () => this.upload.dialog = true);
this.edit.subscription = Event.subscribe("dialog.edit", (ev, data) => {
if (!this.edit.dialog) {
this.edit.index = data.index;
@ -342,10 +349,17 @@
this.edit.dialog = true;
}
});
this.video.subscription = Event.subscribe("dialog.video", (ev, data) => {
this.video.play = data.play;
this.video.album = data.album;
this.$refs.video.show = true;
});
},
destroyed() {
Event.unsubscribe(this.upload.subscription);
Event.unsubscribe(this.edit.subscription);
Event.unsubscribe(this.video.subscription);
}
};
</script>

View file

@ -45,13 +45,13 @@
<v-progress-circular indeterminate color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hidePrivate && photo.PhotoPrivate"
<v-btn v-if="hidePrivate && photo.PhotoPrivate" :ripple="false"
icon flat large absolute
class="p-photo-private opacity-75">
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)"
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
icon flat large absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
@ -61,14 +61,19 @@
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
</v-btn>
<v-btn icon flat large absolute
<v-btn icon flat large absolute :ripple="false"
:class="photo.PhotoFavorite ? 'p-photo-like opacity-75' : 'p-photo-like opacity-50'"
@click.stop.prevent="photo.toggleLike()">
<v-icon v-if="photo.PhotoFavorite" color="white" class="t-like t-on">favorite</v-icon>
<v-icon v-else color="accent lighten-3" class="t-like t-off">favorite_border</v-icon>
</v-btn>
<v-btn v-if="photo.Files.length > 1"
<v-btn v-if="photo.PhotoVideo" color="white" :ripple="false"
outline large fab absolute class="p-photo-play opacity-75" :depressed="false"
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-play">play_arrow</v-icon>
</v-btn>
<v-btn v-else-if="photo.Files.length > 1" :ripple="false"
icon flat large absolute class="p-photo-merged opacity-75"
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>

View file

@ -28,10 +28,15 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="selection.length && $clipboard.has(props.item)" :flat="true" :ripple="false"
icon large absolute class="p-photo-select">
<v-btn v-if="selection.length && $clipboard.has(props.item)" :ripple="false"
flat icon large absolute class="p-photo-select">
<v-icon color="white" class="t-select t-on">check_circle</v-icon>
</v-btn>
<v-btn v-else-if="!selection.length && props.item.PhotoVideo" :ripple="false"
flat icon large absolute class="p-photo-play opacity-75"
@click.stop.prevent="openPhoto(props.index, true)">
<v-icon color="white" class="action-play">play_arrow</v-icon>
</v-btn>
</v-img>
</td>
<td class="p-photo-desc p-pointer" @click.exact="editPhoto(props.index)" style="user-select: none;">
@ -136,9 +141,15 @@
} else {
this.$clipboard.toggle(this.photos[index]);
}
} else if(this.photos[index]) {
let photo = this.photos[index];
if(photo.PhotoVideo) {
this.openPhoto(index, true);
} else {
this.openPhoto(index, false);
}
}
},
onContextMenu(ev, index) {
if (this.$isMobile) {

View file

@ -44,13 +44,13 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hidePrivate && photo.PhotoPrivate"
<v-btn v-if="hidePrivate && photo.PhotoPrivate" :ripple="false"
icon flat small absolute
class="p-photo-private opacity-75">
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)"
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
icon flat small absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
@ -60,14 +60,19 @@
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
</v-btn>
<v-btn icon flat small absolute
<v-btn icon flat small absolute :ripple="false"
:class="photo.PhotoFavorite ? 'p-photo-like opacity-75' : 'p-photo-like opacity-50'"
@click.stop.prevent="photo.toggleLike()">
<v-icon v-if="photo.PhotoFavorite" color="white" class="t-like t-on">favorite</v-icon>
<v-icon v-else color="accent lighten-3" class="t-like t-off">favorite_border</v-icon>
</v-btn>
<v-btn v-if="photo.Files.length > 1"
<v-btn v-if="photo.PhotoVideo" color="white"
outline fab absolute class="p-photo-play opacity-75" :depressed="false" :ripple="false"
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-play">play_arrow</v-icon>
</v-btn>
<v-btn v-else-if="photo.Files.length > 1" :ripple="false"
icon flat small absolute class="p-photo-merged opacity-75"
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>

View file

@ -0,0 +1,128 @@
<template>
<video class="p-video-player" ref="player" :height="height" :width="width" :autoplay="autoplay"
:preload="preload"></video>
</template>
<script>
import "mediaelement";
export default {
name: "p-photo-player",
props: {
show: {
type: Boolean,
required: false,
default: false
},
source: {
type: String,
required: true,
default: ""
},
width: {
type: String,
required: false,
default: "auto"
},
height: {
type: String,
required: false,
default: "auto"
},
preload: {
type: String,
required: false,
default: "none"
},
autoplay: {
type: Boolean,
required: false,
default: false
},
success: {
type: Function,
default() {
return false;
}
},
error: {
type: Function,
default() {
return false;
}
}
},
data: () => ({
refresh: false,
player: null,
}),
mounted() {
this.render();
},
methods: {
render() {
const {MediaElementPlayer} = global;
const self = this;
this.player = new MediaElementPlayer(this.$el, {
videoWidth: this.width,
videoHeight: this.height,
pluginPath: '/static/build/',
shimScriptAccess: 'always',
forceLive: false,
loop: false,
stretching: false,
autoplay: true,
success: (mediaElement, originalNode, instance) => {
instance.setSrc(self.source);
this.success(mediaElement, originalNode, instance);
mediaElement.addEventListener(Hls.Events.MEDIA_ATTACHED, function () {
});
},
error: (e) => {
// console.log(e);
}
});
},
remove() {
if (this.player) {
this.player.remove();
this.player = "";
}
},
setSource(src) {
if (!this.player) {
console.log('source: player not initialized');
return;
}
if (!src) {
return;
}
this.player.height = this.height;
this.player.width = this.width;
this.player.videoHeight = this.height;
this.player.videoWidth = this.width;
this.player.setSrc(src);
this.player.setPoster("");
this.player.load();
},
pause() {
if (this.player) {
this.player.pause();
}
},
},
beforeDestroy() {
this.remove();
},
watch: {
source: function (source) {
if (source) {
this.setSource(source);
}
},
},
}
</script>

View file

@ -1,6 +1,7 @@
@import url("../../node_modules/material-design-icons-iconfont/dist/material-design-icons.css");
@import url("../../node_modules/vuetify/dist/vuetify.min.css");
@import url("../../node_modules/mapbox-gl/dist/mapbox-gl.css");
@import url("video.css");
@import url("colorchange.css");
@import url("maps.css");
@import url("viewer.css");

View file

@ -11,9 +11,15 @@
text-align: center;
}
#photoprism .p-photo-list .p-photo-select {
top: 7px;
left: 7px;
#photoprism .p-photo-list .p-photo-select,
#photoprism .p-photo-list .p-photo-play {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
text-align: center;
}
#photoprism .p-photo-mosaic .p-photo-private,
@ -34,6 +40,17 @@
left: 4px;
}
#photoprism .p-photo-mosaic .p-photo-play,
#photoprism .p-photo-cards .p-photo-play {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
text-align: center;
}
#photoprism .p-photo-mosaic .p-photo-like,
#photoprism .p-photo-cards .p-photo-like {
left: 4px;

View file

@ -0,0 +1,31 @@
@import url("../../node_modules/mediaelement/build/mediaelementplayer.min.css");
.mejs__container {
min-width: auto !important;
}
.mejs__overlay-button {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
:not([style*='display: none']).mejs__controls {
background: none !important;
}
.mejs__controls div{
display: inline-block;
}
.mejs__time-rail {
width: 50%;
}
.mejs__fullscreen > button {
background-position: -80px 0 !important;
}
#photoprism .p-video-dialog {
overflow: hidden !important;
}
#photoprism .p-video-player {
overflow: hidden !important;
}

View file

@ -8,6 +8,7 @@ import PPhotoShareDialog from "./p-photo-share-dialog.vue";
import PAlbumDeleteDialog from "./album/p-album-delete-dialog.vue";
import PLabelDeleteDialog from "./label/p-label-delete-dialog.vue";
import PUploadDialog from "./p-upload-dialog.vue";
import PVideoDialog from "./p-video-dialog.vue";
const dialogs = {};
@ -22,6 +23,7 @@ dialogs.install = (Vue) => {
Vue.component("p-album-delete-dialog", PAlbumDeleteDialog);
Vue.component("p-label-delete-dialog", PLabelDeleteDialog);
Vue.component("p-upload-dialog", PUploadDialog);
Vue.component("p-video-dialog", PVideoDialog);
};
export default dialogs;

View file

@ -0,0 +1,79 @@
<template>
<v-dialog lazy v-model="show" :scrollable="false" :max-width="width" class="p-video-dialog">
<p-video-player v-show="show" ref="player" :source="source" :height="height.toString()" :width="width.toString()" :autoplay="true"></p-video-player>
</v-dialog>
</template>
<script>
export default {
name: 'p-video-dialog',
props: {
play: Object,
album: Object,
},
data() {
return {
show: false,
source: "",
defaultWidth: 640,
defaultHeight: 480,
width: 640,
height: 480,
}
},
methods: {
load(video) {
if (!video) {
this.$notify.error("no video selected");
return;
}
let main = video.mainFile();
let file = video.videoFile();
let uri = video.videoUri();
if (!uri) {
this.$notify.error("no video file found");
return;
}
if(file.FileWidth > 0) {
this.width = file.FileWidth;
} else if(main.FileWidth > 0) {
this.width = main.FileWidth;
} else {
this.width = this.defaultWidth;
}
if(window.innerWidth < (this.width + 50)) {
this.width = window.innerWidth - 50;
}
if(file.FileHeight > 0) {
this.height = file.FileHeight;
} else if(main.FileHeight > 0) {
this.height = main.FileHeight;
} else {
this.height = this.defaultHeight;
}
this.$el.style.height = this.height;
this.$el.style.width = this.width;
this.source = uri;
this.show = true;
},
},
watch: {
play: function (play) {
if (play) {
this.load(play);
}
},
show: function(show) {
if(!show) {
this.$refs.player.pause();
}
}
},
}
</script>

View file

@ -25,8 +25,8 @@ class Photo extends RestModel {
PhotoTitle: "",
TitleSrc: "",
PhotoFavorite: false,
PhotoStory: false,
PhotoPrivate: false,
PhotoVideo: false,
PhotoResolution: 0,
PhotoQuality: 0,
PhotoLat: 0.0,
@ -111,6 +111,30 @@ class Photo extends RestModel {
this.FileHeight = file.FileHeight;
}
videoFile() {
if (!this.Files) {
return false;
}
let file = this.Files.find(f => f.FileType === "mp4");
if (!file) {
file = this.Files.find(f => !!f.FileVideo);
}
return file;
}
videoUri() {
const file = this.videoFile()
if (!file) {
return "";
}
return "/api/v1/videos/" + file.FileHash + "/mp4"
}
mainFile() {
if (!this.Files) {
return false;

View file

@ -137,11 +137,19 @@
Event.publish("dialog.edit", {selection: selection, album: this.album, index: index});
},
openPhoto(index, showMerged) {
if (showMerged) {
if(!this.results[index]) {
return false;
}
if (showMerged && this.results[index].PhotoVideo) {
Event.publish("dialog.video", {play: this.results[index], album: null});
} else if (showMerged) {
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
} else {
this.$viewer.show(Thumb.fromPhotos(this.results), index);
}
return true;
},
loadMore() {
if (this.scrollDisabled) return;

View file

@ -174,7 +174,13 @@
Event.publish("dialog.edit", {selection: selection, album: null, index: index});
},
openPhoto(index, showMerged) {
if (showMerged) {
if(!this.results[index]) {
return false;
}
if (showMerged && this.results[index].PhotoVideo) {
Event.publish("dialog.video", {play: this.results[index], album: null});
} else if (showMerged) {
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
} else {
this.$viewer.show(Thumb.fromPhotos(this.results), index);

View file

@ -48,8 +48,8 @@ const config = {
},
performance: {
hints: isDev ? false : "error",
maxEntrypointSize: 3000000,
maxAssetSize: 3000000,
maxEntrypointSize: 4000000,
maxAssetSize: 4000000,
},
module: {
rules: [

View file

@ -1,13 +1,11 @@
package api
import (
"fmt"
"net/http"
"path"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
@ -47,12 +45,21 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
fileName := path.Join(conf.OriginalsPath(), f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("photo: could not find original for %s", fileName)
log.Errorf("photo: could not find original for %s", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
f.FileMissing = true
entity.Db().Save(&f)
if err := f.Save(); err != nil {
log.Errorf("photo: %s", err)
} else if f.AllFilesMissing() {
log.Infof("photo: deleting photo, all files missing for %s", txt.Quote(f.FileName))
if err := f.Photo.Delete(false); err != nil {
log.Errorf("photo: %s", err)
}
}
return
}
@ -80,9 +87,9 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
}
if c.Query("download") != "" {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
}
c.FileAttachment(thumbnail, f.ShareFileName())
} else {
c.File(thumbnail)
}
})
}

View file

@ -12,6 +12,9 @@ var photoIconSvg = []byte(`
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>`)
var videoIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M0 0h24v24H0z" fill="none"/><path d="M10 8v8l5-4-5-4zm9-5H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>`)
var albumIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
<path d="M0 0h24v24H0z" fill="none"/></svg>`)
@ -35,6 +38,10 @@ func GetSvg(router *gin.RouterGroup) {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
})
router.GET("/svg/video", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
})
router.GET("/svg/label", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
})

68
internal/api/video.go Normal file
View file

@ -0,0 +1,68 @@
package api
import (
"net/http"
"path"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/video"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /api/v1/videos/:hash/:type
//
// Parameters:
// hash: string The photo or video file hash as returned by the search API
// type: string Video type
func GetVideo(router *gin.RouterGroup, conf *config.Config) {
router.GET("/videos/:hash/:type", func(c *gin.Context) {
fileHash := c.Param("hash")
typeName := c.Param("type")
_, ok := video.Types[typeName]
if !ok {
log.Errorf("video: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
f, err := query.FileByHash(fileHash)
if err != nil {
log.Errorf("video: db error %s", err.Error())
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
if f.FileError != "" {
log.Errorf("video: file error %s", f.FileError)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
fileName := path.Join(conf.OriginalsPath(), f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("video: could not find file for %s", fileName)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
f.FileMissing = true
entity.Db().Save(&f)
return
}
if c.Query("download") != "" {
c.FileAttachment(fileName, f.ShareFileName())
} else {
c.File(fileName)
}
return
})
}

View file

@ -113,3 +113,25 @@ func (m File) Changed(fileSize int64, fileModified time.Time) bool {
func (m *File) Purge() error {
return Db().Unscoped().Model(m).Updates(map[string]interface{}{"file_missing": true, "file_primary": false}).Error
}
// AllFilesMissing returns true, if all files for the photo of this file are missing.
func (m *File) AllFilesMissing() bool {
count := 0
if err := Db().Model(&File{}).
Where("photo_id = ? AND b.file_missing = 0", m.PhotoID).
Count(&count).Error; err != nil {
log.Error(err)
}
return count == 0
}
// Save stored the file in the database using the default connection.
func (m *File) Save() error {
if err := Db().Save(m).Error; err != nil {
return err
}
return Db().Model(m).Related(Photo{}).Error
}

View file

@ -499,6 +499,19 @@ func (m *Photo) SetCoordinates(lat, lng float32, altitude int, source string) {
m.LocationSrc = source
}
// AllFilesMissing returns true, if all files for this photo are missing.
func (m *Photo) AllFilesMissing() bool {
count := 0
if err := Db().Model(&File{}).
Where("photo_id = ? AND b.file_missing = 0", m.ID).
Count(&count).Error; err != nil {
log.Error(err)
}
return count == 0
}
// Delete deletes the entity from the database.
func (m *Photo) Delete(permanently bool) error {
if permanently {

View file

@ -186,7 +186,6 @@ func TestConvert_ToJson(t *testing.T) {
})
}
func TestConvert_Start(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")

View file

@ -29,11 +29,11 @@ func Photos(f form.PhotoSearch) (results PhotosResults, count int, err error) {
files.id AS file_id, files.file_uuid, files.file_primary, files.file_missing, files.file_name, files.file_hash,
files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio,
files.file_orientation, files.file_main_color, files.file_colors, files.file_luminance, files.file_chroma,
files.file_diff,
files.file_diff, files.file_video, files.file_length,
cameras.camera_make, cameras.camera_model,
lenses.lens_make, lenses.lens_model,
places.loc_label, places.loc_city, places.loc_state, places.loc_country`).
Joins("JOIN files ON files.photo_id = photos.id AND files.file_type = 'jpg' AND files.file_missing = 0 AND files.deleted_at IS NULL").
Joins("JOIN files ON files.photo_id = photos.id AND files.file_missing = 0 AND files.deleted_at IS NULL AND (files.file_type = 'jpg' OR files.file_video)").
Joins("JOIN cameras ON cameras.id = photos.camera_id").
Joins("JOIN lenses ON lenses.id = photos.lens_id").
Joins("JOIN places ON photos.place_id = places.id").

View file

@ -31,6 +31,7 @@ type PhotosResult struct {
PhotoCountry string
PhotoFavorite bool
PhotoPrivate bool
PhotoVideo bool
PhotoLat float32
PhotoLng float32
PhotoAltitude int
@ -65,6 +66,8 @@ type PhotosResult struct {
FileUUID string
FilePrimary bool
FileMissing bool
FileVideo bool
FileLength time.Duration
FileName string
FileHash string
FileType string

View file

@ -26,6 +26,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetPreview(v1, conf)
api.GetThumbnail(v1, conf)
api.GetDownload(v1, conf)
api.GetVideo(v1, conf)
api.CreateZip(v1, conf)
api.DownloadZip(v1, conf)

View file

@ -75,7 +75,9 @@ type Type struct {
Options []ResampleOption
}
var Types = map[string]Type{
type TypeMap map[string]Type
var Types = TypeMap{
"tile_50": {"tile_500", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
"tile_100": {"tile_500", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
"tile_224": {"tile_500", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},

36
internal/video/video.go Normal file
View file

@ -0,0 +1,36 @@
/*
This package encapsulates JPEG thumbnail generation.
Additional information can be found in our Developer Guide:
https://github.com/photoprism/photoprism/wiki
*/
package video
import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
)
var log = event.Log
type Type struct {
Format fs.FileType
Width int
Height int
Public bool
}
type TypeMap map[string]Type
var TypeMP4 = Type{
Format: fs.TypeMP4,
Width: 0,
Height: 0,
Public: true,
}
var Types = TypeMap{
"": TypeMP4,
"mp4": TypeMP4,
}