Self hosted App 101 Problem solving books Immich DJI 동영상을 스트리밍 가능한 형식으로 변환 방법 DJI 동영상을 맥북프로에서 스트리밍용으로 압축하는 가장 좋은 방법은 무료 인코딩 프로그램인 HandBrake를 사용하는 것입니다. H.264 코덱, 1080p 해상도, 30fps 및 웹 최적화 설정을 적용하면 화질 저하를 최소화하면서 용량을 크게 줄일 수 있습니다. [1, 2, 3, 4] HandBrake를 이용한 변환 방법 프로그램 다운로드 및 실행: HandBrake 공식 웹사이트에서 맥용 버전을 내려받아 실행합니다. [1] 파일 불러오기: 변환할 DJI 원본 동영상 파일을 프로그램 창으로 드래그하여 넣습니다. [1] 프리셋 선택: 상단 메뉴에서 Presets를 누르고 Fast 1080p30 또는 Vimeo YouTube 1080p30 프리셋을 선택합니다. [1] 비디오 설정 (Video 탭): Video Encoder: H.264 선택 (가장 호환성이 높음) Framerate (FPS): Same as source, Constant Framerate 선택 Quality: Constant Quality 슬라이더를 움직여 RF 22 ~ 24 사이로 설정 (숫자가 높을수록 용량이 작아짐) [1, 2, 3] 웹 스트리밍 최적화 (Summary 탭): Web Optimized (또는 Fast Start) 옵션에 체크 (영상이 웹에서 즉시 재생되도록 메타데이터를 앞으로 이동) [1, 2] 인코딩 시작: 상단 녹색의 Start Encode 버튼을 눌러 변환을 완료합니다. [1, 2] 만약 4K 원본 영상을 1080p로 해상도를 낮추거나, 대량의 파일을 동시에 일괄 변환(Queue)하는 방법이 필요하신가요? DJI 동영상 최적 변환 조건 For the best streaming performance and browser compatibility on an Immich server, encode your DJI files using H.264 video (8-bit), AAC audio, and an MP4 container with the Web Optimized (FastStart) box checked. [1] Recommended Handbrake Settings Format: MP4 Web Optimized: Checked (Crucial for streaming so playback starts immediately without downloading the whole file) Video Codec: H.264 (x264) Framerate: Same as source (Peak framerate) Quality: Constant Quality (RF) 20 to 22 (Lower RF means higher quality; 22 is a great sweet spot for drone footage balance) Audio Codec: AAC (Mixdown: Stereo, Bitrate: 160 or 192) [1, 2] Why This Option Works Best for Immich Maximum Compatibility: H.264 plays natively on virtually all web browsers, mobile apps (iOS/Android), and casting devices without forcing your Immich server to spend CPU/GPU resources on-the-fly transcoding. [1, 2] No Stuttering: DJI files often record at very high bitrates (60–100+ Mbps) in H.265/H.264 inside heavy .MOV containers, which choke remote or mobile web streams. Compressing and enabling "FastStart" allows smooth progressive loading. Preserves Originals: Immich will store your optimized file for lightning-fast web playback while keeping your storage workflow efficient. [1] If you want to fine-tune this, let me know: What resolution and framerate are your original DJI files (e.g., 4K at 60fps)? Are you trying to save space or retain absolute maximum visual fidelity? Nextcloud Configure Redis UNIX socket 1 Set Redis to listen on a Unix socketEdit /etc/redis/redis.conf and add or uncomment: unixsocket /var/run/redis/redis-server.sock unixsocketperm 770 You can leave the TCP `port 6379` line as-is or set `port 0` to disable TCP entirely once you confirm the socket works. 2 Fix the socket directory and ownershipThe socket file is created by the redis user (usually `redis`). Your web server user (usually `www-data` for PHP-FPM/nginx or apache) needs read/write access to it. Add www-data to the redis group: sudo usermod -aG redis www-data Then make sure the socket's group matches: unixsocketperm 770 combined with group ownership `redis:redis` means www-data (now in the redis group) can connect. 3 Restart Redis and verify the socket existssudo systemctl restart redis-server ls -l /var/run/redis/redis-server.sock You should see something like srwxrwx--- 1 redis redis ... redis-server.sock. If the file's missing, check `sudo systemctl status redis-server` and `journalctl -u redis-server` for errors — often it's a directory permissions or AppArmor issue. 4 Test the socket connectionredis-cli -s /var/run/redis/redis-server.sock ping This should return PONG. If it hangs or errors, the socket permissions or path are wrong — recheck step 2. 5 Update config.php to use only the socket blockRemove or comment out the TCP `'redis' => array('host' => 'localhost', 'port' => 6379, ...)` block, keeping only: 'redis' => array ( 'host' => '/var/run/redis/redis-server.sock', 'port' => 0, 'timeout' => 1.5, 'dbindex' => 0, ), Use dbindex 0 unless you're intentionally isolating Nextcloud from other apps sharing this Redis instance — keep it consistent, don't leave a stray db index from testing. 6 Clear cache and restart PHP-FPMsudo systemctl restart php8.x-fpm (match your actual PHP version) Then as your Nextcloud admin user, run: sudo -u www-data php occ status sudo -u www-data php occ maintenance:repair This confirms Nextcloud can reach Redis and forces it to re-read config.php. 7 Confirm Nextcloud is actually using itCheck Settings > Administration > Overview for warnings, or run: sudo -u www-data php occ check You can also watch live traffic on the socket with: redis-cli -s /var/run/redis/redis-server.sock monitor while clicking around in Nextcloud — you should see GET/SET/lock commands streaming in. Upgrade php8.3 to php8.4 grep -E 'memory_limit|upload_max_filesize|post_max_size|max_execution_time|opcache|date.timezone|output_buffering' /etc/php/8.3/fpm/php.ini /etc/php/8.3/fpm/conf.d/*.ini  for php.ini   clear_env = no request_terminate_timeout = 300 ; Match max_execution_time to handle long uploads gracefully.   process.priority = -19 ; Optional: Boost priority (requires root and SYS_NICE capability). pm = dynamic ; "dynamic" adjusts the number of child processes based on load; use "static" for dedicated servers with predictable traffic.   pm.max_children = 120 ; Max number of child processes; calculate as (Total RAM - 1G for OS/other services) / 50-100 MB per process. ; Example: 4 GB RAM → ~60-80 children; 8 GB RAM → ~120-150 children.   pm.start_servers = 12 ; Initial processes; set to ~10-20% of max_children.   pm.min_spare_servers = 6 ; Minimum idle processes; set to ~5-10% of max_children.   pm.max_spare_servers = 18 ; Maximum idle processes; set to ~15-25% of max_children.   pm.max_requests = 500 ; Restart child processes after this many requests to prevent memory leaks. for pool.d/www.conf  Check conf.d/*  Copy 10-opcache Karakeep Set AI with Gemini "Inference Jobs" are Karakeep's AI features: after you save a bookmark, it sends the content to a language model (OpenAI or a local Ollama) to generate tags and a summary. Failed: 36 means 36 of those requests errored out and ran out of retries. Your bookmarks are still saved, they just didn't get AI tags or summaries. Nothing else is broken. Find the actual error bash docker logs --tail 200 karakeep 2>&1 | grep -iE "inference|error|fail|ollama|openai" Use your real container name from docker ps. The error message usually tells you which of these it is: Error Cause Fix 401 / Incorrect API key Wrong or missing OPENAI_API_KEY Check the key in your .env or compose file, then recreate the container 429 / quota / rate limit No credit left or too many requests Add billing credit, or throttle ECONNREFUSED / fetch failed Ollama unreachable from inside Docker See below model not found Model name doesn't match what's pulled ollama pull and match INFERENCE_TEXT_MODEL exactly timeout / aborted Model too slow (very common on CPU-only machines) Raise the timeout JSON/parse or schema error Small local models often fail to produce valid structured output Set INFERENCE_OUTPUT_SCHEMA=plain or use a bigger model If you use Ollama Inside a container, localhost is the container itself, not your host. Use the host's address instead: yaml environment: OLLAMA_BASE_URL: http://host.docker.internal:11434 INFERENCE_TEXT_MODEL: llama3.2:3b INFERENCE_IMAGE_MODEL: llava INFERENCE_JOB_TIMEOUT_SEC: 300 INFERENCE_CONTEXT_LENGTH: 4096 extra_hosts: - "host.docker.internal:host-gateway" Ollama also listens only on 127.0.0.1 by default. For Docker to reach it, set OLLAMA_HOST=0.0.0.0 (e.g. systemctl edit ollama and add Environment="OLLAMA_HOST=0.0.0.0"). Firewall port 11434 so it's not exposed publicly. Test from inside the container: bash docker exec karakeep wget -qO- http://host.docker.internal:11434/api/tags The default job timeout is short (about 30 seconds), and on laptop-class CPUs a local model easily takes longer, so raising INFERENCE_JOB_TIMEOUT_SEC is often the whole fix. If you use OpenAI Check OPENAI_API_KEY, that the account has credit, and that INFERENCE_TEXT_MODEL is a model your key can access. If you route through a proxy or compatible provider, OPENAI_BASE_URL has to be correct. Clearing the 36 failures Fix the cause and recreate the container: docker compose up -d. On the admin page, use the admin actions to re-run the failed inference jobs (the wording varies by version). Otherwise, bookmarks only get re-tagged when you trigger it per bookmark. If you don't want AI features Remove the OPENAI_API_KEY / OLLAMA_BASE_URL settings, or set INFERENCE_ENABLE_AUTO_TAGGING=false (and the summarization equivalent if your version has it), and the jobs stop being queued. Which provider are you using, OpenAI or Ollama? If you paste the log lines from the grep above, I can tell you exactly which row applies. Investigating why embeddings run without an explicit setting. That confirms it. Karakeep is asking Gemini for text-embedding-3-small, which Google doesn't have. You have no EMBEDDING_* variables set, so it's using the OpenAI default. The fix is to point it at a Gemini embedding model. 1. Find the model's dimension bash curl -s https://generativelanguage.googleapis.com/v1beta/openai/embeddings \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"model":"gemini-embedding-2","input":"hello"}' \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)['data'][0]['embedding']))" If it prints a number, that's your dimension. If it prints an error, such as a 503 or 429, retry shortly. If the model isn't accepted, repeat with gemini-embedding-001. 2. Add the settings In your compose file, using the model that worked and the number from step 1: yaml EMBEDDING_TEXT_MODEL: gemini-embedding-2 EMBEDDING_DIMENSIONS: Karakeep's default dimension is sized for the OpenAI model, so leaving EMBEDDING_DIMENSIONS unset would cause a mismatch. Karakeep's docs say a wrong value makes it fail to start. 3. Apply and regenerate bash docker compose up -d docker logs --since 1m link.meson.kr 2>&1 | grep -iE "embedding|error" | head If the container starts normally, go to the admin page and click Regenerate Embeddings for All Bookmarks. Use "All", not "Failed Only", since the old vector setup assumed a different model and size. Then watch for errors: bash docker logs -f --since 1m link.meson.kr 2>&1 | grep -iE "embeddings" If you'd rather not use embeddings I can't tell from your variables how auto-indexing got enabled, because EMBEDDING_ENABLE_AUTO_INDEXING isn't in your environment, yet jobs run. In some versions it appears to default on. To turn it off, add EMBEDDING_ENABLE_AUTO_INDEXING: "false", recreate the container, and see whether new bookmarks still queue embedding jobs. I'm not certain that variable stops everything in your version, so verify in the logs. Please tell me the dimension number and whether the container starts cleanly after the change. meson@HERMiT: 15:59:05 /home/app-data/link.meson.kr$ docker logs --since 10m link.meson.kr 2>&1 | grep -i "embeddings" | head -5 2026-10-08T06:59:10.554Z info: Starting embeddings worker ... 2026-10-08T07:02:14.525Z info: <-- POST /api/trpc/admin.regenerateAllBookmarkEmbeddings?batch=1 2026-10-08T07:02:16.499Z debug: [embeddings][579] Embedding text length: 1559 characters 2026-10-08T07:02:16.628Z error: [embeddings][579] embeddings job failed: Error: 404 models/text-embedding-3-small is not found for API version v1main, or is not supported for embedContent. Call ModelService.ListModels to see the list of available models and their supported methods. at async runEmbeddings (file:///app/apps/workers/dist/index.js:87919:4) meson@HERMiT: 16:04:38 /home/app-data/link.meson.kr$ docker exec link.meson.kr env | grep -iE "EMBEDDING" meson@HERMiT: 16:04:44 /home/app-data/link.meson.kr$ curl -s https://generativelanguage.googleapis.com/v1beta/openai/embeddings \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"model":"gemini-embedding-2","input":"hello"}' \ | python3 -c "import sys,json; print(len(json.load(sys.stdin)['data'][0]['embedding']))" 3072 Working out the right embedding dimension setting. The dimension is 3072, so here is the change. Update the compose file yaml EMBEDDING_TEXT_MODEL: gemini-embedding-2 EMBEDDING_DIMENSIONS: 3072 Then recreate the container and check that it starts cleanly: bash docker compose up -d docker logs --since 1m link.meson.kr 2>&1 | grep -iE "embedding|dimension|error|fail" | head Regenerate On the admin page, click Regenerate Embeddings for All Bookmarks, then watch the log: bash docker logs -f --since 1m link.meson.kr 2>&1 | grep -iE "embeddings" A successful job should show Completed successfully rather than the 404. Use "All" rather than "Failed Only", because the vector store was set up for the old dimension and Karakeep's release notes say a model or dimension change needs a full regeneration. If something goes wrong Container fails to start or restarts in a loop with a dimension error: the vector store was created with the old size. Check docker logs link.meson.kr for the exact message and paste it here. I don't want to guess at a database fix without seeing it, and I'd back up the data directory first ( /home/app-data/link.meson.kr and the Docker volumes). 429 or 503 on some embedding jobs: that's the free-tier limit. Wait a few minutes, then use Regenerate Embeddings for Failed Bookmarks Only. Semantic search: it's labeled experimental in Karakeep, so judge it on your own bookmarks. Normal keyword search works the same as before. Tell me if the container starts cleanly and whether the embedding jobs complete. EMBEDDING_TEXT_MODEL: gemini-embedding-2 EMBEDDING_DIMENSIONS: 3072 in .env or docker-compose.yml ? Clarifying the correct syntax for environment variable configuration. Put them wherever your other INFERENCE_* settings already are. That's the safest choice, because Compose then applies them the same way. Which file that is depends on how your setup was built. Check where your other settings live bash cd /home/app-data/link.meson.kr grep -n "INFERENCE_TEXT_MODEL" docker-compose.yml .env 2>/dev/null Found in docker-compose.yml: add the lines there, in the same environment: block, using the YAML format: yaml environment: INFERENCE_TEXT_MODEL: gemini-2.5-flash-lite # whatever you use EMBEDDING_TEXT_MODEL: gemini-embedding-2 EMBEDDING_DIMENSIONS: 3072 Found in .env: add them there, using KEY=value with no spaces and no colon: EMBEDDING_TEXT_MODEL=gemini-embedding-2 EMBEDDING_DIMENSIONS=3072 This works only if the compose file has env_file: .env for the Karakeep service. The official Karakeep compose file does this, but if your service has only an environment: block, a plain .env entry won't reach the container. In that case reference it as EMBEDDING_TEXT_MODEL: ${EMBEDDING_TEXT_MODEL} or just put the value in the compose file. The KEY: value format I showed you is YAML, so it only works in docker-compose.yml. If you paste it into .env, it won't be read correctly. Which is better? The model and dimension aren't secrets, so either file works. Keep your API key in .env with chmod 600 and the plain settings in the compose file. That makes it easy to see your whole configuration in one place. Verify it took effect bash docker compose up -d docker exec link.meson.kr env | grep -iE "EMBEDDING" You should see both lines. If the output is empty, the change didn't reach the container, so check which file you edited and the indentation (YAML needs spaces, not tabs). Calibre-web Unable to fetch metadata A permanently spinning “loading” state in the Fetch Metadata dialog usually means the backend request to the metadata provider failed or never returned. The container logs and your browser’s network tab will show which. Here’s how I’d narrow it down. 1. Check the container log while you search bash docker logs -f --tail 100 calibre-web Run a search in the UI and watch for tracebacks. Typical hits are ConnectionError, Max retries exceeded, Temporary failure in name resolution, or a ModuleNotFoundError (a missing Python dependency in the image). If you want more detail, raise the log level under Admin → Edit Basic Configuration → Logfile Configuration → Log Level: Debug. 2. Test outbound connectivity from inside the container bash docker exec -it calibre-web sh -c 'python3 - <&1' If one of those exists, enter that path in the field. If none do, your image doesn’t include it, so leave the field empty. You only need it if you sync books to Kobo devices. If the field is hidden or won’t clear, check the same page for other binary paths that also fail validation, such as the Calibre ebook-convert path or the unrar path, since any one of them can produce a similar error. Clear or correct whichever one the message names. Restart after saving, since google.py reads the key at import time: bash cd /home/web-data/lib.meson.in docker compose restart Then test Fetch Metadata with only Google ticked. If another error appears when saving, send me the exact text.   Kepubify is installed at /usr/bin/kepubify and is executable, so the error comes from the path stored in the settings. It is probably empty, outdated, or points somewhere else. Open Admin → Edit Basic Configuration → External binaries. Set Path to Kepubify E-Book Converter to exactly this, with no trailing space: /usr/bin/kepubify Fill in your Google Books API key in the same form and save. If the error persists with that path, clear the Kepubify field completely and save. You only need it for Kobo sync, and you can add it back later. To confirm the path works from the container’s side, you can run: bash docker exec lib.meson.in /usr/bin/kepubify --version After a successful save, restart so google.py picks up the key: bash cd /home/web-data/lib.meson.in docker compose restart Then test Fetch Metadata with only Google ticked. If the save shows a different error, send me the exact text.   The binary exists and is executable, so the error is coming from what’s saved in the settings. These steps check what’s stored and, if needed, bypass the form entirely. 1. Check what Calibre-Web has stored (read-only): bash docker exec lib.meson.in python3 -c " import sqlite3 c = sqlite3.connect('/config/app.db') cur = c.execute('select * from settings') cols = [d[0] for d in cur.description] row = cur.fetchone() for k, v in zip(cols, row): if any(s in k for s in ('kepub', 'binar', 'convert', 'rar', 'googlebooks')): print(k, '=', repr(v)) " If the container’s config is mounted somewhere other than /config, adjust the path. If config_kepubifypath or a binaries directory setting holds a stale path, that’s your answer. 2. Test that the app user can run it (the earlier ls only proved the file is executable, not that it works for that user): bash docker exec -u abc lib.meson.in /usr/bin/kepubify --version If it prints a version, the path is good. 3. Fix it in the UI. If a “Binaries directory” or “Path to Calibre Binaries” field is filled in, clear it or correct it, since a wrong value there can also trigger this error. Set the Kepubify path to /usr/bin/kepubify, or leave it empty, and save with the API key. 4. If the form still refuses to save, write the key directly to the database. Replace YOUR_KEY: bash docker compose stop docker run --rm -v "$(pwd)/config:/config" python:3-alpine python3 -c " import sqlite3 c = sqlite3.connect('/config/app.db') c.execute(\"update settings set config_googlebooks_api_key='YOUR_KEY', config_kepubifypath=''\") c.commit() " docker compose start This assumes your compose maps ./config to /config. Check the volumes: line in your compose file and adjust the host path if it differs. Back up app.db first ( cp config/app.db config/app.db.bak), because this edits the live database. It also clears the Kepubify path, which you only need for Kobo sync. Send me the output of step 1 and step 2 if this doesn’t resolve it. Then I can tell you exactly which value is tripping the check.   The stored Kepubify path is already correct ( /usr/bin/kepubify), so the error comes from the form submission rather than the saved config, probably a stray value or whitespace in that field. You don’t need the form to save the key, so write it straight to the database and leave everything else alone. bash cd /home/web-data/lib.meson.in docker exec lib.meson.in cp /config/app.db /config/app.db.bak docker exec -e GKEY='PASTE_YOUR_KEY_HERE' lib.meson.in python3 -c " import sqlite3, os c = sqlite3.connect('/config/app.db') c.execute('update settings set config_googlebooks_api_key=?', (os.environ['GKEY'],)) c.commit() print(c.execute('select length(config_googlebooks_api_key) from settings').fetchone()) " docker compose restart The printed number is the key’s length. It should be about 39 characters for a Google API key, which confirms it was written. Don’t blank out config_kepubifypath this time, since it’s fine as it is. Restart right after the write, because Calibre-Web caches settings in memory and could overwrite the change on its next save. Then test Fetch Metadata with only Google ticked. Side note on the form error: the other suspicious value in your output is config_rarfile_location = '/usr/bin'. That field expects the path to the unrar executable, not a directory, so it’s worth checking: bash docker exec lib.meson.in which unrar If it prints a path, enter that path in the field. If it prints nothing, clear the field. That likely won’t fix the Kepubify message, but a wrong value there can break saving. If you want to dig into the form error later, open F12 → Network, save the form, and look at the request payload for config_kepubifypath to see exactly what the browser sends. If Google still returns nothing after the restart, run one search and send me docker logs --tail 30 lib.meson.in.   That length of 19 means the placeholder text itself was saved: PASTE_YOUR_KEY_HERE is exactly 19 characters. A real Google API key is about 39 characters, usually starting with AIza. Run the command again with your real key in place of the placeholder: bash docker exec -e GKEY='AIzaSy...your_real_key...' lib.meson.in python3 -c " import sqlite3, os c = sqlite3.connect('/config/app.db') c.execute('update settings set config_googlebooks_api_key=?', (os.environ['GKEY'],)) c.commit() print(c.execute('select length(config_googlebooks_api_key) from settings').fetchone()) " The printed length should now be about 39. Then restart the container: bash docker compose restart If you don’t want the key in your shell history, put a space before the command (works if HISTCONTROL=ignorespace, the Ubuntu default), or run history -d on that entry afterwards. Once restarted, open Fetch Metadata on a book with only Google ticked. If nothing comes back, run one search and send me docker logs --tail 30 lib.meson.in.   That means the database write worked and Calibre-Web reads the key. You don’t need the form to save anything else, so you can ignore the Kepubify error unless you want to change a setting later. Saving fails before anything is written, so it hasn’t undone the key. Test it now: Open a book, then Edit Metadata → Fetch Metadata. Untick every provider except Google Books, then search with a title. If results show up, the 429 problem is solved. If you get nothing, run one search and send me: bash docker logs --tail 30 lib.meson.in A warning line from google.py (such as a 403 or 429) would show what Google is returning. If Google works but the spinner returns with all providers ticked, one of the other providers is hanging. Untick them all, tick one at a time, and note which one makes it spin. Then disable that one. Since you’re an admin, the toggle in the dialog is saved, so that fixes it permanently. Fixing the Kepubify save error (optional, for later): run this check, since the earlier ls only showed the file is executable, not that the app user can run it: bash docker exec -u abc lib.meson.in /usr/bin/kepubify --version If that fails, the path works for root but not for the app user, and that’s what Calibre-Web is complaining about. If it prints a version, open F12 → Network, save the form, and check the request payload for config_kepubifypath to see what the browser actually sends. Browser autofill sometimes inserts a different value. Paste what you find, and I’ll look at it with you.   The log shows nothing from a search. google.py logs a warning on any Google failure, so there’s no Google error. Either the request never reaches Calibre-Web, or it reaches it and something hangs without logging. Kepubify is confirmed fine for the app user, so that’s closed. 1. Check that the key works from inside the container, with timing: bash docker exec lib.meson.in python3 -c " import sqlite3, requests, time k = sqlite3.connect('/config/app.db').execute('select config_googlebooks_api_key from settings').fetchone()[0] print('key length:', len(k)) t = time.time() r = requests.get('https://www.googleapis.com/books/v1/volumes?q=test&key=' + k, timeout=15) print(r.status_code, round(time.time() - t, 2), 's') " You want 200 and a short time. 2. Find which provider host hangs. This tests each provider’s site from the container with a hard timeout: bash for u in https://www.amazon.com https://book.douban.com https://lubimyczytac.pl https://scholar.google.com https://comicvine.gamespot.com https://www.googleapis.com; do docker exec lib.meson.in curl -s -o /dev/null -m 10 -w "$u %{http_code} %{time_total}s\n" "$u" || echo "$u TIMEOUT/FAIL" done A TIMEOUT/FAIL or a ~10s result marks a provider that stalls. Scholar and Amazon are the usual suspects, because they tarpit or captcha server IPs. Untick that provider in the dialog, since the dialog’s checkboxes are saved per admin. 3. See what the browser does. This is the key piece, since the server log is silent: Press F12 and open the Network tab. Tick Preserve log. Run the search with only Google ticked. Click the metadata/search request and tell me its status (pending, 200, 500, 504, or “(failed)”) and its time. Also check the Console tab for red errors. 4. Rule out the reverse proxy. Your site sits behind a proxy, and the proxy may be what hangs. Check its error log right after a search (the path depends on your setup, commonly): bash sudo tail -n 20 /var/log/nginx/error.log If the request in the Network tab stays pending and nginx shows upstream timed out, add proxy_read_timeout 120s; to the Calibre-Web location block. If it’s pending and nginx shows nothing, the request is stuck in the app. In that case the Debug log level matters: set it under Admin → Basic Configuration → Logfile Configuration. If that save is blocked by the Kepubify error too, tell me, and I’ll give you the database command for it. Send me the results of steps 1 and 2 and the Network tab status from step 3, and I can name the culprit.   Everything on the network side checks out, so Google and the other providers aren’t the cause. Google key: 39 characters, returns 200 in 1.55 s from inside the container. Other providers: Amazon, Douban, Lubimyczytac and Scholar all respond quickly. ComicVine’s 403 on the homepage is normal, since it needs its own API key anyway. That points at the app or the proxy, not the providers. 1. Read the real application log. docker logs only shows the startup scripts. Calibre-Web writes its own log to a file, which is why your searches never appeared there: bash docker exec lib.meson.in tail -n 50 /config/calibre-web.log Run one search first, then run this. You’re looking for warnings or tracebacks from the metadata code. If the file is quiet, set the log level to Debug in the database, since the form won’t save: bash docker exec lib.meson.in python3 -c " import sqlite3 c = sqlite3.connect('/config/app.db') c.execute('update settings set config_log_level=10') c.commit() " docker compose restart Then search once and tail the file again. To go back later, set the level to 20 (Info). 2. Bypass the reverse proxy. If your compose file publishes port 8083 (check with docker port lib.meson.in), open http://SERVER_IP:8083 directly, or over Tailscale if you use it, and try Fetch Metadata there. Works there but not on lib.meson.in: the proxy is the problem. Add this to the Calibre-Web location block in nginx, then reload: nginx proxy_read_timeout 120s; proxy_buffering off; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; Spins in both: it’s the app or the browser. 3. Check the browser (F12): Network tab → metadata/search: is the status pending, 200, 500 or 504, and is the response empty? A 200 with an empty list can still leave some versions showing “loading”. Console tab: any red errors? A JavaScript error such as a failed script load can freeze the dialog. Also try a hard refresh (Ctrl+Shift+R) and a private window, because the UI scripts can be cached from before an upgrade. Send me the tail of calibre-web.log after one search and the Network status, and I should be able to name the cause.   Your nginx config is the likely culprit, and it fits everything you’ve seen so far. Google, the other providers and the app log all look healthy, so the search request probably succeeds, and the browser then fails to render the results. The suspect is this line in your CSP: script-src 'self' 'unsafe-inline'; with the comment “No unsafe-eval required”. As far as I remember, Calibre-Web’s Fetch Metadata dialog ( get_meta.js) builds its result rows with Underscore’s _.template, which compiles templates using new Function(). Browsers block that unless the CSP allows 'unsafe-eval'. The script then throws an error, and the “loading” message is never replaced. I’m going from memory here, so confirm it first. 1. Confirm in the browser Open F12 → Console, run a search, and look for a red error like: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of the following Content Security Policy directive... In the Network tab, metadata/search should show a plain 200 with JSON results. If both are true, this is the cause. 2. Fix it, only on the edit page Your CSP applies to the whole site, so allow unsafe-eval only where the dialog lives ( /admin/book/). Add this map next to your existing $loggable map in the http context: nginx map $uri $csp_script_src { ~^/admin/book/ "'self' 'unsafe-inline' 'unsafe-eval'"; default "'self' 'unsafe-inline'"; } Then change script-src in your CSP line to use the variable: nginx add_header Content-Security-Policy "default-src 'self'; script-src $csp_script_src; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; media-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self';" always; Update the comment above it as well, since the “no unsafe-eval required” note is no longer accurate. Apply it: bash sudo nginx -t && sudo systemctl reload nginx Hard-refresh the edit page (Ctrl+Shift+R) and retry Fetch Metadata. 3. Quick test without editing anything To check the theory first, load the Calibre-Web port directly ( http://SERVER_IP:8083, or over Tailscale if you use it). That bypasses nginx and its CSP. If the dialog works there, the CSP is confirmed. Nothing else in the config looks wrong. The 300 s proxy_read_timeout is generous, so a proxy timeout isn’t a plausible cause. The static/ and cover/ locations define their own add_header lines, which drops the CSP and the other headers for those responses, but that doesn’t affect this problem. If the Console shows a different error, paste it and I’ll take it from there.   That’s the confirmation. get_meta.js calls Underscore’s _.template, which uses new Function(), and your CSP blocks it because script-src has no 'unsafe-eval'. The search itself is succeeding. The browser just refuses to render the results, so the dialog stays on “loading”. 1. Add the map in the http context, next to your existing $loggable map: nginx # Calibre-Web's Fetch Metadata dialog needs 'unsafe-eval' (Underscore templates) map $uri $csp_script_src { ~^/admin/book/ "'self' 'unsafe-inline' 'unsafe-eval'"; default "'self' 'unsafe-inline'"; } 2. Use it in the CSP in the HTTPS server block, replacing the existing Content-Security-Policy line: nginx add_header Content-Security-Policy "default-src 'self'; script-src $csp_script_src; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; media-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self';" always; Also change the comment above it, which still says “No unsafe-eval required”. 3. Test and reload: bash sudo nginx -t && sudo systemctl reload nginx Hard-refresh the book edit page (Ctrl+Shift+R) and run Fetch Metadata again. The CSP is delivered with the HTML page, so it’s the edit page’s URL ( /admin/book/) that has to match the map. Notes The relaxation applies only to the book edit pages, which only admins or users with edit rights can reach, so the rest of the site keeps the stricter policy. If you’d rather not use a map, you can simply add 'unsafe-eval' to script-src site-wide. That works, but it weakens the policy everywhere. Your Google Books key is still useful: the 429s were real, and the key fixes them. You don’t need the timeout patch to google.py any more, so remove that bind mount if you added it. If you want a different level of security, you could also remove the unsafe-inline from the policy later, but Calibre-Web depends on inline scripts, so I’d leave it. If the dialog still hangs after the reload, check the Console for a new error and send it over.   Glad it’s working. A few cleanup items, if they apply to you: Log level: if you set config_log_level=10 in the database, set it back to 20 (Info) so the log doesn’t grow with debug output. google.py bind mount: if you added it, remove it from the compose file and delete the local copy, then run docker compose up -d. The key now lives in the settings, so the patch isn’t needed. Shell history: your Google API key is in your bash history from the docker exec -e GKEY=... command. Delete it with history | grep GKEY, then history -d . Backup: /config/app.db.bak still exists inside the container’s config volume. It contains the old settings, so delete it when you no longer need it. Kepubify save error: it only affects saving the Basic Configuration form and isn’t blocking anything now. If you ever need to change settings through the UI, send me the request payload and I’ll help you track it down.