2498 lines
62 KiB
Go
2498 lines
62 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"
|
|
)
|
|
|
|
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))
|
|
}
|
|
|
|
func init() {
|
|
sql.Register("sqlite3_custom", &sqlite3.SQLiteDriver{
|
|
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
|
return conn.RegisterFunc("hamming", hammingDistance, true /*pure*/)
|
|
},
|
|
})
|
|
}
|
|
|
|
func openDB(directory string) error {
|
|
var err error
|
|
db, err = sql.Open("sqlite3_custom", "file:"+filepath.Join(directory,
|
|
nameOfDB+"?_foreign_keys=1&_busy_timeout=1000"))
|
|
galleryDirectory = directory
|
|
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 := openDB(fs.Arg(0)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := db.Exec(initializeSQL); 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
|
|
}
|
|
|
|
// --- Web ---------------------------------------------------------------------
|
|
|
|
var hashRE = regexp.MustCompile(`^/.*?/([0-9a-f]{40})$`)
|
|
var staticHandler http.Handler
|
|
|
|
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 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, nil); 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]))
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
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(¶ms); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var result struct {
|
|
Subdirectories []string `json:"subdirectories"`
|
|
Entries []webEntry `json:"entries"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Println(err)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
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(¶ms); 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)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
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(¶ms); 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)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
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 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`)
|
|
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(¶ms); 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)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
func getImageDimensions(sha1 string) (w int64, h int64, err error) {
|
|
err = db.QueryRow(`SELECT width, height FROM image WHERE sha1 = ?`,
|
|
sha1).Scan(&w, &h)
|
|
return
|
|
}
|
|
|
|
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(¶ms); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var result struct {
|
|
Width int64 `json:"width"`
|
|
Height int64 `json:"height"`
|
|
Paths []string `json:"paths"`
|
|
Tags map[string]map[string]float32 `json:"tags"`
|
|
}
|
|
|
|
var err error
|
|
result.Width, result.Height, err = getImageDimensions(params.SHA1)
|
|
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.Paths, err = getImagePaths(params.SHA1)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
result.Tags, err = getImageTags(params.SHA1)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Println(err)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
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) {
|
|
// For distance ∈ {0, 1}, this query is quite inefficient.
|
|
// In exchange, it's generic.
|
|
//
|
|
// If there's a dhash, there should also be thumbnail dimensions,
|
|
// so not bothering with IFNULL on them.
|
|
rows, err := db.Query(`
|
|
SELECT sha1, width * height, IFNULL(thumbw, 0), IFNULL(thumbh, 0)
|
|
FROM image WHERE sha1 <> ? AND 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(¶ms); 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)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
// NOTE: AND will mean MULTIPLY(IFNULL(ta.weight, 0)) per SHA1.
|
|
const searchCTE = `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 = ?
|
|
),
|
|
supertags(tag) AS (
|
|
SELECT DISTINCT ta.tag
|
|
FROM tag_assignment AS ta
|
|
JOIN matches AS m ON m.sha1 = ta.sha1
|
|
),
|
|
scoredtags(tag, score) AS (
|
|
-- The cross join is a deliberate optimization,
|
|
-- and this query may still be really slow.
|
|
SELECT st.tag, AVG(IFNULL(ta.weight, 0)) AS score
|
|
FROM matches AS m
|
|
CROSS JOIN supertags AS st
|
|
LEFT JOIN tag_assignment AS ta
|
|
ON ta.sha1 = m.sha1 AND ta.tag = st.tag
|
|
GROUP BY st.tag
|
|
-- Using the column alias doesn't fail, but it also doesn't work.
|
|
HAVING AVG(IFNULL(ta.weight, 0)) >= 0.01
|
|
)
|
|
`
|
|
|
|
type webTagMatch struct {
|
|
SHA1 string `json:"sha1"`
|
|
ThumbW int64 `json:"thumbW"`
|
|
ThumbH int64 `json:"thumbH"`
|
|
Score float32 `json:"score"`
|
|
}
|
|
|
|
func getTagMatches(tag int64) (matches []webTagMatch, err error) {
|
|
rows, err := db.Query(searchCTE+`
|
|
SELECT sha1, IFNULL(thumbw, 0), IFNULL(thumbh, 0), score
|
|
FROM matches`, tag)
|
|
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 webTagRelated struct {
|
|
Tag string `json:"tag"`
|
|
Score float32 `json:"score"`
|
|
}
|
|
|
|
func getTagRelated(tag int64) (result map[string][]webTagRelated, err error) {
|
|
rows, err := db.Query(searchCTE+`
|
|
SELECT ts.name, t.name, st.score FROM scoredtags AS st
|
|
JOIN tag AS t ON st.tag = t.id
|
|
JOIN tag_space AS ts ON ts.id = t.space
|
|
ORDER BY st.score DESC`, tag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result = make(map[string][]webTagRelated)
|
|
for rows.Next() {
|
|
var (
|
|
space string
|
|
r webTagRelated
|
|
)
|
|
if err = rows.Scan(&space, &r.Tag, &r.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
result[space] = append(result[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(¶ms); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var result struct {
|
|
Matches []webTagMatch `json:"matches"`
|
|
Related map[string][]webTagRelated `json:"related"`
|
|
}
|
|
|
|
space, tag, _ := strings.Cut(params.Query, ":")
|
|
|
|
var tagID int64
|
|
err := db.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 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(tagID); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if result.Related, err = getTagRelated(tagID); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Println(err)
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
// cmdWeb runs a web UI against GD on ADDRESS.
|
|
func cmdWeb(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
|
|
}
|
|
|
|
address := fs.Arg(1)
|
|
|
|
// 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/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
|
|
|
|
// 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("magick", "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 {
|
|
syncPrintf(c, "%s: %s", info.fsPath, ee.Stderr)
|
|
} 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
|
|
}
|
|
|
|
// 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
|
|
)`
|
|
|
|
// cmdSync ensures the given (sub)roots are accurately reflected
|
|
// in the database.
|
|
func cmdSync(fs *flag.FlagSet, args []string) error {
|
|
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),
|
|
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()
|
|
}
|
|
|
|
// --- 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(root, suffix string, hashes []string) (bool, []string, error) {
|
|
db := hashesToFileListing(root, suffix, hashes)
|
|
fs, err := collectFileListing(root)
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
|
|
iDB, iFS, ok, intersection := 0, 0, true, []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] {
|
|
ok = false
|
|
fmt.Printf("only in DB: %s\n", db[iDB])
|
|
iDB++
|
|
} else {
|
|
ok = false
|
|
fmt.Printf("only in FS: %s\n", fs[iFS])
|
|
iFS++
|
|
}
|
|
}
|
|
for _, path := range db[iDB:] {
|
|
ok = false
|
|
fmt.Printf("only in DB: %s\n", path)
|
|
}
|
|
for _, path := range fs[iFS:] {
|
|
ok = false
|
|
fmt.Printf("only in FS: %s\n", 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")
|
|
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(
|
|
filepath.Join(galleryDirectory, nameOfImageRoot), "", allSHA1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
okThumbs, _, err := checkFiles(
|
|
filepath.Join(galleryDirectory, nameOfThumbRoot), ".webp", thumbSHA1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !okImages || !okThumbs {
|
|
ok = false
|
|
}
|
|
|
|
log.Println("checking for dead symlinks")
|
|
for _, path := range intersection {
|
|
if _, err := os.Stat(path); err != nil {
|
|
ok = false
|
|
fmt.Printf("%s: %s\n", path, err)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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("magick", "-limit", "thread", "1", 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 {
|
|
return string(ee.Stderr), 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
|
|
}
|
|
}
|
|
|
|
stmt, err := db.Prepare(`UPDATE image SET dhash = ? WHERE sha1 = ?`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
|
|
var mu sync.Mutex
|
|
return 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
|
|
})
|
|
}
|
|
|
|
// --- 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."},
|
|
"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() {
|
|
// 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()
|
|
}
|
|
|
|
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.Close(); err != nil {
|
|
log.Println(err)
|
|
}
|
|
}
|
|
|
|
if errors.Is(err, errWrongUsage) {
|
|
fs.Usage()
|
|
os.Exit(2)
|
|
} else if err != nil {
|
|
log.Fatalln(err)
|
|
}
|
|
}
|