From 062ed26e9e1a5d4ac30d8d2b944a601c1e5fdc72 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Mon, 10 Apr 2023 17:20:45 +0200 Subject: [PATCH] Fix timeline view + upgrade build --- .github/workflows/release.yml | 10 ++-- README.md | 22 ++++---- api.yaml | 10 ++++ docker/grafana/dashboards/photofield.json | 61 +++++++---------------- go.mod | 2 +- internal/layout/album.go | 2 +- internal/layout/common.go | 22 +++++++- internal/layout/strip.go | 2 +- internal/layout/timeline.go | 2 +- internal/layout/wall.go | 7 ++- internal/openapi/api.gen.go | 16 ++++++ internal/scene/sceneSource.go | 12 ++++- main.go | 31 +++++++++--- ui/src/App.vue | 10 ---- ui/src/api.js | 2 + ui/src/components/CollectionView.vue | 18 ++++++- ui/src/components/ScrollViewer.vue | 3 ++ ui/src/components/StripViewer.vue | 3 ++ 18 files changed, 152 insertions(+), 83 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5e01a8..b0611da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,29 +16,29 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: go-version: 1.19 - name: Set up Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: '16' - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser version: latest diff --git a/README.md b/README.md index 00f3789..d83b001 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ Photofield is a photo viewer built to mainly push the limits of what is possible in terms of the number of photos visible at the same time and at the speed at which they are displayed. The goal is to be as fast or faster than Google Photos on commodity hardware while displaying more photos at the same time. It is -non-invasive and at this point meant to be used to complement other photo -gallery software. +non-invasive and can be used either completely standalone or complementing other +photo gallery software. ### Features @@ -86,13 +86,16 @@ layouts. able to search for photo contents using words like "beach sunset", "a couple kissing", or "cat eyes". ![semantic search for "cat eyes"](docs/assets/semantic-search.jpg) -* **Reuse of existing thumbnails**. Do you have hundreds of gigabytes of - existing thumbnails from an existing system? Me too! Let's just reuse those. - Here are the currently supported thumbnail sources: +* **Flexible media/thumbnail system**. Do you have hundreds of gigabytes of existing + thumbnails from an existing system? Me too! Let's reuse those. Don't have any? + No worries, they will be generated automatically to speed up display. Here are + the currently supported thumbnail sources: + * Bespoke SQLite thumbnail database - `photofield.thumbs.db`. * Synology Moments / Photo Station auto-generated thumbnails in `@eaDir`. - * Embedded JPEG thumbnails (`ThumbnailImage` Exif tag). - * Limited support for extension via `thumbnails` section of - the [Configuration]. + * Embedded JPEG thumbnails - `ThumbnailImage` Exif tag. + * Native Go [image](https://pkg.go.dev/image) package. + * FFmpeg on-the-fly conversion - thumbnails and full sized variants. + * Configurable via the `sources` section of the [Configuration]. * Please [open an issue] for other systems, bonus points for an idea on how to integrate! * **Single file binary**. Thanks to [Go] and [GoReleaser], all the dependencies @@ -112,7 +115,6 @@ transcoding supported right now. ### Limitations -* **No thumbnail generation**. Only pre-generated thumbnails are supported. * **No photo details (yet)**. There is no way to show metadata of a photo in the UI at this point. * **Not optimized for many clients**. As a lot of the normally client-side @@ -172,7 +174,7 @@ default. For further configuration, create a `configuration.yaml` in the services: photofield: - image: ghcr.io/smilyorg/photofield + image: ghcr.io/smilyorg/photofield:latest ports: - 8080:8080 volumes: diff --git a/api.yaml b/api.yaml index 6987f14..a6d1db6 100644 --- a/api.yaml +++ b/api.yaml @@ -101,6 +101,11 @@ paths: schema: $ref: "#/components/schemas/LayoutType" + - name: sort + in: query + schema: + $ref: "#/components/schemas/Sort" + - name: search in: query schema: @@ -629,6 +634,8 @@ components: $ref: "#/components/schemas/LayoutType" search: $ref: "#/components/schemas/Search" + sort: + $ref: "#/components/schemas/Sort" TaskType: type: string @@ -707,6 +714,9 @@ components: Search: type: string + Sort: + type: string + LayoutType: type: string enum: diff --git a/docker/grafana/dashboards/photofield.json b/docker/grafana/dashboards/photofield.json index 1476f87..ca3b1eb 100644 --- a/docker/grafana/dashboards/photofield.json +++ b/docker/grafana/dashboards/photofield.json @@ -213,32 +213,7 @@ }, "unit": "µs" }, - "overrides": [ - { - "__systemRef": "hideSeriesFrom", - "matcher": { - "id": "byNames", - "options": { - "mode": "exclude", - "names": [ - "sqlite" - ], - "prefix": "All except:", - "readOnly": true - } - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": false, - "tooltip": false, - "viz": true - } - } - ] - } - ] + "overrides": [] }, "gridPos": { "h": 8, @@ -692,7 +667,7 @@ "refId": "A" } ], - "title": "Loading", + "title": "Index", "type": "row" }, { @@ -754,14 +729,15 @@ "uid": "RmeUbDMnz" }, "exemplar": true, - "expr": "label_replace({__name__=~\"pf_load_${loader}_pending\"}, \"name\", \"$1\", \"__name__\", \"pf_load_(.*)_pending\")", + "expr": "label_replace({__name__=~\"pf_index_${indexer}_pending\"}, \"name\", \"$1\", \"__name__\", \"pf_index_(.*)_pending\")", "hide": false, "interval": "", "legendFormat": "{{name}}", + "range": true, "refId": "B" } ], - "title": "Pending Loads", + "title": "Pending", "type": "stat" }, { @@ -818,7 +794,7 @@ "uid": "RmeUbDMnz" }, "exemplar": true, - "expr": "rate(pf_load_meta_done[10s])", + "expr": "rate(pf_index_metadata_done[10s])", "hide": false, "instant": false, "interval": "", @@ -826,7 +802,7 @@ "refId": "B" } ], - "title": "Meta Load Rate", + "title": "Metadata Indexing Rate", "type": "stat" }, { @@ -883,7 +859,7 @@ "uid": "RmeUbDMnz" }, "exemplar": true, - "expr": "rate(pf_load_color_done[10s])", + "expr": "rate(pf_index_contents_done[10s])", "hide": false, "instant": false, "interval": "", @@ -891,7 +867,7 @@ "refId": "B" } ], - "title": "Color Load Rate", + "title": "Contents Indexing Rate", "type": "stat" }, { @@ -1946,7 +1922,7 @@ "type": "row" } ], - "refresh": "", + "refresh": "2s", "revision": 1, "schemaVersion": 38, "style": "dark", @@ -2001,7 +1977,7 @@ }, { "current": { - "selected": false, + "selected": true, "text": [ "All" ], @@ -2011,8 +1987,9 @@ }, "hide": 0, "includeAll": true, + "label": "", "multi": true, - "name": "loader", + "name": "indexer", "options": [ { "selected": true, @@ -2021,16 +1998,16 @@ }, { "selected": false, - "text": "meta", - "value": "meta" + "text": "metadata", + "value": "metadata" }, { "selected": false, - "text": "color", - "value": "color" + "text": "contents", + "value": "contents" } ], - "query": "meta,color", + "query": "metadata,contents", "queryValue": "", "skipUrlSync": false, "type": "custom" @@ -2097,7 +2074,7 @@ ] }, "time": { - "from": "now-3h", + "from": "now-15m", "to": "now" }, "timepicker": { diff --git a/go.mod b/go.mod index 76e52d7..5d712f2 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_model v0.2.0 + github.com/pyroscope-io/client v0.7.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/sheerun/queue v1.0.1 github.com/tdewolff/canvas v0.0.0-20200504121106-e2600b35c365 @@ -64,7 +65,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect - github.com/pyroscope-io/client v0.7.0 // indirect github.com/pyroscope-io/godeltaprof v0.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/tdewolff/minify/v2 v2.7.1-0.20200112204046-70870d25a935 // indirect diff --git a/internal/layout/album.go b/internal/layout/album.go index 12086f4..f42c4e9 100644 --- a/internal/layout/album.go +++ b/internal/layout/album.go @@ -77,7 +77,7 @@ func LayoutAlbum(layout Layout, collection collection.Collection, scene *render. limit := collection.Limit infos := collection.GetInfos(source, image.ListOptions{ - OrderBy: image.DateAsc, + OrderBy: image.ListOrder(layout.Order), Limit: limit, }) diff --git a/internal/layout/common.go b/internal/layout/common.go index e08095c..9fb6960 100644 --- a/internal/layout/common.go +++ b/internal/layout/common.go @@ -24,8 +24,28 @@ const ( Strip Type = "STRIP" ) +type Order int + +const ( + None Order = iota + DateAsc Order = iota + DateDesc Order = iota +) + +func OrderFromSort(s string) Order { + switch s { + case "+date": + return DateAsc + case "-date": + return DateDesc + default: + return None + } +} + type Layout struct { - Type Type `json:"type"` + Type Type `json:"type"` + Order Order `json:"order"` ViewportWidth float64 ViewportHeight float64 ImageHeight float64 diff --git a/internal/layout/strip.go b/internal/layout/strip.go index 47478b0..96cc921 100644 --- a/internal/layout/strip.go +++ b/internal/layout/strip.go @@ -26,7 +26,7 @@ func LayoutStrip(layout Layout, collection collection.Collection, scene *render. ) } else { infos = collection.GetInfos(source, image.ListOptions{ - OrderBy: image.DateAsc, + OrderBy: image.ListOrder(layout.Order), Limit: limit, }) } diff --git a/internal/layout/timeline.go b/internal/layout/timeline.go index c15340e..5b9100a 100644 --- a/internal/layout/timeline.go +++ b/internal/layout/timeline.go @@ -72,7 +72,7 @@ func LayoutTimeline(layout Layout, collection collection.Collection, scene *rend limit := collection.Limit infos := collection.GetInfos(source, image.ListOptions{ - OrderBy: image.DateDesc, + OrderBy: image.ListOrder(layout.Order), Limit: limit, }) diff --git a/internal/layout/wall.go b/internal/layout/wall.go index 8f86223..ed115bd 100644 --- a/internal/layout/wall.go +++ b/internal/layout/wall.go @@ -13,7 +13,7 @@ import ( func LayoutWall(layout Layout, collection collection.Collection, scene *render.Scene, source *image.Source) { infos := collection.GetInfos(source, image.ListOptions{ - OrderBy: image.DateAsc, + OrderBy: image.ListOrder(layout.Order), Limit: collection.Limit, }) @@ -34,6 +34,9 @@ func LayoutWall(layout Layout, collection collection.Collection, scene *render.S photoCount := len(section.infos) edgeCount := int(math.Sqrt(float64(photoCount))) + if edgeCount < 1 { + edgeCount = 1 + } scene.Bounds.W = layout.ViewportWidth cols := edgeCount @@ -51,7 +54,7 @@ func LayoutWall(layout Layout, collection collection.Collection, scene *render.S rows := int(math.Ceil(float64(photoCount) / float64(cols))) - scene.Bounds.H = math.Ceil(float64(rows)) * (imageHeight + layoutConfig.LineSpacing) + scene.Bounds.H = float64(rows) * (imageHeight + layoutConfig.LineSpacing) sceneMargin := 10. layoutConfig.ImageHeight = imageHeight diff --git a/internal/openapi/api.gen.go b/internal/openapi/api.gen.go index d47f953..34580e9 100644 --- a/internal/openapi/api.gen.go +++ b/internal/openapi/api.gen.go @@ -126,6 +126,7 @@ type SceneParams struct { ImageHeight *ImageHeight `json:"image_height,omitempty"` Layout LayoutType `json:"layout"` Search *Search `json:"search,omitempty"` + Sort *Sort `json:"sort,omitempty"` ViewportHeight ViewportHeight `json:"viewport_height"` ViewportWidth ViewportWidth `json:"viewport_width"` } @@ -133,6 +134,9 @@ type SceneParams struct { // Search defines model for Search. type Search string +// Sort defines model for Sort. +type Sort string + // Task defines model for Task. type Task struct { CollectionId *CollectionId `json:"collection_id,omitempty"` @@ -179,6 +183,7 @@ type GetScenesParams struct { ViewportHeight *ViewportHeight `json:"viewport_height,omitempty"` ImageHeight *ImageHeight `json:"image_height,omitempty"` Layout *LayoutType `json:"layout,omitempty"` + Sort *Sort `json:"sort,omitempty"` Search *Search `json:"search,omitempty"` } @@ -517,6 +522,17 @@ func (siw *ServerInterfaceWrapper) GetScenes(w http.ResponseWriter, r *http.Requ return } + // ------------- Optional query parameter "sort" ------------- + if paramValue := r.URL.Query().Get("sort"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter sort: %s", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "search" ------------- if paramValue := r.URL.Query().Get("search"); paramValue != "" { diff --git a/internal/scene/sceneSource.go b/internal/scene/sceneSource.go index 948123e..126e5af 100644 --- a/internal/scene/sceneSource.go +++ b/internal/scene/sceneSource.go @@ -215,9 +215,17 @@ func sceneConfigEqual(a SceneConfig, b SceneConfig) bool { return false } - return a.Layout.Type != "" && + if a.Layout.Type != "" && b.Layout.Type != "" && - a.Layout.Type == b.Layout.Type + a.Layout.Type != b.Layout.Type { + return false + } + + if a.Layout.Order != b.Layout.Order { + return false + } + + return true } func (source *SceneSource) GetScenesWithConfig(config SceneConfig) []*render.Scene { diff --git a/main.go b/main.go index 2e4e834..96c1aa0 100644 --- a/main.go +++ b/main.go @@ -345,27 +345,39 @@ func (*Api) PostScenes(w http.ResponseWriter, r *http.Request) { } sceneConfig := defaultSceneConfig + + collection := getCollectionById(string(data.CollectionId)) + if collection == nil { + problem(w, r, http.StatusBadRequest, "Collection not found") + return + } + sceneConfig.Collection = *collection + sceneConfig.Layout.ViewportWidth = float64(data.ViewportWidth) sceneConfig.Layout.ViewportHeight = float64(data.ViewportHeight) sceneConfig.Layout.ImageHeight = 0 if data.ImageHeight != nil { sceneConfig.Layout.ImageHeight = float64(*data.ImageHeight) } + if sceneConfig.Collection.Layout != "" { + sceneConfig.Layout.Type = layout.Type(sceneConfig.Collection.Layout) + } if data.Layout != "" { sceneConfig.Layout.Type = layout.Type(data.Layout) } + if data.Sort != nil { + sceneConfig.Layout.Order = layout.OrderFromSort(string(*data.Sort)) + if sceneConfig.Layout.Order == layout.None { + problem(w, r, http.StatusBadRequest, "Invalid sort") + return + } + } if data.Search != nil { sceneConfig.Scene.Search = string(*data.Search) if sceneConfig.Layout.Type != layout.Strip { sceneConfig.Layout.Type = layout.Search } } - collection := getCollectionById(string(data.CollectionId)) - if collection == nil { - problem(w, r, http.StatusBadRequest, "Collection not found") - return - } - sceneConfig.Collection = *collection scene := sceneSource.Add(sceneConfig, imageSource) @@ -387,6 +399,13 @@ func (*Api) GetScenes(w http.ResponseWriter, r *http.Request, params openapi.Get if params.Layout != nil { sceneConfig.Layout.Type = layout.Type(*params.Layout) } + if params.Sort != nil { + sceneConfig.Layout.Order = layout.OrderFromSort(string(*params.Sort)) + if sceneConfig.Layout.Order == layout.None { + problem(w, r, http.StatusBadRequest, "Invalid sort") + return + } + } if params.Search != nil { sceneConfig.Scene.Search = string(*params.Search) if sceneConfig.Layout.Type != layout.Strip { diff --git a/ui/src/App.vue b/ui/src/App.vue index bc0c925..f06eca0 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -154,15 +154,6 @@ export default { }, setup(props) { const collectionId = toRef(props, "collectionId"); - const layoutOptions = computed(() => { - return [ - { label: `Default`, value: "DEFAULT" }, - { label: "Album", value: "ALBUM" }, - { label: "Timeline", value: "TIMELINE" }, - { label: "Wall", value: "WALL" }, - ] - }) - const router = useRouter(); const route = useRoute(); const query = computed(() => route.query); @@ -206,7 +197,6 @@ export default { goHome, query, setQuery, - layoutOptions, remoteTasks, remoteTasksUpdateUntilDone, indexTasks, diff --git a/ui/src/api.js b/ui/src/api.js index 0233c29..a0e6d5c 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -142,6 +142,7 @@ export function useApi(getUrl, config) { export function useScene({ collectionId, layout, + sort, imageHeight, viewport, search, @@ -152,6 +153,7 @@ export function useScene({ viewport?.height?.value && { layout: layout.value, + sort: sort.value, image_height: imageHeight?.value || undefined, collection_id: collectionId.value, viewport_width: viewport.width.value, diff --git a/ui/src/components/CollectionView.vue b/ui/src/components/CollectionView.vue index 7b257a1..ea466da 100644 --- a/ui/src/components/CollectionView.vue +++ b/ui/src/components/CollectionView.vue @@ -6,6 +6,7 @@ :interactive="!stripVisible" :collectionId="collectionId" :layout="layout" + :sort="sort" :imageHeight="imageHeight" :search="search" :debug="debug" @@ -22,6 +23,7 @@ :class="{ visible: stripVisible }" :interactive="stripVisible" :collectionId="collectionId" + :sort="sort" :regionId="transitionRegionId || regionId" :search="search" :debug="debug" @@ -42,6 +44,7 @@ import { useRoute, useRouter } from 'vue-router'; import StripViewer from './StripViewer.vue'; import ScrollViewer from './ScrollViewer.vue'; +import { useApi } from '../api'; const props = defineProps([ "collectionId", @@ -94,8 +97,21 @@ const scenes = computed(() => { }); watch(scenes, scenes => emit("scenes", scenes)); +const { data: collection } = useApi( + () => collectionId.value && `/collections/${collectionId.value}` +); + const layout = computed(() => { - return route.query.layout; + return route.query.layout || collection.value?.layout || undefined; +}) + +const sort = computed(() => { + switch (layout.value) { + case "TIMELINE": + return "-date"; + default: + return "+date"; + } }) const imageHeight = computed(() => { diff --git a/ui/src/components/ScrollViewer.vue b/ui/src/components/ScrollViewer.vue index 1c69bd4..d861a19 100644 --- a/ui/src/components/ScrollViewer.vue +++ b/ui/src/components/ScrollViewer.vue @@ -78,6 +78,7 @@ const props = defineProps({ collectionId: String, regionId: String, layout: String, + sort: String, imageHeight: Number, search: String, debug: Object, @@ -100,6 +101,7 @@ const { collectionId, scrollbar, layout, + sort, imageHeight, search, debug, @@ -112,6 +114,7 @@ const lastView = ref(null); const { scene, recreate: recreateScene, filesPerSecond } = useScene({ layout, + sort, collectionId, imageHeight, viewport, diff --git a/ui/src/components/StripViewer.vue b/ui/src/components/StripViewer.vue index 0d2c2b0..562d4dc 100644 --- a/ui/src/components/StripViewer.vue +++ b/ui/src/components/StripViewer.vue @@ -83,6 +83,7 @@ const props = defineProps({ interactive: Boolean, collectionId: String, regionId: String, + sort: String, search: String, debug: Object, fullpage: Boolean, @@ -101,6 +102,7 @@ const { interactive, regionId, collectionId, + sort, search, debug, } = toRefs(props); @@ -120,6 +122,7 @@ const viewport = useViewport(container); const { scene, recreate: recreateScene } = useScene({ layout: ref("STRIP"), + sort, collectionId, viewport, search,