Files
p 739c1e2f6b
Alpine 3.24 Success
Debian Bookworm Success
Add and use a DB view for paths and tags
The view is mainly useful for querying the database
with the command line sqlite3 utility.

Letting SQLite serialize the JSON is an inconsequential
performance improvement.
2026-08-05 04:27:00 +02:00

2870 lines
71 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"html/template"
"image"
"image/color"
"io"
"io/fs"
"log"
"math"
"math/bits"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mattn/go-sqlite3"
"golang.org/x/image/draw"
"golang.org/x/image/webp"
)
// #include <unistd.h>
import "C"
var (
db *sql.DB // sqlite database
galleryDirectory string // gallery directory
// taskSemaphore limits parallel computations.
taskSemaphore semaphore
)
const (
nameOfDB = "gallery.db"
nameOfImageRoot = "images"
nameOfThumbRoot = "thumbs"
)
func hammingDistance(a, b int64) int {
return bits.OnesCount64(uint64(a) ^ uint64(b))
}
type productAggregator float64
func (pa *productAggregator) Step(v float64) {
*pa = productAggregator(float64(*pa) * v)
}
func (pa *productAggregator) Done() float64 {
return float64(*pa)
}
func newProductAggregator() *productAggregator {
pa := productAggregator(1)
return &pa
}
func init() {
sql.Register("sqlite3_custom", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
if err := conn.RegisterFunc(
"hamming", hammingDistance, true /*pure*/); err != nil {
return err
}
if err := conn.RegisterAggregator(
"product", newProductAggregator, true /*pure*/); err != nil {
return err
}
return nil
},
})
}
func openDB(directory string) error {
galleryDirectory = directory
var err error
db, err = sql.Open("sqlite3_custom", "file:"+filepath.Join(directory,
nameOfDB+"?_foreign_keys=1&_busy_timeout=1000"))
if err != nil {
return err
}
_, err = db.Exec(initializeSQL)
return err
}
func imagePath(sha1 string) string {
return filepath.Join(galleryDirectory,
nameOfImageRoot, sha1[:2], sha1)
}
func thumbPath(sha1 string) string {
return filepath.Join(galleryDirectory,
nameOfThumbRoot, sha1[:2], sha1+".webp")
}
func dbCollectStrings(query string, a ...any) ([]string, error) {
rows, err := db.Query(query, a...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
result = append(result, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
func idForDirectoryPath(tx *sql.Tx, path []string, create bool) (int64, error) {
var parent sql.NullInt64
for _, name := range path {
if err := tx.QueryRow(`SELECT id FROM node
WHERE parent IS ? AND name = ? AND sha1 IS NULL`,
parent, name).Scan(&parent); err == nil {
continue
} else if !errors.Is(err, sql.ErrNoRows) {
return 0, err
} else if !create {
return 0, err
}
// This fails when trying to override a leaf node.
// That needs special handling.
if result, err := tx.Exec(
`INSERT INTO node(parent, name) VALUES (?, ?)`,
parent, name); err != nil {
return 0, err
} else if id, err := result.LastInsertId(); err != nil {
return 0, err
} else {
parent = sql.NullInt64{Int64: id, Valid: true}
}
}
return parent.Int64, nil
}
func decodeWebPath(path string) []string {
// Relative paths could be handled differently,
// but right now, they're assumed to start at the root.
result := []string{}
for _, crumb := range strings.Split(path, "/") {
if crumb != "" {
result = append(result, crumb)
}
}
return result
}
// --- Semaphore ---------------------------------------------------------------
type semaphore chan struct{}
func newSemaphore(size int) semaphore { return make(chan struct{}, size) }
func (s semaphore) release() { <-s }
func (s semaphore) acquire(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case s <- struct{}{}:
}
// Give priority to context cancellation.
select {
case <-ctx.Done():
s.release()
return ctx.Err()
default:
}
return nil
}
// --- Progress bar ------------------------------------------------------------
type progressBar struct {
sync.Mutex
current int
target int
}
func newProgressBar(target int) *progressBar {
pb := &progressBar{current: 0, target: target}
pb.Update()
return pb
}
func (pb *progressBar) Stop() {
// The minimum thing that works: just print a newline.
os.Stdout.WriteString("\n")
}
func (pb *progressBar) Update() {
if pb.target < 0 {
fmt.Printf("\r%d/?", pb.current)
return
}
var fraction int
if pb.target != 0 {
fraction = int(float32(pb.current) / float32(pb.target) * 100)
}
target := fmt.Sprintf("%d", pb.target)
fmt.Printf("\r%*d/%s (%2d%%)", len(target), pb.current, target, fraction)
}
func (pb *progressBar) Step() {
pb.Lock()
defer pb.Unlock()
pb.current++
pb.Update()
}
func (pb *progressBar) Interrupt(callback func()) {
pb.Lock()
defer pb.Unlock()
pb.Stop()
defer pb.Update()
callback()
}
// --- Parallelization ---------------------------------------------------------
type parallelFunc func(item string) (message string, err error)
// parallelize runs the callback in parallel on a list of strings,
// reporting progress and any non-fatal messages.
func parallelize(strings []string, callback parallelFunc) error {
pb := newProgressBar(len(strings))
defer pb.Stop()
ctx, cancel := context.WithCancelCause(context.Background())
wg := sync.WaitGroup{}
for _, item := range strings {
if taskSemaphore.acquire(ctx) != nil {
break
}
wg.Add(1)
go func(item string) {
defer taskSemaphore.release()
defer wg.Done()
if message, err := callback(item); err != nil {
cancel(err)
} else if message != "" {
pb.Interrupt(func() { log.Printf("%s: %s\n", item, message) })
}
pb.Step()
}(item)
}
wg.Wait()
if ctx.Err() != nil {
return context.Cause(ctx)
}
return nil
}
// --- Initialization ----------------------------------------------------------
// cmdInit initializes a "gallery directory" that contains gallery.sqlite,
// images, thumbs.
func cmdInit(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return errWrongUsage
}
if err := os.MkdirAll(fs.Arg(0), 0755); err != nil {
return err
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
// XXX: There's technically no reason to keep images as symlinks,
// we might just keep absolute paths in the database as well.
if err := os.MkdirAll(
filepath.Join(galleryDirectory, nameOfImageRoot), 0755); err != nil {
return err
}
if err := os.MkdirAll(
filepath.Join(galleryDirectory, nameOfThumbRoot), 0755); err != nil {
return err
}
return nil
}
// --- API: Browse -------------------------------------------------------------
func getSubdirectories(tx *sql.Tx, parent int64) (names []string, err error) {
return dbCollectStrings(`SELECT name FROM node
WHERE IFNULL(parent, 0) = ? AND sha1 IS NULL`, parent)
}
type webEntry struct {
SHA1 string `json:"sha1"`
Name string `json:"name"`
Modified int64 `json:"modified"`
ThumbW int64 `json:"thumbW"`
ThumbH int64 `json:"thumbH"`
}
func getSubentries(tx *sql.Tx, parent int64) (entries []webEntry, err error) {
rows, err := tx.Query(`
SELECT i.sha1, n.name, n.mtime, IFNULL(i.thumbw, 0), IFNULL(i.thumbh, 0)
FROM node AS n
JOIN image AS i ON n.sha1 = i.sha1
WHERE n.parent = ?`, parent)
if err != nil {
return nil, err
}
defer rows.Close()
entries = []webEntry{}
for rows.Next() {
var e webEntry
if err = rows.Scan(
&e.SHA1, &e.Name, &e.Modified, &e.ThumbW, &e.ThumbH); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func handleAPIBrowse(w http.ResponseWriter, r *http.Request) {
var params struct {
Path string
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var result struct {
Subdirectories []string `json:"subdirectories"`
Entries []webEntry `json:"entries"`
Related map[string][]webTagRelated `json:"related"`
}
tx, err := db.Begin()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer tx.Rollback()
parent, err := idForDirectoryPath(tx, decodeWebPath(params.Path), false)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
result.Subdirectories, err = getSubdirectories(tx, parent)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
result.Entries, err = getSubentries(tx, parent)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cte := fmt.Sprintf(`WITH matches(sha1) AS (
SELECT sha1 FROM node WHERE parent = %d AND sha1 IS NOT NULL
)`, parent)
result.Related, err = getTagRelated(tx, cte, len(result.Entries))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- API: Tags ---------------------------------------------------------------
type webTagNamespace struct {
Description string `json:"description"`
Tags map[string]int64 `json:"tags"`
}
func getTags(nsID int64) (result map[string]int64, err error) {
rows, err := db.Query(`
SELECT t.name, COUNT(ta.tag) AS count
FROM tag AS t
LEFT JOIN tag_assignment AS ta ON t.id = ta.tag
WHERE t.space = ?
GROUP BY t.id`, nsID)
if err != nil {
return
}
defer rows.Close()
result = make(map[string]int64)
for rows.Next() {
var (
name string
count int64
)
if err = rows.Scan(&name, &count); err != nil {
return
}
result[name] = count
}
return result, rows.Err()
}
func getTagNamespaces(match *string) (
result map[string]webTagNamespace, err error) {
var rows *sql.Rows
if match != nil {
rows, err = db.Query(`SELECT id, name, IFNULL(description, '')
FROM tag_space WHERE name = ?`, *match)
} else {
rows, err = db.Query(`SELECT id, name, IFNULL(description, '')
FROM tag_space`)
}
if err != nil {
return
}
defer rows.Close()
result = make(map[string]webTagNamespace)
for rows.Next() {
var (
id int64
name string
ns webTagNamespace
)
if err = rows.Scan(&id, &name, &ns.Description); err != nil {
return
}
if ns.Tags, err = getTags(id); err != nil {
return
}
result[name] = ns
}
return result, rows.Err()
}
func handleAPITags(w http.ResponseWriter, r *http.Request) {
var params struct {
Namespace *string
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
result, err := getTagNamespaces(params.Namespace)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- API: Duplicates ---------------------------------------------------------
type webDuplicateImage struct {
SHA1 string `json:"sha1"`
ThumbW int64 `json:"thumbW"`
ThumbH int64 `json:"thumbH"`
Occurences int64 `json:"occurences"`
}
// A hamming distance of zero (direct dhash match) will be more than sufficient.
const duplicatesCTE = `WITH
duplicated(dhash, count) AS (
SELECT dhash, COUNT(*) AS count FROM image
WHERE dhash IS NOT NULL
GROUP BY dhash HAVING count > 1
),
multipathed(sha1, count) AS (
SELECT n.sha1, COUNT(*) AS count FROM node AS n
JOIN image AS i ON i.sha1 = n.sha1
WHERE i.dhash IS NULL
OR i.dhash NOT IN (SELECT dhash FROM duplicated)
GROUP BY n.sha1 HAVING count > 1
)
`
func getDuplicatesSimilar(stmt *sql.Stmt, dhash int64) (
result []webDuplicateImage, err error) {
rows, err := stmt.Query(dhash)
if err != nil {
return nil, err
}
defer rows.Close()
result = []webDuplicateImage{}
for rows.Next() {
var image webDuplicateImage
if err = rows.Scan(&image.SHA1, &image.ThumbW, &image.ThumbH,
&image.Occurences); err != nil {
return nil, err
}
result = append(result, image)
}
return result, rows.Err()
}
func getDuplicates1(result [][]webDuplicateImage) (
[][]webDuplicateImage, error) {
stmt, err := db.Prepare(`
SELECT i.sha1, IFNULL(i.thumbw, 0), IFNULL(i.thumbh, 0),
COUNT(*) AS occurences
FROM image AS i
JOIN node AS n ON n.sha1 = i.sha1
WHERE i.dhash = ?
GROUP BY n.sha1`)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := db.Query(duplicatesCTE + `SELECT dhash FROM duplicated`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
group []webDuplicateImage
dhash int64
)
if err = rows.Scan(&dhash); err != nil {
return nil, err
}
if group, err = getDuplicatesSimilar(stmt, dhash); err != nil {
return nil, err
}
result = append(result, group)
}
return result, rows.Err()
}
func getDuplicates2(result [][]webDuplicateImage) (
[][]webDuplicateImage, error) {
stmt, err := db.Prepare(`
SELECT i.sha1, IFNULL(i.thumbw, 0), IFNULL(i.thumbh, 0),
COUNT(*) AS occurences
FROM image AS i
JOIN node AS n ON n.sha1 = i.sha1
WHERE i.sha1 = ?
GROUP BY n.sha1`)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := db.Query(duplicatesCTE + `SELECT sha1 FROM multipathed`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
image webDuplicateImage
sha1 string
)
if err = rows.Scan(&sha1); err != nil {
return nil, err
}
if err := stmt.QueryRow(sha1).Scan(&image.SHA1,
&image.ThumbW, &image.ThumbH, &image.Occurences); err != nil {
return nil, err
}
result = append(result, []webDuplicateImage{image})
}
return result, rows.Err()
}
func handleAPIDuplicates(w http.ResponseWriter, r *http.Request) {
var params struct{}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var (
result = [][]webDuplicateImage{}
err error
)
if result, err = getDuplicates1(result); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if result, err = getDuplicates2(result); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- API: Orphans ------------------------------------------------------------
type webOrphanImage struct {
SHA1 string `json:"sha1"`
ThumbW int64 `json:"thumbW"`
ThumbH int64 `json:"thumbH"`
Tags int64 `json:"tags"`
}
type webOrphan struct {
webOrphanImage
LastPath string `json:"lastPath"`
Replacement *webOrphanImage `json:"replacement"`
}
func getOrphanReplacement(webPath string) (*webOrphanImage, error) {
tx, err := db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
path := decodeWebPath(webPath)
if len(path) == 0 {
return nil, nil
}
parent, err := idForDirectoryPath(tx, path[:len(path)-1], false)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, err
}
var image webOrphanImage
err = db.QueryRow(`SELECT i.sha1,
IFNULL(i.thumbw, 0), IFNULL(i.thumbh, 0), COUNT(ta.sha1) AS tags
FROM node AS n
JOIN image AS i ON n.sha1 = i.sha1
LEFT JOIN tag_assignment AS ta ON n.sha1 = ta.sha1
WHERE n.parent = ? AND n.name = ?
GROUP BY n.sha1`, parent, path[len(path)-1]).Scan(
&image.SHA1, &image.ThumbW, &image.ThumbH, &image.Tags)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, err
}
return &image, nil
}
func getOrphans() (result []webOrphan, err error) {
rows, err := db.Query(`SELECT o.sha1, o.path,
IFNULL(i.thumbw, 0), IFNULL(i.thumbh, 0), COUNT(ta.sha1) AS tags
FROM orphan AS o
JOIN image AS i ON o.sha1 = i.sha1
LEFT JOIN tag_assignment AS ta ON o.sha1 = ta.sha1
GROUP BY o.sha1
ORDER BY path`)
if err != nil {
return nil, err
}
defer rows.Close()
result = []webOrphan{}
for rows.Next() {
var orphan webOrphan
if err = rows.Scan(&orphan.SHA1, &orphan.LastPath,
&orphan.ThumbW, &orphan.ThumbH, &orphan.Tags); err != nil {
return nil, err
}
orphan.Replacement, err = getOrphanReplacement(orphan.LastPath)
if err != nil {
return nil, err
}
result = append(result, orphan)
}
return result, rows.Err()
}
func handleAPIOrphans(w http.ResponseWriter, r *http.Request) {
var params struct{}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
result, err := getOrphans()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- API: Image view ---------------------------------------------------------
func handleAPIOpen(w http.ResponseWriter, r *http.Request) {
var params struct {
SHA1 string
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !webSettings.Local {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !isValidSHA1(params.SHA1) {
http.Error(w, "invalid SHA1", http.StatusBadRequest)
return
}
// We don't particularly care about all possible paths,
// just try not to open the image within our storage directories,
// as that could make image viewers create many pointless thumbnails.
//
// So long as galleryDirectory doesn't start with "-" (on Unix-likes),
// the path is safe to pass as an argument to the launcher.
path := imagePath(params.SHA1)
if link, err := os.Readlink(path); err == nil {
if filepath.IsAbs(link) {
path = link
} else {
path = filepath.Join(filepath.Dir(path), link)
}
}
for _, launcher := range []string{"explorer.exe", "open", "xdg-open"} {
launcher, err := exec.LookPath(launcher)
if err != nil {
continue
}
// At the cost of not being able to report errors, don't let this block.
go func() {
if err := exec.Command(launcher, path).Run(); err != nil {
log.Printf("%s %s: %s", launcher, path, err.Error())
}
}()
return
}
http.Error(w, "no launcher", http.StatusInternalServerError)
}
func getImagePaths(sha1 string) (paths []string, err error) {
rows, err := db.Query(`WITH RECURSIVE paths(parent, path) AS (
SELECT parent, name AS path FROM node WHERE sha1 = ?
UNION ALL
SELECT n.parent, n.name || '/' || p.path
FROM node AS n JOIN paths AS p ON n.id = p.parent
) SELECT path FROM paths WHERE parent IS NULL`, sha1)
if err != nil {
return nil, err
}
defer rows.Close()
paths = []string{}
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
return nil, err
}
paths = append(paths, path)
}
return paths, rows.Err()
}
func getImageTags(sha1 string) (map[string]map[string]float32, error) {
rows, err := db.Query(`
SELECT ts.name, t.name, ta.weight FROM tag_assignment AS ta
JOIN tag AS t ON t.id = ta.tag
JOIN tag_space AS ts ON ts.id = t.space
WHERE ta.sha1 = ?`, sha1)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]map[string]float32)
for rows.Next() {
var (
space, tag string
weight float32
)
if err := rows.Scan(&space, &tag, &weight); err != nil {
return nil, err
}
tags := result[space]
if tags == nil {
tags = make(map[string]float32)
result[space] = tags
}
tags[tag] = weight
}
return result, rows.Err()
}
func handleAPIInfo(w http.ResponseWriter, r *http.Request) {
var params struct {
SHA1 string
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Let's not build JSON manually if SQLite can do this for us.
var (
width, height int64
paths, tags []byte
)
err := db.QueryRow(`SELECT width, height, paths, tags
FROM image_with_metadata WHERE sha1 = ?`, params.SHA1).
Scan(&width, &height, &paths, &tags)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, err.Error(), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
result := struct {
Width int64 `json:"width"`
Height int64 `json:"height"`
Paths json.RawMessage `json:"paths"`
Tags json.RawMessage `json:"tags"`
}{
Width: width,
Height: height,
Paths: json.RawMessage(paths),
Tags: json.RawMessage(tags),
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- API: Image similar ------------------------------------------------------
type webSimilarImage struct {
SHA1 string `json:"sha1"`
PixelsRatio float32 `json:"pixelsRatio"`
ThumbW int64 `json:"thumbW"`
ThumbH int64 `json:"thumbH"`
Paths []string `json:"paths"`
}
func getSimilar(sha1 string, dhash int64, pixels int64, distance int) (
result []webSimilarImage, err error) {
// If there's a dhash, there should also be thumbnail dimensions.
var rows *sql.Rows
common := `SELECT sha1, width * height, IFNULL(thumbw, 0), IFNULL(thumbh, 0)
FROM image WHERE sha1 <> ? AND `
if distance == 0 {
rows, err = db.Query(common+`dhash = ?`, sha1, dhash)
} else {
// This is generic, but quite inefficient for distance ∈ {0, 1}.
rows, err = db.Query(common+`dhash IS NOT NULL
AND hamming(dhash, ?) = ?`, sha1, dhash, distance)
}
if err != nil {
return nil, err
}
defer rows.Close()
result = []webSimilarImage{}
for rows.Next() {
var (
match webSimilarImage
matchPixels int64
)
if err = rows.Scan(&match.SHA1,
&matchPixels, &match.ThumbW, &match.ThumbH); err != nil {
return nil, err
}
if match.Paths, err = getImagePaths(match.SHA1); err != nil {
return nil, err
}
match.PixelsRatio = float32(matchPixels) / float32(pixels)
result = append(result, match)
}
return result, rows.Err()
}
func getSimilarGroups(sha1 string, dhash int64, pixels int64,
output map[string][]webSimilarImage) error {
var err error
for distance := 0; distance <= 1; distance++ {
output[fmt.Sprintf("Perceptual distance %d", distance)], err =
getSimilar(sha1, dhash, pixels, distance)
if err != nil {
return err
}
}
return nil
}
func handleAPISimilar(w http.ResponseWriter, r *http.Request) {
var params struct {
SHA1 string
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var result struct {
Info webSimilarImage `json:"info"`
Groups map[string][]webSimilarImage `json:"groups"`
}
result.Info = webSimilarImage{SHA1: params.SHA1, PixelsRatio: 1}
if paths, err := getImagePaths(params.SHA1); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else {
result.Info.Paths = paths
}
var (
width, height int64
dhash sql.NullInt64
)
err := db.QueryRow(`
SELECT width, height, dhash, IFNULL(thumbw, 0), IFNULL(thumbh, 0)
FROM image WHERE sha1 = ?`, params.SHA1).Scan(&width, &height, &dhash,
&result.Info.ThumbW, &result.Info.ThumbH)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, err.Error(), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
result.Groups = make(map[string][]webSimilarImage)
if dhash.Valid {
if err := getSimilarGroups(
params.SHA1, dhash.Int64, width*height, result.Groups); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- API: Search -------------------------------------------------------------
// The SQL building is the most miserable part of the whole program.
const searchCTE1 = `WITH
matches(sha1, thumbw, thumbh, score) AS (
SELECT i.sha1, i.thumbw, i.thumbh, ta.weight AS score
FROM tag_assignment AS ta
JOIN image AS i ON i.sha1 = ta.sha1
WHERE ta.tag = %d
)
`
const searchCTEMulti = `WITH
positive(tag) AS (VALUES %s),
filtered(sha1) AS (%s),
matches(sha1, thumbw, thumbh, score) AS (
SELECT i.sha1, i.thumbw, i.thumbh,
product(IFNULL(ta.weight, 0)) AS score
FROM image AS i, positive AS p
JOIN filtered AS c ON i.sha1 = c.sha1
LEFT JOIN tag_assignment AS ta ON ta.sha1 = i.sha1 AND ta.tag = p.tag
GROUP BY i.sha1
)
`
func searchQueryToCTE(tx *sql.Tx, query string) (string, error) {
positive, negative := []int64{}, []int64{}
for _, word := range strings.Split(query, " ") {
if word == "" {
continue
}
space, tag, _ := strings.Cut(word, ":")
negated := false
if strings.HasPrefix(space, "-") {
space = space[1:]
negated = true
}
var tagID int64
err := tx.QueryRow(`
SELECT t.id FROM tag AS t
JOIN tag_space AS ts ON t.space = ts.id
WHERE ts.name = ? AND t.name = ?`, space, tag).Scan(&tagID)
if err != nil {
return "", err
}
if negated {
negative = append(negative, tagID)
} else {
positive = append(positive, tagID)
}
}
// Don't return most of the database, and simplify the following builder.
if len(positive) == 0 {
return "", errors.New("search is too wide")
}
// Optimise single tag searches.
if len(positive) == 1 && len(negative) == 0 {
return fmt.Sprintf(searchCTE1, positive[0]), nil
}
values := fmt.Sprintf(`(%d)`, positive[0])
filtered := fmt.Sprintf(
`SELECT sha1 FROM tag_assignment WHERE tag = %d`, positive[0])
for _, tagID := range positive[1:] {
values += fmt.Sprintf(`, (%d)`, tagID)
filtered += fmt.Sprintf(` INTERSECT
SELECT sha1 FROM tag_assignment WHERE tag = %d`, tagID)
}
for _, tagID := range negative {
filtered += fmt.Sprintf(` EXCEPT
SELECT sha1 FROM tag_assignment WHERE tag = %d`, tagID)
}
return fmt.Sprintf(searchCTEMulti, values, filtered), nil
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
type webTagMatch struct {
SHA1 string `json:"sha1"`
ThumbW int64 `json:"thumbW"`
ThumbH int64 `json:"thumbH"`
Score float32 `json:"score"`
}
func getTagMatches(tx *sql.Tx, cte string) (matches []webTagMatch, err error) {
rows, err := tx.Query(cte + `
SELECT sha1, IFNULL(thumbw, 0), IFNULL(thumbh, 0), score
FROM matches`)
if err != nil {
return nil, err
}
defer rows.Close()
matches = []webTagMatch{}
for rows.Next() {
var match webTagMatch
if err = rows.Scan(&match.SHA1,
&match.ThumbW, &match.ThumbH, &match.Score); err != nil {
return nil, err
}
matches = append(matches, match)
}
return matches, rows.Err()
}
type webTagSupertag struct {
space string
tag string
score float32
}
func getTagSupertags(tx *sql.Tx, cte string) (
result map[int64]*webTagSupertag, err error) {
rows, err := tx.Query(cte + `
SELECT DISTINCT ta.tag, ts.name, t.name
FROM tag_assignment AS ta
JOIN matches AS m ON m.sha1 = ta.sha1
JOIN tag AS t ON ta.tag = t.id
JOIN tag_space AS ts ON ts.id = t.space`)
if err != nil {
return nil, err
}
defer rows.Close()
result = make(map[int64]*webTagSupertag)
for rows.Next() {
var (
tag int64
st webTagSupertag
)
if err = rows.Scan(&tag, &st.space, &st.tag); err != nil {
return nil, err
}
result[tag] = &st
}
return result, rows.Err()
}
type webTagRelated struct {
Tag string `json:"tag"`
Score float32 `json:"score"`
}
func getTagRelated(tx *sql.Tx, cte string, matches int) (
result map[string][]webTagRelated, err error) {
// Not sure if this level of efficiency is achievable directly in SQL.
supertags, err := getTagSupertags(tx, cte)
if err != nil {
return nil, err
}
rows, err := tx.Query(cte + `
SELECT ta.tag, ta.weight
FROM tag_assignment AS ta
JOIN matches AS m ON m.sha1 = ta.sha1`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
tag int64
weight float32
)
if err = rows.Scan(&tag, &weight); err != nil {
return nil, err
}
supertags[tag].score += weight
}
result = make(map[string][]webTagRelated)
for _, info := range supertags {
if score := info.score / float32(matches); score >= 0.1 {
r := webTagRelated{Tag: info.tag, Score: score}
result[info.space] = append(result[info.space], r)
}
}
return result, rows.Err()
}
func handleAPISearch(w http.ResponseWriter, r *http.Request) {
var params struct {
Query string
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var result struct {
Matches []webTagMatch `json:"matches"`
Related map[string][]webTagRelated `json:"related"`
}
tx, err := db.Begin()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer tx.Rollback()
cte, err := searchQueryToCTE(tx, params.Query)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, err.Error(), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if result.Matches, err = getTagMatches(tx, cte); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if result.Related, err = getTagRelated(tx, cte,
len(result.Matches)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Println(err)
}
}
// --- Web ---------------------------------------------------------------------
var hashRE = regexp.MustCompile(`^/.*?/([0-9a-f]{40})$`)
var staticHandler http.Handler
var webSettings struct {
Local bool `json:"local"`
}
var page = template.Must(template.New("/").Parse(`<!DOCTYPE html><html><head>
<title>Gallery</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel=stylesheet href=style.css>
</head><body>
<noscript>This is a web application, and requires Javascript.</noscript>
<script>let settings = {{ . }}</script>
<script src=mithril.js></script>
<script src=gallery.js></script>
</body></html>`))
func handleRequest(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
staticHandler.ServeHTTP(w, r)
return
}
if err := page.Execute(w, &webSettings); err != nil {
log.Println(err)
}
}
func handleImages(w http.ResponseWriter, r *http.Request) {
if m := hashRE.FindStringSubmatch(r.URL.Path); m == nil {
http.NotFound(w, r)
} else {
http.ServeFile(w, r, imagePath(m[1]))
}
}
func handleThumbs(w http.ResponseWriter, r *http.Request) {
if m := hashRE.FindStringSubmatch(r.URL.Path); m == nil {
http.NotFound(w, r)
} else {
http.ServeFile(w, r, thumbPath(m[1]))
}
}
// cmdWeb runs a web UI against GD on ADDRESS.
func cmdWeb(fs *flag.FlagSet, args []string) error {
local := fs.Bool(
"local", false, "enable features appropriate for running locally")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 2 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
address := fs.Arg(1)
webSettings.Local = *local
// This separation is not strictly necessary,
// but having an elementary level of security doesn't hurt either.
staticHandler = http.FileServer(http.Dir("public"))
http.HandleFunc("/", handleRequest)
http.HandleFunc("/image/", handleImages)
http.HandleFunc("/thumb/", handleThumbs)
http.HandleFunc("/api/browse", handleAPIBrowse)
http.HandleFunc("/api/tags", handleAPITags)
http.HandleFunc("/api/duplicates", handleAPIDuplicates)
http.HandleFunc("/api/orphans", handleAPIOrphans)
http.HandleFunc("/api/open", handleAPIOpen)
http.HandleFunc("/api/info", handleAPIInfo)
http.HandleFunc("/api/similar", handleAPISimilar)
http.HandleFunc("/api/search", handleAPISearch)
host, port, err := net.SplitHostPort(address)
if err != nil {
log.Println(err)
} else if host == "" {
log.Println("http://" + net.JoinHostPort("localhost", port))
} else {
log.Println("http://" + address)
}
s := &http.Server{
Addr: address,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
MaxHeaderBytes: 32 << 10,
}
return s.ListenAndServe()
}
// --- Sync --------------------------------------------------------------------
type syncFileInfo struct {
dbID int64 // DB node ID, or zero if there was none
dbParent int64 // where the file was to be stored
dbName string // the name under which it was to be stored
fsPath string // symlink target
fsMtime int64 // last modified Unix timestamp, used a bit like an ID
err error // any processing error
sha1 string // raw content hash, empty to skip file
width int // image width in pixels
height int // image height in pixels
}
type syncContext struct {
ctx context.Context
tx *sql.Tx
info chan syncFileInfo
pb *progressBar
stmtOrphan *sql.Stmt
stmtDisposeSub *sql.Stmt
stmtDisposeAll *sql.Stmt
// exclude specifies filesystem paths that should be seen as missing.
exclude *regexp.Regexp
// linked tracks which image hashes we've checked so far in the run.
linked map[string]struct{}
}
func syncPrintf(c *syncContext, format string, v ...any) {
c.pb.Interrupt(func() { log.Printf(format+"\n", v...) })
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
type syncNode struct {
dbID int64
dbName string
dbMtime int64
dbSHA1 string
}
func (n *syncNode) dbIsDir() bool { return n.dbSHA1 == "" }
type syncFile struct {
fsName string
fsMtime int64
fsIsDir bool
}
type syncPair struct {
db *syncNode
fs *syncFile
}
// syncGetNodes returns direct children of a DB node, ordered by name.
// SQLite, like Go, compares strings byte-wise by default.
func syncGetNodes(tx *sql.Tx, dbParent int64) (nodes []syncNode, err error) {
// This works even for the root, which doesn't exist as a DB node.
rows, err := tx.Query(`SELECT id, name, IFNULL(mtime, 0), IFNULL(sha1, '')
FROM node WHERE IFNULL(parent, 0) = ? ORDER BY name`, dbParent)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var node syncNode
if err = rows.Scan(&node.dbID,
&node.dbName, &node.dbMtime, &node.dbSHA1); err != nil {
return
}
nodes = append(nodes, node)
}
return nodes, rows.Err()
}
// syncGetFiles returns direct children of a FS directory, ordered by name.
func syncGetFiles(fsPath string) (files []syncFile, err error) {
dir, err := os.Open(fsPath)
if err != nil {
return
}
defer dir.Close()
entries, err := dir.ReadDir(0)
if err != nil {
return
}
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
return files, err
}
files = append(files, syncFile{
fsName: entry.Name(),
fsMtime: info.ModTime().Unix(),
fsIsDir: entry.IsDir(),
})
}
sort.Slice(files,
func(a, b int) bool { return files[a].fsName < files[b].fsName })
return
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
func syncIsImage(path string) (bool, error) {
out, err := exec.Command("xdg-mime", "query", "filetype", path).Output()
if err != nil {
return false, err
}
return bytes.HasPrefix(out, []byte("image/")), nil
}
func syncPingImage(path string) (int, int, error) {
out, err := exec.Command("identify", "-limit", "thread", "1",
"-ping", "-format", "%w %h", path+"[0]").Output()
if err != nil {
return 0, 0, err
}
var w, h int
_, err = fmt.Fscanf(bytes.NewReader(out), "%d %d", &w, &h)
return w, h, err
}
func syncProcess(c *syncContext, info *syncFileInfo) error {
// Skip videos, which ImageMagick can process, but we don't want it to,
// so that they're not converted 1:1 to WebP.
pathIsImage, err := syncIsImage(info.fsPath)
if err != nil {
return err
}
if !pathIsImage {
return nil
}
info.width, info.height, err = syncPingImage(info.fsPath)
if err != nil {
return err
}
f, err := os.Open(info.fsPath)
if err != nil {
return err
}
defer f.Close()
// We could make this at least somewhat interruptible by c.ctx,
// though it would still work poorly.
hash := sha1.New()
_, err = io.CopyBuffer(hash, f, make([]byte, 65536))
if err != nil {
return err
}
info.sha1 = hex.EncodeToString(hash.Sum(nil))
return nil
}
// syncEnqueue runs file scanning, which can be CPU and I/O expensive,
// in parallel. The goroutine only touches the filesystem, read-only.
func syncEnqueue(c *syncContext, info syncFileInfo) error {
if err := taskSemaphore.acquire(c.ctx); err != nil {
return err
}
go func(info syncFileInfo) {
defer taskSemaphore.release()
info.err = syncProcess(c, &info)
c.info <- info
}(info)
return nil
}
// syncDequeue flushes the result queue of finished asynchronous tasks.
func syncDequeue(c *syncContext) error {
for {
select {
case <-c.ctx.Done():
return c.ctx.Err()
case info := <-c.info:
if err := syncPostProcess(c, info); err != nil {
return err
}
default:
return nil
}
}
}
// syncDispose creates orphan records for the entire subtree given by nodeID
// as appropriate, then deletes all nodes within the subtree. The subtree root
// node is not deleted if "keepNode" is true.
//
// Orphans keep their thumbnail files, as evidence.
func syncDispose(c *syncContext, nodeID int64, keepNode bool) error {
if _, err := c.stmtOrphan.Exec(nodeID); err != nil {
return err
}
if keepNode {
if _, err := c.stmtDisposeSub.Exec(nodeID); err != nil {
return err
}
} else {
if _, err := c.stmtDisposeAll.Exec(nodeID); err != nil {
return err
}
}
return nil
}
func syncImageResave(c *syncContext, path string, target string) error {
dirname, _ := filepath.Split(path)
if err := os.MkdirAll(dirname, 0755); err != nil {
return err
}
for {
// Try to remove anything standing in the way.
err := os.Remove(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
// TODO: Make it possible to copy or reflink (ioctl FICLONE).
err = os.Symlink(target, path)
if err == nil || !errors.Is(err, fs.ErrExist) {
return err
}
}
}
func syncImageSave(c *syncContext, sha1 string, target string) error {
if _, ok := c.linked[sha1]; ok {
return nil
}
ok, path := false, imagePath(sha1)
if link, err := os.Readlink(path); err == nil {
ok = link == target
} else {
// If it exists, but it is not a symlink, let it be.
// Even though it may not be a regular file.
ok = errors.Is(err, syscall.EINVAL)
}
if !ok {
if err := syncImageResave(c, path, target); err != nil {
return err
}
}
c.linked[sha1] = struct{}{}
return nil
}
func syncImage(c *syncContext, info syncFileInfo) error {
if _, err := c.tx.Exec(`INSERT INTO image(sha1, width, height)
VALUES (?, ?, ?) ON CONFLICT(sha1) DO NOTHING`,
info.sha1, info.width, info.height); err != nil {
return err
}
return syncImageSave(c, info.sha1, info.fsPath)
}
func syncPostProcess(c *syncContext, info syncFileInfo) error {
defer c.pb.Step()
// TODO: When replacing an image node (whether it has or doesn't have
// other links to keep it alive), we could offer copying all tags,
// though this needs another table to track it.
// (If it's equivalent enough, the dhash will stay the same,
// so user can resolve this through the duplicates feature.)
switch {
case info.err != nil:
// * → error
if ee, ok := info.err.(*exec.ExitError); ok {
message := string(ee.Stderr)
if message == "" {
message = ee.String()
}
syncPrintf(c, "%s: %s", info.fsPath, message)
} else {
return info.err
}
fallthrough
case info.sha1 == "":
// 0 → 0
if info.dbID == 0 {
return nil
}
// D → 0, F → 0
// TODO: Make it possible to disable removal (for copying only?)
return syncDispose(c, info.dbID, false /*keepNode*/)
case info.dbID == 0:
// 0 → F
if err := syncImage(c, info); err != nil {
return err
}
if _, err := c.tx.Exec(`INSERT INTO node(parent, name, mtime, sha1)
VALUES (?, ?, ?, ?)`,
info.dbParent, info.dbName, info.fsMtime, info.sha1); err != nil {
return err
}
return nil
default:
// D → F, F → F (this statement is a no-op with the latter)
if err := syncDispose(c, info.dbID, true /*keepNode*/); err != nil {
return err
}
// Even if the hash didn't change, see comment in syncDirectoryPair().
if err := syncImage(c, info); err != nil {
return err
}
if _, err := c.tx.Exec(`UPDATE node SET mtime = ?, sha1 = ?
WHERE id = ?`, info.fsMtime, info.sha1, info.dbID); err != nil {
return err
}
return nil
}
}
func syncDirectoryPair(c *syncContext, dbParent int64, fsPath string,
pair syncPair) error {
db, fs, fsInfo := pair.db, pair.fs, syncFileInfo{dbParent: dbParent}
if db != nil {
fsInfo.dbID = db.dbID
}
if fs != nil {
fsInfo.dbName = fs.fsName
fsInfo.fsPath = filepath.Join(fsPath, fs.fsName)
fsInfo.fsMtime = fs.fsMtime
}
switch {
case db == nil && fs == nil:
// 0 → 0, unreachable.
case db == nil && fs.fsIsDir:
// 0 → D
var id int64
if result, err := c.tx.Exec(`INSERT INTO node(parent, name)
VALUES (?, ?)`, dbParent, fs.fsName); err != nil {
return err
} else if id, err = result.LastInsertId(); err != nil {
return err
}
return syncDirectory(c, id, fsInfo.fsPath)
case db == nil:
// 0 → F (or 0 → 0)
return syncEnqueue(c, fsInfo)
case fs == nil:
// D → 0, F → 0
// TODO: Make it possible to disable removal (for copying only?)
return syncDispose(c, db.dbID, false /*keepNode*/)
case db.dbIsDir() && fs.fsIsDir:
// D → D
return syncDirectory(c, db.dbID, fsInfo.fsPath)
case db.dbIsDir():
// D → F (or D → 0)
return syncEnqueue(c, fsInfo)
case fs.fsIsDir:
// F → D
if err := syncDispose(c, db.dbID, true /*keepNode*/); err != nil {
return err
}
if _, err := c.tx.Exec(`UPDATE node
SET mtime = NULL, sha1 = NULL WHERE id = ?`, db.dbID); err != nil {
return err
}
return syncDirectory(c, db.dbID, fsInfo.fsPath)
case db.dbMtime != fs.fsMtime:
// F → F (or F → 0)
// Assuming that any content modifications change the timestamp.
return syncEnqueue(c, fsInfo)
default:
// F → F
// Try to fix symlinks, to handle the following situations:
// 1. Image A occurs in paths 1 and 2, we use a symlink to path 1,
// and path 1 is removed from the filesystem:
// path 2 would not resolve if the mtime didn't change.
// 2. Image A occurs in paths 1 and 2, we use a symlink to path 1,
// and path 1 is changed:
// path 2 would resolve to the wrong file.
// This may relink images with multiple occurences unnecessarily,
// but it will always fix the roots that are being synced.
if err := syncImageSave(c, db.dbSHA1, fsInfo.fsPath); err != nil {
return err
}
}
return nil
}
func syncDirectory(c *syncContext, dbParent int64, fsPath string) error {
db, err := syncGetNodes(c.tx, dbParent)
if err != nil {
return err
}
fs, err := syncGetFiles(fsPath)
if err != nil {
return err
}
// This would not be fatal, but it has annoying consequences.
if _, ok := slices.BinarySearchFunc(fs, syncFile{fsName: nameOfDB},
func(a, b syncFile) int {
return strings.Compare(a.fsName, b.fsName)
}); ok {
syncPrintf(c, "%s may be a gallery directory, treating as empty",
fsPath)
fs = nil
}
if c.exclude != nil {
fs = slices.DeleteFunc(fs, func(f syncFile) bool {
return c.exclude.MatchString(filepath.Join(fsPath, f.fsName))
})
}
// Convert differences to a form more convenient for processing.
iDB, iFS, pairs := 0, 0, []syncPair{}
for iDB < len(db) && iFS < len(fs) {
if db[iDB].dbName == fs[iFS].fsName {
pairs = append(pairs, syncPair{&db[iDB], &fs[iFS]})
iDB++
iFS++
} else if db[iDB].dbName < fs[iFS].fsName {
pairs = append(pairs, syncPair{&db[iDB], nil})
iDB++
} else {
pairs = append(pairs, syncPair{nil, &fs[iFS]})
iFS++
}
}
for i := range db[iDB:] {
pairs = append(pairs, syncPair{&db[iDB+i], nil})
}
for i := range fs[iFS:] {
pairs = append(pairs, syncPair{nil, &fs[iFS+i]})
}
for _, pair := range pairs {
if err := syncDequeue(c); err != nil {
return err
}
if err := syncDirectoryPair(c, dbParent, fsPath, pair); err != nil {
return err
}
}
return nil
}
func syncRoot(c *syncContext, dbPath []string, fsPath string) error {
// TODO: Support synchronizing individual files.
// This can only be treated as 0 → F, F → F, or D → F, that is,
// a variation on current syncEnqueue(), but dbParent must be nullable.
// Figure out a database root (not trying to convert F → D on conflict,
// also because we don't know yet if the argument is a directory).
//
// Synchronizing F → D or * → F are special cases not worth implementing.
dbParent, err := idForDirectoryPath(c.tx, dbPath, true)
if err != nil {
return err
}
if err := syncDirectory(c, dbParent, fsPath); err != nil {
return err
}
// Wait for all tasks to finish, and process the results of their work.
for i := 0; i < cap(taskSemaphore); i++ {
if err := taskSemaphore.acquire(c.ctx); err != nil {
return err
}
}
if err := syncDequeue(c); err != nil {
return err
}
// This is not our semaphore, so prepare it for the next user.
for i := 0; i < cap(taskSemaphore); i++ {
taskSemaphore.release()
}
// Delete empty directories, from the bottom of the tree up to,
// but not including, the inserted root.
//
// We need to do this at the end due to our recursive handling,
// as well as because of asynchronous file filtering.
stmt, err := c.tx.Prepare(`
WITH RECURSIVE subtree(id, parent, sha1, level) AS (
SELECT id, parent, sha1, 1 FROM node WHERE id = ?
UNION ALL
SELECT n.id, n.parent, n.sha1, s.level + 1
FROM node AS n JOIN subtree AS s ON n.parent = s.id
) DELETE FROM node WHERE id IN (
SELECT id FROM subtree WHERE level <> 1 AND sha1 IS NULL
AND id NOT IN (SELECT parent FROM node WHERE parent IS NOT NULL)
)`)
if err != nil {
return err
}
for {
if result, err := stmt.Exec(dbParent); err != nil {
return err
} else if n, err := result.RowsAffected(); err != nil {
return err
} else if n == 0 {
return nil
}
}
}
type syncPath struct {
db []string // database path, in terms of nodes
fs string // normalized filesystem path
}
// syncResolveRoots normalizes filesystem paths given in command line arguments,
// and figures out a database path for each. Duplicates are skipped or rejected.
func syncResolveRoots(args []string, fullpaths bool) (
roots []*syncPath, err error) {
for i := range args {
fs, err := filepath.Abs(filepath.Clean(args[i]))
if err != nil {
return nil, err
}
roots = append(roots,
&syncPath{decodeWebPath(filepath.ToSlash(fs)), fs})
}
if fullpaths {
// Filter out duplicates. In this case, they're just duplicated work.
slices.SortFunc(roots, func(a, b *syncPath) int {
return strings.Compare(a.fs, b.fs)
})
roots = slices.CompactFunc(roots, func(a, b *syncPath) bool {
if a.fs != b.fs && !strings.HasPrefix(b.fs, a.fs+"/") {
return false
}
log.Printf("asking to sync path twice: %s\n", b.fs)
return true
})
} else {
// Keep just the basenames.
for _, path := range roots {
if len(path.db) > 0 {
path.db = path.db[len(path.db)-1:]
}
}
// Different filesystem paths mapping to the same DB location
// are definitely a problem we would like to avoid,
// otherwise we don't care.
slices.SortFunc(roots, func(a, b *syncPath) int {
return slices.Compare(a.db, b.db)
})
for i := 1; i < len(roots); i++ {
if slices.Equal(roots[i-1].db, roots[i].db) {
return nil, fmt.Errorf("duplicate root: %v", roots[i].db)
}
}
}
return
}
const disposeCTE = `WITH RECURSIVE
root(id, sha1, parent, path) AS (
SELECT id, sha1, parent, name FROM node WHERE id = ?
UNION ALL
SELECT r.id, r.sha1, n.parent, n.name || '/' || r.path
FROM node AS n JOIN root AS r ON n.id = r.parent
),
children(id, sha1, path, level) AS (
SELECT id, sha1, path, 1 FROM root WHERE parent IS NULL
UNION ALL
SELECT n.id, n.sha1, c.path || '/' || n.name, c.level + 1
FROM node AS n JOIN children AS c ON n.parent = c.id
),
removed(sha1, count, path) AS (
SELECT sha1, COUNT(*) AS count, MIN(path) AS path
FROM children
GROUP BY sha1
),
orphaned(sha1, path, count, total) AS (
SELECT r.sha1, r.path, r.count, COUNT(*) AS total
FROM removed AS r
JOIN node ON node.sha1 = r.sha1
GROUP BY node.sha1
HAVING count = total
)`
type excludeRE struct{ re *regexp.Regexp }
func (re *excludeRE) String() string { return fmt.Sprintf("%v", re.re) }
func (re *excludeRE) Set(value string) error {
var err error
re.re, err = regexp.Compile(value)
return err
}
// cmdSync ensures the given (sub)roots are accurately reflected
// in the database.
func cmdSync(fs *flag.FlagSet, args []string) error {
var exclude excludeRE
fs.Var(&exclude, "exclude", "exclude paths matching regular expression")
fullpaths := fs.Bool("fullpaths", false, "don't basename arguments")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 2 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
roots, err := syncResolveRoots(fs.Args()[1:], *fullpaths)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// In case of a failure during processing, the only retained side effects
// on the filesystem tree are:
// - Fixing dead symlinks to images.
// - Creating symlinks to images that aren't used by anything.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Mild hack: upgrade the transaction to a write one straight away,
// in order to rule out deadlocks (preventable failure).
if _, err := tx.Exec(`END TRANSACTION;
BEGIN IMMEDIATE TRANSACTION`); err != nil {
return err
}
c := syncContext{ctx: ctx, tx: tx, pb: newProgressBar(-1),
exclude: exclude.re, linked: make(map[string]struct{})}
defer c.pb.Stop()
if c.stmtOrphan, err = c.tx.Prepare(disposeCTE + `
INSERT OR IGNORE INTO orphan(sha1, path)
SELECT sha1, path FROM orphaned`); err != nil {
return err
}
if c.stmtDisposeSub, err = c.tx.Prepare(disposeCTE + `
DELETE FROM node WHERE id
IN (SELECT DISTINCT id FROM children WHERE level <> 1)`); err != nil {
return err
}
if c.stmtDisposeAll, err = c.tx.Prepare(disposeCTE + `
DELETE FROM node WHERE id
IN (SELECT DISTINCT id FROM children)`); err != nil {
return err
}
// Info tasks take a position in the task semaphore channel.
// then fill the info channel.
//
// Immediately after syncDequeue(), the info channel is empty,
// but the semaphore might be full.
//
// By having at least one position in the info channel,
// we allow at least one info task to run to semaphore release,
// so that syncEnqueue() doesn't deadlock.
//
// By making it the same size as the semaphore,
// the end of this function doesn't need to dequeue while waiting.
// It also prevents goroutine leaks despite leaving them running--
// once they finish their job, they're gone,
// and eventually the info channel would get garbage collected.
//
// The additional slot is there to handle the one result
// that may be placed while syncEnqueue() waits for the semaphore,
// i.e., it is for the result of the task that syncEnqueue() spawns.
c.info = make(chan syncFileInfo, cap(taskSemaphore)+1)
for _, root := range roots {
if err := syncRoot(&c, root.db, root.fs); err != nil {
return err
}
}
return tx.Commit()
}
// --- Removal -----------------------------------------------------------------
// cmdRemove is for manual removal of subtrees from the database.
// Beware that inputs are database, not filesystem paths.
func cmdRemove(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 2 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, path := range fs.Args()[1:] {
var id sql.NullInt64
for _, name := range decodeWebPath(path) {
if err := tx.QueryRow(`SELECT id FROM node
WHERE parent IS ? AND name = ?`,
id, name).Scan(&id); err != nil {
return err
}
}
if id.Int64 == 0 {
return errors.New("can't remove root")
}
if _, err = tx.Exec(disposeCTE+`
INSERT OR IGNORE INTO orphan(sha1, path)
SELECT sha1, path FROM orphaned`, id); err != nil {
return err
}
if _, err = tx.Exec(disposeCTE+`
DELETE FROM node WHERE id
IN (SELECT DISTINCT id FROM children)`, id); err != nil {
return err
}
}
return tx.Commit()
}
// --- Forgetting --------------------------------------------------------------
// cmdForget is for purging orphaned images from the database.
func cmdForget(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 2 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Creating a temporary database seems justifiable in this case.
_, err = tx.Exec(
`CREATE TEMPORARY TABLE forgotten (sha1 TEXT PRIMARY KEY)`)
if err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT INTO forgotten (sha1) VALUES (?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, sha1 := range fs.Args()[1:] {
if _, err := stmt.Exec(sha1); err != nil {
return err
}
}
rows, err := tx.Query(`DELETE FROM forgotten
WHERE sha1 IN (SELECT sha1 FROM node)
OR sha1 NOT IN (SELECT sha1 FROM image)
RETURNING sha1`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var sha1 string
if err := rows.Scan(&sha1); err != nil {
return err
}
log.Printf("not an orphan or not known at all: %s", sha1)
}
if _, err = tx.Exec(`
DELETE FROM tag_assignment WHERE sha1 IN (SELECT sha1 FROM forgotten);
DELETE FROM orphan WHERE sha1 IN (SELECT sha1 FROM forgotten);
DELETE FROM image WHERE sha1 IN (SELECT sha1 FROM forgotten);
`); err != nil {
return err
}
rows, err = tx.Query(`SELECT sha1 FROM forgotten`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var sha1 string
if err := rows.Scan(&sha1); err != nil {
return err
}
if err := os.Remove(imagePath(sha1)); err != nil &&
!os.IsNotExist(err) {
log.Printf("%s", err)
}
if err := os.Remove(thumbPath(sha1)); err != nil &&
!os.IsNotExist(err) {
log.Printf("%s", err)
}
}
return tx.Commit()
}
// --- Tagging -----------------------------------------------------------------
// cmdTag mass imports tags from data passed on stdin as a TSV
// of SHA1 TAG WEIGHT entries.
func cmdTag(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 2 || fs.NArg() > 3 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
space := fs.Arg(1)
var description sql.NullString
if fs.NArg() >= 3 {
description = sql.NullString{String: fs.Arg(2), Valid: true}
}
// Note that starting as a write transaction prevents deadlocks.
// Imports are rare, and just bulk load data, so this scope is fine.
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`INSERT OR IGNORE INTO tag_space(name, description)
VALUES (?, ?)`, space, description); err != nil {
return err
}
var spaceID int64
if err := tx.QueryRow(`SELECT id FROM tag_space WHERE name = ?`,
space).Scan(&spaceID); err != nil {
return err
}
// XXX: It might make sense to pre-erase all tag assignments within
// the given space for that image, the first time we see it:
//
// DELETE FROM tag_assignment
// WHERE sha1 = ? AND tag IN (SELECT id FROM tag WHERE space = ?)
//
// or even just clear the tag space completely:
//
// DELETE FROM tag_assignment
// WHERE tag IN (SELECT id FROM tag WHERE space = ?);
// DELETE FROM tag WHERE space = ?;
stmt, err := tx.Prepare(`INSERT INTO tag_assignment(sha1, tag, weight)
VALUES (?, (SELECT id FROM tag WHERE space = ? AND name = ?), ?)
ON CONFLICT DO UPDATE SET weight = ?`)
if err != nil {
return err
}
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), "\t")
if len(fields) != 3 {
return errors.New("invalid input format")
}
sha1, tag := fields[0], fields[1]
weight, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
return err
}
if _, err := tx.Exec(
`INSERT OR IGNORE INTO tag(space, name) VALUES (?, ?);`,
spaceID, tag); err != nil {
return nil
}
if _, err := stmt.Exec(sha1, spaceID, tag, weight, weight); err != nil {
log.Printf("%s: %s\n", sha1, err)
}
}
if err := scanner.Err(); err != nil {
return err
}
return tx.Commit()
}
// --- Check -------------------------------------------------------------------
func isValidSHA1(hash string) bool {
if len(hash) != sha1.Size*2 || strings.ToLower(hash) != hash {
return false
}
if _, err := hex.DecodeString(hash); err != nil {
return false
}
return true
}
func hashesToFileListing(root, suffix string, hashes []string) []string {
// Note that we're semi-duplicating {image,thumb}Path().
paths := []string{root}
for _, hash := range hashes {
dir := filepath.Join(root, hash[:2])
paths = append(paths, dir, filepath.Join(dir, hash+suffix))
}
slices.Sort(paths)
return slices.Compact(paths)
}
func collectFileListing(root string) (paths []string, err error) {
err = filepath.WalkDir(root,
func(path string, d fs.DirEntry, err error) error {
paths = append(paths, path)
return err
})
// Even though it should already be sorted somehow.
slices.Sort(paths)
return
}
func checkFiles(gc bool,
root, suffix string, hashes []string) (bool, []string, error) {
db := hashesToFileListing(root, suffix, hashes)
fs, err := collectFileListing(root)
if err != nil {
return false, nil, err
}
// There are two legitimate cases of FS-only database files:
// 1. There is no code to unlink images at all
// (although sync should create orphan records for everything).
// 2. thumbnail: failures may result in an unreferenced garbage image.
ok := true
onlyDB := func(path string) {
ok = false
fmt.Printf("only in DB: %s\n", path)
}
onlyFS := func(path string) {
if !gc {
ok = false
fmt.Printf("only in FS: %s\n", path)
} else if err := os.Remove(path); err != nil {
ok = false
fmt.Printf("only in FS (removing failed): %s: %s\n", path, err)
} else {
fmt.Printf("only in FS (removing): %s\n", path)
}
}
iDB, iFS, intersection := 0, 0, []string{}
for iDB < len(db) && iFS < len(fs) {
if db[iDB] == fs[iFS] {
intersection = append(intersection, db[iDB])
iDB++
iFS++
} else if db[iDB] < fs[iFS] {
onlyDB(db[iDB])
iDB++
} else {
onlyFS(fs[iFS])
iFS++
}
}
for _, path := range db[iDB:] {
onlyDB(path)
}
for _, path := range fs[iFS:] {
onlyFS(path)
}
return ok, intersection, nil
}
func checkHash(path string) (message string, err error) {
f, err := os.Open(path)
if err != nil {
return err.Error(), nil
}
defer f.Close()
// We get 2 levels of parent directories in here, just filter them out.
if fi, err := f.Stat(); err != nil {
return err.Error(), nil
} else if fi.IsDir() {
return "", nil
}
hash := sha1.New()
_, err = io.CopyBuffer(hash, f, make([]byte, 65536))
if err != nil {
return err.Error(), nil
}
sha1 := hex.EncodeToString(hash.Sum(nil))
if sha1 != filepath.Base(path) {
return fmt.Sprintf("mismatch, found %s", sha1), nil
}
return "", nil
}
func checkHashes(paths []string) (bool, error) {
log.Println("checking image hashes")
var failed atomic.Bool
err := parallelize(paths, func(path string) (string, error) {
message, err := checkHash(path)
if message != "" {
failed.Store(true)
}
return message, err
})
return !failed.Load(), err
}
// cmdCheck carries out various database consistency checks.
func cmdCheck(fs *flag.FlagSet, args []string) error {
full := fs.Bool("full", false, "verify image hashes")
gc := fs.Bool("gc", false, "garbage collect database files")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
// Check if hashes are in the right format.
log.Println("checking image hashes")
allSHA1, err := dbCollectStrings(`SELECT sha1 FROM image`)
if err != nil {
return err
}
ok := true
for _, hash := range allSHA1 {
if !isValidSHA1(hash) {
ok = false
fmt.Printf("invalid image SHA1: %s\n", hash)
}
}
// This is, rather obviously, just a strict subset.
// Although it doesn't run in the same transaction.
thumbSHA1, err := dbCollectStrings(`SELECT sha1 FROM image
WHERE thumbw IS NOT NULL OR thumbh IS NOT NULL`)
if err != nil {
return err
}
// This somewhat duplicates {image,thumb}Path().
log.Println("checking SQL against filesystem")
okImages, intersection, err := checkFiles(*gc,
filepath.Join(galleryDirectory, nameOfImageRoot), "", allSHA1)
if err != nil {
return err
}
okThumbs, _, err := checkFiles(*gc,
filepath.Join(galleryDirectory, nameOfThumbRoot), ".webp", thumbSHA1)
if err != nil {
return err
}
if !okImages || !okThumbs {
ok = false
}
log.Println("checking for dead symlinks (should become orphans on sync)")
for _, path := range intersection {
if _, err := os.Stat(path); err != nil {
ok = false
fmt.Printf("%s: %s\n", path, err.(*os.PathError).Unwrap())
}
}
if *full {
if ok2, err := checkHashes(intersection); err != nil {
return err
} else if !ok2 {
ok = false
}
}
if !ok {
return errors.New("detected inconsistencies")
}
return nil
}
// --- Thumbnailing ------------------------------------------------------------
func identifyThumbnail(path string) (w, h int, err error) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
config, err := webp.DecodeConfig(f)
if err != nil {
return
}
return config.Width, config.Height, nil
}
func makeThumbnail(load bool, pathImage, pathThumb string) (
w, h int, err error) {
if load {
if w, h, err = identifyThumbnail(pathThumb); err == nil {
return
}
}
thumbDirname, _ := filepath.Split(pathThumb)
if err := os.MkdirAll(thumbDirname, 0755); err != nil {
return 0, 0, err
}
// This is still too much, but it will be effective enough.
memoryLimit := strconv.FormatInt(
int64(C.sysconf(C._SC_PHYS_PAGES)*C.sysconf(C._SC_PAGE_SIZE))/
int64(cap(taskSemaphore)), 10)
// Create a normalized thumbnail. Since we don't particularly need
// any complex processing, such as surrounding metadata,
// simply push it through ImageMagick.
//
// - http://www.ericbrasseur.org/gamma.html
// - https://www.imagemagick.org/Usage/thumbnails/
// - https://imagemagick.org/script/command-line-options.php#layers
//
// "info:" output is written for each frame, which is why we delete
// all of them but the first one beforehands.
//
// TODO: See if we can optimize resulting WebP animations.
// (Do -layers optimize* apply to this format at all?)
cmd := exec.Command("convert", "-limit", "thread", "1",
// Do not invite the OOM killer, a particularly unpleasant guest.
"-limit", "memory", memoryLimit,
// ImageMagick creates files in /tmp, but that tends to be a tmpfs,
// which is backed by memory. The path could also be moved elsewhere:
// -define registry:temporary-path=/var/tmp
"-limit", "map", "0", "-limit", "disk", "0",
pathImage, "-coalesce", "-colorspace", "RGB", "-auto-orient", "-strip",
"-resize", "256x128>", "-colorspace", "sRGB",
"-format", "%w %h", "+write", pathThumb, "-delete", "1--1", "info:")
out, err := cmd.Output()
if err != nil {
return 0, 0, err
}
_, err = fmt.Fscanf(bytes.NewReader(out), "%d %d", &w, &h)
return w, h, err
}
// cmdThumbnail generates missing thumbnails, in parallel.
func cmdThumbnail(fs *flag.FlagSet, args []string) error {
load := fs.Bool("load", false, "try to load existing thumbnail files")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
hexSHA1 := fs.Args()[1:]
if len(hexSHA1) == 0 {
// Get all unique images in the database with no thumbnail.
var err error
hexSHA1, err = dbCollectStrings(`SELECT sha1 FROM image
WHERE thumbw IS NULL OR thumbh IS NULL`)
if err != nil {
return err
}
}
stmt, err := db.Prepare(
`UPDATE image SET thumbw = ?, thumbh = ? WHERE sha1 = ?`)
if err != nil {
return err
}
defer stmt.Close()
var mu sync.Mutex
return parallelize(hexSHA1, func(sha1 string) (message string, err error) {
pathImage := imagePath(sha1)
pathThumb := thumbPath(sha1)
w, h, err := makeThumbnail(*load, pathImage, pathThumb)
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
if message = string(ee.Stderr); message != "" {
return message, nil
}
return ee.String(), nil
}
return "", err
}
mu.Lock()
defer mu.Unlock()
_, err = stmt.Exec(w, h, sha1)
return "", err
})
}
// --- Perceptual hash ---------------------------------------------------------
type linearImage struct {
img image.Image
}
func newLinearImage(img image.Image) *linearImage {
return &linearImage{img: img}
}
func (l *linearImage) ColorModel() color.Model { return l.img.ColorModel() }
func (l *linearImage) Bounds() image.Rectangle { return l.img.Bounds() }
func unSRGB(c uint32) uint8 {
n := float64(c) / 0xffff
if n <= 0.04045 {
return uint8(n * (255.0 / 12.92))
}
return uint8(math.Pow((n+0.055)/(1.055), 2.4) * 255.0)
}
func (l *linearImage) At(x, y int) color.Color {
r, g, b, a := l.img.At(x, y).RGBA()
return color.RGBA{
R: unSRGB(r), G: unSRGB(g), B: unSRGB(b), A: uint8(a >> 8)}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// isWebPAnimation returns whether the given ReadSeeker starts a WebP animation.
// See https://developers.google.com/speed/webp/docs/riff_container
func isWebPAnimation(rs io.ReadSeeker) (bool, error) {
b := make([]byte, 21)
if _, err := rs.Read(b); err != nil {
return false, err
}
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return false, err
}
return bytes.Equal(b[:4], []byte("RIFF")) &&
bytes.Equal(b[8:16], []byte("WEBPVP8X")) &&
b[20]&0b00000010 != 0, nil
}
var errIsAnimation = errors.New("cannot perceptually hash animations")
func dhashWebP(rs io.ReadSeeker) (uint64, error) {
if a, err := isWebPAnimation(rs); err != nil {
return 0, err
} else if a {
return 0, errIsAnimation
}
// Doing this entire thing in Go is SLOW, but convenient.
source, err := webp.Decode(rs)
if err != nil {
return 0, err
}
var (
linear = newLinearImage(source)
resized = image.NewNRGBA64(image.Rect(0, 0, 9, 8))
)
draw.CatmullRom.Scale(resized, resized.Bounds(),
linear, linear.Bounds(), draw.Src, nil)
var hash uint64
for y := 0; y < 8; y++ {
var grey [9]float32
for x := 0; x < 9; x++ {
rgba := resized.NRGBA64At(x, y)
grey[x] = 0.2126*float32(rgba.R) +
0.7152*float32(rgba.G) +
0.0722*float32(rgba.B)
}
var row uint64
if grey[0] < grey[1] {
row |= 1 << 7
}
if grey[1] < grey[2] {
row |= 1 << 6
}
if grey[2] < grey[3] {
row |= 1 << 5
}
if grey[3] < grey[4] {
row |= 1 << 4
}
if grey[4] < grey[5] {
row |= 1 << 3
}
if grey[5] < grey[6] {
row |= 1 << 2
}
if grey[6] < grey[7] {
row |= 1 << 1
}
if grey[7] < grey[8] {
row |= 1 << 0
}
hash = hash<<8 | row
}
return hash, nil
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
func makeDhash(sha1 string) (uint64, error) {
pathThumb := thumbPath(sha1)
f, err := os.Open(pathThumb)
if err != nil {
return 0, err
}
defer f.Close()
return dhashWebP(f)
}
// cmdDhash computes perceptual hashes from thumbnails.
func cmdDhash(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 {
return errWrongUsage
}
if err := openDB(fs.Arg(0)); err != nil {
return err
}
hexSHA1 := fs.Args()[1:]
if len(hexSHA1) == 0 {
var err error
hexSHA1, err = dbCollectStrings(`SELECT sha1 FROM image
WHERE thumbw IS NOT NULL AND thumbh IS NOT NULL AND dhash IS NULL`)
if err != nil {
return err
}
}
// Commits are very IO-expensive in both WAL and non-WAL SQLite,
// so write this in one go. For a middle ground, we could batch the updates.
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Mild hack: upgrade the transaction to a write one straight away,
// in order to rule out deadlocks (preventable failure).
if _, err := tx.Exec(`END TRANSACTION;
BEGIN IMMEDIATE TRANSACTION`); err != nil {
return err
}
stmt, err := tx.Prepare(`UPDATE image SET dhash = ? WHERE sha1 = ?`)
if err != nil {
return err
}
defer stmt.Close()
var mu sync.Mutex
err = parallelize(hexSHA1, func(sha1 string) (message string, err error) {
hash, err := makeDhash(sha1)
if errors.Is(err, errIsAnimation) {
// Ignoring this common condition.
return "", nil
} else if err != nil {
return err.Error(), nil
}
mu.Lock()
defer mu.Unlock()
_, err = stmt.Exec(int64(hash), sha1)
return "", err
})
if err != nil {
return err
}
return tx.Commit()
}
// --- Main --------------------------------------------------------------------
var errWrongUsage = errors.New("wrong usage")
var commands = map[string]struct {
handler func(*flag.FlagSet, []string) error
usage string
function string
}{
"init": {cmdInit, "GD", "Initialize a database."},
"web": {cmdWeb, "GD ADDRESS", "Launch a web interface."},
"tag": {cmdTag, "GD SPACE [DESCRIPTION]", "Import tags."},
"sync": {cmdSync, "GD ROOT...", "Synchronise with the filesystem."},
"remove": {cmdRemove, "GD PATH...", "Remove database subtrees."},
"forget": {cmdForget, "GD SHA1...", "Dispose of orphans."},
"check": {cmdCheck, "GD", "Run consistency checks."},
"thumbnail": {cmdThumbnail, "GD [SHA1...]", "Generate thumbnails."},
"dhash": {cmdDhash, "GD [SHA1...]", "Compute perceptual hashes."},
}
func usage() {
f := flag.CommandLine.Output()
fmt.Fprintf(f, "Usage: %s COMMAND [ARG...]\n", os.Args[0])
flag.PrintDefaults()
// The alphabetic ordering is unfortunate, but tolerable.
keys := []string{}
for key := range commands {
keys = append(keys, key)
}
sort.Strings(keys)
fmt.Fprintf(f, "\nCommands:\n")
for _, key := range keys {
fmt.Fprintf(f, " %s [OPTION...] %s\n \t%s\n",
key, commands[key].usage, commands[key].function)
}
}
func main() {
threads := flag.Int("threads", -1, "level of parallelization")
// This implements the -h switch for us by default.
// The rest of the handling here closely follows what flag does internally.
flag.Usage = usage
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
os.Exit(2)
}
cmd, ok := commands[flag.Arg(0)]
if !ok {
fmt.Fprintf(flag.CommandLine.Output(),
"unknown command: %s\n", flag.Arg(0))
flag.Usage()
os.Exit(2)
}
fs := flag.NewFlagSet(flag.Arg(0), flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintf(fs.Output(),
"Usage: %s [OPTION...] %s\n%s\n",
fs.Name(), cmd.usage, cmd.function)
fs.PrintDefaults()
}
if *threads > 0 {
taskSemaphore = newSemaphore(*threads)
} else {
taskSemaphore = newSemaphore(runtime.NumCPU())
}
err := cmd.handler(fs, flag.Args()[1:])
// Note that the database object has a closing finalizer,
// we just additionally print any errors coming from there.
if db != nil {
if _, err := db.Exec(`PRAGMA optimize`); err != nil {
log.Println(err)
}
if err := db.Close(); err != nil {
log.Println(err)
}
}
if errors.Is(err, errWrongUsage) {
fs.Usage()
os.Exit(2)
} else if err != nil {
log.Fatalln(err)
}
}