From 04f6de7061c2928d91c22d47b7d412d327d56658 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:49:16 +0200 Subject: [PATCH 01/32] feat: Implement API album display with password protection and expiration. --- config.example.yaml | 1 + config.schema.json | 3 + frontend/src/css/redirects.css | 669 +++++++++++++++++--- frontend/src/ts/menu.ts | 666 +++++++++++++++++++ internal/config/config.go | 4 + internal/immich/immich_album.go | 22 +- internal/routes/routes_albums.go | 78 +++ internal/templates/partials/redirects.templ | 48 +- main.go | 2 + 9 files changed, 1397 insertions(+), 96 deletions(-) create mode 100644 internal/routes/routes_albums.go diff --git a/config.example.yaml b/config.example.yaml index 360b4865..57042d97 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -151,6 +151,7 @@ kiosk: fetched_assets_size: 1000 http_timeout: 20 password: "" + api_albums_password: "" # password to protect access to API albums selection cache: true # cache select api calls prefetch: true # fetch assets in the background asset_weighting: true # use weighting when picking assets diff --git a/config.schema.json b/config.schema.json index b5fa284a..ffe5ef31 100644 --- a/config.schema.json +++ b/config.schema.json @@ -482,6 +482,9 @@ "required": ["name", "url"] } }, + "api_albums_password": { + "type": "string" + }, "demo_mode": { "type": "boolean" } diff --git a/frontend/src/css/redirects.css b/frontend/src/css/redirects.css index fa094c30..f567fc95 100644 --- a/frontend/src/css/redirects.css +++ b/frontend/src/css/redirects.css @@ -1,100 +1,597 @@ #redirects-container { - display: none; - * { - user-select: text; - } + display: none; + * { + user-select: text; + } } .polling-paused.redirects-open { - .frame--image img, - .frame--background img, - .frame--video video { - filter: grayscale(1) blur(4px) brightness(0.4); - } + .frame--image img, + .frame--background img, + .frame--video video { + filter: grayscale(1) blur(4px) brightness(0.4); + } + + #redirects-container { + position: absolute; + margin: 0.4rem; + width: calc(100% - 0.8rem); + height: calc(100% - 0.8rem); + top: 0; + left: 0; + right: 0; + bottom: 0; + + border-radius: 0.4rem; + + padding: 8.12rem 2rem 2rem 2rem; + display: flex; + justify-content: center; + align-items: center; + background-color: rgb(51 52 96 / 60%); + + z-index: var(--z-overlay); + } + + #redirects-container .redirects { + width: 100%; + max-width: 50rem; + height: 100%; + padding: 0; + margin: 0; + overflow-y: auto; + display: flex; + flex-direction: column; - #redirects-container { + .redirects--shadow { + position: relative; + z-index: var(--z-base); + &::before, + &::after { + content: ""; position: absolute; - margin: 0.4rem; - width: calc(100% - 0.8rem); - height: calc(100% - 0.8rem); - top: 0; left: 0; right: 0; + height: 20%; + pointer-events: none; + background-image: radial-gradient( + 60% 50% at 50% 0%, + var(--fade-gradient) + ); + z-index: var(--z-below); + opacity: 0.6; + } + + &::after { bottom: 0; + top: unset; + background-image: radial-gradient( + 60% 50% at 50% 100%, + var(--fade-gradient) + ); + } + } - border-radius: 0.4rem; - - padding: 8.12rem 2rem 2rem 2rem; - display: flex; - justify-content: center; - align-items: center; - background-color: rgb(51 52 96 / 60%); - - z-index: var(--z-overlay); - } - - #redirects-container .redirects { - width: 100%; - max-width: 40rem; - height: 100%; - padding: 0; - margin: 0; - overflow-y: auto; - display: flex; - flex-direction: column; - - .redirects--shadow { - position: relative; - z-index: var(--z-base); - &::before, - &::after { - content: ""; - position: absolute; - left: 0; - right: 0; - height: 20%; - pointer-events: none; - background-image: radial-gradient( - 60% 50% at 50% 0%, - var(--fade-gradient) - ); - z-index: var(--z-below); - opacity: 0.6; - } - - &::after { - bottom: 0; - top: unset; - background-image: radial-gradient( - 60% 50% at 50% 100%, - var(--fade-gradient) - ); - } - } - } - - .redirects a { - display: block; - padding: 1.5rem 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.2); - text-decoration: none; - color: #fff; - } - - .redirects a:hover, - .redirects a:focus { - background-color: var(--mint-green); - color: var(--cool-grey); - font-weight: bold; + .redirects-content { + display: flex; + gap: 2rem; + padding: 1rem; + } + } + + .redirects a { + display: block; + padding: 1.5rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + text-decoration: none; + color: #fff; + } + + .redirects a:hover, + .redirects a:focus { + background-color: var(--mint-green); + color: var(--cool-grey); + font-weight: bold; + } + + .redirects-category { + flex: 1; + display: flex; + flex-direction: column; + background: rgba(255, 255, 255, 0.03); + border-radius: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.1); + overflow: hidden; + } + + .redirects-category:last-child { + margin-bottom: 0; + } + + .category-header { + padding: 1rem; + font-size: 1rem; + font-weight: 600; + color: var(--mint-green); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 2px solid var(--mint-green); + background: rgba(255, 255, 255, 0.05); + flex-shrink: 0; + } + + .albums-tabs { + display: flex; + overflow-x: auto; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + /* Custom scrollbar */ + &::-webkit-scrollbar { + height: 4px; + } + + &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + } + } + + .album-tab { + padding: 0.75rem 1rem; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + background-color: rgba(255, 255, 255, 0.05); + } + + &.active { + color: var(--mint-green); + border-bottom-color: var(--mint-green); + background-color: rgba(30, 210, 187, 0.05); + } + } + + .albums-search-container { + padding: 0.75rem 1rem 0 1rem; + display: flex; + gap: 0.5rem; + position: relative; + } + + #album-search-input { + width: 100%; + padding: 0.6rem 2.5rem 0.6rem 0.8rem; + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0.3rem; + color: var(--text-primary); + font-size: 0.9rem; + transition: all 0.2s ease; + + &:focus { + outline: none; + border-color: var(--mint-green); + background-color: rgba(255, 255, 255, 0.15); + } + + &::placeholder { + color: var(--text-secondary); + } + } + + .clear-search-btn { + position: absolute; + right: 7.5rem; /* Adjusted to make room for select all button */ + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + padding: 0; + line-height: 1; + display: none; + + &:hover { + color: var(--text-primary); + } + } + + .select-all-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0.3rem; + color: var(--text-secondary); + font-size: 0.8rem; + padding: 0 0.8rem; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s ease; + height: 100%; + display: flex; + align-items: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + color: var(--text-primary); + } + + &.active { + background: rgba(30, 210, 187, 0.2); + border-color: var(--mint-green); + color: var(--mint-green); + } + } + .albums-sort-controls { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.02); + flex-shrink: 0; + } + + .sort-btn { + padding: 0.4rem 0.8rem; + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0.3rem; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + } + + &.active { + background-color: var(--mint-green); + color: var(--cool-grey); + border-color: var(--mint-green); + font-weight: 600; + } + } + + .refresh-btn { + background-color: rgba(30, 210, 187, 0.1); + color: var(--mint-green); + border-color: rgba(30, 210, 187, 0.3); + font-size: 1rem; + padding: 0.4rem 0.6rem; + min-width: 2.2rem; + + &:hover { + background-color: rgba(30, 210, 187, 0.2); + border-color: rgba(30, 210, 187, 0.5); + transform: rotate(90deg); + } + + &:active { + transform: rotate(180deg); + } + } + + .api-albums-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + max-height: 400px; + + /* Custom scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: var(--mint-green); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(30, 210, 187, 0.8); + } + } + + .loading-albums { + padding: 2rem; + text-align: center; + color: var(--text-secondary); + font-style: italic; + } + + .password-prompt { + padding: 2rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + } + + .password-prompt-title { + font-size: 1.2rem; + font-weight: 600; + color: var(--mint-green); + margin-bottom: 0.5rem; + } + + .password-prompt input[type="password"] { + padding: 0.75rem; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 0.4rem; + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + font-size: 1rem; + width: 100%; + max-width: 250px; + text-align: center; + + &:focus { + outline: none; + border-color: var(--mint-green); + background-color: rgba(255, 255, 255, 0.15); + } + + &::placeholder { + color: var(--text-secondary); + } + } + + .password-prompt button { + padding: 0.5rem 1.5rem; + border: none; + border-radius: 0.3rem; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.2s ease; + margin: 0 0.25rem; + } + + .password-prompt #submit-password-btn { + background-color: var(--mint-green); + color: var(--cool-grey); + + &:hover { + background-color: rgba(30, 210, 187, 0.8); + } + } + + .password-prompt #cancel-password-btn { + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.2); + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + } + } + + .password-error { + padding: 2rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + } + + .password-error-title { + font-size: 1.2rem; + font-weight: 600; + color: #ff6b6b; + margin-bottom: 0.5rem; + } + + .password-error-message { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.4; + margin-bottom: 1rem; + } + + .password-error button { + padding: 0.5rem 1.5rem; + border: none; + border-radius: 0.3rem; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.2s ease; + margin: 0 0.25rem; + } + + .password-error #retry-password-btn { + background-color: var(--mint-green); + color: var(--cool-grey); + + &:hover { + background-color: rgba(30, 210, 187, 0.8); + } + } + + .password-error #cancel-password-btn { + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.2); + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + } + } + + .password-countdown { + padding: 0.5rem 1rem; + margin-bottom: 0.5rem; + background-color: rgba(255, 210, 187, 0.1); + border: 1px solid rgba(255, 210, 187, 0.3); + border-radius: 0.3rem; + color: #ffd2bb; + font-size: 0.85rem; + font-weight: 500; + text-align: center; + display: none; + } + + .album-item { + display: flex; + align-items: center; + margin: 0.5rem 0; + padding: 0.75rem 1rem; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; + cursor: pointer; + } + + .album-item:hover { + background-color: rgba(255, 255, 255, 0.08); + border-color: rgba(30, 210, 187, 0.3); + } + + .album-item input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--mint-green); + cursor: pointer; + margin-right: 1rem; + } + + .album-item label { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + cursor: pointer; + } + + .album-name { + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; + } + + .album-count { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 400; + } + + .api-albums-actions { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.2); + display: flex; + justify-content: center; + } + + .apply-albums-btn { + padding: 0.75rem 2rem; + background-color: var(--mint-green); + color: var(--cool-grey); + border: none; + border-radius: 0.4rem; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background-color: rgba(30, 210, 187, 0.8); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(30, 210, 187, 0.3); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: rgba(255, 255, 255, 0.2); + color: var(--text-secondary); + } + } + + /* Mobile responsiveness */ + @media (max-width: 768px) { + .redirects-content { + flex-direction: column; + gap: 1rem; + padding: 0.5rem; + } + + .category-header { + font-size: 1rem; + padding: 0.75rem; + } + + .albums-sort-controls { + padding: 0.5rem 0.75rem; + gap: 0.25rem; + } + + .sort-btn { + padding: 0.3rem 0.6rem; + font-size: 0.8rem; + } + + .api-albums-list { + max-height: 250px; + padding: 0.25rem; + } + + .album-item { + padding: 0.6rem; + margin: 0.4rem 0; + } + + .album-item label { + gap: 2px; + } + + .album-name { + font-size: 0.95rem; + } + + .album-count { + font-size: 0.8rem; } -} -/* .frameless */ -.frameless.redirects-open { - #redirects-container { - margin: 0; - width: 100%; - height: 100%; - border-radius: 0; + .apply-albums-btn { + padding: 0.6rem 1.5rem; + font-size: 0.9rem; } + } } diff --git a/frontend/src/ts/menu.ts b/frontend/src/ts/menu.ts index c5a7ee70..fb4c5b1d 100644 --- a/frontend/src/ts/menu.ts +++ b/frontend/src/ts/menu.ts @@ -24,6 +24,597 @@ let allowMoreInfo: boolean; let infoKeyPress: () => void; let redirectsKeyPress: () => void; +// Store albums data for sorting +let allAlbums: Record> = {}; +let activeTab: string = "Main Library"; +const selectedAlbumIds: Set = new Set(); +let currentSort: string = 'name'; +let currentSearchQuery: string = ''; +let albumsLoaded: boolean = false; +let apiAlbumsPassword: string | null = null; +let passwordTimer: number | null = null; +let countdownInterval: number | null = null; + +/** + * Clears the password timer + */ +function clearPasswordTimer(): void { + if (passwordTimer !== null) { + clearTimeout(passwordTimer); + passwordTimer = null; + } + if (countdownInterval !== null) { + clearInterval(countdownInterval); + countdownInterval = null; + } + // Hide countdown display + hidePasswordCountdown(); +} + +/** + * Handles password expiration after timer runs out + */ +function handlePasswordExpiration(): void { + // Clear the stored password + apiAlbumsPassword = null; + // Clear the timer and interval + if (passwordTimer !== null) { + clearTimeout(passwordTimer); + passwordTimer = null; + } + if (countdownInterval !== null) { + clearInterval(countdownInterval); + countdownInterval = null; + } + // Reset albums loaded state so it will try to load again + albumsLoaded = false; + // Hide countdown + hidePasswordCountdown(); + // Show password prompt again + showPasswordPrompt(); +} + +/** + * Updates the password countdown display + */ +function updatePasswordCountdown(remainingSeconds: number): void { + const countdownElement = document.getElementById("password-countdown"); + if (countdownElement) { + // Ensure remainingSeconds is not negative + const safeSeconds = Math.max(0, remainingSeconds); + const minutes = Math.floor(safeSeconds / 60); + const seconds = safeSeconds % 60; + const formattedSeconds = seconds < 10 ? `0${seconds}` : seconds.toString(); + countdownElement.textContent = `Password expires in ${minutes}:${formattedSeconds}`; + } +} + +/** + * Shows the password countdown display + */ +function showPasswordCountdown(): void { + const albumsList = document.getElementById("api-albums-list"); + if (!albumsList) return; + + // Check if countdown element already exists + let countdownElement = document.getElementById("password-countdown"); + if (!countdownElement) { + countdownElement = document.createElement("div"); + countdownElement.id = "password-countdown"; + countdownElement.className = "password-countdown"; + albumsList.insertBefore(countdownElement, albumsList.firstChild); + } + countdownElement.style.display = "block"; +} + +/** + * Hides the password countdown display + */ +function hidePasswordCountdown(): void { + const countdownElement = document.getElementById("password-countdown"); + if (countdownElement) { + countdownElement.style.display = "none"; + } +} + +/** + * Starts the password expiration timer (1 minute) + */ +function startPasswordTimer(): void { + // Clear any existing timer + clearPasswordTimer(); + + let remainingSeconds = 60; + + // Show countdown + showPasswordCountdown(); + updatePasswordCountdown(remainingSeconds); + + // Start countdown interval (updates every second) + countdownInterval = window.setInterval(() => { + remainingSeconds--; + + // Stop the interval if we've reached 0 or negative + if (remainingSeconds <= 0) { + if (countdownInterval !== null) { + clearInterval(countdownInterval); + countdownInterval = null; + } + return; + } + + updatePasswordCountdown(remainingSeconds); + }, 1000); + + // Start expiration timer + passwordTimer = window.setTimeout(handlePasswordExpiration, 60000); +} + +/** + * Loads and displays albums from the API + */ +async function loadApiAlbums(): Promise { + // Prevent loading albums multiple times + if (albumsLoaded) { + renderAlbumsList(); + return; + } + + const albumsList = document.getElementById("api-albums-list") as HTMLElement; + + if (!albumsList) return; + + try { + const url = "/api/albums"; + const headers: HeadersInit = {}; + + if (apiAlbumsPassword) { + headers["X-Kiosk-Password"] = apiAlbumsPassword; + } + + const response = await fetch(url, { + headers: headers + }); + + if (response.status === 401) { + const errorData = await response.json().catch(() => ({})); + if (errorData.code === "password_required") { + // Password required + showPasswordPrompt(); + return; + } else if (errorData.code === "invalid_password") { + // Wrong password provided + showPasswordError(); + return; + } + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const albumsData = await response.json(); + + // Handle both old (array) and new (map) response formats for backward compatibility + if (Array.isArray(albumsData)) { + // Remove duplicates based on album ID + const uniqueAlbums = albumsData.filter((album: {id: string; albumName: string; assetCount: number}, index: number, self: Array<{id: string; albumName: string; assetCount: number}>) => + index === self.findIndex((a) => a.id === album.id) + ); + allAlbums = { "Main Library": uniqueAlbums }; + } else { + // Process each user's albums + allAlbums = {}; + Object.keys(albumsData).forEach(user => { + const userAlbums = albumsData[user]; + const uniqueAlbums = userAlbums.filter((album: {id: string; albumName: string; assetCount: number}, index: number, self: Array<{id: string; albumName: string; assetCount: number}>) => + index === self.findIndex((a) => a.id === album.id) + ); + allAlbums[user] = uniqueAlbums; + }); + } + + // Initialize selected albums from URL if first load + // We check if any albums are loaded across all users + const totalAlbumsCount = Object.keys(allAlbums).reduce((acc, key) => acc + allAlbums[key].length, 0); + + if (totalAlbumsCount > 0 && selectedAlbumIds.size === 0) { + const urlParams = new URLSearchParams(window.location.search); + const albumsFromUrl = urlParams.getAll('album'); + albumsFromUrl.forEach(id => { + selectedAlbumIds.add(id); + }); + } + + albumsLoaded = true; + + renderTabs(); + renderAlbumsList(); + updateApplyButtonState(); + + // Start the password expiration timer + startPasswordTimer(); + } catch (error) { + console.error("Failed to load albums:", error); + albumsList.innerHTML = '
Failed to load albums
'; + } +} + +/** + * Shows password prompt for API albums + */ +function showPasswordPrompt(): void { + const albumsList = document.getElementById("api-albums-list") as HTMLElement; + const sortControls = document.querySelector(".albums-sort-controls") as HTMLElement; + const albumsActions = document.querySelector(".api-albums-actions") as HTMLElement; + + if (!albumsList) return; + + // Clear any existing timer since we're prompting for password again + clearPasswordTimer(); + + // Hide sort controls and actions when showing password prompt + if (sortControls) sortControls.style.display = "none"; + const searchContainer = document.querySelector(".albums-search-container") as HTMLElement; + if (searchContainer) searchContainer.style.display = "none"; + const tabsContainer = document.getElementById("albums-tabs"); + if (tabsContainer) tabsContainer.style.display = "none"; + if (albumsActions) albumsActions.style.display = "none"; + + albumsList.innerHTML = ` +
+
Enter Password
+ + + +
+ `; + + const submitBtn = document.getElementById("submit-password-btn") as HTMLButtonElement; + const cancelBtn = document.getElementById("cancel-password-btn") as HTMLButtonElement; + const passwordInput = document.getElementById("api-albums-password") as HTMLInputElement; + + if (submitBtn && cancelBtn && passwordInput) { + submitBtn.addEventListener("click", () => { + const password = passwordInput.value.trim(); + if (password) { + apiAlbumsPassword = password; + loadApiAlbums(); + } + }); + + cancelBtn.addEventListener("click", () => { + // Clear timer and reset state when canceling + clearPasswordTimer(); + apiAlbumsPassword = null; + albumsLoaded = false; + albumsList.innerHTML = '
Access cancelled
'; + }); + + passwordInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + submitBtn.click(); + } + }); + + // Focus the password input + passwordInput.focus(); + } +} + +/** + * Shows error message for incorrect password + */ +function showPasswordError(): void { + const albumsList = document.getElementById("api-albums-list") as HTMLElement; + const sortControls = document.querySelector(".albums-sort-controls") as HTMLElement; + const albumsActions = document.querySelector(".api-albums-actions") as HTMLElement; + + if (!albumsList) return; + + // Clear the stored password and timer so user can try again + apiAlbumsPassword = null; + clearPasswordTimer(); + + // Hide sort controls and actions when showing password error + if (sortControls) sortControls.style.display = "none"; + const searchContainer = document.querySelector(".albums-search-container") as HTMLElement; + if (searchContainer) searchContainer.style.display = "none"; + const tabsContainer = document.getElementById("albums-tabs"); + if (tabsContainer) tabsContainer.style.display = "none"; + if (albumsActions) albumsActions.style.display = "none"; + + albumsList.innerHTML = ` +
+
Incorrect Password
+
The password you entered is incorrect. Please try again.
+ + +
+ `; + + const retryBtn = document.getElementById("retry-password-btn") as HTMLButtonElement; + const cancelBtn = document.getElementById("cancel-password-btn") as HTMLButtonElement; + + if (retryBtn && cancelBtn) { + retryBtn.addEventListener("click", () => { + showPasswordPrompt(); + }); + + cancelBtn.addEventListener("click", () => { + // Clear timer and reset state when canceling + clearPasswordTimer(); + albumsLoaded = false; + albumsList.innerHTML = '
Access cancelled
'; + }); + } +} + +/** + * Renders the tabs for switching between users + */ +function renderTabs(): void { + const tabsContainer = document.getElementById("albums-tabs"); + if (!tabsContainer) return; + + // Only show tabs if we have more than one user/library + const users = Object.keys(allAlbums); + if (users.length <= 1) { + tabsContainer.style.display = "none"; + return; + } + + tabsContainer.style.display = "flex"; + tabsContainer.innerHTML = ""; + + // Sort users so "Main Library" is always first + users.sort((a, b) => { + if (a === "Main Library") return -1; + if (b === "Main Library") return 1; + return a.localeCompare(b); + }); + + // Ensure active tab is valid + if (!allAlbums[activeTab] && users.length > 0) { + activeTab = users[0]; + } + + users.forEach(user => { + const tab = document.createElement("div"); + tab.className = `album-tab ${user === activeTab ? "active" : ""}`; + tab.textContent = user; + tab.addEventListener("click", () => { + activeTab = user; + renderTabs(); // Re-render to update active class + renderAlbumsList(); + }); + tabsContainer.appendChild(tab); + }); +} + +/** + * Renders the albums list with current sorting + */ +function renderAlbumsList(): void { + const albumsList = document.getElementById("api-albums-list") as HTMLElement; + const sortControls = document.querySelector(".albums-sort-controls") as HTMLElement; + const albumsActions = document.querySelector(".api-albums-actions") as HTMLElement; + + if (!albumsList) return; + + // Check if password has expired (no password but albums were previously loaded) + const totalAlbumsCount = Object.keys(allAlbums).reduce((acc, key) => acc + allAlbums[key].length, 0); + if (apiAlbumsPassword === null && albumsLoaded === false && totalAlbumsCount > 0) { + showPasswordPrompt(); + return; + } + + // Show sort controls and actions when rendering albums + if (sortControls) sortControls.style.display = "flex"; + const searchContainer = document.querySelector(".albums-search-container") as HTMLElement; + if (searchContainer) searchContainer.style.display = "flex"; + const tabsContainer = document.getElementById("albums-tabs"); + // Only show tabs container if we have multiple users (handled in renderTabs, but ensure visibility here if needed) + if (tabsContainer && Object.keys(allAlbums).length > 1) tabsContainer.style.display = "flex"; + if (albumsActions) albumsActions.style.display = "flex"; + + // Preserve countdown element if it exists + const existingCountdown = document.getElementById("password-countdown"); + albumsList.innerHTML = ""; + + // Re-add countdown if it existed + if (existingCountdown && existingCountdown.style.display !== "none") { + albumsList.appendChild(existingCountdown); + } + + const currentAlbums = allAlbums[activeTab] || []; + + if (currentAlbums.length === 0) { + albumsList.innerHTML = '
No albums found
'; + return; + } + + // Filter albums based on search query + let filteredAlbums = currentAlbums; + if (currentSearchQuery) { + const query = currentSearchQuery.toLowerCase(); + filteredAlbums = currentAlbums.filter(album => + album.albumName.toLowerCase().includes(query) + ); + } + + // Sort albums based on current sort setting + const sortedAlbums = [...filteredAlbums].sort((a, b) => { + switch (currentSort) { + case 'count-asc': + return a.assetCount - b.assetCount; + case 'count-desc': + return b.assetCount - a.assetCount; + default: + return a.albumName.localeCompare(b.albumName); + } + }); + + sortedAlbums.forEach((album: {id: string; albumName: string; assetCount: number}) => { + const item = document.createElement("div"); + item.className = "album-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = `album-${album.id}`; + checkbox.value = album.id; + checkbox.checked = selectedAlbumIds.has(album.id); + checkbox.addEventListener("change", (e) => { + const target = e.target as HTMLInputElement; + if (target.checked) { + selectedAlbumIds.add(album.id); + } else { + selectedAlbumIds.delete(album.id); + } + updateApplyButtonState(); + updateSelectAllBtn(); + }); + + const label = document.createElement("label"); + label.htmlFor = `album-${album.id}`; + + const nameSpan = document.createElement("span"); + nameSpan.className = "album-name"; + nameSpan.textContent = album.albumName; + + const countSpan = document.createElement("span"); + countSpan.className = "album-count"; + countSpan.textContent = `${album.assetCount} assets`; + + label.appendChild(nameSpan); + label.appendChild(countSpan); + + item.appendChild(checkbox); + item.appendChild(label); + albumsList.appendChild(item); + }); +} + +/** + * Refreshes the albums from the API + */ +function refreshAlbums(): void { + // Reset albums loaded state to force reload + albumsLoaded = false; + // Clear any existing timer since we're refreshing + clearPasswordTimer(); + // Load albums again + loadApiAlbums(); +} + +/** + * Handles sorting button clicks + */ +function handleSortChange(sortType: string): void { + currentSort = sortType; + + // Update active button state + const sortButtons = document.querySelectorAll('.sort-btn'); + sortButtons.forEach(btn => { + btn.classList.remove('active'); + if ((btn as HTMLElement).dataset.sort === sortType) { + btn.classList.add('active'); + } + }); + + // Re-render the list with new sorting + renderAlbumsList(); +} + +/** + * Updates the apply button state based on checkbox selections + */ +function updateApplyButtonState(): void { + const applyBtn = document.getElementById("apply-albums-btn") as HTMLButtonElement; + + if (applyBtn) { + applyBtn.disabled = selectedAlbumIds.size === 0; + } +} + +/** + * Applies the selected albums and navigates + */ +function applyApiAlbumsSelection(): void { + const selectedAlbums = Array.from(selectedAlbumIds); + + if (selectedAlbums.length === 0) { + return; + } + + const albumParams = selectedAlbums.map(id => `album=${encodeURIComponent(id)}`).join("&"); + const url = `/?${albumParams}`; + + window.location.href = url; +} + +function updateClearSearchBtn() { + const clearSearchBtn = document.getElementById("clear-search-btn") as HTMLButtonElement; + if (clearSearchBtn) { + clearSearchBtn.style.display = currentSearchQuery ? "block" : "none"; + } +} + +function getVisibleAlbums() { + const albums = allAlbums[activeTab] || []; + if (!currentSearchQuery) return albums; + + const query = currentSearchQuery.toLowerCase(); + return albums.filter(album => + album.albumName.toLowerCase().includes(query) + ); +} + +function updateSelectAllBtn() { + const selectAllBtn = document.getElementById("select-all-albums-btn") as HTMLButtonElement; + if (!selectAllBtn) return; + + const visibleAlbums = getVisibleAlbums(); + if (visibleAlbums.length === 0) { + selectAllBtn.textContent = "Select All"; + selectAllBtn.classList.remove("active"); + return; + } + + const allSelected = visibleAlbums.every(album => selectedAlbumIds.has(album.id)); + + if (allSelected) { + selectAllBtn.textContent = "Deselect All"; + selectAllBtn.classList.add("active"); + } else { + selectAllBtn.textContent = "Select All"; + selectAllBtn.classList.remove("active"); + } +} + +function toggleSelectAll() { + const visibleAlbums = getVisibleAlbums(); + if (visibleAlbums.length === 0) return; + + const allSelected = visibleAlbums.every(album => selectedAlbumIds.has(album.id)); + + if (allSelected) { + // Deselect all visible + visibleAlbums.forEach(album => { + selectedAlbumIds.delete(album.id); + }); + } else { + // Select all visible + visibleAlbums.forEach(album => { + selectedAlbumIds.add(album.id); + }); + } + + renderAlbumsList(); + updateApplyButtonState(); + updateSelectAllBtn(); +} + /** * Disables both next and previous asset navigation buttons * @returns {void} @@ -86,6 +677,12 @@ function toggleAssetOverlay(): void { function redirectKeyHandler(e: KeyboardEvent) { if (!redirects) return; + // Ignore key events if typing in an input field + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") { + return; + } + switch (e.code) { case "ArrowDown": e.preventDefault(); // Prevent page scrolling @@ -139,6 +736,9 @@ function hideRedirectsOverlay(): void { document.removeEventListener("keydown", redirectKeyHandler); linkOverlayVisible = false; + + // Clear password timer when closing the overlay + clearPasswordTimer(); } /** @@ -177,11 +777,77 @@ function initMenu( if (redirectsContainer) { redirects = redirectsContainer.querySelectorAll("a"); + + // Load API albums when redirects menu is initialized + loadApiAlbums(); + + // Add event listener for apply albums button + const applyBtn = document.getElementById("apply-albums-btn") as HTMLButtonElement; + if (applyBtn) { + applyBtn.addEventListener("click", applyApiAlbumsSelection); + } + + // Add event listeners for sort and refresh buttons + const sortButtons = document.querySelectorAll('.sort-btn'); + sortButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + const button = e.target as HTMLElement; + const sortType = button.dataset.sort; + const isRefresh = button.id === 'refresh-albums-btn'; + + if (isRefresh) { + refreshAlbums(); + } else if (sortType) { + handleSortChange(sortType); + } + }); + }); + + // Add event listeners for search + const searchInput = document.getElementById('album-search-input') as HTMLInputElement; + const clearSearchBtn = document.getElementById('clear-search-btn') as HTMLButtonElement; + const selectAllBtn = document.getElementById("select-all-albums-btn") as HTMLButtonElement; + + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + currentSearchQuery = target.value.trim(); + renderAlbumsList(); + updateClearSearchBtn(); + updateSelectAllBtn(); + }); + } + + if (clearSearchBtn) { + clearSearchBtn.addEventListener('click', () => { + currentSearchQuery = ''; + if (searchInput) searchInput.value = ''; + renderAlbumsList(); + updateClearSearchBtn(); + updateSelectAllBtn(); + }); + } + + if (selectAllBtn) { + selectAllBtn.addEventListener("click", toggleSelectAll); + } } allowMoreInfo = showMoreInfo; infoKeyPress = handleInfoKeyPress; redirectsKeyPress = handleRedirectsKeyPress; + + if (nextAssetMenuButton) { + nextAssetMenuButton.addEventListener("click", () => { + hideRedirectsOverlay(); + }); + } + + if (prevAssetMenuButton) { + prevAssetMenuButton.addEventListener("click", () => { + hideRedirectsOverlay(); + }); + } } export { diff --git a/internal/config/config.go b/internal/config/config.go index 4f91e8cf..5c5ee882 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,6 +100,9 @@ type KioskSettings struct { // Password the password used to add authentication to the frontend Password string `json:"-" yaml:"password" mapstructure:"password" default:"" redact:"true"` + // APIAlbumsPassword the password used to protect access to API albums + APIAlbumsPassword string `json:"-" yaml:"api_albums_password" mapstructure:"api_albums_password" default:"" redact:"true"` + // Redirects defines a list of URL redirections with friendly names Redirects []Redirect `yaml:"redirects" mapstructure:"redirects" default:"[]"` @@ -477,6 +480,7 @@ func bindEnvironmentVariables(v *viper.Viper) error { {"kiosk.fetched_assets_size", "KIOSK_FETCHED_ASSETS_SIZE"}, {"kiosk.http_timeout", "KIOSK_HTTP_TIMEOUT"}, {"kiosk.password", "KIOSK_PASSWORD"}, + {"kiosk.api_albums_password", "KIOSK_API_ALBUMS_PASSWORD"}, {"kiosk.cache", "KIOSK_CACHE"}, {"kiosk.prefetch", "KIOSK_PREFETCH"}, {"kiosk.asset_weighting", "KIOSK_ASSET_WEIGHTING"}, diff --git a/internal/immich/immich_album.go b/internal/immich/immich_album.go index fa26e790..ca7b18a2 100644 --- a/internal/immich/immich_album.go +++ b/internal/immich/immich_album.go @@ -101,9 +101,25 @@ func (a *Asset) allSharedAlbums(requestID, deviceID string) (Albums, string, err func (a *Asset) allAlbums(requestID, deviceID string) (Albums, string, error) { owned, ownedURL, ownedErr := a.albums(requestID, deviceID, false, "", false) shared, sharedURL, sharedErr := a.albums(requestID, deviceID, true, "", false) - all := make(Albums, len(owned)+len(shared)) - copy(all, owned) - copy(all[len(owned):], shared) + + // Combine owned and shared albums, removing duplicates by ID + albumMap := make(map[string]Album) + + // Add owned albums first + for _, album := range owned { + albumMap[album.ID] = album + } + + // Add shared albums (will overwrite if already exists, but that's fine since it's the same album) + for _, album := range shared { + albumMap[album.ID] = album + } + + // Convert map back to slice + all := make(Albums, 0, len(albumMap)) + for _, album := range albumMap { + all = append(all, album) + } var err error if ownedErr != nil { diff --git a/internal/routes/routes_albums.go b/internal/routes/routes_albums.go new file mode 100644 index 00000000..4d862d36 --- /dev/null +++ b/internal/routes/routes_albums.go @@ -0,0 +1,78 @@ +package routes + +import ( + "net/http" + + "github.com/charmbracelet/log" + "github.com/damongolding/immich-kiosk/internal/common" + "github.com/damongolding/immich-kiosk/internal/config" + "github.com/damongolding/immich-kiosk/internal/immich" + "github.com/labstack/echo/v4" +) + +// Albums returns an Echo handler that fetches and returns all albums from Immich. +func Albums(baseConfig *config.Config, com *common.Common) echo.HandlerFunc { + return func(c echo.Context) error { + requestData, err := InitializeRequestData(c, baseConfig) + if err != nil { + return err + } + + if requestData == nil { + log.Info("Refreshing clients") + return nil + } + + requestID := requestData.RequestID + deviceID := requestData.DeviceID + requestConfig := requestData.RequestConfig + + log.Debug( + requestID, + "method", c.Request().Method, + "path", c.Request().URL.String(), + ) + + // Check if API albums are password protected + if requestConfig.Kiosk.APIAlbumsPassword != "" { + providedPassword := c.Request().Header.Get("X-Kiosk-Password") + if providedPassword == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Password required", "code": "password_required"}) + } + if providedPassword != requestConfig.Kiosk.APIAlbumsPassword { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password", "code": "invalid_password"}) + } + } + + // Initialize response map + response := make(map[string]immich.Albums) + + // Fetch main library albums + asset := immich.New(com.Context(), requestConfig) + mainAlbums, err := asset.AllAlbums(requestID, deviceID) + if err != nil { + log.Error("Failed to fetch albums for main library", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch albums"}) + } + response["Main Library"] = mainAlbums + + // Fetch albums for other users + for user, apiKey := range requestConfig.ImmichUsersAPIKeys { + // Create a copy of config with user's API key + userConfig := requestConfig + userConfig.ImmichAPIKey = apiKey + userConfig.SelectedUser = user + + userAsset := immich.New(com.Context(), userConfig) + userAlbums, err := userAsset.AllAlbums(requestID, deviceID) + if err != nil { + log.Error("Failed to fetch albums for user", "user", user, "error", err) + // Continue fetching for other users even if one fails + continue + } + response[user] = userAlbums + } + + return c.JSON(http.StatusOK, response) + } +} diff --git a/internal/templates/partials/redirects.templ b/internal/templates/partials/redirects.templ index 61434be2..c1361433 100644 --- a/internal/templates/partials/redirects.templ +++ b/internal/templates/partials/redirects.templ @@ -25,14 +25,48 @@ templ Redirects(redirects []config.Redirect, queries url.Values) { if hasPassword { { redirectName } - } else { - { redirectName } + +
+ + if len(redirects) > 0 { +
+
Albums from Config
+ for _ , redirect := range redirects { + {{ redirectName, _ := strings.CutPrefix(redirect.Name, "/") }} + if hasPassword { + { redirectName } + } else { + { redirectName } + } + } +
} - } + + +
+
Select Albums From API
+
+ +
+
+ + + +
+
+ + + + +
+
+
Loading albums...
+
+
+ +
+
+
diff --git a/main.go b/main.go index f5b255c0..76e54acf 100644 --- a/main.go +++ b/main.go @@ -181,6 +181,8 @@ func main() { }) } + e.GET("/api/albums", routes.Albums(baseConfig, c)) + e.GET("/", routes.Home(baseConfig, c)) e.GET("/health", func(c echo.Context) error { From d316da90844c8d8c7532a4e4b74615a0c9d39c64 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:28:17 +0000 Subject: [PATCH 02/32] Update clock.templ --- internal/templates/partials/clock.templ | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/templates/partials/clock.templ b/internal/templates/partials/clock.templ index 674e4c8f..3f28db4a 100644 --- a/internal/templates/partials/clock.templ +++ b/internal/templates/partials/clock.templ @@ -4,6 +4,7 @@ import ( "fmt" "github.com/damongolding/immich-kiosk/internal/config" "github.com/damongolding/immich-kiosk/internal/utils" + "github.com/goodsign/monday" "strings" "time" ) @@ -12,14 +13,11 @@ import ( // If no date format is specified in config, it uses the default date layout. func clockDate(c config.Config) string { clockDateFormat := utils.DateToLayout(c.DateFormat) - if clockDateFormat == "" { clockDateFormat = config.DefaultDateLayout } - t := time.Now() - - return t.Format(clockDateFormat) + return monday.Format(time.Now(), clockDateFormat, c.SystemLang) } // clockTime returns the current time formatted according to the configuration settings. From 688370de168d18fa3261e2c1c5014acebbfeccf6 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:30:32 +0000 Subject: [PATCH 03/32] deps --- .zed/settings.json | 4 +- biome.json | 2 +- frontend/biome.json | 2 +- frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 148 ++++++++++++++++++++-------------------- 5 files changed, 81 insertions(+), 81 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index a579b905..5f5711c3 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -7,7 +7,7 @@ "JavaScript": { "formatter": { "external": { - "command": "biome", + "command": "./frontend/node_modules/.bin/biome", "arguments": [ "check", "--stdin-file-path", @@ -20,7 +20,7 @@ "TypeScript": { "formatter": { "external": { - "command": "biome", + "command": "./frontend/node_modules/.bin/biome", "arguments": [ "check", "--stdin-file-path", diff --git a/biome.json b/biome.json index 3f19cbec..978637f4 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", "formatter": { "enabled": true, "indentStyle": "space", diff --git a/frontend/biome.json b/frontend/biome.json index de9d5269..76d18687 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,6 +1,6 @@ { "extends": "//", - "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/frontend/package.json b/frontend/package.json index ae6d216c..cc047c8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,8 @@ "node": "22.17.0" }, "devDependencies": { - "@biomejs/biome": "2.3.4", - "autoprefixer": "^10.4.21", + "@biomejs/biome": "2.3.8", + "autoprefixer": "^10.4.22", "choices.js": "^11.1.0", "date-fns": "^4.1.0", "dompurify": "^3.3.0", @@ -38,5 +38,5 @@ "not op_mini all", "not IE 11" ], - "packageManager": "pnpm@10.22.0" + "packageManager": "pnpm@10.24.0" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ab993a45..dd7a7dbc 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: 2.3.4 - version: 2.3.4 + specifier: 2.3.8 + version: 2.3.8 autoprefixer: - specifier: ^10.4.21 - version: 10.4.21(postcss@8.5.6) + specifier: ^10.4.22 + version: 10.4.22(postcss@8.5.6) choices.js: specifier: ^11.1.0 version: 11.1.0 @@ -44,55 +44,55 @@ importers: packages: - '@biomejs/biome@2.3.4': - resolution: {integrity: sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w==} + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.4': - resolution: {integrity: sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q==} + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.4': - resolution: {integrity: sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA==} + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.4': - resolution: {integrity: sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ==} + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.3.4': - resolution: {integrity: sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA==} + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.3.4': - resolution: {integrity: sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA==} + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.3.4': - resolution: {integrity: sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q==} + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.3.4': - resolution: {integrity: sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw==} + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.4': - resolution: {integrity: sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig==} + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -268,15 +268,15 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - autoprefixer@10.4.21: - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + autoprefixer@10.4.22: + resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 - baseline-browser-mapping@2.8.25: - resolution: {integrity: sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==} + baseline-browser-mapping@2.8.32: + resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} hasBin: true binary-extensions@2.3.0: @@ -287,13 +287,13 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.27.0: - resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - caniuse-lite@1.0.30001754: - resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} choices.js@11.1.0: resolution: {integrity: sha512-mIt0uLhedHg2ea/K2PACrVpt391vRGHuOoctPAiHcyemezwzNMxj7jOzNEk8e7EbjLh0S0sspDkSCADOKz9kcw==} @@ -328,8 +328,8 @@ packages: dompurify@3.3.0: resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} - electron-to-chromium@1.5.249: - resolution: {integrity: sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==} + electron-to-chromium@1.5.262: + resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -356,8 +356,8 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} fs-extra@11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} @@ -478,8 +478,8 @@ packages: peerDependencies: postcss: ^8.1.0 - postcss-selector-parser@7.1.0: - resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} postcss-value-parser@4.2.0: @@ -557,8 +557,8 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true @@ -572,39 +572,39 @@ packages: snapshots: - '@biomejs/biome@2.3.4': + '@biomejs/biome@2.3.8': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.4 - '@biomejs/cli-darwin-x64': 2.3.4 - '@biomejs/cli-linux-arm64': 2.3.4 - '@biomejs/cli-linux-arm64-musl': 2.3.4 - '@biomejs/cli-linux-x64': 2.3.4 - '@biomejs/cli-linux-x64-musl': 2.3.4 - '@biomejs/cli-win32-arm64': 2.3.4 - '@biomejs/cli-win32-x64': 2.3.4 + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 - '@biomejs/cli-darwin-arm64@2.3.4': + '@biomejs/cli-darwin-arm64@2.3.8': optional: true - '@biomejs/cli-darwin-x64@2.3.4': + '@biomejs/cli-darwin-x64@2.3.8': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.4': + '@biomejs/cli-linux-arm64-musl@2.3.8': optional: true - '@biomejs/cli-linux-arm64@2.3.4': + '@biomejs/cli-linux-arm64@2.3.8': optional: true - '@biomejs/cli-linux-x64-musl@2.3.4': + '@biomejs/cli-linux-x64-musl@2.3.8': optional: true - '@biomejs/cli-linux-x64@2.3.4': + '@biomejs/cli-linux-x64@2.3.8': optional: true - '@biomejs/cli-win32-arm64@2.3.4': + '@biomejs/cli-win32-arm64@2.3.8': optional: true - '@biomejs/cli-win32-x64@2.3.4': + '@biomejs/cli-win32-x64@2.3.8': optional: true '@esbuild/aix-ppc64@0.27.0': @@ -699,17 +699,17 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - autoprefixer@10.4.21(postcss@8.5.6): + autoprefixer@10.4.22(postcss@8.5.6): dependencies: - browserslist: 4.27.0 - caniuse-lite: 1.0.30001754 - fraction.js: 4.3.7 + browserslist: 4.28.0 + caniuse-lite: 1.0.30001757 + fraction.js: 5.3.4 normalize-range: 0.1.2 picocolors: 1.1.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 - baseline-browser-mapping@2.8.25: {} + baseline-browser-mapping@2.8.32: {} binary-extensions@2.3.0: {} @@ -717,15 +717,15 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.27.0: + browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.25 - caniuse-lite: 1.0.30001754 - electron-to-chromium: 1.5.249 + baseline-browser-mapping: 2.8.32 + caniuse-lite: 1.0.30001757 + electron-to-chromium: 1.5.262 node-releases: 2.0.27 - update-browserslist-db: 1.1.4(browserslist@4.27.0) + update-browserslist-db: 1.1.4(browserslist@4.28.0) - caniuse-lite@1.0.30001754: {} + caniuse-lite@1.0.30001757: {} choices.js@11.1.0: dependencies: @@ -765,7 +765,7 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - electron-to-chromium@1.5.249: {} + electron-to-chromium@1.5.262: {} emoji-regex@8.0.0: {} @@ -808,7 +808,7 @@ snapshots: dependencies: to-regex-range: 5.0.1 - fraction.js@4.3.7: {} + fraction.js@5.3.4: {} fs-extra@11.3.2: dependencies: @@ -890,14 +890,14 @@ snapshots: postcss-load-config@5.1.0(postcss@8.5.6): dependencies: lilconfig: 3.1.3 - yaml: 2.8.1 + yaml: 2.8.2 optionalDependencies: postcss: 8.5.6 postcss-nested@7.0.2(postcss@8.5.6): dependencies: postcss: 8.5.6 - postcss-selector-parser: 7.1.0 + postcss-selector-parser: 7.1.1 postcss-reporter@7.1.0(postcss@8.5.6): dependencies: @@ -905,7 +905,7 @@ snapshots: postcss: 8.5.6 thenby: 1.3.4 - postcss-selector-parser@7.1.0: + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -959,9 +959,9 @@ snapshots: universalify@2.0.1: {} - update-browserslist-db@1.1.4(browserslist@4.27.0): + update-browserslist-db@1.1.4(browserslist@4.28.0): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -975,7 +975,7 @@ snapshots: y18n@5.0.8: {} - yaml@2.8.1: {} + yaml@2.8.2: {} yargs-parser@21.1.1: {} From f02a32d887a2a05694dc36aa543b1bb052aed395 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:34:36 +0000 Subject: [PATCH 04/32] deps --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 81b2736b..4af94e71 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/goodsign/monday v1.0.2 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 - github.com/klauspost/compress v1.18.1 + github.com/klauspost/compress v1.18.2 github.com/labstack/echo/v4 v4.13.4 github.com/mcuadros/go-defaults v1.2.0 github.com/oapi-codegen/runtime v1.1.2 diff --git a/go.sum b/go.sum index be0cc34b..ea010183 100644 --- a/go.sum +++ b/go.sum @@ -169,8 +169,8 @@ github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYA github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= From 97d83504f3ebeb414d9ed141ce5762996702b3e2 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:40:30 +0000 Subject: [PATCH 05/32] version bump --- taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taskfile.yml b/taskfile.yml index e58fb534..61d1e6b4 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -1,6 +1,6 @@ version: "3" env: - VERSION: 0.27.0 + VERSION: 0.28.1 includes: frontend: From 64f26c0f418ea73ba645be43478c24978c444bcd Mon Sep 17 00:00:00 2001 From: Mike Mulhearn Date: Wed, 26 Nov 2025 02:04:53 -0800 Subject: [PATCH 06/32] feat: Add image background blur to zoom and super zoom image effects This change is intended to add the image background blur to zoom and super zoom image effects when background blur is enabled. Current example config where background blur is absent: background_blur: true background_blur_amount: 4 layout: splitview image_fit: contain image_effect: zoom image_effect_amount: 120 --- internal/routes/routes_asset_helpers.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/routes/routes_asset_helpers.go b/internal/routes/routes_asset_helpers.go index 84bd64f8..a008a1a1 100644 --- a/internal/routes/routes_asset_helpers.go +++ b/internal/routes/routes_asset_helpers.go @@ -375,8 +375,7 @@ func imageToBase64(img image.Image, config config.Config, requestID, deviceID st func processBlurredImage(img image.Image, assetType immich.AssetType, config config.Config, requestID, deviceID string, isPrefetch bool) (string, error) { isImage := assetType == immich.ImageType shouldSkipBlur := !config.BackgroundBlur || - (strings.EqualFold(config.ImageFit, "cover") && !config.LivePhotos) || - (config.ImageEffect != "" && config.ImageEffect != "none" && config.Layout != "single" && !config.LivePhotos) + (strings.EqualFold(config.ImageFit, "cover") && !config.LivePhotos) if isImage && shouldSkipBlur { return "", nil From 79e8703fc47ecaf6f23c580af263aea97d95766a Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:47:11 +0000 Subject: [PATCH 07/32] skipBlurHelper much easier to read now IMO --- internal/routes/routes_asset_helpers.go | 28 +++++++++- internal/routes/routes_test.go | 68 +++++++++++++++++++++++++ internal/video/video.go | 3 +- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/internal/routes/routes_asset_helpers.go b/internal/routes/routes_asset_helpers.go index a008a1a1..430071df 100644 --- a/internal/routes/routes_asset_helpers.go +++ b/internal/routes/routes_asset_helpers.go @@ -370,12 +370,36 @@ func imageToBase64(img image.Image, config config.Config, requestID, deviceID st return imgBytes, nil } +// shouldSkipBlur +// - Blur is skipped when blur is off or when an image effect is used with “cover” +// - If “cover” is used with Live Photos, blur is always kept. +func shouldSkipBlur(config config.Config) bool { + if !config.BackgroundBlur { + return true + } + + usingImageCover := strings.EqualFold(config.ImageFit, "cover") + + // Skip if using image cover with live photos off + if coverNoLiveVideo := usingImageCover && !config.LivePhotos; coverNoLiveVideo { + return true + } + + // using zoom or smart-zoom effect + hasImageEffect := config.ImageEffect != "" && config.ImageEffect != "none" + + if hasImageEffect && usingImageCover && !config.LivePhotos { + return true + } + + return false +} + // processBlurredImage applies a blur effect to the image if required by the configuration. // It returns the blurred image as a base64 string and an error if any occurs. func processBlurredImage(img image.Image, assetType immich.AssetType, config config.Config, requestID, deviceID string, isPrefetch bool) (string, error) { isImage := assetType == immich.ImageType - shouldSkipBlur := !config.BackgroundBlur || - (strings.EqualFold(config.ImageFit, "cover") && !config.LivePhotos) + shouldSkipBlur := shouldSkipBlur(config) if isImage && shouldSkipBlur { return "", nil diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index b6bb07dd..62d65c24 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -155,3 +155,71 @@ func TestTruncateURLQueries(t *testing.T) { }) } } + +func TestShouldSkipBlur(t *testing.T) { + tests := []struct { + name string + config config.Config + shouldSkip bool + }{ + { + name: "Blur off disables blur", + config: config.Config{BackgroundBlur: false}, + shouldSkip: true, + }, + { + name: "Blur on, image fit cover, live photos enabled", + config: config.Config{BackgroundBlur: true, ImageFit: "cover", LivePhotos: true}, + shouldSkip: false, + }, + { + name: "Blur on, image fit cover, live photos disabled", + config: config.Config{BackgroundBlur: true, ImageFit: "cover", LivePhotos: false}, + shouldSkip: true, + }, + { + name: "Blur on, image fit cover, image effect zoom", + config: config.Config{BackgroundBlur: true, ImageFit: "cover", ImageEffect: "zoom"}, + shouldSkip: true, + }, + { + name: "Blur on, image fit cover, image effect smart-zoom", + config: config.Config{BackgroundBlur: true, ImageFit: "cover", ImageEffect: "smart-zoom"}, + shouldSkip: true, + }, + { + name: "Blur on, image fit contain, no effect", + config: config.Config{BackgroundBlur: true, ImageFit: "contain", ImageEffect: ""}, + shouldSkip: false, + }, + { + name: "Blur on, image fit cover, effect none", + config: config.Config{BackgroundBlur: true, ImageFit: "cover", ImageEffect: "none"}, + shouldSkip: true, + }, + { + name: "Blur on, image fit cover, effect zoom, live photos enabled", + config: config.Config{BackgroundBlur: true, ImageFit: "cover", ImageEffect: "zoom", LivePhotos: true}, + shouldSkip: false, + }, + { + name: "Blur on, image fit contain, image effect smart-zoom", + config: config.Config{BackgroundBlur: true, ImageFit: "contain", ImageEffect: "smart-zoom"}, + shouldSkip: false, + }, + { + name: "Blur on, layout splitview, image fit contain, image effect zoom, live photos disabled", + config: config.Config{BackgroundBlur: true, Layout: "splitview", ImageFit: "contain", ImageEffect: "zoom", LivePhotos: false}, + shouldSkip: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldSkipBlur(tt.config) + if got != tt.shouldSkip { + t.Errorf("shouldSkipBlur() = %v, want %v", got, tt.shouldSkip) + } + }) + } +} diff --git a/internal/video/video.go b/internal/video/video.go index 9036fefa..202b963b 100644 --- a/internal/video/video.go +++ b/internal/video/video.go @@ -279,7 +279,8 @@ func (v *Manager) DownloadVideo(immichAsset immich.Asset, requestConfig config.C log.Error("Image BytesToImage", "err", imgErr) } - img = utils.ApplyExifOrientation(img, immichAsset.ExifInfo.Orientation) + // I think this is incorrectly rotating the blurred background for videos + // img = utils.ApplyExifOrientation(img, immichAsset.ExifInfo.Orientation) if requestConfig.OptimizeImages { img, imgErr = utils.OptimizeImage(img, requestConfig.ClientData.Width, requestConfig.ClientData.Height) From 13bb50fa46834ed078bb7c959ba5527413d37fa6 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:40:18 +0000 Subject: [PATCH 08/32] simplify --- internal/routes/routes_asset_helpers.go | 9 +-------- internal/video/video.go | 3 --- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/internal/routes/routes_asset_helpers.go b/internal/routes/routes_asset_helpers.go index 430071df..738f6ae2 100644 --- a/internal/routes/routes_asset_helpers.go +++ b/internal/routes/routes_asset_helpers.go @@ -381,14 +381,7 @@ func shouldSkipBlur(config config.Config) bool { usingImageCover := strings.EqualFold(config.ImageFit, "cover") // Skip if using image cover with live photos off - if coverNoLiveVideo := usingImageCover && !config.LivePhotos; coverNoLiveVideo { - return true - } - - // using zoom or smart-zoom effect - hasImageEffect := config.ImageEffect != "" && config.ImageEffect != "none" - - if hasImageEffect && usingImageCover && !config.LivePhotos { + if usingImageCover && !config.LivePhotos { return true } diff --git a/internal/video/video.go b/internal/video/video.go index 202b963b..963b507a 100644 --- a/internal/video/video.go +++ b/internal/video/video.go @@ -279,9 +279,6 @@ func (v *Manager) DownloadVideo(immichAsset immich.Asset, requestConfig config.C log.Error("Image BytesToImage", "err", imgErr) } - // I think this is incorrectly rotating the blurred background for videos - // img = utils.ApplyExifOrientation(img, immichAsset.ExifInfo.Orientation) - if requestConfig.OptimizeImages { img, imgErr = utils.OptimizeImage(img, requestConfig.ClientData.Width, requestConfig.ClientData.Height) if imgErr != nil { From c0f0255f49c15d98c324411699f28c4f7388f8d1 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:48:56 +0000 Subject: [PATCH 09/32] docs --- internal/routes/routes_asset_helpers.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/routes/routes_asset_helpers.go b/internal/routes/routes_asset_helpers.go index 738f6ae2..a9a29256 100644 --- a/internal/routes/routes_asset_helpers.go +++ b/internal/routes/routes_asset_helpers.go @@ -370,9 +370,9 @@ func imageToBase64(img image.Image, config config.Config, requestID, deviceID st return imgBytes, nil } -// shouldSkipBlur -// - Blur is skipped when blur is off or when an image effect is used with “cover” -// - If “cover” is used with Live Photos, blur is always kept. +// shouldSkipBlur determines whether background blur should be skipped. +// - Blur is skipped when BackgroundBlur is disabled. +// - Blur is skipped when ImageFit is "cover" and LivePhotos is disabled. func shouldSkipBlur(config config.Config) bool { if !config.BackgroundBlur { return true @@ -392,9 +392,9 @@ func shouldSkipBlur(config config.Config) bool { // It returns the blurred image as a base64 string and an error if any occurs. func processBlurredImage(img image.Image, assetType immich.AssetType, config config.Config, requestID, deviceID string, isPrefetch bool) (string, error) { isImage := assetType == immich.ImageType - shouldSkipBlur := shouldSkipBlur(config) + skipBlur := shouldSkipBlur(config) - if isImage && shouldSkipBlur { + if isImage && skipBlur { return "", nil } From ef998afae7f5544eff09f9729df174daa2e57916 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:54:04 +0000 Subject: [PATCH 10/32] add weather api secret --- config.schema.json | 2 +- internal/config/config.go | 14 +++++++------ internal/config/config_validation.go | 31 +++++++++++++++++++++------- internal/video/video.go | 2 +- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/config.schema.json b/config.schema.json index ffe5ef31..b42d214f 100644 --- a/config.schema.json +++ b/config.schema.json @@ -325,7 +325,7 @@ "type": "boolean" } }, - "required": ["name", "lat", "lon", "api"] + "required": ["name", "lat", "lon"] } }, "webhooks": { diff --git a/internal/config/config.go b/internal/config/config.go index 5c5ee882..5fede010 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,12 +57,14 @@ const ( redactedMarker = "REDACTED" // Secrets - systemdCredDirEnv = "CREDENTIALS_DIRECTORY" - systemdCredAPIKeyFileEnv = "kiosk_immich_api_key" - systemdCredPasswordFileEnv = "kiosk_password_file" - - apiKeyFileEnv = "KIOSK_IMMICH_API_KEY_FILE" - passwordFileEnv = "KIOSK_PASSWORD_FILE" + systemdCredDirEnv = "CREDENTIALS_DIRECTORY" + systemdCredAPIKeyFileEnv = "kiosk_immich_api_key" + systemdCredPasswordFileEnv = "kiosk_password_file" + systemdCredWeatherAPIKeyFileEnv = "kiosk_weather_api_key_file" + + apiKeyFileEnv = "KIOSK_IMMICH_API_KEY_FILE" + passwordFileEnv = "KIOSK_PASSWORD_FILE" + weatherAPIKeyFileEnv = "KIOSK_WEATHER_API_KEY_FILE" ) type OfflineMode struct { diff --git a/internal/config/config_validation.go b/internal/config/config_validation.go index 91f8800a..73900c69 100644 --- a/internal/config/config_validation.go +++ b/internal/config/config_validation.go @@ -104,9 +104,6 @@ func loadSecretFromFile(filePath string) (string, bool) { } func (c *Config) checkSecrets() { - if c.ImmichAPIKey != "" { - return - } apiKeyFile := os.Getenv(apiKeyFileEnv) if apiKeyFile != "" { @@ -126,6 +123,20 @@ func (c *Config) checkSecrets() { } } + weatherAPIFile := os.Getenv(weatherAPIKeyFileEnv) + if weatherAPIFile != "" { + weatherAPIFile = filepath.Clean(weatherAPIFile) + if weatherAPIKey, ok := loadSecretFromFile(weatherAPIFile); ok { + log.Info("Loaded weather API key", "source", "docker secret") + for i, location := range c.WeatherLocations { + if location.API == "" { + log.Info("Added weather API key to", "location", location.Name) + c.WeatherLocations[i].API = weatherAPIKey + } + } + } + } + credsDir := os.Getenv(systemdCredDirEnv) if credsDir != "" { systemdAPIFile := filepath.Clean(filepath.Join(credsDir, systemdCredAPIKeyFileEnv)) @@ -139,6 +150,14 @@ func (c *Config) checkSecrets() { log.Info("Loaded password", "source", "systemd credential") c.Kiosk.Password = password } + + systemdWeatherAPIFile := filepath.Clean(filepath.Join(credsDir, systemdCredWeatherAPIKeyFileEnv)) + if weatherAPIKey, ok := loadSecretFromFile(systemdWeatherAPIFile); ok { + log.Info("Loaded weather api", "source", "systemd credential") + for _, location := range c.WeatherLocations { + location.API = weatherAPIKey + } + } } } @@ -259,11 +278,7 @@ func (c *Config) checkWeatherLocations() { missingFields = append(missingFields, "longitude") } if w.API == "" { - if c.Kiosk.DemoMode && os.Getenv("KIOSK_DEMO_WEATHER_API") != "" { - w.API = os.Getenv("KIOSK_DEMO_WEATHER_API") - } else { - missingFields = append(missingFields, "API key") - } + missingFields = append(missingFields, "API key") } if w.Default { if c.HasWeatherDefault { diff --git a/internal/video/video.go b/internal/video/video.go index 963b507a..0a6425b1 100644 --- a/internal/video/video.go +++ b/internal/video/video.go @@ -64,7 +64,7 @@ func initialise() error { return err } - log.Info("created video tmp dir", "path", customTempVideoDir) + log.Info("Created video tmp dir", "path", customTempVideoDir) return nil } From c46440d405ee115f9c08fdafd428140d62fee13a Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:14:37 +0000 Subject: [PATCH 11/32] Update internal/config/config_validation.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/config/config_validation.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/config/config_validation.go b/internal/config/config_validation.go index 73900c69..bc39c4fc 100644 --- a/internal/config/config_validation.go +++ b/internal/config/config_validation.go @@ -154,8 +154,11 @@ func (c *Config) checkSecrets() { systemdWeatherAPIFile := filepath.Clean(filepath.Join(credsDir, systemdCredWeatherAPIKeyFileEnv)) if weatherAPIKey, ok := loadSecretFromFile(systemdWeatherAPIFile); ok { log.Info("Loaded weather api", "source", "systemd credential") - for _, location := range c.WeatherLocations { - location.API = weatherAPIKey + for i, location := range c.WeatherLocations { + if location.API == "" { + log.Info("Added weather API key to", "location", location.Name) + c.WeatherLocations[i].API = weatherAPIKey + } } } } From bd8cd13063303b4cada6fdf2f1a229beb0805a1d Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:29:59 +0000 Subject: [PATCH 12/32] typo --- internal/config/config_validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config_validation.go b/internal/config/config_validation.go index bc39c4fc..4df679a2 100644 --- a/internal/config/config_validation.go +++ b/internal/config/config_validation.go @@ -153,7 +153,7 @@ func (c *Config) checkSecrets() { systemdWeatherAPIFile := filepath.Clean(filepath.Join(credsDir, systemdCredWeatherAPIKeyFileEnv)) if weatherAPIKey, ok := loadSecretFromFile(systemdWeatherAPIFile); ok { - log.Info("Loaded weather api", "source", "systemd credential") + log.Info("Loaded weather API key", "source", "systemd credential") for i, location := range c.WeatherLocations { if location.API == "" { log.Info("Added weather API key to", "location", location.Name) From 540b5d2070a21d2d6309351c7e7abce67398889d Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:09:53 +0000 Subject: [PATCH 13/32] extends cache expiry when longer then the default 5 min --- internal/cache/cache.go | 10 +++++++--- internal/immich/immich_helpers.go | 2 +- internal/immich/immich_memories.go | 2 +- internal/routes/routes_asset_helpers.go | 2 +- internal/templates/views/views_about.templ | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index aeadcf64..339e72ab 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -83,8 +83,12 @@ func Get(s string) (any, bool) { // Set adds an item to the cache with the default expiration time. // If the key already exists, its value will be overwritten. -func Set(key string, x any) { - kioskCache.Set(key, x, gocache.DefaultExpiration) +func Set(key string, x any, deviceDuration int) { + if deviceDuration >= int(defaultExpiration) { + kioskCache.Set(key, x, gocache.DefaultExpiration) + } + deviceDurationPlusMin := (time.Duration(deviceDuration) * time.Second) + time.Minute + SetWithExpiration(key, x, deviceDurationPlusMin) } // SetWithExpiration adds an item to the cache with the specified expiration duration. @@ -152,5 +156,5 @@ func assetToCache[T any](viewDataToAdd T, requestConfig *config.Config, deviceID cachedViewData = append([]T{viewDataToAdd}, cachedViewData...) } - Set(viewCacheKey, cachedViewData) + Set(viewCacheKey, cachedViewData, requestConfig.Duration) } diff --git a/internal/immich/immich_helpers.go b/internal/immich/immich_helpers.go index 65aaa27b..074dc5d7 100644 --- a/internal/immich/immich_helpers.go +++ b/internal/immich/immich_helpers.go @@ -85,7 +85,7 @@ func withImmichAPICache[T APIResponse](immichAPICall apiCall, requestID, deviceI return nil, contentType, err } - cache.Set(apiCacheKey, jsonBytes) + cache.Set(apiCacheKey, jsonBytes, requestConfig.Duration) if requestConfig.Kiosk.DebugVerbose { log.Debug(requestID+" Cache saved", "url", apiURL) } diff --git a/internal/immich/immich_memories.go b/internal/immich/immich_memories.go index 5d3062b6..f3b83c62 100644 --- a/internal/immich/immich_memories.go +++ b/internal/immich/immich_memories.go @@ -116,7 +116,7 @@ func (a *Asset) memoriesWithPastDays(requestID, deviceID string, assetCount bool return memories, apiURL, marshalErr } - cache.Set(cacheKey, b) + cache.Set(cacheKey, b, a.requestConfig.Duration) return memories, apiURL, nil } diff --git a/internal/routes/routes_asset_helpers.go b/internal/routes/routes_asset_helpers.go index a9a29256..4507bc7f 100644 --- a/internal/routes/routes_asset_helpers.go +++ b/internal/routes/routes_asset_helpers.go @@ -670,7 +670,7 @@ func renderCachedViewData(c echo.Context, cachedViewData []common.ViewData, requ cacheKey := cache.ViewCacheKey(c.Request().URL.String(), deviceID) viewDataToRender := cachedViewData[0] - cache.Set(cacheKey, cachedViewData[1:]) + cache.Set(cacheKey, cachedViewData[1:], requestConfig.Duration) // Update history which will be outdated in cache utils.TrimHistory(&requestConfig.History, kiosk.HistoryLimit) diff --git a/internal/templates/views/views_about.templ b/internal/templates/views/views_about.templ index 11c9b914..64097e8d 100644 --- a/internal/templates/views/views_about.templ +++ b/internal/templates/views/views_about.templ @@ -177,7 +177,7 @@ func getLatestRelease(owner, repo string) (string, string) { link = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", owner, repo, release.TagName) - cache.Set(url, release) + cache.Set(url, release, 0) return release.TagName, link } From 93ef2a021362563b8904d9cf31769eada5da2131 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:15:15 +0000 Subject: [PATCH 14/32] Update cache.go --- internal/cache/cache.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 339e72ab..55c6ca99 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -81,8 +81,10 @@ func Get(s string) (any, bool) { return kioskCache.Get(s) } -// Set adds an item to the cache with the default expiration time. -// If the key already exists, its value will be overwritten. +// Set stores a value in the cache under the given key. +// If deviceDuration is less than the defaultExpiration, the default expiration is used. +// Otherwise, the item expires after deviceDuration plus one extra minute. +// If the key already exists, its value is replaced. func Set(key string, x any, deviceDuration int) { if deviceDuration >= int(defaultExpiration) { kioskCache.Set(key, x, gocache.DefaultExpiration) From b0199aa7df81c88bbd6458d3ba375efff5999b51 Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:17:23 +0000 Subject: [PATCH 15/32] =?UTF-8?q?fix=20unit=20mismatch=20=F0=9F=99=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cache/cache.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 55c6ca99..c8e1978f 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -86,10 +86,11 @@ func Get(s string) (any, bool) { // Otherwise, the item expires after deviceDuration plus one extra minute. // If the key already exists, its value is replaced. func Set(key string, x any, deviceDuration int) { - if deviceDuration >= int(defaultExpiration) { + deviceDurationPlusMin := (time.Duration(deviceDuration) * time.Second) + time.Minute + if deviceDurationPlusMin >= defaultExpiration { kioskCache.Set(key, x, gocache.DefaultExpiration) + return } - deviceDurationPlusMin := (time.Duration(deviceDuration) * time.Second) + time.Minute SetWithExpiration(key, x, deviceDurationPlusMin) } From 0dc210c811ab9f535cd0cc816b2562dacea423ed Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:22:42 +0000 Subject: [PATCH 16/32] Update cache.go --- internal/cache/cache.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index c8e1978f..646283c9 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -87,7 +87,7 @@ func Get(s string) (any, bool) { // If the key already exists, its value is replaced. func Set(key string, x any, deviceDuration int) { deviceDurationPlusMin := (time.Duration(deviceDuration) * time.Second) + time.Minute - if deviceDurationPlusMin >= defaultExpiration { + if deviceDurationPlusMin <= defaultExpiration { kioskCache.Set(key, x, gocache.DefaultExpiration) return } From e78ee105c022667686a4a4457374ae3b7e15895d Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:39:06 +0000 Subject: [PATCH 17/32] 0 and neg check --- internal/cache/cache.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 646283c9..18a37bfd 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -86,6 +86,11 @@ func Get(s string) (any, bool) { // Otherwise, the item expires after deviceDuration plus one extra minute. // If the key already exists, its value is replaced. func Set(key string, x any, deviceDuration int) { + if deviceDuration < 0 { + log.Warn("Negative duration provided, using default expiration", "deviceDuration", deviceDuration) + kioskCache.Set(key, x, gocache.DefaultExpiration) + return + } deviceDurationPlusMin := (time.Duration(deviceDuration) * time.Second) + time.Minute if deviceDurationPlusMin <= defaultExpiration { kioskCache.Set(key, x, gocache.DefaultExpiration) From 41aaf93fa503a53e1d2b9e25656e2ff111dd0aad Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:22:41 +0000 Subject: [PATCH 18/32] Create cache_test.go --- internal/cache/cache_test.go | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 internal/cache/cache_test.go diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 00000000..6bdb89b4 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,73 @@ +package cache + +import ( + "fmt" + "os" + "testing" + "time" +) + +func TestMain(m *testing.M) { + Initialize() + code := m.Run() + kioskCache.Flush() + os.Exit(code) +} + +func TestCacheSet(t *testing.T) { + + tests := []struct { + name string + duration int + want time.Duration + }{ + { + name: "Zero duration", + duration: 0, + want: defaultExpiration, + }, + { + name: "Less then default expiration", + duration: 10, + want: defaultExpiration, + }, + { + name: "More then default expiration", + duration: 360, // 6 minutes + want: (6 * time.Minute) + time.Minute, + }, + { + name: "30 minutes more then default expiration", + duration: 1800, // 30 minutes + want: (30 * time.Minute) + time.Minute, + }, + { + name: "Negative duration", + duration: -10, + want: defaultExpiration, + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + key := fmt.Sprintf("test_%d", i) + + expected := time.Now().Add(tt.want) + + Set(key, key, tt.duration) + _, expiration, found := kioskCache.GetWithExpiration(key) + if !found { + t.Errorf("Expected key '%s' to be found in cache", key) + } + + expirationStr := expiration.Format("2006-01-02 15:04:05") + expectedStr := expected.Format("2006-01-02 15:04:05") + + if expirationStr != expectedStr { + t.Errorf("Expected expiration '%v', got '%v'", expectedStr, expirationStr) + } + }) + } + +} From 4bfd3d85929c49f562302643958d6d89d6a3d05a Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:36:47 +0000 Subject: [PATCH 19/32] better test --- internal/cache/cache_test.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 6bdb89b4..9530d5b4 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -10,7 +10,7 @@ import ( func TestMain(m *testing.M) { Initialize() code := m.Run() - kioskCache.Flush() + Flush() os.Exit(code) } @@ -27,7 +27,7 @@ func TestCacheSet(t *testing.T) { want: defaultExpiration, }, { - name: "Less then default expiration", + name: "Less than default expiration", duration: 10, want: defaultExpiration, }, @@ -37,7 +37,7 @@ func TestCacheSet(t *testing.T) { want: (6 * time.Minute) + time.Minute, }, { - name: "30 minutes more then default expiration", + name: "30 minutes. More than default expiration", duration: 1800, // 30 minutes want: (30 * time.Minute) + time.Minute, }, @@ -46,6 +46,11 @@ func TestCacheSet(t *testing.T) { duration: -10, want: defaultExpiration, }, + { + name: "Exactly default expiration", + duration: 300, + want: defaultExpiration + time.Minute, + }, } for i, tt := range tests { @@ -61,12 +66,12 @@ func TestCacheSet(t *testing.T) { t.Errorf("Expected key '%s' to be found in cache", key) } - expirationStr := expiration.Format("2006-01-02 15:04:05") - expectedStr := expected.Format("2006-01-02 15:04:05") - - if expirationStr != expectedStr { - t.Errorf("Expected expiration '%v', got '%v'", expectedStr, expirationStr) + diff := expiration.Sub(expected) + const tolerance = 2 * time.Second + if diff < -tolerance || diff > tolerance { + t.Errorf("expected expiration within %v of %v, got %v (diff %v)", tolerance, expected, expiration, diff) } + }) } From cf01e864e9a71698ee16dfcbfff01d3bb7bc731f Mon Sep 17 00:00:00 2001 From: Damon <2184238+damongolding@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:48:05 +0000 Subject: [PATCH 20/32] init --- go.mod | 3 +- go.sum | 4 ++ internal/i18n/i18n.go | 54 +++++++++++++++++++++++ internal/templates/partials/menu.templ | 18 ++++---- internal/templates/views/views_home.templ | 10 ++++- locales/de.toml | 18 ++++++++ locales/en.toml | 18 ++++++++ locales/es.toml | 18 ++++++++ locales/fr.toml | 18 ++++++++ main.go | 7 +++ 10 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 internal/i18n/i18n.go create mode 100644 locales/de.toml create mode 100644 locales/en.toml create mode 100644 locales/es.toml create mode 100644 locales/fr.toml diff --git a/go.mod b/go.mod index 4af94e71..03c5de91 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,10 @@ require ( github.com/klauspost/compress v1.18.2 github.com/labstack/echo/v4 v4.13.4 github.com/mcuadros/go-defaults v1.2.0 + github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/oapi-codegen/runtime v1.1.2 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pelletier/go-toml/v2 v2.2.4 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -76,7 +78,6 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oliamb/cutter v0.2.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index ea010183..57a2476d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/EdlinOrg/prominentcolor v1.0.0 h1:sQNY8Dtsv3PK3J1LbmrDmtlZm9Y9U8Loi1iZIl4YN3Y= github.com/EdlinOrg/prominentcolor v1.0.0/go.mod h1:mYmDsxfcmBz6izH/SqtSzfsUiZdPNPpPgUPKCZq70KQ= github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= @@ -214,6 +216,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 00000000..773703c4 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,54 @@ +package i18n + +import ( + "embed" + + "github.com/charmbracelet/log" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/pelletier/go-toml/v2" + "golang.org/x/text/language" +) + +var ( + LocaleFS embed.FS + Bundle *i18n.Bundle +) + +// Init initializes the i18n bundle and loads message files. +func Init() error { + Bundle = i18n.NewBundle(language.English) + Bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + + locales := []string{"en", "de", "fr", "es"} + for _, loc := range locales { + if _, err := Bundle.LoadMessageFileFS(LocaleFS, "locales/"+loc+".toml"); err != nil { + return err + } + } + + return nil +} + +// T returns a translation function for the given locale. +func T(locale string) func(string) string { + defaultLocalizer := i18n.NewLocalizer(Bundle, "en") + localizer := i18n.NewLocalizer(Bundle, locale) + + return func(key string) string { + translated, err := localizer.Localize(&i18n.LocalizeConfig{ + MessageID: key, + }) + if err == nil { + return translated + } + + defaultTranslated, err := defaultLocalizer.Localize(&i18n.LocalizeConfig{ + MessageID: key, + }) + if err != nil { + log.Error("failed to translate", "error", err) + return key // fallback + } + return defaultTranslated + } +} diff --git a/internal/templates/partials/menu.templ b/internal/templates/partials/menu.templ index 3d41d1f1..6675741f 100644 --- a/internal/templates/partials/menu.templ +++ b/internal/templates/partials/menu.templ @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/charmbracelet/log" "github.com/damongolding/immich-kiosk/internal/common" + "github.com/damongolding/immich-kiosk/internal/i18n" "github.com/damongolding/immich-kiosk/internal/webhooks" "net/url" "time" @@ -46,6 +47,7 @@ func webhookAttributes(viewData common.ViewData, event, secret string) templ.Att } templ Menu(viewData common.ViewData, queries url.Values, secret string) { + {{ t := i18n.T(string(viewData.SystemLang)) }} {{ hasCustomNavigation := viewData.Webhooks.ContainsEvent(webhooks.UserNavigationCustom.String()) }}