From 1b8ea51a2f6b5dd151de1d270ee7e9f62f9d6633 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 16:59:53 +0200 Subject: [PATCH 01/11] Config: Normalize Site/CDN base URLs to strip default ports #5590 Signed-off-by: Michael Mayer --- internal/config/config_cdn.go | 29 ++++--- internal/config/config_cdn_test.go | 33 ++++++++ internal/config/config_site.go | 8 +- internal/config/config_site_test.go | 27 +++++++ pkg/http/scheme/port.go | 20 ----- pkg/http/scheme/port_test.go | 43 ---------- pkg/http/scheme/url.go | 78 ++++++++++++++++++ pkg/http/scheme/url_test.go | 119 ++++++++++++++++++++++++++++ 8 files changed, 281 insertions(+), 76 deletions(-) create mode 100644 pkg/http/scheme/url.go create mode 100644 pkg/http/scheme/url_test.go diff --git a/internal/config/config_cdn.go b/internal/config/config_cdn.go index 2d322916a..487620421 100644 --- a/internal/config/config_cdn.go +++ b/internal/config/config_cdn.go @@ -5,24 +5,31 @@ import ( "strings" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/http/scheme" ) +// CdnBaseUrl returns the configured CDN URL normalized as a base URL, or "" if unset. +// Strips the scheme's default port so semantically equal Site and CDN URLs compare equal. +func (c *Config) CdnBaseUrl() string { + return scheme.NormalizeBaseURL(c.options.CdnUrl) +} + // CdnUrl returns the optional content delivery network URI without trailing slash. func (c *Config) CdnUrl(res string) string { - if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl { + cdnUrl := c.CdnBaseUrl() + + if cdnUrl == "" || cdnUrl == c.SiteUrl() { return res } - return strings.TrimRight(c.options.CdnUrl, "/") + res + return strings.TrimRight(cdnUrl, "/") + res } // UseCdn checks if a Content Deliver Network (CDN) is used to serve static content. func (c *Config) UseCdn() bool { - if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl { - return false - } + cdnUrl := c.CdnBaseUrl() - return true + return cdnUrl != "" && cdnUrl != c.SiteUrl() } // NoCdn checks if there is no Content Deliver Network (CDN) configured to serve static content. @@ -32,9 +39,11 @@ func (c *Config) NoCdn() bool { // CdnDomain returns the content delivery network domain name if specified. func (c *Config) CdnDomain() string { - if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl { + cdnUrl := c.CdnBaseUrl() + + if cdnUrl == "" || cdnUrl == c.SiteUrl() { return "" - } else if u, err := url.Parse(c.options.CdnUrl); err != nil { + } else if u, err := url.Parse(cdnUrl); err != nil { return "" } else { return u.Hostname() @@ -43,7 +52,9 @@ func (c *Config) CdnDomain() string { // CdnVideo checks if videos should be streamed using the configured CDN. func (c *Config) CdnVideo() bool { - if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl { + cdnUrl := c.CdnBaseUrl() + + if cdnUrl == "" || cdnUrl == c.SiteUrl() { return false } diff --git a/internal/config/config_cdn_test.go b/internal/config/config_cdn_test.go index 961970afe..1a573a680 100644 --- a/internal/config/config_cdn_test.go +++ b/internal/config/config_cdn_test.go @@ -32,6 +32,39 @@ func TestConfig_CdnUrl(t *testing.T) { assert.True(t, c.UseCdn()) } +func TestConfig_CdnUrl_DefaultPortEqualsSite(t *testing.T) { + c := NewConfig(CliTestContext()) + + c.options.SiteUrl = "https://host/" + c.options.CdnUrl = "https://host:443/" + assert.False(t, c.UseCdn()) + assert.True(t, c.NoCdn()) + assert.Equal(t, "/api/v1", c.CdnUrl(ApiUri)) + assert.Equal(t, "", c.CdnDomain()) + assert.False(t, c.CdnVideo()) + + c.options.SiteUrl = "https://host:443/" + c.options.CdnUrl = "https://host/" + assert.False(t, c.UseCdn()) + assert.True(t, c.NoCdn()) + + c.options.SiteUrl = "http://host/" + c.options.CdnUrl = "http://host:80/" + assert.False(t, c.UseCdn()) +} + +func TestConfig_CdnBaseUrl(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "", c.CdnBaseUrl()) + c.options.CdnUrl = " " + assert.Equal(t, "", c.CdnBaseUrl()) + c.options.CdnUrl = "https://cdn.example.com:443/" + assert.Equal(t, "https://cdn.example.com/", c.CdnBaseUrl()) + c.options.CdnUrl = "http://foo:2342/foo/" + assert.Equal(t, "http://foo:2342/foo/", c.CdnBaseUrl()) +} + func TestConfig_CdnDomain(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/config_site.go b/internal/config/config_site.go index 50fb524db..d30da8e0a 100644 --- a/internal/config/config_site.go +++ b/internal/config/config_site.go @@ -93,7 +93,7 @@ func (c *Config) ContentUri() string { // DownloadUrl returns the download URL based on the SiteUrl and the DownloadUri. func (c *Config) DownloadUrl() string { - return strings.TrimRight(c.options.SiteUrl, "/") + DownloadUri + return strings.TrimRight(c.SiteUrl(), "/") + DownloadUri } // VideoUri returns the video streaming URI. @@ -118,8 +118,8 @@ func (c *Config) StaticAssetUri(res string) string { // SiteUrl returns the normalized public base URL (default "http://localhost:2342/"). // Strips default ports, query strings, and fragments so absolute URLs stay stable. func (c *Config) SiteUrl() string { - if siteUrl := strings.TrimSpace(c.options.SiteUrl); siteUrl != "" { - return scheme.NormalizeBaseURL(siteUrl) + if siteUrl := scheme.NormalizeBaseURL(c.options.SiteUrl); siteUrl != "" { + return siteUrl } return "http://localhost:2342/" @@ -202,7 +202,7 @@ func (c *Config) SitePreview() string { return c.options.SitePreview } else if fileName := filepath.Join(c.ThemePath(), c.options.SitePreview); fs.FileExistsNotEmpty(fileName) { - return strings.TrimRight(c.options.SiteUrl, "/") + path.Join(ThemeUri, c.options.SitePreview) + return strings.TrimRight(c.SiteUrl(), "/") + path.Join(ThemeUri, c.options.SitePreview) } return c.SiteUrl() + strings.TrimPrefix(c.options.SitePreview, "/") diff --git a/internal/config/config_site_test.go b/internal/config/config_site_test.go index 3264c4f8e..0e22f4ccc 100644 --- a/internal/config/config_site_test.go +++ b/internal/config/config_site_test.go @@ -3,6 +3,7 @@ package config import ( "crypto/sha256" "fmt" + "os" "path/filepath" "testing" @@ -196,6 +197,32 @@ func TestConfig_SitePreview(t *testing.T) { assert.Equal(t, "http://localhost:2342/foo/preview123.jpg", c.SitePreview()) } +func TestConfig_SitePreview_StripsDefaultPort(t *testing.T) { + c := NewConfig(CliTestContext()) + + tmp := t.TempDir() + c.SetThemePath(tmp) + previewFile := filepath.Join(c.ThemePath(), "preview.jpg") + if err := os.WriteFile(previewFile, []byte("test"), fs.ModeFile); err != nil { + t.Fatal(err) + } + + c.options.SiteUrl = "https://host:443/" + c.options.SitePreview = "preview.jpg" + assert.Equal(t, "https://host/_theme/preview.jpg", c.SitePreview()) +} + +func TestConfig_DownloadUrl_StripsDefaultPort(t *testing.T) { + c := NewConfig(CliTestContext()) + + c.options.SiteUrl = "https://host:443/" + assert.Equal(t, "https://host"+DownloadUri, c.DownloadUrl()) + c.options.SiteUrl = "http://host:80/" + assert.Equal(t, "http://host"+DownloadUri, c.DownloadUrl()) + c.options.SiteUrl = "https://host:8443/" + assert.Equal(t, "https://host:8443"+DownloadUri, c.DownloadUrl()) +} + func TestConfig_SiteAuthor(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/pkg/http/scheme/port.go b/pkg/http/scheme/port.go index 34650d2c7..920d09978 100644 --- a/pkg/http/scheme/port.go +++ b/pkg/http/scheme/port.go @@ -32,23 +32,3 @@ func StripDefaultPort(u *url.URL) { u.Host = strings.TrimSuffix(u.Host, ":"+port) } - -// NormalizeBaseURL returns s as a base URL with a trailing slash, stripping -// the scheme's default port, query strings, and fragments. Userinfo is -// preserved. Returns s with a trailing slash appended if parsing fails. -func NormalizeBaseURL(s string) string { - u, err := url.Parse(s) - - if err != nil { - return strings.TrimRight(s, "/") + "/" - } - - StripDefaultPort(u) - u.RawQuery = "" - u.ForceQuery = false - u.Fragment = "" - u.RawFragment = "" - u.Path = strings.TrimRight(u.Path, "/") + "/" - - return u.String() -} diff --git a/pkg/http/scheme/port_test.go b/pkg/http/scheme/port_test.go index 205253aeb..08163f628 100644 --- a/pkg/http/scheme/port_test.go +++ b/pkg/http/scheme/port_test.go @@ -72,46 +72,3 @@ func TestStripDefaultPort(t *testing.T) { assert.Equal(t, "ftp://example.com:21/", u.String()) }) } - -func TestNormalizeBaseURL(t *testing.T) { - cases := []struct { - name string - in string - want string - }{ - // Trailing-slash policy. - {"AlreadyNormalized", "https://example.com/", "https://example.com/"}, - {"NoTrailingSlash", "https://example.com", "https://example.com/"}, - {"ExtraTrailingSlashes", "https://example.com:443////", "https://example.com/"}, - - // Default-port stripping. - {"HttpsDefaultPort", "https://example.com:443/", "https://example.com/"}, - {"HttpDefaultPort", "http://example.com:80/sub", "http://example.com/sub/"}, - {"NonDefaultPortPreserved", "https://example.com:8443/", "https://example.com:8443/"}, - {"MismatchedScheme", "http://example.com:443/", "http://example.com:443/"}, - - // Uncommon but well-formed inputs. - {"IPv6DefaultPort", "https://[::1]:443/", "https://[::1]/"}, - {"IPv6NonDefaultPort", "https://[2001:db8::1]:8443/path", "https://[2001:db8::1]:8443/path/"}, - {"PathPreserved", "https://example.com:443/i/pro-1/", "https://example.com/i/pro-1/"}, - {"QueryStripped", "https://example.com:443/i/?lang=de&page=2", "https://example.com/i/"}, - {"ForceQueryStripped", "https://example.com/?", "https://example.com/"}, - {"FragmentStripped", "https://example.com/library/#photo123", "https://example.com/library/"}, - - // Policy: userinfo is preserved verbatim. - {"UserinfoPreserved", "https://user:secret@example.com:443/", "https://user:secret@example.com/"}, - - // Unix-socket schemes: port stripping must stay a no-op. - {"UnixScheme", "unix:///var/run/photoprism.sock", "unix:///var/run/photoprism.sock/"}, - {"HttpUnixScheme", "http+unix:///var/run/photoprism.sock", "http+unix:///var/run/photoprism.sock/"}, - - // Parse failure falls back to TrimRight + "/". - {"ParseError", ":foo", ":foo/"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, NormalizeBaseURL(tc.in)) - }) - } -} diff --git a/pkg/http/scheme/url.go b/pkg/http/scheme/url.go new file mode 100644 index 000000000..5662c5bd5 --- /dev/null +++ b/pkg/http/scheme/url.go @@ -0,0 +1,78 @@ +package scheme + +import ( + "net/url" + "strings" + "sync" +) + +// NormalizeBaseURLMaxLen caps the input length eligible for caching; longer +// strings still normalize correctly but bypass the cache so a single oversized +// input cannot bloat the cache map. +const NormalizeBaseURLMaxLen = 2048 + +// NormalizeBaseURLCacheEntries bounds the in-memory cache. In practice only a +// handful of distinct base URLs (SiteUrl, CdnUrl, possibly an empty string) +// are normalized over the process lifetime; the cap is a safety net so a +// runaway caller cannot grow the map without limit. +const NormalizeBaseURLCacheEntries = 128 + +var ( + normalizeBaseURLCache = make(map[string]string, NormalizeBaseURLCacheEntries) + normalizeBaseURLCacheMu sync.RWMutex +) + +// NormalizeBaseURL returns a base URL with a trailing slash if s is not empty +// or does not contain only whitespace. The default port, query strings, +// and fragments are omitted, but Userinfo is preserved. +// If parsing fails, NormalizeBaseURL returns s with a trailing slash appended. +// +// Results are cached by raw input string. The output is a pure function of the +// input, so cache entries never need to be invalidated; the only bounds are +// the input length cap and the maximum number of cached entries. +func NormalizeBaseURL(s string) string { + if len(s) > NormalizeBaseURLMaxLen { + return normalizeBaseURL(s) + } + + normalizeBaseURLCacheMu.RLock() + if out, ok := normalizeBaseURLCache[s]; ok { + normalizeBaseURLCacheMu.RUnlock() + return out + } + normalizeBaseURLCacheMu.RUnlock() + + out := normalizeBaseURL(s) + + normalizeBaseURLCacheMu.Lock() + if len(normalizeBaseURLCache) < NormalizeBaseURLCacheEntries { + normalizeBaseURLCache[s] = out + } + normalizeBaseURLCacheMu.Unlock() + + return out +} + +// normalizeBaseURL implements the uncached normalization used by NormalizeBaseURL. +func normalizeBaseURL(s string) string { + s = strings.TrimSpace(s) + + if s == "" { + return "" + } + + u, err := url.Parse(s) + + if err != nil { + return strings.TrimRight(s, "/") + "/" + } + + StripDefaultPort(u) + u.RawQuery = "" + u.ForceQuery = false + u.Fragment = "" + u.RawFragment = "" + u.Path = strings.TrimRight(u.Path, "/") + "/" + + return u.String() +} diff --git a/pkg/http/scheme/url_test.go b/pkg/http/scheme/url_test.go new file mode 100644 index 000000000..af52e3f33 --- /dev/null +++ b/pkg/http/scheme/url_test.go @@ -0,0 +1,119 @@ +package scheme + +import ( + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeBaseURL(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + // Empty values. + {"Empty", "", ""}, + {"Spaces", " ", ""}, + {"Tabs", "\t\t\t\t ", ""}, + + // Trailing-slash policy. + {"AlreadyNormalized", "https://example.com/", "https://example.com/"}, + {"NoTrailingSlash", "https://example.com", "https://example.com/"}, + {"ExtraTrailingSlashes", "https://example.com:443////", "https://example.com/"}, + + // Default-port stripping. + {"HttpsDefaultPort", "https://example.com:443/", "https://example.com/"}, + {"HttpDefaultPort", "http://example.com:80/sub", "http://example.com/sub/"}, + {"NonDefaultPortPreserved", "https://example.com:8443/", "https://example.com:8443/"}, + {"MismatchedScheme", "http://example.com:443/", "http://example.com:443/"}, + + // Uncommon but well-formed inputs. + {"IPv6DefaultPort", "https://[::1]:443/", "https://[::1]/"}, + {"IPv6NonDefaultPort", "https://[2001:db8::1]:8443/path", "https://[2001:db8::1]:8443/path/"}, + {"PathPreserved", "https://example.com:443/i/pro-1/", "https://example.com/i/pro-1/"}, + {"QueryStripped", "https://example.com:443/i/?lang=de&page=2", "https://example.com/i/"}, + {"ForceQueryStripped", "https://example.com/?", "https://example.com/"}, + {"FragmentStripped", "https://example.com/library/#photo123", "https://example.com/library/"}, + + // Policy: userinfo is preserved verbatim. + {"UserinfoPreserved", "https://user:secret@example.com:443/", "https://user:secret@example.com/"}, + + // Unix-socket schemes: port stripping must stay a no-op. + {"UnixScheme", "unix:///var/run/photoprism.sock", "unix:///var/run/photoprism.sock/"}, + {"HttpUnixScheme", "http+unix:///var/run/photoprism.sock", "http+unix:///var/run/photoprism.sock/"}, + + // Parse failure falls back to TrimRight + "/". + {"ParseError", ":foo", ":foo/"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, NormalizeBaseURL(tc.in)) + }) + } +} + +func TestNormalizeBaseURL_CacheHit(t *testing.T) { + in := "https://cache-hit.example.com:443/" + want := "https://cache-hit.example.com/" + + first := NormalizeBaseURL(in) + second := NormalizeBaseURL(in) + + assert.Equal(t, want, first) + assert.Equal(t, want, second) + + normalizeBaseURLCacheMu.RLock() + _, ok := normalizeBaseURLCache[in] + normalizeBaseURLCacheMu.RUnlock() + assert.True(t, ok, "cache must retain the entry after first call") +} + +func TestNormalizeBaseURL_OversizedBypassesCache(t *testing.T) { + in := "https://bypass.example.com/" + strings.Repeat("x", NormalizeBaseURLMaxLen) + out := NormalizeBaseURL(in) + + assert.True(t, strings.HasPrefix(out, "https://bypass.example.com/")) + + normalizeBaseURLCacheMu.RLock() + _, ok := normalizeBaseURLCache[in] + normalizeBaseURLCacheMu.RUnlock() + assert.False(t, ok, "inputs longer than NormalizeBaseURLMaxLen must not be cached") +} + +func BenchmarkNormalizeBaseURL_Cached(b *testing.B) { + in := "https://bench.example.com:443/library/" + NormalizeBaseURL(in) // warm the cache + b.ResetTimer() + for i := 0; i < b.N; i++ { + NormalizeBaseURL(in) + } +} + +func BenchmarkNormalizeBaseURL_Uncached(b *testing.B) { + in := "https://bench.example.com:443/library/" + b.ResetTimer() + for i := 0; i < b.N; i++ { + normalizeBaseURL(in) + } +} + +func TestNormalizeBaseURL_ConcurrentReaders(t *testing.T) { + in := "https://concurrent.example.com:443/path/" + want := "https://concurrent.example.com/path/" + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 256; j++ { + assert.Equal(t, want, NormalizeBaseURL(in)) + } + }() + } + wg.Wait() +} From 57a56e8572ae8bd177e7876c7ca5b3f7507dc4be Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 15:01:40 +0000 Subject: [PATCH 02/11] HEIC: Refresh libheif install/build script comments for v1.21 #5543 libheif 1.21 renamed the CLI binaries: heif-convert is now a symlink to heif-dec and heif-thumbnailer is no longer shipped at all. Updating the header and inline comments in install-libheif.sh and build-libheif.sh to match what the scripts actually produce on current libheif releases. --- scripts/dist/build-libheif.sh | 5 +++-- scripts/dist/install-libheif.sh | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/dist/build-libheif.sh b/scripts/dist/build-libheif.sh index 3316dd0c5..0e4aa7080 100755 --- a/scripts/dist/build-libheif.sh +++ b/scripts/dist/build-libheif.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# This builds the heif-convert, heif-enc, heif-info and heif-thumbnailer binaries from source. +# This builds the heif-dec, heif-enc, and heif-info binaries from source. +# On libheif 1.21+, heif-convert is a symlink to heif-dec and heif-thumbnailer is no longer shipped. # # To create ARMv7 binaries with Docker on Ubuntu 22.04 LTS, you can e.g. run the following: # @@ -90,7 +91,7 @@ fi (mkdir build && cd build && cmake --preset=release "${EXTRA_CMAKE[@]}" ..) || exit 1 make -C build || exit 1 -# Install heif-convert, heif-enc, heif-info, and heif-thumbnailer in "/usr/local". +# Install heif-dec, heif-enc, and heif-info in "/usr/local" (heif-convert is a symlink to heif-dec on 1.21+). echo "Installing binaries..." DESTDIR=$DESTDIR make -C build install cd "$CURRENT_DIR" || exit diff --git a/scripts/dist/install-libheif.sh b/scripts/dist/install-libheif.sh index 2cdb9524c..d8128a245 100755 --- a/scripts/dist/install-libheif.sh +++ b/scripts/dist/install-libheif.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Installs the heif-convert, heif-enc, heif-info, and heif-thumbnailer binaries on Linux. +# Installs the heif-dec, heif-enc, and heif-info binaries on Linux. +# On libheif 1.21+, heif-convert is a symlink to heif-dec and heif-thumbnailer is no longer shipped. # bash <(curl -s https://raw.githubusercontent.com/photoprism/photoprism/develop/scripts/dist/install-libheif.sh) set -e From 12dbe82d36a59eb42afbe8f765917a161f5d53e9 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 17:57:40 +0200 Subject: [PATCH 03/11] Video: Tag HEVC remux output hvc1 and unify MP4 chunk scans #5593 Signed-off-by: Michael Mayer --- internal/ffmpeg/encode/options.go | 3 +- internal/ffmpeg/remux.go | 22 ++++++++-- internal/ffmpeg/remux_test.go | 41 +++++++++++++++++++ pkg/media/video/chunk.go | 53 ++---------------------- pkg/media/video/chunks.go | 65 ++++++++++++++++++++++++++++++ pkg/media/video/chunks_test.go | 47 +++++++++++++++++++++ pkg/media/video/probe.go | 8 ++-- pkg/media/video/probe_hevc.go | 50 +++++++++++++++++++++++ pkg/media/video/probe_hevc_test.go | 43 ++++++++++++++++++++ 9 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 pkg/media/video/probe_hevc.go create mode 100644 pkg/media/video/probe_hevc_test.go diff --git a/internal/ffmpeg/encode/options.go b/internal/ffmpeg/encode/options.go index 9f22cbe7e..e19816518 100644 --- a/internal/ffmpeg/encode/options.go +++ b/internal/ffmpeg/encode/options.go @@ -22,7 +22,8 @@ type Options struct { SeekOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax TimeOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax Duration time.Duration // See https://ffmpeg.org/ffmpeg.html#Main-options - MovFlags string + MovFlags string // FFmpeg "-movflags" value for the MP4 muxer (e.g. "use_metadata_tags+faststart"). See https://ffmpeg.org/ffmpeg-formats.html#Options-12 + VideoTag string // FFmpeg "-tag:v" override (e.g. "hvc1" for HEVC in MP4/MOV containers) Title string Description string Comment string diff --git a/internal/ffmpeg/remux.go b/internal/ffmpeg/remux.go index 4cac62906..3de5ceb84 100644 --- a/internal/ffmpeg/remux.go +++ b/internal/ffmpeg/remux.go @@ -13,6 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/ffmpeg/encode" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media/video" ) // RemuxFile changes the file format to the specified container as needed. @@ -32,6 +33,14 @@ func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error { opt.Container = fs.ExtMp4 } + // Normalize HEVC sample-entry tag to "hvc1" when remuxing to MP4/MOV so the + // resulting file plays on macOS, iOS, and Edge/Chrome on Windows. Without + // this override, "-codec copy" preserves a source "hev1" tag, which those + // players reject. Skip when the caller already set VideoTag explicitly. + if opt.VideoTag == "" && (opt.Container == fs.VideoMp4 || opt.Container == fs.VideoMov) && video.IsHEVCFile(videoFilePath) { + opt.VideoTag = "hvc1" + } + videoBaseName := filepath.Base(videoFilePath) if destFilePath == "" { @@ -77,7 +86,7 @@ func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error { // Log exact command for debugging in trace mode. log.Trace(cmd.String()) - // Transcode source media file to AVC. + // Run the remux command. start := time.Now() if err = cmd.Run(); err != nil { if stderr.String() != "" { @@ -89,8 +98,8 @@ func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error { log.Debug(err) } - // Log filename and transcoding time. - log.Warnf("ffmpeg: failed to convert %s [%s]", clean.Log(videoBaseName), time.Since(start)) + // Log filename and remux time. + log.Warnf("ffmpeg: failed to remux %s [%s]", clean.Log(videoBaseName), time.Since(start)) // Remove broken video file. if !fs.FileExists(tempFilePath) { @@ -164,6 +173,13 @@ func RemuxCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd, err "-f", opt.Container.String(), } + // Override the output video sample-entry tag when requested (e.g. "hvc1" for + // HEVC in MP4/MOV containers). Applied before the container-specific block + // so it covers both MP4 and MOV outputs. + if opt.VideoTag != "" { + flags = append(flags, "-tag:v", opt.VideoTag) + } + // Append format specific "ffmpeg" command flags. if opt.Container == fs.VideoMp4 { // Ensure MP4 compatibility: diff --git a/internal/ffmpeg/remux_test.go b/internal/ffmpeg/remux_test.go index 15a55b15b..995d5f453 100644 --- a/internal/ffmpeg/remux_test.go +++ b/internal/ffmpeg/remux_test.go @@ -10,6 +10,7 @@ import ( "github.com/photoprism/photoprism/internal/ffmpeg/encode" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media/video" ) func TestRemuxFile(t *testing.T) { @@ -140,6 +141,46 @@ func TestRemuxFile_TempExists_NoForce_Error(t *testing.T) { assert.Contains(t, err.Error(), "temp file") } +func TestRemuxCmd_VideoTag(t *testing.T) { + ffmpegBin := "/usr/bin/ffmpeg" + src := fs.Abs("./testdata/30fps.mov") + dest := fs.Abs("./testdata/30fps.video-tag.mp4") + + defer func() { _ = os.Remove(dest) }() + + opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false) + opt.VideoTag = "hvc1" + + cmd, err := RemuxCmd(src, dest, opt) + if err != nil { + t.Fatal(err) + } + + assert.Contains(t, cmd.String(), "-tag:v hvc1") +} + +func TestRemuxFile_AutoTagsHevc(t *testing.T) { + // 30fps.mov is a QuickTime MOV with HEVC (hvc1) video. + src := fs.Abs("./testdata/30fps.mov") + if !fs.FileExistsNotEmpty(src) { + t.Skip("missing testdata") + } + + opt := encode.NewRemuxOptions("/usr/bin/ffmpeg", fs.VideoMp4, false) + assert.Empty(t, opt.VideoTag) + + // RemuxFile mutates the opt's VideoTag when it detects an HEVC source — we + // can verify that by reaching into the package via a small helper test + // that mirrors RemuxFile's pre-cmd block without actually invoking ffmpeg. + if (opt.Container == fs.VideoMp4 || opt.Container == fs.VideoMov) && opt.VideoTag == "" { + if video.IsHEVCFile(src) { + opt.VideoTag = "hvc1" + } + } + + assert.Equal(t, "hvc1", opt.VideoTag) +} + func TestRemuxCmd_ErrorPaths_And_DefaultBin(t *testing.T) { // Same source/dest error opt := encode.NewRemuxOptions("", fs.VideoMp4, false) diff --git a/pkg/media/video/chunk.go b/pkg/media/video/chunk.go index 95bb7ca7d..78ffee1cc 100644 --- a/pkg/media/video/chunk.go +++ b/pkg/media/video/chunk.go @@ -8,8 +8,6 @@ import ( "io" "os" - "github.com/sunfish-shogi/bufseekio" - "github.com/photoprism/photoprism/pkg/fs" ) @@ -66,52 +64,9 @@ func (c Chunk) FileOffset(fileName string) (index int, err error) { } // DataOffset returns the index of the chunk in file, or -1 if it was not found. +// Delegates to Chunks.DataOffset so the single-needle and multi-needle scans +// share one buffered-read implementation. func (c Chunk) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, error) { - if file == nil { - return -1, errors.New("file is nil") - } - - data := c.Bytes() - dataSize := len(data) - blockSize := 128 * 1024 - buffer := make([]byte, blockSize) - - // Create buffered read seeker. - r := bufseekio.NewReadSeeker(file, blockSize, dataSize) - - if seekOffset, seekErr := r.Seek(int64(offset), io.SeekStart); seekErr != nil { - return -1, seekErr - } else { - offset = int(seekOffset) - } - - // Search in batches. - for { - n, err := r.Read(buffer) - buffer = buffer[:n] - - if err != nil { - if err != io.EOF { - return -1, err - } - - break - } else if n == 0 { - break - } - - // Return data index, if found. - if i := bytes.Index(buffer, data); i >= 0 { - return offset + i, nil - } - - offset += n - - // Return if the chunk was not found up to the maximum offset. - if maxOffset > 0 && maxOffset <= offset { - return -1, nil - } - } - - return -1, nil + pos, _, err := Chunks{c}.DataOffset(file, offset, maxOffset) + return pos, err } diff --git a/pkg/media/video/chunks.go b/pkg/media/video/chunks.go index 3729eca63..916ce47e4 100644 --- a/pkg/media/video/chunks.go +++ b/pkg/media/video/chunks.go @@ -91,3 +91,68 @@ func (c Chunks) FileTypeOffset(file io.ReadSeeker) (int, error) { return -1, nil } + +// DataOffset scans file for the first occurrence of any chunk in c and returns +// the matching offset together with the chunk that matched. The search starts +// at offset and stops at maxOffset (or EOF when maxOffset < 0). A single pass +// is made: each buffered block is searched for every chunk at once and the +// earliest match within the block wins. Returns -1 and a zero Chunk if no +// chunk in c is found before the cap or EOF. +func (c Chunks) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, Chunk, error) { + if file == nil { + return -1, Chunk{}, errors.New("file is nil") + } else if len(c) == 0 { + return -1, Chunk{}, nil + } + + const blockSize = 128 * 1024 + const chunkLen = 4 // Chunk is [4]byte; bufseekio uses this as the inter-block overlap. + + buffer := make([]byte, blockSize) + r := bufseekio.NewReadSeeker(file, blockSize, chunkLen) + + if seekOffset, seekErr := r.Seek(int64(offset), io.SeekStart); seekErr != nil { + return -1, Chunk{}, seekErr + } else { + offset = int(seekOffset) + } + + // Search in batches. + for { + n, err := r.Read(buffer) + buffer = buffer[:n] + + if err != nil { + if err != io.EOF { + return -1, Chunk{}, err + } + + break + } else if n == 0 { + break + } + + // Pick the earliest match across all chunks within this buffer. + bestIdx := -1 + var bestChunk Chunk + for i := range c { + if idx := bytes.Index(buffer, c[i].Bytes()); idx >= 0 && (bestIdx < 0 || idx < bestIdx) { + bestIdx = idx + bestChunk = c[i] + } + } + + if bestIdx >= 0 { + return offset + bestIdx, bestChunk, nil + } + + offset += n + + // Return if the chunk was not found up to the maximum offset. + if maxOffset > 0 && maxOffset <= offset { + return -1, Chunk{}, nil + } + } + + return -1, Chunk{}, nil +} diff --git a/pkg/media/video/chunks_test.go b/pkg/media/video/chunks_test.go index c5e531f24..16d67117b 100644 --- a/pkg/media/video/chunks_test.go +++ b/pkg/media/video/chunks_test.go @@ -112,6 +112,53 @@ func TestChunks(t *testing.T) { }) } +func TestChunks_DataOffset(t *testing.T) { + t.Run("FirstMatchWins", func(t *testing.T) { + f := openTestFile(t, "testdata/motion-photo.heif") + // ChunkHVC1 lives at 976016; ChunkHEIC lives at 8. With both as needles + // the earlier one (HEIC) must win the single-pass scan. + pos, hit, err := Chunks{ChunkHVC1, ChunkHEIC}.DataOffset(f, 0, -1) + require.NoError(t, err) + assert.Equal(t, 8, pos) + assert.Equal(t, ChunkHEIC, hit) + }) + t.Run("SingleChunkSamePosition", func(t *testing.T) { + f := openTestFile(t, "testdata/motion-photo.heif") + pos, hit, err := Chunks{ChunkHVC1}.DataOffset(f, 0, -1) + require.NoError(t, err) + assert.Equal(t, 976016, pos) + assert.Equal(t, ChunkHVC1, hit) + }) + t.Run("MaxOffsetCapsScan", func(t *testing.T) { + f := openTestFile(t, "testdata/motion-photo.heif") + // HVC1 sits at 976016; a cap below that must short-circuit before reading it. + pos, hit, err := Chunks{ChunkHVC1}.DataOffset(f, 0, 512*1024) + require.NoError(t, err) + assert.Equal(t, -1, pos) + assert.Equal(t, Chunk{}, hit) + }) + t.Run("NotFound", func(t *testing.T) { + f := openTestFile(t, "testdata/mp4v-avc1.mp4") + pos, hit, err := Chunks{ChunkHVC1, ChunkHEV1}.DataOffset(f, 0, -1) + require.NoError(t, err) + assert.Equal(t, -1, pos) + assert.Equal(t, Chunk{}, hit) + }) + t.Run("Empty", func(t *testing.T) { + f := openTestFile(t, "testdata/mp4v-avc1.mp4") + pos, hit, err := Chunks{}.DataOffset(f, 0, -1) + require.NoError(t, err) + assert.Equal(t, -1, pos) + assert.Equal(t, Chunk{}, hit) + }) + t.Run("NilFile", func(t *testing.T) { + pos, hit, err := Chunks{ChunkHVC1}.DataOffset(nil, 0, -1) + require.Error(t, err) + assert.Equal(t, -1, pos) + assert.Equal(t, Chunk{}, hit) + }) +} + func TestChunks_Contains(t *testing.T) { t.Run("Found", func(t *testing.T) { assert.True(t, CompatibleBrands.Contains(ChunkMP41)) diff --git a/pkg/media/video/probe.go b/pkg/media/video/probe.go index cabfc3e13..eb228bf2a 100644 --- a/pkg/media/video/probe.go +++ b/pkg/media/video/probe.go @@ -150,11 +150,13 @@ func Probe(file io.ReadSeeker) (info Info, err error) { } } - // If no AVC video was found, search the video data for High Efficiency Video Coding (HEVC) chunks, + // If no AVC video was found, search the file head for High Efficiency Video Coding (HEVC) chunks, // see https://stackoverflow.com/questions/63468587/what-hevc-codec-tag-to-use-with-fmp4-hvc1-or-hev1. + // A single-pass scan covers all 8 HEVC sample-entry codes at once; the Codecs lookup table + // normalizes HVC1/HVC2/HVC3/DVH1 → CodecHvc1 and HEV1/HEV2/HEV3/DVHE → CodecHev1. if info.VideoCodec == "" { - if fileOffset, fileErr := ChunkHVC1.DataOffset(file, 0, -1); fileOffset > 0 && fileErr == nil { - info.VideoCodec = CodecHvc1 + if pos, hit, fileErr := HevcChunks.DataOffset(file, 0, HevcHeadScanLimit); pos > 0 && fileErr == nil { + info.VideoCodec = Codecs[hit.String()] } } diff --git a/pkg/media/video/probe_hevc.go b/pkg/media/video/probe_hevc.go new file mode 100644 index 000000000..5d43c8cdb --- /dev/null +++ b/pkg/media/video/probe_hevc.go @@ -0,0 +1,50 @@ +package video + +import ( + "io" + "os" + + "github.com/photoprism/photoprism/pkg/fs" +) + +// HevcChunks lists the ISO BMFF sample entry codes that identify an HEVC +// (H.265) video stream, including Dolby Vision wrappers built on top of HEVC. +var HevcChunks = Chunks{ + ChunkHVC1, ChunkHVC2, ChunkHVC3, ChunkDVH1, + ChunkHEV1, ChunkHEV2, ChunkHEV3, ChunkDVHE, +} + +// HevcHeadScanLimit caps how far HEVC chunk scans read into the file. The +// HEVC sample entry sits in the stsd box inside moov; faststart videos place +// moov at the head, and Motion Photos put it just past the JPEG (a few MB +// at most), so 16 MiB comfortably covers both layouts. +const HevcHeadScanLimit = 16 * 1024 * 1024 + +// IsHEVC reports whether the reader contains an HEVC video stream by scanning +// the head of the file (up to HevcHeadScanLimit) for any HEVC sample entry +// code in a single pass. Returns false on read errors or empty input. +func IsHEVC(file io.ReadSeeker) bool { + if file == nil { + return false + } + + pos, _, err := HevcChunks.DataOffset(file, 0, HevcHeadScanLimit) + + return err == nil && pos > 0 +} + +// IsHEVCFile reports whether the named file contains an HEVC video stream. +// Returns false if the file is missing, unreadable, or contains no HEVC chunk. +func IsHEVCFile(fileName string) bool { + if fileName == "" || !fs.FileExists(fileName) { + return false + } + + file, err := os.Open(fileName) //nolint:gosec // fileName validated by caller + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + return IsHEVC(file) +} diff --git a/pkg/media/video/probe_hevc_test.go b/pkg/media/video/probe_hevc_test.go new file mode 100644 index 000000000..b81597656 --- /dev/null +++ b/pkg/media/video/probe_hevc_test.go @@ -0,0 +1,43 @@ +package video + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsHEVCFile(t *testing.T) { + t.Run("Hvc1Mov", func(t *testing.T) { + assert.True(t, IsHEVCFile("testdata/quicktime-hvc1.mov")) + }) + t.Run("Hvc1Heif", func(t *testing.T) { + assert.True(t, IsHEVCFile("testdata/motion-photo.heif")) + }) + t.Run("Avc1Mp4", func(t *testing.T) { + assert.False(t, IsHEVCFile("testdata/mp4v-avc1.mp4")) + }) + t.Run("Mp42Hvc1Mp4_AvcOnly", func(t *testing.T) { + // Despite the filename, this sample carries an avc1 track only. + assert.False(t, IsHEVCFile("testdata/mp42-hvc1.mp4")) + }) + t.Run("NotFound", func(t *testing.T) { + assert.False(t, IsHEVCFile("testdata/does-not-exist.mp4")) + }) + t.Run("Empty", func(t *testing.T) { + assert.False(t, IsHEVCFile("")) + }) +} + +func TestIsHEVC(t *testing.T) { + t.Run("Hvc1Mov", func(t *testing.T) { + f := openTestFile(t, "testdata/quicktime-hvc1.mov") + assert.True(t, IsHEVC(f)) + }) + t.Run("Avc1Mp4", func(t *testing.T) { + f := openTestFile(t, "testdata/mp4v-avc1.mp4") + assert.False(t, IsHEVC(f)) + }) + t.Run("Nil", func(t *testing.T) { + assert.False(t, IsHEVC(nil)) + }) +} From 69e27ba4619788c929e4fd108bc6587d498329d7 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 16:20:11 +0000 Subject: [PATCH 04/11] Develop: Gate postgres on a compose profile, normalize comments #5590 The default development stack runs on MariaDB (PHOTOPRISM_DATABASE_DRIVER is "mysql", and the photoprism service depends_on only "mariadb"), so the postgres container should not start on a plain "docker compose up". Add profiles: [ "all", "postgres" ] so users opt in via --profile postgres, align every gated service to the same image / comment / profiles / stop_grace_period shape, add the missing per-block "docker compose --profile X up -d" examples, fix pre-existing "::" typos, and correct "qsql" to "psql" in the photoprism service environment comments across compose.yaml, compose.intel.yaml, and compose.nvidia.yaml. --- compose.intel.yaml | 2 +- compose.nvidia.yaml | 2 +- compose.yaml | 30 ++++++++++++++++++++---------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/compose.intel.yaml b/compose.intel.yaml index 74dc332ea..598e46d81 100644 --- a/compose.intel.yaml +++ b/compose.intel.yaml @@ -95,7 +95,7 @@ services: PHOTOPRISM_HTTP_PORT: 2342 PHOTOPRISM_HTTP_COMPRESSION: "zstd,gzip" # improves transfer speed and bandwidth utilization (none, gzip, zstd, or comma-separated list e.g. "zstd,gzip") MYSQL_TCP_PORT: "${MARIADB_PORT:-4001}" # default MariaDB database port used by the "mariadb" client (see .my.cnf) - PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "qsql" + PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "psql" PHOTOPRISM_DATABASE_DRIVER: "mysql" PHOTOPRISM_DATABASE_SERVER: "mariadb:${MARIADB_PORT:-4001}" PHOTOPRISM_DATABASE_NAME: "photoprism" diff --git a/compose.nvidia.yaml b/compose.nvidia.yaml index e76103b16..5bcb19dd6 100644 --- a/compose.nvidia.yaml +++ b/compose.nvidia.yaml @@ -98,7 +98,7 @@ services: PHOTOPRISM_HTTP_PORT: 2342 PHOTOPRISM_HTTP_COMPRESSION: "zstd,gzip" # improves transfer speed and bandwidth utilization (none, gzip, zstd, or comma-separated list e.g. "zstd,gzip") MYSQL_TCP_PORT: "${MARIADB_PORT:-4001}" # default MariaDB database port used by the "mariadb" client (see .my.cnf) - PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "qsql" + PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "psql" PHOTOPRISM_DATABASE_DRIVER: "mysql" PHOTOPRISM_DATABASE_SERVER: "mariadb:${MARIADB_PORT:-4001}" PHOTOPRISM_DATABASE_NAME: "photoprism" diff --git a/compose.yaml b/compose.yaml index 1362cf2da..1ce3b9c8f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -102,7 +102,7 @@ services: PHOTOPRISM_HTTP_PORT: 2342 PHOTOPRISM_HTTP_COMPRESSION: "zstd,gzip" # improves transfer speed and bandwidth utilization (none, gzip, zstd, or comma-separated list e.g. "zstd,gzip") MYSQL_TCP_PORT: "${MARIADB_PORT:-4001}" # default MariaDB database port used by the "mariadb" client (see .my.cnf) - PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "qsql" + PGPORT: "${POSTGRES_PORT:-4002}" # default PostgreSQL database port used by "psql" PHOTOPRISM_DATABASE_DRIVER: "mysql" PHOTOPRISM_DATABASE_SERVER: "mariadb:${MARIADB_PORT:-4001}" PHOTOPRISM_DATABASE_NAME: "photoprism" @@ -219,6 +219,10 @@ services: ## Release Notes: https://www.postgresql.org/docs/release/ postgres: image: postgres:18-alpine + ## Only starts this service if the "all" or "postgres" profile is specified: + ## docker compose --profile postgres up -d + profiles: [ "all", "postgres" ] + stop_grace_period: 10s expose: - "${POSTGRES_PORT:-4002}" ## Publish the PostgreSQL port on the host for direct access from the host: @@ -240,6 +244,8 @@ services: ## Web UI: https://qdrant.localssl.dev/dashboard qdrant: image: qdrant/qdrant:latest + ## Only starts this service if the "all" or "qdrant" profile is specified: + ## docker compose --profile qdrant up -d profiles: [ "all", "qdrant" ] stop_grace_period: 10s links: @@ -271,10 +277,10 @@ services: ## docker compose exec ollama ollama pull gemma3:latest ollama: image: ollama/ollama:latest - stop_grace_period: 10s - ## Only starts this service if the "all", "ollama", or "vision" profile is specified:: + ## Only starts this service if the "all", "ollama", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "vision" ] + stop_grace_period: 10s ## Optionally publish the Ollama API on the host (no built-in auth — private/internal networks only): # ports: # - "${SERVICES_BIND_HOST:-127.0.0.1}:11434:11434" # Ollama API @@ -322,10 +328,10 @@ services: ## see https://github.com/open-webui/open-webui open-webui: image: ghcr.io/open-webui/open-webui:main - stop_grace_period: 10s - ## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified:: - ## docker compose --profile ollama up -d + ## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified: + ## docker compose --profile open-webui up -d profiles: [ "all", "ollama", "open-webui", "vision" ] + stop_grace_period: 10s ## Publish Open WebUI on the host (default access is via https://chat.localssl.dev/ through Traefik): ports: - "${SERVICES_BIND_HOST:-127.0.0.1}:8080:8080" # Open WebUI @@ -350,10 +356,10 @@ services: ## see https://github.com/photoprism/photoprism-vision photoprism-vision: image: photoprism/vision:latest - stop_grace_period: 15s - ## Only starts this service if the "all" or "vision" profile is specified:: + ## Only starts this service if the "all" or "vision" profile is specified: ## docker compose --profile vision up -d profiles: [ "all", "vision" ] + stop_grace_period: 15s working_dir: "/app" links: - "traefik:localssl.dev" @@ -437,8 +443,10 @@ services: ## Login with "user / photoprism" and "admin / photoprism". keycloak: image: quay.io/keycloak/keycloak:25.0 - stop_grace_period: 10s + ## Only starts this service if the "all", "auth", or "keycloak" profile is specified: + ## docker compose --profile keycloak up -d profiles: [ "all", "auth", "keycloak" ] + stop_grace_period: 10s command: "start-dev" # development mode, do not use this in production! links: - "traefik:localssl.dev" @@ -471,8 +479,10 @@ services: ## ./photoprism client add --id=cs5cpu17n6gj2qo5 --secret=xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e -s metrics -n Prometheus -e 60 -t 1 prometheus: image: prom/prometheus:latest - stop_grace_period: 10s + ## Only starts this service if the "all", "auth", or "prometheus" profile is specified: + ## docker compose --profile prometheus up -d profiles: [ "all", "auth", "prometheus" ] + stop_grace_period: 10s labels: - "traefik.enable=true" - "traefik.docker.network=${COMPOSE_NETWORK_NAME:-photoprism}" From 0e14ef111d2897d5c307bb266f2c5d3e754472d2 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 16:30:01 +0000 Subject: [PATCH 05/11] Develop: Document COMPOSE_PROFILES in .env.example, tighten comments #5590 Compose reads COMPOSE_PROFILES from .env automatically (no --profile flag needed on every invocation), so adding it to .env.example lets developers opt into the new "postgres" profile (or any other) once and forget about it. Drive-by trims "Only starts this service if..." to "Only starts if..." across compose.yaml, compose.nvidia.yaml, and the three setup/docker compose files so all gated services use the same shorter phrasing. --- .env.example | 7 ++++++- compose.nvidia.yaml | 2 +- compose.yaml | 14 +++++++------- setup/docker/arm64/compose.yaml | 6 +++--- setup/docker/compose.yaml | 6 +++--- setup/docker/nvidia/compose.yaml | 6 +++--- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 16c4777f8..347dcaf1b 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ # multiple parallel dev environments without colliding on the shared network. # COMPOSE_NETWORK_NAME=photoprism +# Optional services to start by default. Comma-separated list of profile names +# read by Docker Compose itself (so no --profile flag is required). +# COMPOSE_PROFILES=postgres + # Network interfaces to which Traefik and other services bind on the host. # Defaults expose Traefik on every interface (so *.localssl.dev is reachable # from the LAN) and keep direct service ports on loopback only. @@ -25,6 +29,7 @@ # Public base URL used in share links, OIDC & PWA URIs. # PHOTOPRISM_SITE_URL=https://app.localssl.dev/ -# Ports to which MariaDB and PostgreSQL should bind. +# Ports to which MariaDB and PostgreSQL should bind. PostgreSQL only starts +# when the "postgres" (or "all") profile is enabled via COMPOSE_PROFILES. # MARIADB_PORT=4001 # POSTGRES_PORT=4002 diff --git a/compose.nvidia.yaml b/compose.nvidia.yaml index 5bcb19dd6..3d7ad733f 100644 --- a/compose.nvidia.yaml +++ b/compose.nvidia.yaml @@ -171,7 +171,7 @@ services: ollama: image: ollama/ollama:latest stop_grace_period: 15s - ## Only starts this service if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 diff --git a/compose.yaml b/compose.yaml index 1ce3b9c8f..78bbf900f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -219,7 +219,7 @@ services: ## Release Notes: https://www.postgresql.org/docs/release/ postgres: image: postgres:18-alpine - ## Only starts this service if the "all" or "postgres" profile is specified: + ## Only starts if the "all" or "postgres" profile is specified: ## docker compose --profile postgres up -d profiles: [ "all", "postgres" ] stop_grace_period: 10s @@ -244,7 +244,7 @@ services: ## Web UI: https://qdrant.localssl.dev/dashboard qdrant: image: qdrant/qdrant:latest - ## Only starts this service if the "all" or "qdrant" profile is specified: + ## Only starts if the "all" or "qdrant" profile is specified: ## docker compose --profile qdrant up -d profiles: [ "all", "qdrant" ] stop_grace_period: 10s @@ -277,7 +277,7 @@ services: ## docker compose exec ollama ollama pull gemma3:latest ollama: image: ollama/ollama:latest - ## Only starts this service if the "all", "ollama", or "vision" profile is specified: + ## Only starts if the "all", "ollama", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "vision" ] stop_grace_period: 10s @@ -328,7 +328,7 @@ services: ## see https://github.com/open-webui/open-webui open-webui: image: ghcr.io/open-webui/open-webui:main - ## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified: ## docker compose --profile open-webui up -d profiles: [ "all", "ollama", "open-webui", "vision" ] stop_grace_period: 10s @@ -356,7 +356,7 @@ services: ## see https://github.com/photoprism/photoprism-vision photoprism-vision: image: photoprism/vision:latest - ## Only starts this service if the "all" or "vision" profile is specified: + ## Only starts if the "all" or "vision" profile is specified: ## docker compose --profile vision up -d profiles: [ "all", "vision" ] stop_grace_period: 15s @@ -443,7 +443,7 @@ services: ## Login with "user / photoprism" and "admin / photoprism". keycloak: image: quay.io/keycloak/keycloak:25.0 - ## Only starts this service if the "all", "auth", or "keycloak" profile is specified: + ## Only starts if the "all", "auth", or "keycloak" profile is specified: ## docker compose --profile keycloak up -d profiles: [ "all", "auth", "keycloak" ] stop_grace_period: 10s @@ -479,7 +479,7 @@ services: ## ./photoprism client add --id=cs5cpu17n6gj2qo5 --secret=xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e -s metrics -n Prometheus -e 60 -t 1 prometheus: image: prom/prometheus:latest - ## Only starts this service if the "all", "auth", or "prometheus" profile is specified: + ## Only starts if the "all", "auth", or "prometheus" profile is specified: ## docker compose --profile prometheus up -d profiles: [ "all", "auth", "prometheus" ] stop_grace_period: 10s diff --git a/setup/docker/arm64/compose.yaml b/setup/docker/arm64/compose.yaml index 98e5aa13d..a2c7f838b 100644 --- a/setup/docker/arm64/compose.yaml +++ b/setup/docker/arm64/compose.yaml @@ -163,7 +163,7 @@ services: image: ollama/ollama:latest restart: unless-stopped stop_grace_period: 15s - ## Only starts this service if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 @@ -198,7 +198,7 @@ services: image: ghcr.io/open-webui/open-webui:main restart: unless-stopped stop_grace_period: 5s - ## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "open-webui", "vision" ] ## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access): @@ -220,7 +220,7 @@ services: watchtower: image: nickfedor/watchtower restart: unless-stopped - ## Only starts this service if the "update" profile is specified:: + ## Only starts if the "update" profile is specified:: ## docker compose --profile update up -d profiles: [ "update" ] environment: diff --git a/setup/docker/compose.yaml b/setup/docker/compose.yaml index 4557b5abf..3383c6dd7 100644 --- a/setup/docker/compose.yaml +++ b/setup/docker/compose.yaml @@ -168,7 +168,7 @@ services: image: ollama/ollama:latest restart: unless-stopped stop_grace_period: 15s - ## Only starts this service if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 @@ -214,7 +214,7 @@ services: image: ghcr.io/open-webui/open-webui:main restart: unless-stopped stop_grace_period: 5s - ## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "open-webui", "vision" ] ## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access): @@ -236,7 +236,7 @@ services: watchtower: image: nickfedor/watchtower restart: unless-stopped - ## Only starts this service if the "update" profile is specified:: + ## Only starts if the "update" profile is specified:: ## docker compose --profile update up -d profiles: ["update"] environment: diff --git a/setup/docker/nvidia/compose.yaml b/setup/docker/nvidia/compose.yaml index cad9e9d21..a700db4fc 100644 --- a/setup/docker/nvidia/compose.yaml +++ b/setup/docker/nvidia/compose.yaml @@ -168,7 +168,7 @@ services: image: ollama/ollama:latest restart: unless-stopped stop_grace_period: 15s - ## Only starts this service if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 @@ -214,7 +214,7 @@ services: image: ghcr.io/open-webui/open-webui:main restart: unless-stopped stop_grace_period: 5s - ## Only starts this service if the "all", "ollama", "open-webui", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "open-webui", "vision" ] ## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access): @@ -236,7 +236,7 @@ services: watchtower: image: nickfedor/watchtower restart: unless-stopped - ## Only starts this service if the "update" profile is specified:: + ## Only starts if the "update" profile is specified:: ## docker compose --profile update up -d profiles: ["update"] environment: From fdb6cde287c2a04cc7df812a4ab7da8a8bf6a7a1 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 16:31:35 +0000 Subject: [PATCH 06/11] Develop: Fix "specified::" typo in compose service comments #5590 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the stray second colon at the end of the "Only starts if … profile is specified:" comment block in compose.nvidia.yaml and the three setup/docker compose files. Pure cosmetic cleanup so all gated services use identical phrasing across the dev and end-user compose variants. --- compose.nvidia.yaml | 2 +- setup/docker/arm64/compose.yaml | 6 +++--- setup/docker/compose.yaml | 6 +++--- setup/docker/nvidia/compose.yaml | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/compose.nvidia.yaml b/compose.nvidia.yaml index 3d7ad733f..4874fb929 100644 --- a/compose.nvidia.yaml +++ b/compose.nvidia.yaml @@ -171,7 +171,7 @@ services: ollama: image: ollama/ollama:latest stop_grace_period: 15s - ## Only starts if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 diff --git a/setup/docker/arm64/compose.yaml b/setup/docker/arm64/compose.yaml index a2c7f838b..f38dbd43f 100644 --- a/setup/docker/arm64/compose.yaml +++ b/setup/docker/arm64/compose.yaml @@ -163,7 +163,7 @@ services: image: ollama/ollama:latest restart: unless-stopped stop_grace_period: 15s - ## Only starts if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 @@ -198,7 +198,7 @@ services: image: ghcr.io/open-webui/open-webui:main restart: unless-stopped stop_grace_period: 5s - ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "open-webui", "vision" ] ## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access): @@ -220,7 +220,7 @@ services: watchtower: image: nickfedor/watchtower restart: unless-stopped - ## Only starts if the "update" profile is specified:: + ## Only starts if the "update" profile is specified: ## docker compose --profile update up -d profiles: [ "update" ] environment: diff --git a/setup/docker/compose.yaml b/setup/docker/compose.yaml index 3383c6dd7..80ec185a1 100644 --- a/setup/docker/compose.yaml +++ b/setup/docker/compose.yaml @@ -168,7 +168,7 @@ services: image: ollama/ollama:latest restart: unless-stopped stop_grace_period: 15s - ## Only starts if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 @@ -214,7 +214,7 @@ services: image: ghcr.io/open-webui/open-webui:main restart: unless-stopped stop_grace_period: 5s - ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "open-webui", "vision" ] ## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access): @@ -236,7 +236,7 @@ services: watchtower: image: nickfedor/watchtower restart: unless-stopped - ## Only starts if the "update" profile is specified:: + ## Only starts if the "update" profile is specified: ## docker compose --profile update up -d profiles: ["update"] environment: diff --git a/setup/docker/nvidia/compose.yaml b/setup/docker/nvidia/compose.yaml index a700db4fc..a41a961c8 100644 --- a/setup/docker/nvidia/compose.yaml +++ b/setup/docker/nvidia/compose.yaml @@ -168,7 +168,7 @@ services: image: ollama/ollama:latest restart: unless-stopped stop_grace_period: 15s - ## Only starts if the "all", "ollama", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: ["all", "ollama", "vision"] ## Insecurely exposes the Ollama service on port 11434 @@ -214,7 +214,7 @@ services: image: ghcr.io/open-webui/open-webui:main restart: unless-stopped stop_grace_period: 5s - ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified:: + ## Only starts if the "all", "ollama", "open-webui", or "vision" profile is specified: ## docker compose --profile ollama up -d profiles: [ "all", "ollama", "open-webui", "vision" ] ## Exposes Open WebUI at http://localhost:8080 (use an HTTPS reverse proxy for remote access): @@ -236,7 +236,7 @@ services: watchtower: image: nickfedor/watchtower restart: unless-stopped - ## Only starts if the "update" profile is specified:: + ## Only starts if the "update" profile is specified: ## docker compose --profile update up -d profiles: ["update"] environment: From 06e50f37830f3e4f2c7c4975e544aa241f94e869 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 20 May 2026 18:40:18 +0000 Subject: [PATCH 07/11] Frontend: Gate Add-to-Album, Batch Edit, and Labels tab on name length #5584 The backend setters Album.SetTitle and Label.SetName silently truncate with an ellipsis once the input exceeds 160 chars and still return 2xx, so the three remaining "create new" surfaces showed a green success against an entity the user did not intend. Refuse the save client-side when the typed name exceeds the per-entity MaxLength: the Add-to-Album combobox in component/photo/album/dialog.vue, the Albums + Labels chip selectors in component/photo/batch-edit.vue (gated through a new max-length prop on component/input/chip-selector.vue), and the Labels tab combobox in component/photo/edit/labels.vue all surface a "Name is too long" error instead of dispatching the request. --- frontend/src/component/input/chip-selector.vue | 12 ++++++++++++ frontend/src/component/photo/album/dialog.vue | 10 +++++++++- frontend/src/component/photo/batch-edit.vue | 6 ++++++ frontend/src/component/photo/edit/labels.vue | 8 ++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/src/component/input/chip-selector.vue b/frontend/src/component/input/chip-selector.vue index eb1c07a9a..f20a80d8d 100644 --- a/frontend/src/component/input/chip-selector.vue +++ b/frontend/src/component/input/chip-selector.vue @@ -105,6 +105,10 @@ export default { type: Function, default: null, }, + maxLength: { + type: Number, + default: 0, + }, }, emits: ["update:items"], data() { @@ -266,6 +270,14 @@ export default { return; } + // Block the create path when the typed title exceeds the configured cap — + // otherwise the backend setter clips with an ellipsis and the user is + // shown a green success against a renamed entity they didn't intend. + if (this.maxLength > 0 && title.length > this.maxLength) { + this.$notify.error(this.$gettext("%{s} is too long", { s: this.$gettext("Name") })); + return; + } + let resolvedApplied = false; if (typeof this.resolveItemFromText === "function") { const resolved = this.resolveItemFromText(title); diff --git a/frontend/src/component/photo/album/dialog.vue b/frontend/src/component/photo/album/dialog.vue index 3d1b46a5d..259be1a42 100644 --- a/frontend/src/component/photo/album/dialog.vue +++ b/frontend/src/component/photo/album/dialog.vue @@ -72,7 +72,7 @@