Compare commits
26 Commits
4db2b1f963
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 914f10f502 | |||
| e3dbedc59f | |||
| 73d820ddaa | |||
| f5ceb872e0 | |||
| e79d15de2e | |||
| 3c6df2e777 | |||
| 1fb9919ec3 | |||
| 932f08642c | |||
| d63c467ef9 | |||
| 494a54fa46 | |||
| 7772cd8064 | |||
| a471c21681 | |||
| 89e25c560b | |||
| 279a042561 | |||
| d3fb5e15e9 | |||
| f131cee6de | |||
| 6faff4d269 | |||
| 4844394334 | |||
| 3f824c4bdf | |||
| 5ca7aef3f1 | |||
| 2fe1bd8ffe | |||
| 556d4d6c1b | |||
| 0b68feff80 | |||
| 70c975c231 | |||
| 0495f34d77 | |||
| 6e9d20be11 |
@@ -2,6 +2,10 @@
|
||||
|
||||
## Working Rule
|
||||
- This file is both backlog and handover log.
|
||||
- Future plans written in this file should be written in Korean by default.
|
||||
- When a meaningful coding/documentation task is completed, the workflow should aim to finish with a push to the git remote when the remote is available.
|
||||
- Every task handled in this repository should add or update a corresponding work note in `TODO.md` before the task is considered complete.
|
||||
- Git push authentication for this repo currently relies on the local credential file at `.local/git-credentials.psd1`; if push auth fails, retry using that file before treating the change as local-only.
|
||||
- Every meaningful change should record:
|
||||
- what changed
|
||||
- why it changed
|
||||
@@ -29,6 +33,11 @@
|
||||
- Artgrid metadata fidelity is improved, but stable public hover-video preview extraction is still not solved.
|
||||
- Frontend now logs more useful API and debug information than earlier versions.
|
||||
- A local self-test workflow now exists and should be run before container builds or pushes.
|
||||
- A fresh-machine bootstrap was revalidated in a user-local toolchain setup on `2026-03-17`; `go test ./...` and `bash scripts/selftest.sh` now pass in that setup.
|
||||
- Result modal sizing is now being constrained to the viewport, and modal-only source-summary translation is now part of the active implementation path.
|
||||
- Card summaries now also translate lazily to Korean, and Gemini negative-assessment handling now drives stronger follow-up search behavior than before.
|
||||
- Search preview delivery is now moving away from persistent on-disk preview caching toward live proxy / live transcode behavior, with Google Video preview reuse added to result cards and modal playback.
|
||||
- The latest search-breadth / modal-fitting experiment from `5ca7aef` has been rolled back after live regression was confirmed.
|
||||
|
||||
## Current Architecture
|
||||
- `backend/main.go`
|
||||
@@ -224,6 +233,10 @@
|
||||
- Frontend JavaScript still has no Node-based lint/build step in this environment.
|
||||
- Search cards now separate source snippet from AI reason, but metadata fidelity still depends on source enrichment quality.
|
||||
- Gemini notes are now intended to be Korean, but final output quality still depends on Gemini response consistency.
|
||||
- Source Summary translation now depends on Google Translate HTTP availability; frontend silently falls back to original summary text if translation fails.
|
||||
- The result modal should now stay within viewport height, but this still needs real browser confirmation on multiple short-height displays because CSS-only constraints were the source of the latest user-visible regression.
|
||||
- Artgrid preview playback now has a server-side ffmpeg transcode path for `.m3u8` style preview URLs, but this trades storage savings for runtime CPU cost.
|
||||
- The reverted `5ca7aef` experiment showed that simply widening collector caps and Gemini candidate count can backfire when the added candidates are weak: final visible count fell sharply even though backend raw candidate count increased.
|
||||
- The local self-test script is better than before, but it is still a smoke test, not full integration coverage.
|
||||
|
||||
## Current Risks Around Search Quality
|
||||
@@ -255,6 +268,149 @@
|
||||
- backend debug broadcasts
|
||||
|
||||
## Recent Change Log
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Removed the remaining inline `Powered by GIPHY` / prompt-chip bar from Zone A image mode so the image search view now shows only the shared search controls and the results area.
|
||||
- Why it changed:
|
||||
- The user wanted that image-mode top strip removed entirely instead of reduced or restyled.
|
||||
- How it was verified:
|
||||
- static review of `frontend/index.html` and `frontend/app.js`
|
||||
- What is still risky or incomplete:
|
||||
- None beyond the usual need for a browser hard refresh if an older cached frontend bundle is still open in a tab.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Restored the video-search request path to tolerate a scheme-less `SEARXNG_BASE_URL` such as `192.168.1.66:8087` by normalizing it to `http://...` during search-service initialization.
|
||||
- Added regression coverage so the video search service keeps accepting the older style base URL configuration used in live deployment.
|
||||
- Why it changed:
|
||||
- Real user logs showed video search failing immediately with `first path segment in URL cannot contain colon`, which traced back to a scheme-less SearXNG base URL in the deployed environment.
|
||||
- How it was verified:
|
||||
- log review of `ai-media-hub-2026-03-24T08-09-23-204Z.log`
|
||||
- added unit coverage for scheme-less base URL normalization
|
||||
- What is still risky or incomplete:
|
||||
- Go tests could not be rerun in this environment because `go` is currently unavailable here, so this fix is verified by code-path review plus the added test only.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Corrected the Unraid template GIPHY download path mapping from `/downloads/giphy` to `/app/downloads/giphy` so it matches the backend default download directory layout.
|
||||
- Why it changed:
|
||||
- The previous template target path dropped the `/app` prefix and did not match the application’s runtime default for `GIPHY_DOWNLOAD_DIR`.
|
||||
- How it was verified:
|
||||
- static review of `unraid-template.xml`
|
||||
- What is still risky or incomplete:
|
||||
- Existing Unraid installs that already created the older path mapping may need their template field refreshed or reapplied to align with the corrected container path.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Removed the large GIPHY image-mode info box entirely and replaced it with a minimal inline prompt bar plus `Powered by GIPHY` label.
|
||||
- Hardened frontend visibility toggling so stale cached HTML/JS combinations do not crash on missing elements.
|
||||
- Bumped the frontend asset version again so browsers are forced onto the latest image-search UI bundle after the GIPHY panel changes.
|
||||
- Why it changed:
|
||||
- Real user logs showed a client-side `Cannot read properties of null (reading 'classList')` error caused by stale frontend asset mismatch, which prevented image results from rendering, and the remaining large image-mode box was still not desired in the UI.
|
||||
- How it was verified:
|
||||
- log review of `ai-media-hub-2026-03-24T07-48-19-085Z.log`
|
||||
- `node --check frontend/app.js`
|
||||
- What is still risky or incomplete:
|
||||
- Users with aggressively cached browser sessions may still need one hard refresh to fully drop older HTML/JS combinations already loaded in an open tab.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Simplified the GIPHY image-search UX so it presents raw search results instead of looking like an AI-evaluated result flow.
|
||||
- Updated the image-mode copy to describe direct GIPHY search results, and changed the shared preview modal labels/content for GIPHY items from AI-note style metadata to plain result/source info.
|
||||
- Why it changed:
|
||||
- The image-search experience should behave like a straightforward provider search result browser, not like the video-side Gemini review flow.
|
||||
- How it was verified:
|
||||
- `node --check frontend/app.js`
|
||||
- What is still risky or incomplete:
|
||||
- This is a UX clarification pass; the backend still uses Gemini only for multilingual query expansion and does not do visual evaluation on GIPHY items.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Relaxed Gemini image-query expansion parsing so loose plain-text numbered lists can still be accepted when the model prepends explanatory text instead of returning a clean JSON object.
|
||||
- Removed the GIPHY image-mode search meta box from the frontend so the image UI stays visually simpler.
|
||||
- Stopped surfacing the Gemini image-expansion fallback warning directly in the image-search UI when the backend can still continue with usable fallback queries.
|
||||
- Why it changed:
|
||||
- Real log review showed Gemini image expansion sometimes returned text like `Here is the JSON requested`, which triggered fallback even though the model output still contained useful query candidates, and the extra meta box was not adding enough value to justify the space it consumed.
|
||||
- How it was verified:
|
||||
- log review of `ai-media-hub-2026-03-24T07-25-42-827Z.log`
|
||||
- `node --check frontend/app.js`
|
||||
- What is still risky or incomplete:
|
||||
- This improves tolerance for one common Gemini formatting deviation, but fully free-form model output can still fall back if it does not contain recoverable query lines.
|
||||
- Go tests still could not be rerun in this environment because `go` is currently unavailable here.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Removed the redundant `GIPHY Download Dir` variable field from the Unraid template and kept the dedicated `GIPHY Downloads` path mapping as the single user-facing download-path control.
|
||||
- Why it changed:
|
||||
- The earlier template exposed both a path mapping and a matching container-path variable for the same GIPHY download location, which was unnecessarily confusing in Unraid.
|
||||
- How it was verified:
|
||||
- static review of `unraid-template.xml`
|
||||
- What is still risky or incomplete:
|
||||
- The application still supports `GIPHY_DOWNLOAD_DIR` as an environment variable, but the Unraid template now intentionally relies on the path mapping plus the backend default container path to reduce duplicated inputs.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Replaced the earlier frontend-only image prototype with an integrated GIPHY image/GIF search flow.
|
||||
- Added backend GIPHY search aggregation with dedupe, up to `100` results, secure download handling, and Gemini-driven multilingual image query expansion into exactly `5` English queries.
|
||||
- Reused the existing result modal for enlarged GIPHY preview and download actions, and added an internal-scroll image results panel with visible `Powered by GIPHY` attribution.
|
||||
- Updated startup config and Unraid template fields for `GIPHY_ENABLED`, `GIPHY_API_KEY`, `GIPHY_MAX_RESULTS`, `GIPHY_RATING`, `GIPHY_LANG`, `GIPHY_DOWNLOAD_DIR`, and `GEMINI_MODEL`.
|
||||
- Added backend tests covering Gemini image-expansion parsing/fallback, GIPHY aggregation/cap behavior, download validation, and handler-level API paths.
|
||||
- Why it changed:
|
||||
- The app needed a production-usable image/GIF provider added incrementally without breaking the existing video search experience.
|
||||
- How it was verified:
|
||||
- `node --check frontend/app.js`
|
||||
- static code review of backend/frontend wiring and new test coverage
|
||||
- What is still risky or incomplete:
|
||||
- This environment currently does not expose `go` or `gofmt`, so Go formatting and `go test ./...` could not be rerun locally in this turn even though new tests were added.
|
||||
- Live browser QA and real GIPHY credential validation still need to be performed in a runtime environment with working API keys.
|
||||
- Push of commit `d63c467` failed on `2026-03-24` with remote error `unable to create temporary object directory` / `unpacker error`, so the latest GIPHY feature batch is currently local-only until the remote accepts a retry.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Added a working-rule note that git push authentication for this repository should be retried with the local credential file `.local/git-credentials.psd1` before leaving work in a local-only state.
|
||||
- Why it changed:
|
||||
- The repository already stores the active git credential source locally, so the handover rules should point to it explicitly when push authentication fails.
|
||||
- How it was verified:
|
||||
- local file presence check for `.local/git-credentials.psd1`
|
||||
- What is still risky or incomplete:
|
||||
- The credential file is a local-machine dependency, so future environment changes still need to keep the file available and valid.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Added an operating rule that every completed task in this repository should also be reflected in `TODO.md`.
|
||||
- Why it changed:
|
||||
- The repository workflow was tightened so the handover file stays current after every task instead of only after larger batches of work.
|
||||
- How it was verified:
|
||||
- `git diff -- TODO.md`
|
||||
- What is still risky or incomplete:
|
||||
- This rule still depends on disciplined execution in each turn, so future work should keep verifying that `TODO.md` is updated before commit/push.
|
||||
- Follow-up push of commit `a471c21` failed on `2026-03-24` with `Authentication failed for 'https://git.savethenurse.com/savethenurse/ai-media-hub/'`, so the latest rule update is currently local-only until credentials are restored.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Added a frontend-only `Video / Image` media-type toggle to Zone A.
|
||||
- Kept the existing backend-connected video search flow as the default mode.
|
||||
- Added an image-search prototype panel with sample prompt chips and test images.
|
||||
- Added mock image-result cards so the image-search layout can be reviewed before backend image search exists.
|
||||
- Why it changed:
|
||||
- Image search is planned next, and the user wanted the Zone A UI shape in place first so the workflow can be tested before backend integration.
|
||||
- How it was verified:
|
||||
- `node --check frontend/app.js`
|
||||
- What is still risky or incomplete:
|
||||
- This is UI-only; image mode does not call a backend API yet.
|
||||
- The test images currently use placeholder assets and do not represent final data contracts or modal behavior.
|
||||
|
||||
- Date: `2026-03-24`
|
||||
- What changed:
|
||||
- Added an operating rule that future plans recorded in this repo should be written in Korean by default.
|
||||
- Added an operating rule that completed meaningful work should aim to end with a push to the git remote when available.
|
||||
- Why it changed:
|
||||
- The active collaboration workflow for this repository was updated so planning language and completion expectations stay explicit in the handover file.
|
||||
- How it was verified:
|
||||
- `git diff -- TODO.md`
|
||||
- What is still risky or incomplete:
|
||||
- Automatic push can still fail if remote auth or network state changes, so each turn should continue recording push failures explicitly when they happen.
|
||||
|
||||
- Date: `2026-03-16`
|
||||
- What changed:
|
||||
- Stabilized the Gemini visual-review path after widened search budgets caused full-batch “no candidate thumbnails or preview frames” failures.
|
||||
@@ -401,6 +557,26 @@
|
||||
- `SEARXNG_GOOGLE_VIDEO_ENGINE`
|
||||
- `SEARXNG_WEB_ENGINE`
|
||||
- `GEMINI_API_KEY`
|
||||
- `GEMINI_MODEL`
|
||||
- `GIPHY_ENABLED`
|
||||
- `GIPHY_API_KEY`
|
||||
- `GIPHY_MAX_RESULTS`
|
||||
- `GIPHY_RATING`
|
||||
- `GIPHY_LANG`
|
||||
- `GIPHY_DOWNLOAD_DIR`
|
||||
|
||||
## Local Development Environment Notes
|
||||
- This machine started without `go`, `pip`, `ffmpeg`, `yt-dlp`, or `node`.
|
||||
- To unblock local development without root:
|
||||
- installed Go `1.24.0` under `~/.local/tooling/go`
|
||||
- installed `pip` in user site with `get-pip.py --user --break-system-packages`
|
||||
- installed `imageio-ffmpeg` in user site and linked its bundled `ffmpeg` into `~/.local/bin/ffmpeg`
|
||||
- installed `yt-dlp` standalone binary into `~/.local/bin/yt-dlp`
|
||||
- linked `go` and `gofmt` into `~/.local/bin`
|
||||
- appended `~/.local/bin` and `~/.local/tooling/go/bin` to `PATH` in `~/.bashrc`
|
||||
- `node` is still not installed on this machine.
|
||||
- This is acceptable for the current repo because there is still no Node-based frontend build or lint workflow in-tree.
|
||||
- If future frontend work adds a Node toolchain, document the exact version and setup steps here before pushing.
|
||||
|
||||
## Local Self-Test Workflow
|
||||
- Primary command:
|
||||
@@ -419,6 +595,9 @@
|
||||
- this is a smoke test, not a browser-level verification suite
|
||||
|
||||
## Verified Locally In This Environment
|
||||
- [x] user-local Go toolchain available on `PATH`
|
||||
- [x] user-local `ffmpeg` available on `PATH`
|
||||
- [x] user-local `yt-dlp` available on `PATH`
|
||||
- [x] `go build -o /tmp/ai-media-hub ./backend`
|
||||
- [x] `go test ./...`
|
||||
- [x] Python syntax check for worker + self-test helper
|
||||
@@ -426,6 +605,7 @@
|
||||
- [x] local `/api/search` against mock SearXNG through `scripts/selftest.sh`
|
||||
- [x] local `/api/upload` through `scripts/selftest.sh`
|
||||
- [ ] full browser-level validation was not fully reproducible in this environment
|
||||
- [ ] Node-based frontend lint/build step is still unavailable in this environment
|
||||
|
||||
## Recent Change Log
|
||||
- Date: `2026-03-16`
|
||||
@@ -520,12 +700,19 @@
|
||||
- Current state:
|
||||
- latest work in this environment has been pushed successfully multiple times after the earlier remote unpacker issue
|
||||
- the older push failure note is historical context only and should not be treated as the current repo state
|
||||
- on `2026-03-17`, local commits `6e9d20b` and `0495f34` were created during fresh-machine environment validation and documentation cleanup
|
||||
- push from this machine was initially blocked by missing HTTPS auth helper configuration, but succeeded after explicit credentials were provided by the user
|
||||
- Operational note:
|
||||
- because the frontend is static and aggressively cached, browser hard refreshes are often required after UI / modal / preview changes
|
||||
|
||||
## Highest-Value Next Steps
|
||||
- [ ] Reduce `/api/search` latency further without collapsing result count
|
||||
- [ ] Rebuild the reverted search-expansion work from the previous stable baseline, but only after measuring where candidate quality collapses between ranked pool and final merge
|
||||
- [ ] Build a repeatable repo-local bootstrap script or documented setup command set for non-root machines so fresh PC setup does not depend on shell history
|
||||
- [ ] Improve Envato / Artgrid preview acquisition reliability so Gemini Vision sees real frames more often
|
||||
- [ ] Browser-verify the new result modal at multiple viewport heights and confirm translated Source Summary readability on real long descriptions
|
||||
- [ ] Evaluate whether the new Gemini supplemental-query generation is reducing irrelevant results on a small fixed benchmark query set
|
||||
- [ ] Measure runtime cost of live Artgrid preview transcoding and decide whether bounded in-memory throttling or concurrency caps are needed
|
||||
- [ ] Revisit Google Video UX:
|
||||
- current YouTube embed was abandoned due error `153`
|
||||
- current in-app panel is more reliable but less rich than a true embedded watch page
|
||||
@@ -591,6 +778,126 @@
|
||||
- If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise
|
||||
|
||||
## Recent Change Log
|
||||
- Date: `2026-03-18`
|
||||
- What changed:
|
||||
- Resumed and completed the interrupted search-timeout mitigation work that had been left locally after the rollback to `f131cee`.
|
||||
- Split the search-service deadline into:
|
||||
- collection deadline
|
||||
- enrichment deadline with a reserved window
|
||||
- Reduced collector fan-out on the reverted baseline:
|
||||
- fewer base queries
|
||||
- no per-request query shuffling
|
||||
- earlier stop when a collector repeatedly returns `0` results before producing any accepted item
|
||||
- Raised `Google Video` max results to `12` so visible count does not collapse as hard when Envato / Artgrid are cold.
|
||||
- Added unit coverage for the search/enrichment deadline split helper.
|
||||
- Why it changed:
|
||||
- The user-provided log `ai-media-hub-2026-03-18T04-44-11-440Z.log` showed:
|
||||
- repeated collector passes with many `rawCount: 0`
|
||||
- `search_service:deadline_reached`
|
||||
- `partialDueToDeadline: true`
|
||||
- final warning `search returned partial results to avoid gateway timeout`
|
||||
- only `Google Video` surviving into the final result set with `resultCount: 8`
|
||||
- The real bottleneck in that log was collector-side time waste before enrichment/Gemini, not another Gemini output-format issue.
|
||||
- How it was verified:
|
||||
- PowerShell with repo-local tooling:
|
||||
- `go test ./...`
|
||||
- `node --check frontend/app.js`
|
||||
- What is still risky or incomplete:
|
||||
- This should reduce timeout pressure and improve visible count in the common “Envato/Artgrid zero streak” case, but upstream SearXNG quality can still dominate the final pool.
|
||||
- A full app-boot smoke flow was not reintroduced into this reverted baseline in this turn.
|
||||
|
||||
- Date: `2026-03-17`
|
||||
- What changed:
|
||||
- Reverted commit `5ca7aef` (`Strengthen search breadth and modal fitting`) to restore the previous stable search/modal baseline.
|
||||
- Revalidated the rollback state locally.
|
||||
- Why it changed:
|
||||
- The user reported that search results became too sparse again and that `search returned partial results to avoid gateway timeout` reappeared.
|
||||
- The provided log `ai-media-hub-2026-03-17T04-19-08-889Z.log` showed:
|
||||
- backend candidate pool still reaching `30`
|
||||
- Gemini candidate cap widened to `24`
|
||||
- `visualRejectCount: 12`
|
||||
- final visible result count collapsing to `5`
|
||||
- That indicates the widened experiment increased weak/low-visual candidates and pushed the request back toward deadline pressure without improving visible result count.
|
||||
- How it was verified:
|
||||
- `go test ./...`
|
||||
- `bash scripts/selftest.sh`
|
||||
- What is still risky or incomplete:
|
||||
- This rollback restores the earlier baseline but does not solve the underlying request to improve visible result count.
|
||||
- The next attempt should start from the reverted baseline and target the actual bottleneck between ranked pool and final merge, instead of only widening raw query breadth.
|
||||
|
||||
- Date: `2026-03-17`
|
||||
- What changed:
|
||||
- If the first search pass plus Gemini filtering still leaves too few visible results, the backend now performs an additional coverage-expansion search/evaluation pass before final fallback filling.
|
||||
- Search result cards and the result modal now reuse the existing direct-download preview probe path for Google Video, so YouTube-backed results can surface actual playable preview streams instead of staying thumbnail-only.
|
||||
- Artgrid-style `.m3u8` previews now have a server-side ffmpeg transcode route so the frontend can request a directly playable MP4 stream when plain HLS playback is unreliable.
|
||||
- Persistent preview-file caching to disk was removed from the preview proxy path so one-off preview traffic does not keep accumulating files under the downloads cache area.
|
||||
- Modal spacing, media height, and text sizing were tightened again so the popup is more likely to fit without scrolling on shorter displays.
|
||||
- Why it changed:
|
||||
- The latest user feedback said the search result count had become too low after stricter Gemini filtering, the popup still felt too large, Google Video already had a better preview path in direct-download mode, Artgrid previews still were not reliably playable, and the server should prefer not retaining disposable preview artifacts on disk.
|
||||
- How it was verified:
|
||||
- `go test ./...`
|
||||
- `bash scripts/selftest.sh`
|
||||
- `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py`
|
||||
- What is still risky or incomplete:
|
||||
- The extra coverage-expansion pass can improve visible count but may increase latency when upstream SearXNG quality is poor.
|
||||
- Live ffmpeg transcoding avoids preview-file accumulation but may become CPU-heavy under concurrent preview playback.
|
||||
- Real browser validation is still needed for the exact viewport in the latest screenshot and for actual Artgrid preview playback behavior.
|
||||
|
||||
- Date: `2026-03-17`
|
||||
- What changed:
|
||||
- Removed the visible `AI Recommended` badge from search cards.
|
||||
- Extended lazy summary translation so result cards, not just the modal, now request Korean `Source Summary` text as they come into view.
|
||||
- Reworked Gemini vision parsing and recommendation metadata so candidate assessments now distinguish `positive`, `unclear`, `irrelevant`, and `inappropriate`, with short search hints for weak/negative results.
|
||||
- Removed the direct `Preview evidence pending` filler path from normal merge behavior and moved fallback filling to a later, more neutral stage.
|
||||
- Upgraded supplemental search behavior so follow-up queries can be generated from Gemini feedback and provider/source mix instead of relying only on the old fixed fallback list.
|
||||
- Tightened modal height handling again so the overlay and internal panels can scroll without pushing the popup past the viewport.
|
||||
- Why it changed:
|
||||
- The user reported that the modal could still overflow the browser, that the `AI Recommended` chip was unnecessary noise, that card-level source summaries should also be translated, and that weak/negative Gemini evaluations should trigger smarter additional searching instead of surfacing low-confidence filler results.
|
||||
- How it was verified:
|
||||
- `go test ./...`
|
||||
- `bash scripts/selftest.sh`
|
||||
- `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py`
|
||||
- What is still risky or incomplete:
|
||||
- The new supplemental-query generation depends on Gemini availability for the smartest path and falls back to deterministic query building when Gemini text generation is unavailable.
|
||||
- Card-level lazy translation reduces request burst compared with translating everything during `/api/search`, but still adds client-side translation traffic during scrolling.
|
||||
- Real browser validation is still needed to confirm the modal no longer exceeds the viewport on the exact user display conditions shown in the screenshot.
|
||||
|
||||
- Date: `2026-03-17`
|
||||
- What changed:
|
||||
- Added `POST /api/translate/summary` so the result modal can translate `Source Summary` text to Korean on demand with in-process caching.
|
||||
- Reworked result modal sizing so the popup stays within the viewport and the summary area scrolls internally instead of stretching the whole dialog off-screen.
|
||||
- Replaced the false-positive near-deadline warning heuristic with explicit deadline metadata from search collection / enrichment / Gemini stages.
|
||||
- Added an Artgrid API `403` cooldown guard so repeated clip enrichments stop re-hitting the same blocked JSON endpoint for a while and fall back to HTML parsing directly.
|
||||
- Added backend tests for timeout-warning gating, summary translation caching, and Artgrid `403` skip behavior.
|
||||
- Why it changed:
|
||||
- The provided browser log showed a successful `~55s` search still surfacing `search returned partial results to avoid gateway timeout`, and the user reported that the result popup could overflow the viewport and that untranslated / very long source summaries made the modal feel too large.
|
||||
- How it was verified:
|
||||
- `go test ./...`
|
||||
- `bash scripts/selftest.sh`
|
||||
- `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py`
|
||||
- What is still risky or incomplete:
|
||||
- Full browser-level confirmation of the resized modal and lazy summary translation still needs live UI validation.
|
||||
- The Artgrid `403` guard reduces repeated waste, but it does not solve the underlying upstream access restriction.
|
||||
|
||||
- Date: `2026-03-17`
|
||||
- What changed:
|
||||
- Re-audited the repository structure and `TODO.md` handover state on a fresh machine.
|
||||
- Bootstrapped a user-local development toolchain because the machine initially lacked `go`, `pip`, `ffmpeg`, and `yt-dlp`.
|
||||
- Revalidated the documented self-test flow and updated this handover document with environment notes, current verification status, and next-step priorities.
|
||||
- Committed the documentation update locally as `6e9d20b` (`Document fresh-machine setup and status`) and `0495f34` (`Record local-only push failure`), then completed the delayed push after credentials were supplied.
|
||||
- Why it changed:
|
||||
- Ongoing work will happen from different PCs, so the project needed an explicit record of what this repo requires to become runnable when root-level package installation is unavailable.
|
||||
- How it was verified:
|
||||
- `go version`
|
||||
- `ffmpeg -version`
|
||||
- `yt-dlp --version`
|
||||
- `go test ./...`
|
||||
- `bash scripts/selftest.sh`
|
||||
- What is still risky or incomplete:
|
||||
- This machine still has no `node`, so any future frontend build/lint workflow will need an explicit setup step before it can become part of local verification.
|
||||
- The environment bootstrap done here is user-local rather than repo-automated, so reproducing it on another PC is still partly manual until a bootstrap script or setup doc lands.
|
||||
- HTTPS push on this machine currently depends on manually supplied credentials, so a safer long-term credential helper or SSH setup is still worth documenting later.
|
||||
|
||||
- Date: `2026-03-16`
|
||||
- What changed:
|
||||
- When Gemini batch evaluation is only partially successful, the API now explicitly backfills the final list with preview-capable ranked candidates instead of leaving the visible result set too thin.
|
||||
|
||||
+176
-50
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -34,6 +33,7 @@ type App struct {
|
||||
WorkerScript string
|
||||
SearchService *services.SearchService
|
||||
GeminiService *services.GeminiService
|
||||
GiphyService *services.GiphyService
|
||||
Hub *Hub
|
||||
}
|
||||
|
||||
@@ -144,10 +144,14 @@ func RegisterRoutes(router *gin.Engine, app *App) {
|
||||
router.GET("/ws", app.handleWS)
|
||||
router.GET("/api/history/check", app.checkDuplicate)
|
||||
router.GET("/api/preview/stream", app.streamPreview)
|
||||
router.GET("/api/preview/transcode", app.transcodePreview)
|
||||
router.POST("/api/download/preview", app.previewDownload)
|
||||
router.POST("/api/upload", app.uploadFile)
|
||||
router.POST("/api/download", app.startDownload)
|
||||
router.POST("/api/translate/summary", app.translateSummary)
|
||||
router.POST("/api/search", app.searchMedia)
|
||||
router.POST("/api/giphy/search", app.searchGiphy)
|
||||
router.POST("/api/giphy/download", app.downloadGiphy)
|
||||
}
|
||||
|
||||
func (a *App) debug(message string, data any) {
|
||||
@@ -235,15 +239,6 @@ func (a *App) streamPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if isCacheablePreview(target, contentType) {
|
||||
if cachedPath, err := a.cachePreviewResponse(target, contentType, resp.Body); err == nil {
|
||||
a.debug("preview:proxy:cache_hit_write", gin.H{"target": target, "cachedPath": cachedPath})
|
||||
c.File(cachedPath)
|
||||
return
|
||||
}
|
||||
a.debug("preview:proxy:cache_write_error", gin.H{"target": target, "error": err.Error()})
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
c.Header("Content-Type", contentType)
|
||||
}
|
||||
@@ -251,6 +246,61 @@ func (a *App) streamPreview(c *gin.Context) {
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func (a *App) transcodePreview(c *gin.Context) {
|
||||
target := strings.TrimSpace(c.Query("url"))
|
||||
if target == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
|
||||
return
|
||||
}
|
||||
|
||||
headers := fmt.Sprintf("User-Agent: Mozilla/5.0\r\nReferer: %s\r\n", inferPreviewReferer(target))
|
||||
cmd := exec.CommandContext(
|
||||
c.Request.Context(),
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-headers", headers,
|
||||
"-i", target,
|
||||
"-an",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "30",
|
||||
"-movflags", "frag_keyframe+empty_moov+faststart",
|
||||
"-f", "mp4",
|
||||
"pipe:1",
|
||||
)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "video/mp4")
|
||||
c.Status(http.StatusOK)
|
||||
_, copyErr := io.Copy(c.Writer, stdout)
|
||||
errOutput, _ := io.ReadAll(io.LimitReader(stderr, 2048))
|
||||
waitErr := cmd.Wait()
|
||||
if copyErr != nil {
|
||||
a.debug("preview:transcode:copy_error", gin.H{"target": target, "error": copyErr.Error()})
|
||||
return
|
||||
}
|
||||
if waitErr != nil {
|
||||
a.debug("preview:transcode:error", gin.H{"target": target, "error": waitErr.Error(), "stderr": strings.TrimSpace(string(errOutput))})
|
||||
return
|
||||
}
|
||||
a.debug("preview:transcode:complete", gin.H{"target": target})
|
||||
}
|
||||
|
||||
func (a *App) uploadFile(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -343,6 +393,28 @@ func (a *App) previewDownload(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, preview)
|
||||
}
|
||||
|
||||
func (a *App) translateSummary(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
|
||||
return
|
||||
}
|
||||
|
||||
translated, err := a.GeminiService.TranslateSummaryToKorean(req.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"translatedText": translated})
|
||||
}
|
||||
|
||||
func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) {
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url})
|
||||
a.debug("download command started", gin.H{"url": url, "start": start, "end": end, "quality": quality, "outputPath": outputPath})
|
||||
@@ -416,7 +488,7 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
|
||||
enabledPlatforms := normalizePlatforms(req.Platforms)
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching " + selectedPlatformLabel(enabledPlatforms), "progress": 35})
|
||||
results, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second))
|
||||
results, searchMeta, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second))
|
||||
if err != nil {
|
||||
a.debug("search backend failed", gin.H{"error": err.Error(), "variants": queryVariants, "durationMs": time.Since(started).Milliseconds()})
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()})
|
||||
@@ -441,10 +513,12 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75})
|
||||
recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(a.GeminiService, req.Query, scored, deadline.Add(-5*time.Second))
|
||||
a.debug("search gemini evaluation", geminiStats)
|
||||
supplementalDeadlineLimited := false
|
||||
if services.NeedsSupplementalExploration(recommended) && time.Now().Before(deadline.Add(-10*time.Second)) {
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82})
|
||||
explorationQueries := buildSupplementalQueries(req.Query, queryVariants)
|
||||
extraResults, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second))
|
||||
explorationQueries := buildSupplementalQueries(a.GeminiService, req.Query, queryVariants, recommended)
|
||||
extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second))
|
||||
supplementalDeadlineLimited = extraMeta.PartialDueToDeadline
|
||||
if extraErr == nil && len(extraResults) > 0 {
|
||||
results = mergeSearchResults(results, extraResults)
|
||||
scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results)
|
||||
@@ -468,6 +542,8 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if services.NeedsSupplementalExploration(recommended) {
|
||||
supplementalDeadlineLimited = true
|
||||
}
|
||||
if geminiErr != nil && len(recommended) == 0 {
|
||||
warning := geminiErr.Error()
|
||||
@@ -481,15 +557,45 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
merged := services.MergeRecommendations(recommended, scored, 16)
|
||||
targetCount := 16
|
||||
merged := services.MergeRecommendations(recommended, scored, targetCount)
|
||||
if geminiErr != nil {
|
||||
merged = services.BackfillRecommendations(
|
||||
merged,
|
||||
scored,
|
||||
16,
|
||||
targetCount,
|
||||
"Gemini 배치 일부가 실패해 미리보기 가능한 상위 후보를 보강했습니다.",
|
||||
)
|
||||
}
|
||||
if len(merged) < targetCount && time.Now().Before(deadline.Add(-5*time.Second)) {
|
||||
coverageQueries := buildCoverageQueries(req.Query, queryVariants, recommended, merged)
|
||||
if len(coverageQueries) > 0 {
|
||||
a.debug("search coverage query variants", gin.H{"variants": coverageQueries, "variantCount": len(coverageQueries), "existingCount": len(merged)})
|
||||
extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(coverageQueries, enabledPlatforms, deadline.Add(-5*time.Second))
|
||||
supplementalDeadlineLimited = supplementalDeadlineLimited || extraMeta.PartialDueToDeadline
|
||||
if extraErr == nil && len(extraResults) > 0 {
|
||||
results = mergeSearchResults(results, extraResults)
|
||||
scored = services.RankSearchResults(strings.Join(coverageQueries[:min(len(coverageQueries), 3)], " "), results)
|
||||
reviewedLinks := services.ReviewedRecommendationLinks(recommended)
|
||||
supplementalCandidates := services.SelectUnevaluatedCandidates(scored, reviewedLinks, services.RemainingGeminiCapacity(recommended))
|
||||
if len(supplementalCandidates) > 0 {
|
||||
extraRecommended, extraStats, extraGeminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(
|
||||
a.GeminiService,
|
||||
req.Query,
|
||||
supplementalCandidates,
|
||||
deadline.Add(-2*time.Second),
|
||||
)
|
||||
recommended = services.MergeUniqueRecommendations(recommended, extraRecommended)
|
||||
geminiStats = services.MergeGeminiBatchStats(geminiStats, extraStats)
|
||||
geminiErr = combineSearchWarnings(geminiErr, extraGeminiErr)
|
||||
}
|
||||
merged = services.MergeRecommendations(recommended, scored, targetCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(merged) < targetCount {
|
||||
merged = services.BackfillRecommendations(merged, scored, targetCount, "추가 검색 후에도 충분한 결과가 부족해 시각 자산이 있는 후보를 제한적으로 보강했습니다.")
|
||||
}
|
||||
merged = services.RandomizeTopRecommendations(merged, 6)
|
||||
for idx := range merged {
|
||||
merged[idx] = services.DecorateRecommendationMedia(merged[idx])
|
||||
@@ -503,13 +609,17 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
if warning != "" {
|
||||
response["warning"] = warning
|
||||
}
|
||||
if time.Now().After(deadline.Add(-2*time.Second)) && warning == "" {
|
||||
if shouldWarnPartialSearch(searchMeta, geminiStats, supplementalDeadlineLimited, warning) {
|
||||
response["warning"] = "search returned partial results to avoid gateway timeout"
|
||||
}
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100})
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func shouldWarnPartialSearch(meta services.SearchExecutionMeta, stats services.GeminiBatchStats, supplementalDeadlineLimited bool, warning string) bool {
|
||||
return warning == "" && (meta.PartialDueToDeadline || stats.DeadlineLimited || supplementalDeadlineLimited)
|
||||
}
|
||||
|
||||
func normalizeFilename(name string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(name))
|
||||
ext := filepath.Ext(base)
|
||||
@@ -582,15 +692,39 @@ func selectedPlatformLabel(platforms map[string]bool) string {
|
||||
return strings.Join(labels, ", ")
|
||||
}
|
||||
|
||||
func buildSupplementalQueries(query string, existing []string) []string {
|
||||
func buildSupplementalQueries(service *services.GeminiService, query string, existing []string, reviewed []services.AIRecommendation) []string {
|
||||
if service != nil {
|
||||
if generated, err := service.BuildSupplementalQueries(query, existing, reviewed); err == nil && len(generated) > 0 {
|
||||
return mergeSupplementalQuerySets(existing, generated)
|
||||
}
|
||||
}
|
||||
return buildDeterministicSupplementalQueries(query, existing, reviewed)
|
||||
}
|
||||
|
||||
func buildDeterministicSupplementalQueries(query string, existing []string, reviewed []services.AIRecommendation) []string {
|
||||
candidates := append([]string{}, existing...)
|
||||
for _, item := range reviewed {
|
||||
if item.Assessment == "positive" && item.SearchHint != "" {
|
||||
candidates = append(candidates, item.SearchHint)
|
||||
}
|
||||
if (item.Assessment == "unclear" || services.IsExcludedAssessment(item.Assessment)) && item.SearchHint != "" {
|
||||
candidates = append(candidates, query+" "+item.SearchHint)
|
||||
}
|
||||
}
|
||||
candidates = append(candidates,
|
||||
query+" cinematic stock footage",
|
||||
query+" editorial b-roll",
|
||||
query+" establishing shot",
|
||||
query+" drone footage",
|
||||
query+" authentic candid couple",
|
||||
query+" urban park lifestyle footage",
|
||||
)
|
||||
return mergeSupplementalQuerySets(nil, candidates)
|
||||
}
|
||||
|
||||
func mergeSupplementalQuerySets(base, extra []string) []string {
|
||||
candidates := append([]string{}, base...)
|
||||
candidates = append(candidates, extra...)
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
@@ -621,6 +755,31 @@ func mergeSearchResults(base, extra []services.SearchResult) []services.SearchRe
|
||||
return merged
|
||||
}
|
||||
|
||||
func buildCoverageQueries(query string, existing []string, reviewed []services.AIRecommendation, merged []services.AIRecommendation) []string {
|
||||
candidates := append([]string{}, existing...)
|
||||
positiveHints := 0
|
||||
for _, item := range reviewed {
|
||||
if item.Assessment == "positive" && item.SearchHint != "" && positiveHints < 3 {
|
||||
candidates = append(candidates, item.SearchHint)
|
||||
positiveHints++
|
||||
}
|
||||
}
|
||||
if len(merged) < 8 {
|
||||
candidates = append(candidates,
|
||||
query+" stock footage",
|
||||
query+" lifestyle footage",
|
||||
query+" candid couple footage",
|
||||
query+" editorial scene",
|
||||
)
|
||||
} else {
|
||||
candidates = append(candidates,
|
||||
query+" establishing shot",
|
||||
query+" cinematic b-roll",
|
||||
)
|
||||
}
|
||||
return mergeSupplementalQuerySets(nil, candidates)
|
||||
}
|
||||
|
||||
func combineSearchWarnings(base, extra error) error {
|
||||
switch {
|
||||
case base == nil:
|
||||
@@ -788,39 +947,6 @@ func rewriteM3U8Playlist(body, target string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func isCacheablePreview(target, contentType string) bool {
|
||||
lower := strings.ToLower(target + " " + contentType)
|
||||
return strings.Contains(lower, ".mp4") || strings.Contains(lower, "video/mp4")
|
||||
}
|
||||
|
||||
func (a *App) cachePreviewResponse(target, contentType string, body io.Reader) (string, error) {
|
||||
if a.PreviewCacheDir == "" {
|
||||
return "", fmt.Errorf("preview cache dir is not configured")
|
||||
}
|
||||
if err := os.MkdirAll(a.PreviewCacheDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sum := sha1.Sum([]byte(target))
|
||||
ext := ".bin"
|
||||
if strings.Contains(strings.ToLower(target), ".mp4") || strings.Contains(strings.ToLower(contentType), "video/mp4") {
|
||||
ext = ".mp4"
|
||||
}
|
||||
path := filepath.Join(a.PreviewCacheDir, fmt.Sprintf("%x%s", sum, ext))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func summarizeOutput(prefix string, output []byte, err error) string {
|
||||
trimmed := strings.TrimSpace(string(output))
|
||||
if trimmed == "" && err != nil {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ai-media-hub/backend/services"
|
||||
)
|
||||
|
||||
func TestShouldWarnPartialSearchDoesNotWarnForCompletedSearch(t *testing.T) {
|
||||
if shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{}, false, "") {
|
||||
t.Fatal("expected no warning when search completed without deadline limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWarnPartialSearchWarnsWhenDeadlineLimited(t *testing.T) {
|
||||
if !shouldWarnPartialSearch(services.SearchExecutionMeta{PartialDueToDeadline: true}, services.GeminiBatchStats{}, false, "") {
|
||||
t.Fatal("expected warning when search collection was deadline limited")
|
||||
}
|
||||
if !shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{DeadlineLimited: true}, false, "") {
|
||||
t.Fatal("expected warning when gemini stage was deadline limited")
|
||||
}
|
||||
if !shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{}, true, "") {
|
||||
t.Fatal("expected warning when supplemental exploration was deadline limited")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ai-media-hub/backend/models"
|
||||
"ai-media-hub/backend/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (a *App) searchGiphy(c *gin.Context) {
|
||||
if a.GiphyService == nil || !a.GiphyService.Config.Enabled {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "giphy search is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Query = strings.TrimSpace(req.Query)
|
||||
if req.Query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "expanding query for GIPHY", "progress": 20})
|
||||
resp, err := a.GiphyService.SearchImages(req.Query, req.MaxResults)
|
||||
if err != nil {
|
||||
a.debug("giphy:search_error", gin.H{"query": req.Query, "error": err.Error()})
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "giphy search failed", "progress": 100, "message": err.Error()})
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
a.debug("giphy:search_complete", gin.H{
|
||||
"query": req.Query,
|
||||
"expandedQueries": resp.ExpandedQueries,
|
||||
"total": resp.Total,
|
||||
"warning": resp.Warning,
|
||||
})
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "giphy search complete", "progress": 100, "count": resp.Total})
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (a *App) downloadGiphy(c *gin.Context) {
|
||||
if a.GiphyService == nil || !a.GiphyService.Config.Enabled {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"ok": false, "error": "GIPHY_DISABLED", "message": "GIPHY download is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ProviderID string `json:"providerId"`
|
||||
Title string `json:"title"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
OriginalQuery string `json:"originalQuery"`
|
||||
SelectedExpansionQuery string `json:"selectedExpansionQuery"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_REQUEST", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := a.GiphyService.DownloadMedia(services.GiphyDownloadRequest{
|
||||
ProviderID: req.ProviderID,
|
||||
Title: req.Title,
|
||||
DownloadURL: req.DownloadURL,
|
||||
OriginalQuery: req.OriginalQuery,
|
||||
SelectedExpansionQuery: req.SelectedExpansionQuery,
|
||||
})
|
||||
if err != nil {
|
||||
a.debug("giphy:download_error", gin.H{
|
||||
"providerId": req.ProviderID,
|
||||
"title": req.Title,
|
||||
"error": err.Error(),
|
||||
})
|
||||
status := http.StatusBadGateway
|
||||
if resp.Error == "INVALID_REQUEST" || resp.Error == "INVALID_DOWNLOAD_URL" {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, resp)
|
||||
return
|
||||
}
|
||||
|
||||
if a.DB != nil {
|
||||
if recordID, dbErr := models.InsertDownload(a.DB, req.DownloadURL, "GIPHY", resp.SavedPath, "completed"); dbErr == nil {
|
||||
_ = models.MarkDownloadCompleted(a.DB, recordID, "completed")
|
||||
}
|
||||
}
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "giphy-download", "status": "giphy download complete", "progress": 100, "fileName": resp.FileName})
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ai-media-hub/backend/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSearchGiphyEndpointReturnsExpandedQueriesAndItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"happy dog\",\"happy dog gif\",\"dog reaction\",\"dog meme gif\",\"animated dog sticker\"]}"}]}}]}`))
|
||||
})
|
||||
mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"dog-1","title":"Happy Dog","slug":"happy-dog","rating":"g","url":"https://giphy.com/gifs/dog-1","images":{"original":{"url":"https://media.giphy.com/media/dog-1/giphy.gif","width":"480","height":"270"},"fixed_width":{"url":"https://media.giphy.com/media/dog-1/200w.gif","width":"200","height":"113"},"fixed_width_still":{"url":"https://media.giphy.com/media/dog-1/200w_s.gif"}}}]}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
gemini := services.NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
gemini.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
gemini.GenerateEndpoint = server.URL + "/generate"
|
||||
|
||||
giphy := services.NewGiphyService(services.GiphyConfig{
|
||||
Enabled: true,
|
||||
APIKey: "test-key",
|
||||
MaxResults: 100,
|
||||
BaseURL: server.URL,
|
||||
}, gemini)
|
||||
giphy.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
app := &App{GiphyService: giphy, Hub: NewHub()}
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, app)
|
||||
|
||||
body := bytes.NewBufferString(`{"query":"행복한 강아지","maxResults":100}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/giphy/search", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
ExpandedQueries []string `json:"expandedQueries"`
|
||||
Items []services.GiphyResult `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(payload.ExpandedQueries) != 5 || len(payload.Items) == 0 {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadGiphyEndpointRejectsNonGiphyHost(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
app := &App{
|
||||
GiphyService: services.NewGiphyService(services.GiphyConfig{
|
||||
Enabled: true,
|
||||
APIKey: "test-key",
|
||||
DownloadDir: t.TempDir(),
|
||||
}, nil),
|
||||
Hub: NewHub(),
|
||||
}
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, app)
|
||||
|
||||
body := bytes.NewBufferString(`{"providerId":"x","title":"bad","downloadUrl":"https://example.com/file.gif"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/giphy/download", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "INVALID_DOWNLOAD_URL") {
|
||||
t.Fatalf("expected invalid host error, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
+60
-1
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ai-media-hub/backend/handlers"
|
||||
"ai-media-hub/backend/models"
|
||||
@@ -17,8 +19,16 @@ func main() {
|
||||
root := envOrDefault("APP_ROOT", "/app")
|
||||
dbPath := envOrDefault("SQLITE_PATH", filepath.Join(root, "db", "media.db"))
|
||||
downloadsDir := envOrDefault("DOWNLOADS_DIR", filepath.Join(root, "downloads"))
|
||||
giphyDownloadDir := envOrDefault("GIPHY_DOWNLOAD_DIR", filepath.Join(downloadsDir, "giphy"))
|
||||
frontendDir := envOrDefault("FRONTEND_DIR", filepath.Join(root, "frontend"))
|
||||
workerScript := envOrDefault("WORKER_SCRIPT", filepath.Join(root, "worker", "downloader.py"))
|
||||
geminiAPIKey := os.Getenv("GEMINI_API_KEY")
|
||||
geminiModel := envOrDefault("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
giphyEnabled := envBoolOrDefault("GIPHY_ENABLED", true)
|
||||
giphyAPIKey := os.Getenv("GIPHY_API_KEY")
|
||||
giphyMaxResults := envIntOrDefault("GIPHY_MAX_RESULTS", 100)
|
||||
giphyRating := envOrDefault("GIPHY_RATING", "g")
|
||||
giphyLang := envOrDefault("GIPHY_LANG", "en")
|
||||
|
||||
db, err := models.InitDB(dbPath)
|
||||
if err != nil {
|
||||
@@ -29,6 +39,25 @@ func main() {
|
||||
if err := handlers.EnsurePaths(downloadsDir, workerScript); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if geminiAPIKey == "" {
|
||||
log.Printf("warning: GEMINI_API_KEY is not configured; query expansion will use fallback behavior")
|
||||
}
|
||||
if giphyEnabled && strings.TrimSpace(giphyAPIKey) == "" {
|
||||
log.Fatal("GIPHY_ENABLED is true but GIPHY_API_KEY is not configured")
|
||||
}
|
||||
if err := os.MkdirAll(giphyDownloadDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
geminiService := services.NewGeminiService(geminiAPIKey, geminiModel)
|
||||
giphyService := services.NewGiphyService(services.GiphyConfig{
|
||||
Enabled: giphyEnabled,
|
||||
APIKey: giphyAPIKey,
|
||||
MaxResults: giphyMaxResults,
|
||||
Rating: giphyRating,
|
||||
Lang: giphyLang,
|
||||
DownloadDir: giphyDownloadDir,
|
||||
}, geminiService)
|
||||
|
||||
app := &handlers.App{
|
||||
DB: db,
|
||||
@@ -40,7 +69,8 @@ func main() {
|
||||
os.Getenv("SEARXNG_GOOGLE_VIDEO_ENGINE"),
|
||||
os.Getenv("SEARXNG_WEB_ENGINE"),
|
||||
),
|
||||
GeminiService: services.NewGeminiService(os.Getenv("GEMINI_API_KEY")),
|
||||
GeminiService: geminiService,
|
||||
GiphyService: giphyService,
|
||||
Hub: handlers.NewHub(),
|
||||
}
|
||||
app.SearchService.Debug = func(message string, data any) {
|
||||
@@ -49,6 +79,9 @@ func main() {
|
||||
app.GeminiService.Debug = func(message string, data any) {
|
||||
app.Hub.Broadcast("debug", gin.H{"message": message, "data": data})
|
||||
}
|
||||
app.GiphyService.Debug = func(message string, data any) {
|
||||
app.Hub.Broadcast("debug", gin.H{"message": message, "data": data})
|
||||
}
|
||||
|
||||
router := gin.Default()
|
||||
handlers.RegisterRoutes(router, app)
|
||||
@@ -75,3 +108,29 @@ func envOrDefault(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBoolOrDefault(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
switch value {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
case "":
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func envIntOrDefault(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
+117
-37
@@ -27,15 +27,16 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type SearchService struct {
|
||||
BaseURL string
|
||||
GoogleVideoEngine string
|
||||
WebEngine string
|
||||
Client *http.Client
|
||||
collectors []searchCollector
|
||||
Debug func(message string, data any)
|
||||
cacheMu sync.Mutex
|
||||
searchCache map[string]cachedSearchResults
|
||||
fetchCache map[string]cachedFetchResult
|
||||
BaseURL string
|
||||
GoogleVideoEngine string
|
||||
WebEngine string
|
||||
Client *http.Client
|
||||
collectors []searchCollector
|
||||
Debug func(message string, data any)
|
||||
cacheMu sync.Mutex
|
||||
searchCache map[string]cachedSearchResults
|
||||
fetchCache map[string]cachedFetchResult
|
||||
artgridAPIBlockedUntil time.Time
|
||||
}
|
||||
|
||||
type cachedSearchResults struct {
|
||||
@@ -48,6 +49,12 @@ type cachedFetchResult struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type SearchExecutionMeta struct {
|
||||
PartialDueToDeadline bool `json:"partialDueToDeadline"`
|
||||
}
|
||||
|
||||
const searchEnrichmentReserve = 4 * time.Second
|
||||
|
||||
func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchService {
|
||||
if googleVideoEngine == "" {
|
||||
googleVideoEngine = "google videos"
|
||||
@@ -56,7 +63,7 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi
|
||||
webEngine = "google"
|
||||
}
|
||||
return &SearchService{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
BaseURL: normalizeBaseURL(baseURL),
|
||||
GoogleVideoEngine: googleVideoEngine,
|
||||
WebEngine: webEngine,
|
||||
Client: &http.Client{Timeout: 20 * time.Second},
|
||||
@@ -70,14 +77,27 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, error) {
|
||||
func normalizeBaseURL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(trimmed, "://") {
|
||||
trimmed = "http://" + trimmed
|
||||
}
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
|
||||
func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, SearchExecutionMeta, error) {
|
||||
return s.SearchMediaWithDeadline(queries, enabledPlatforms, time.Time{})
|
||||
}
|
||||
|
||||
func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatforms map[string]bool, deadline time.Time) ([]SearchResult, error) {
|
||||
func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatforms map[string]bool, deadline time.Time) ([]SearchResult, SearchExecutionMeta, error) {
|
||||
meta := SearchExecutionMeta{}
|
||||
if s.BaseURL == "" {
|
||||
return nil, fmt.Errorf("searxng base url is not configured")
|
||||
return nil, meta, fmt.Errorf("searxng base url is not configured")
|
||||
}
|
||||
collectionDeadline, enrichmentDeadline := splitSearchDeadlines(deadline)
|
||||
s.debug("search_service:start", map[string]any{
|
||||
"queries": queries,
|
||||
"enabledPlatforms": enabledPlatforms,
|
||||
@@ -87,13 +107,14 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
sourceCounts := map[string]int{}
|
||||
results := make([]SearchResult, 0, 90)
|
||||
var lastErr error
|
||||
collectorZeroStreak := map[string]int{}
|
||||
|
||||
baseQueries := limitQueries(queries, 8)
|
||||
shuffleStrings(baseQueries)
|
||||
primaryQueries := baseQueries[:minInt(len(baseQueries), 3)]
|
||||
runSearchPass := func(bases []string, onlyMissing bool) {
|
||||
for _, base := range bases {
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
if !collectionDeadline.IsZero() && time.Now().After(collectionDeadline) {
|
||||
meta.PartialDueToDeadline = true
|
||||
s.debug("search_service:deadline_reached", map[string]any{"stage": "runSearchPass", "base": base})
|
||||
return
|
||||
}
|
||||
@@ -102,7 +123,8 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
continue
|
||||
}
|
||||
for _, collector := range s.collectors {
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
if !collectionDeadline.IsZero() && time.Now().After(collectionDeadline) {
|
||||
meta.PartialDueToDeadline = true
|
||||
s.debug("search_service:deadline_reached", map[string]any{"stage": "collectorLoop", "collector": collector.Name()})
|
||||
return
|
||||
}
|
||||
@@ -116,7 +138,6 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
continue
|
||||
}
|
||||
searchQueries := collector.BuildQueries(base)
|
||||
shuffleStrings(searchQueries)
|
||||
searchQueries = limitCollectorQueries(collector.Name(), searchQueries, onlyMissing)
|
||||
s.debug("search_service:collector_queries", map[string]any{
|
||||
"collector": collector.Name(),
|
||||
@@ -125,7 +146,8 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
"searchQueries": searchQueries,
|
||||
})
|
||||
for _, searchQuery := range searchQueries {
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
if !collectionDeadline.IsZero() && time.Now().After(collectionDeadline) {
|
||||
meta.PartialDueToDeadline = true
|
||||
s.debug("search_service:deadline_reached", map[string]any{"stage": "queryLoop", "collector": collector.Name(), "query": searchQuery})
|
||||
return
|
||||
}
|
||||
@@ -148,6 +170,11 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
"rawCount": len(items),
|
||||
"sourceCount": sourceCounts[collector.Name()],
|
||||
})
|
||||
if len(items) == 0 && sourceCounts[collector.Name()] == 0 {
|
||||
collectorZeroStreak[collector.Name()]++
|
||||
} else {
|
||||
collectorZeroStreak[collector.Name()] = 0
|
||||
}
|
||||
for _, item := range items {
|
||||
item = normalizeResultForCollector(collector.Name(), item)
|
||||
if item.Link == "" || seen[item.Link] || !collector.Accept(item) {
|
||||
@@ -160,6 +187,14 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
break
|
||||
}
|
||||
}
|
||||
if collectorZeroStreak[collector.Name()] >= 2 && sourceCounts[collector.Name()] == 0 {
|
||||
s.debug("search_service:collector_skip_after_zero_streak", map[string]any{
|
||||
"collector": collector.Name(),
|
||||
"base": base,
|
||||
"streak": collectorZeroStreak[collector.Name()],
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,28 +206,44 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
||||
}
|
||||
|
||||
if len(results) == 0 && lastErr != nil {
|
||||
return nil, lastErr
|
||||
return nil, meta, lastErr
|
||||
}
|
||||
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
return sourceWeight(results[i].Source) > sourceWeight(results[j].Source)
|
||||
})
|
||||
s.debug("search_service:complete", map[string]any{
|
||||
"resultCount": len(results),
|
||||
"sourceCounts": sourceCounts,
|
||||
"hadError": lastErr != nil,
|
||||
"resultCount": len(results),
|
||||
"sourceCounts": sourceCounts,
|
||||
"hadError": lastErr != nil,
|
||||
"partialDueToDeadline": meta.PartialDueToDeadline,
|
||||
})
|
||||
return s.EnrichResultsWithDeadline(results, deadline), nil
|
||||
enriched, enrichMeta := s.EnrichResultsWithDeadline(results, enrichmentDeadline)
|
||||
meta.PartialDueToDeadline = meta.PartialDueToDeadline || enrichMeta.PartialDueToDeadline
|
||||
return enriched, meta, nil
|
||||
}
|
||||
|
||||
func splitSearchDeadlines(deadline time.Time) (time.Time, time.Time) {
|
||||
if deadline.IsZero() {
|
||||
return time.Time{}, time.Time{}
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= searchEnrichmentReserve {
|
||||
return deadline, deadline
|
||||
}
|
||||
return deadline.Add(-searchEnrichmentReserve), deadline
|
||||
}
|
||||
|
||||
func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult {
|
||||
return s.EnrichResultsWithDeadline(results, time.Time{})
|
||||
enriched, _ := s.EnrichResultsWithDeadline(results, time.Time{})
|
||||
return enriched
|
||||
}
|
||||
|
||||
func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadline time.Time) []SearchResult {
|
||||
func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadline time.Time) ([]SearchResult, SearchExecutionMeta) {
|
||||
meta := SearchExecutionMeta{}
|
||||
limit := minInt(len(results), 18)
|
||||
if limit == 0 {
|
||||
return results
|
||||
return results, meta
|
||||
}
|
||||
s.debug("search_service:enrich_start", map[string]any{
|
||||
"total": len(results),
|
||||
@@ -203,12 +254,16 @@ func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadli
|
||||
copy(enriched, results)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var metaMu sync.Mutex
|
||||
sem := make(chan struct{}, 4)
|
||||
for idx := 0; idx < limit; idx++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
metaMu.Lock()
|
||||
meta.PartialDueToDeadline = true
|
||||
metaMu.Unlock()
|
||||
return
|
||||
}
|
||||
sem <- struct{}{}
|
||||
@@ -231,7 +286,7 @@ func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadli
|
||||
}
|
||||
wg.Wait()
|
||||
s.debug("search_service:enrich_complete", map[string]any{"limit": limit})
|
||||
return enriched
|
||||
return enriched, meta
|
||||
}
|
||||
|
||||
func (s *SearchService) enrichResult(result SearchResult) SearchResult {
|
||||
@@ -323,19 +378,32 @@ func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult {
|
||||
s.debug("search_service:enrich_artgrid_start", map[string]any{"link": result.Link, "clipId": clipID})
|
||||
|
||||
apiURL := "https://artgrid.io/api/clip/details?clipId=" + clipID
|
||||
body, err := s.fetchJSONText(apiURL)
|
||||
if err == nil {
|
||||
urls := collectURLs(body)
|
||||
if !hasUsableThumbnail(result.ThumbnailURL) {
|
||||
result.ThumbnailURL = pickArtgridImageURL(urls, clipID)
|
||||
var err error
|
||||
if s.shouldSkipArtgridAPI() {
|
||||
s.debug("search_service:enrich_artgrid_api_skip", map[string]any{
|
||||
"link": result.Link,
|
||||
"clipId": clipID,
|
||||
"reason": "cached_403_guard",
|
||||
})
|
||||
} else {
|
||||
var body string
|
||||
body, err = s.fetchJSONText(apiURL)
|
||||
if err == nil {
|
||||
urls := collectURLs(body)
|
||||
if !hasUsableThumbnail(result.ThumbnailURL) {
|
||||
result.ThumbnailURL = pickArtgridImageURL(urls, clipID)
|
||||
}
|
||||
if result.PreviewVideoURL == "" {
|
||||
result.PreviewVideoURL = pickVideoURL(urls)
|
||||
}
|
||||
}
|
||||
if result.PreviewVideoURL == "" {
|
||||
result.PreviewVideoURL = pickVideoURL(urls)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "status 403") {
|
||||
s.blockArtgridAPI(15 * time.Minute)
|
||||
}
|
||||
s.debug("search_service:enrich_artgrid_api_error", map[string]any{"link": result.Link, "clipId": clipID, "error": err.Error()})
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.debug("search_service:enrich_artgrid_api_error", map[string]any{"link": result.Link, "clipId": clipID, "error": err.Error()})
|
||||
}
|
||||
|
||||
if result.ThumbnailURL == "" || result.PreviewVideoURL == "" {
|
||||
html, err := s.fetchText(result.Link)
|
||||
@@ -540,6 +608,18 @@ func (s *SearchService) setCachedFetchResult(key, body string, ttl time.Duration
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchService) shouldSkipArtgridAPI() bool {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
return !s.artgridAPIBlockedUntil.IsZero() && time.Now().Before(s.artgridAPIBlockedUntil)
|
||||
}
|
||||
|
||||
func (s *SearchService) blockArtgridAPI(ttl time.Duration) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
s.artgridAPIBlockedUntil = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
func (s *SearchService) debug(message string, data any) {
|
||||
if s != nil && s.Debug != nil {
|
||||
s.Debug(message, data)
|
||||
|
||||
@@ -2,7 +2,12 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -176,3 +181,86 @@ func TestSearchServiceFetchCacheRoundTrip(t *testing.T) {
|
||||
t.Fatalf("unexpected cached body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchServiceNormalizesSchemeLessBaseURL(t *testing.T) {
|
||||
service := NewSearchService("192.168.1.66:8087", "", "")
|
||||
if service.BaseURL != "http://192.168.1.66:8087" {
|
||||
t.Fatalf("expected normalized base url, got %q", service.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSearchDeadlinesReservesEnrichmentWindow(t *testing.T) {
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
collectionDeadline, enrichmentDeadline := splitSearchDeadlines(deadline)
|
||||
|
||||
if enrichmentDeadline.IsZero() {
|
||||
t.Fatal("expected enrichment deadline to be preserved")
|
||||
}
|
||||
if !collectionDeadline.Before(enrichmentDeadline) {
|
||||
t.Fatalf("expected collection deadline before enrichment deadline, got %v >= %v", collectionDeadline, enrichmentDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSearchDeadlinesDoesNotReserveWhenBudgetTooSmall(t *testing.T) {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
collectionDeadline, enrichmentDeadline := splitSearchDeadlines(deadline)
|
||||
|
||||
if !collectionDeadline.Equal(enrichmentDeadline) {
|
||||
t.Fatalf("expected identical deadlines, got %v and %v", collectionDeadline, enrichmentDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchServiceSkipsArtgridAPIAfter403(t *testing.T) {
|
||||
var apiRequests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/clip/details"):
|
||||
apiRequests.Add(1)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
case strings.HasPrefix(r.URL.Path, "/clip/114756/"):
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprintf(w, `<html><head><title>Friendly Couple | Stock Video Footage - Artgrid.io</title><meta property="og:title" content="Friendly Couple"><meta property="og:description" content="A warm couple moment"></head><body><script>window.__clip="%s";</script></body></html>`, "114756")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewSearchService(server.URL, "", "")
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse test server url: %v", err)
|
||||
}
|
||||
service.Client = &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
if clone.URL.Host == "artgrid.io" {
|
||||
clone.URL.Scheme = serverURL.Scheme
|
||||
clone.URL.Host = serverURL.Host
|
||||
clone.Host = serverURL.Host
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(clone)
|
||||
}),
|
||||
}
|
||||
|
||||
item := SearchResult{
|
||||
Link: "https://artgrid.io/clip/114756/friendly-couple",
|
||||
Source: "Artgrid",
|
||||
}
|
||||
|
||||
first := service.enrichArtgrid(item)
|
||||
second := service.enrichArtgrid(item)
|
||||
|
||||
if apiRequests.Load() != 1 {
|
||||
t.Fatalf("expected artgrid API to be skipped after first 403, got %d requests", apiRequests.Load())
|
||||
}
|
||||
if first.Title == "" || second.Title == "" {
|
||||
t.Fatalf("expected HTML fallback enrichment to preserve title, got %#v %#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
+273
-4
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
type GeminiService struct {
|
||||
APIKey string
|
||||
Model string
|
||||
Client *http.Client
|
||||
GenerateEndpoint string
|
||||
TranslateEndpoint string
|
||||
@@ -55,6 +56,8 @@ type AIRecommendation struct {
|
||||
Source string `json:"source"`
|
||||
Reason string `json:"reason"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Assessment string `json:"assessment,omitempty"`
|
||||
SearchHint string `json:"searchHint,omitempty"`
|
||||
MediaMode string `json:"mediaMode,omitempty"`
|
||||
EmbedURL string `json:"embedUrl,omitempty"`
|
||||
PreviewBlockedReason string `json:"previewBlockedReason,omitempty"`
|
||||
@@ -67,11 +70,15 @@ type QueryExpansion struct {
|
||||
Querywords []string `json:"querywords"`
|
||||
}
|
||||
|
||||
func NewGeminiService(apiKey string) *GeminiService {
|
||||
func NewGeminiService(apiKey, model string) *GeminiService {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "gemini-2.5-flash"
|
||||
}
|
||||
return &GeminiService{
|
||||
APIKey: apiKey,
|
||||
Model: model,
|
||||
Client: &http.Client{Timeout: 40 * time.Second},
|
||||
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent",
|
||||
TranslateEndpoint: "https://translate.googleapis.com/translate_a/single",
|
||||
visualCache: map[string]cachedVisualData{},
|
||||
translationCache: map[string]cachedStringValue{},
|
||||
@@ -97,6 +104,110 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) ExpandImageQueries(query string) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("query is empty")
|
||||
}
|
||||
cacheKey := "image-expansion\n" + trimmed
|
||||
if cached, ok := g.getCachedExpansion(cacheKey); ok {
|
||||
g.debug("gemini:image_expand_cache_hit", map[string]any{"query": trimmed, "expanded": cached})
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
fallback := buildFallbackImageQueries(trimmed, g.TranslateQuery(trimmed))
|
||||
if g.APIKey == "" {
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, fmt.Errorf("gemini api key is not configured")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]string{{
|
||||
"text": "Return exactly 5 concise English search queries for GIPHY image or GIF search. Respond with JSON only in this shape: {\"queries\":[\"...\",\"...\",\"...\",\"...\",\"...\"]}. Keep the queries meaning-preserving, practical, deduplicated, and concise.",
|
||||
}},
|
||||
},
|
||||
"contents": []map[string]any{{
|
||||
"parts": []map[string]string{{
|
||||
"text": "User query: " + trimmed + "\nGenerate exactly 5 English search queries for GIPHY image or GIF search. Include a direct translation, a common phrasing, and only relevant related variants.",
|
||||
}},
|
||||
}},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "application/json",
|
||||
"temperature": 0.2,
|
||||
"maxOutputTokens": 160,
|
||||
},
|
||||
}
|
||||
|
||||
rawText, err := g.generateText(body)
|
||||
if err != nil {
|
||||
g.debug("gemini:image_expand_error", map[string]any{"query": trimmed, "error": err.Error()})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
jsonText, err := extractJSONObject(rawText)
|
||||
if err != nil {
|
||||
if looseQueries := parseLooseImageExpansionLines(rawText); len(looseQueries) == 5 {
|
||||
g.setCachedExpansion(cacheKey, looseQueries, 15*time.Minute)
|
||||
g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": looseQueries, "mode": "loose_text"})
|
||||
return looseQueries, nil
|
||||
}
|
||||
g.debug("gemini:image_expand_parse_error", map[string]any{"query": trimmed, "error": err.Error()})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Queries []string `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonText), &payload); err != nil {
|
||||
g.debug("gemini:image_expand_json_error", map[string]any{"query": trimmed, "error": err.Error(), "raw": truncateForError(rawText, 200)})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
queries := normalizeImageExpansionQueries(payload.Queries)
|
||||
if len(queries) != 5 {
|
||||
err := fmt.Errorf("gemini image expansion returned %d queries", len(queries))
|
||||
g.debug("gemini:image_expand_invalid_count", map[string]any{"query": trimmed, "queries": queries, "error": err.Error()})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
g.setCachedExpansion(cacheKey, queries, 15*time.Minute)
|
||||
g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": queries})
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
cacheKey := "summary-ko\n" + trimmed
|
||||
if cached, ok := g.getCachedTranslation(cacheKey); ok {
|
||||
g.debug("gemini:summary_translate_cache_hit", map[string]any{"length": len(trimmed)})
|
||||
return cached, nil
|
||||
}
|
||||
if !looksMostlyASCII(trimmed) {
|
||||
g.setCachedTranslation(cacheKey, trimmed, 15*time.Minute)
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
g.debug("gemini:summary_translate_attempt", map[string]any{"length": len(trimmed)})
|
||||
translated, err := g.translateViaGoogleToTarget(trimmed, "ko")
|
||||
if err != nil {
|
||||
g.debug("gemini:summary_translate_error", map[string]any{"length": len(trimmed), "error": err.Error()})
|
||||
return "", err
|
||||
}
|
||||
translated = strings.TrimSpace(translated)
|
||||
if translated == "" {
|
||||
return "", fmt.Errorf("google translate summary returned empty translation")
|
||||
}
|
||||
g.debug("gemini:summary_translate_success", map[string]any{"length": len(trimmed)})
|
||||
g.setCachedTranslation(cacheKey, translated, 15*time.Minute)
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) TranslateQuery(query string) string {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if trimmed == "" {
|
||||
@@ -222,10 +333,17 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
||||
parts := []geminiPart{
|
||||
{
|
||||
"text": `You are a professional video editor. Analyze whether each provided visual is suitable as a usable scene or shot for the user's requested keyword. Return JSON only in this shape:
|
||||
{"recommendations":[{"index":0,"verdict":"Yes","reason":"short reason","recommended":true}]}
|
||||
{"recommendations":[{"index":0,"verdict":"Yes","reason":"short reason","recommended":true,"assessment":"positive","searchHint":"short english hint"}]}
|
||||
Return one entry for every analyzed candidate. Use Korean for every reason. Keep reasons concise but specific enough to explain usefulness.
|
||||
Set verdict to "Yes" or "No" for every candidate. "Yes" means the scene is usable and relevant for editing against the user's keyword. "No" means it is not suitable or not relevant enough.
|
||||
Set recommended=true only when verdict is "Yes". Set recommended=false when verdict is "No".
|
||||
Set assessment to one of: positive, unclear, irrelevant, inappropriate.
|
||||
- positive: directly usable and relevant to the query
|
||||
- unclear: visually ambiguous, weak, or not confident enough
|
||||
- irrelevant: visibly unrelated to the query intent
|
||||
- inappropriate: low-quality, spammy, misleading, meme-like, or otherwise unsuitable for professional editing
|
||||
When assessment is not positive, provide searchHint as a short English stock-footage search phrase that could help find better candidates. Keep it under 8 words.
|
||||
When assessment is positive, searchHint may be empty.
|
||||
Prefer cinematic b-roll, stock footage, editorial footage, clean composition, usable establishing shots, and professional media thumbnails.
|
||||
Avoid clickbait faces, exaggerated expressions, meme aesthetics, low-information thumbnails, sensational text overlays, or gossip-style imagery.
|
||||
Favor scenes that look directly useful for professional editing, sequencing, establishing, cutaway, or mood-building usage.
|
||||
@@ -310,6 +428,8 @@ User query: ` + query,
|
||||
Verdict string `json:"verdict"`
|
||||
Reason string `json:"reason"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Assessment string `json:"assessment"`
|
||||
SearchHint string `json:"searchHint"`
|
||||
} `json:"recommendations"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
@@ -323,6 +443,7 @@ User query: ` + query,
|
||||
}
|
||||
src := candidates[rec.Index]
|
||||
recommended := rec.Recommended || strings.EqualFold(strings.TrimSpace(rec.Verdict), "yes")
|
||||
assessment := normalizeAssessment(rec.Assessment, recommended)
|
||||
recommendations = append(recommendations, AIRecommendation{
|
||||
Title: src.Title,
|
||||
Link: src.Link,
|
||||
@@ -332,6 +453,8 @@ User query: ` + query,
|
||||
Source: src.Source,
|
||||
Reason: normalizeKoreanReason(rec.Reason),
|
||||
Recommended: recommended,
|
||||
Assessment: assessment,
|
||||
SearchHint: normalizeSearchHint(rec.SearchHint),
|
||||
})
|
||||
}
|
||||
g.debug("gemini:vision_complete", map[string]any{
|
||||
@@ -342,6 +465,72 @@ User query: ` + query,
|
||||
return recommendations, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) BuildSupplementalQueries(query string, existing []string, reviewed []AIRecommendation) ([]string, error) {
|
||||
baseExisting := make([]string, 0, len(existing))
|
||||
for _, item := range existing {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed != "" {
|
||||
baseExisting = append(baseExisting, trimmed)
|
||||
}
|
||||
}
|
||||
if len(baseExisting) == 0 {
|
||||
baseExisting = append(baseExisting, query)
|
||||
}
|
||||
|
||||
positive := make([]string, 0, 3)
|
||||
negativeHints := make([]string, 0, 4)
|
||||
sourceCounts := map[string]int{}
|
||||
for _, item := range reviewed {
|
||||
sourceCounts[item.Source]++
|
||||
if item.Assessment == "positive" && len(positive) < 3 {
|
||||
positive = append(positive, truncateForError(strings.TrimSpace(item.Title), 80))
|
||||
}
|
||||
if (item.Assessment == "irrelevant" || item.Assessment == "inappropriate" || item.Assessment == "unclear") && item.SearchHint != "" && len(negativeHints) < 4 {
|
||||
negativeHints = append(negativeHints, item.SearchHint)
|
||||
}
|
||||
}
|
||||
|
||||
if g.APIKey == "" {
|
||||
return nil, fmt.Errorf("gemini api key is not configured")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]string{{
|
||||
"text": "You generate improved stock-footage search phrases. Return 3 to 5 plain English search phrases only, one per line, no numbering, no quotes, no explanations.",
|
||||
}},
|
||||
},
|
||||
"contents": []map[string]any{{
|
||||
"parts": []map[string]string{{
|
||||
"text": fmt.Sprintf("Original query: %s\nExisting search phrases: %s\nPositive candidate titles: %s\nNegative or weak search hints: %s\nSource distribution: Envato=%d, Artgrid=%d, Google Video=%d\nGenerate improved English search phrases that avoid weak or irrelevant results and increase provider diversity.",
|
||||
query,
|
||||
strings.Join(baseExisting, " | "),
|
||||
strings.Join(positive, " | "),
|
||||
strings.Join(negativeHints, " | "),
|
||||
sourceCounts["Envato"],
|
||||
sourceCounts["Artgrid"],
|
||||
sourceCounts["Google Video"],
|
||||
),
|
||||
}},
|
||||
}},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "text/plain",
|
||||
"temperature": 0.3,
|
||||
"maxOutputTokens": 120,
|
||||
},
|
||||
}
|
||||
|
||||
rawText, err := g.generateText(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queries := parseSupplementalQueryLines(rawText)
|
||||
if len(queries) == 0 {
|
||||
return nil, fmt.Errorf("gemini returned no supplemental queries")
|
||||
}
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) debug(message string, data any) {
|
||||
if g != nil && g.Debug != nil {
|
||||
g.Debug(message, data)
|
||||
@@ -617,6 +806,34 @@ func truncateForError(text string, limit int) string {
|
||||
return trimmed[:limit] + "..."
|
||||
}
|
||||
|
||||
func parseLooseImageExpansionLines(text string) []string {
|
||||
candidates := make([]string, 0, 8)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
trimmed = strings.TrimPrefix(trimmed, "- ")
|
||||
trimmed = strings.TrimPrefix(trimmed, "* ")
|
||||
trimmed = strings.TrimPrefix(trimmed, "1. ")
|
||||
trimmed = strings.TrimPrefix(trimmed, "2. ")
|
||||
trimmed = strings.TrimPrefix(trimmed, "3. ")
|
||||
trimmed = strings.TrimPrefix(trimmed, "4. ")
|
||||
trimmed = strings.TrimPrefix(trimmed, "5. ")
|
||||
trimmed = strings.TrimSpace(strings.Trim(trimmed, "\"'`"))
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "json") || strings.HasPrefix(lower, "output") {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, trimmed)
|
||||
}
|
||||
queries := normalizeImageExpansionQueries(candidates)
|
||||
if len(queries) < 5 {
|
||||
return nil
|
||||
}
|
||||
return queries[:5]
|
||||
}
|
||||
|
||||
func normalizeKoreanReason(reason string) string {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
if trimmed == "" {
|
||||
@@ -625,6 +842,50 @@ func normalizeKoreanReason(reason string) string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeAssessment(assessment string, recommended bool) string {
|
||||
switch strings.ToLower(strings.TrimSpace(assessment)) {
|
||||
case "positive", "unclear", "irrelevant", "inappropriate":
|
||||
return strings.ToLower(strings.TrimSpace(assessment))
|
||||
}
|
||||
if recommended {
|
||||
return "positive"
|
||||
}
|
||||
return "unclear"
|
||||
}
|
||||
|
||||
func normalizeSearchHint(text string) string {
|
||||
trimmed := strings.Join(strings.Fields(strings.TrimSpace(strings.Trim(text, "\"'`"))), " ")
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if len(trimmed) > 80 {
|
||||
return trimmed[:80]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func parseSupplementalQueryLines(text string) []string {
|
||||
lines := strings.Split(text, "\n")
|
||||
seen := map[string]bool{}
|
||||
queries := make([]string, 0, 5)
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(strings.Trim(line, "\"'`-0123456789. "))
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
queries = append(queries, trimmed)
|
||||
if len(queries) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
func buildSearchQueries(originalQuery, englishQuery string) []string {
|
||||
base := strings.TrimSpace(englishQuery)
|
||||
if base == "" {
|
||||
@@ -784,11 +1045,19 @@ func isOvercompressedTranslation(original, translated string) bool {
|
||||
}
|
||||
|
||||
func (g *GeminiService) translateViaGoogle(query string) (string, error) {
|
||||
return g.translateViaGoogleToTarget(query, "en")
|
||||
}
|
||||
|
||||
func (g *GeminiService) translateViaGoogleToTarget(query, targetLanguage string) (string, error) {
|
||||
baseURL := g.TranslateEndpoint
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = "https://translate.googleapis.com/translate_a/single"
|
||||
}
|
||||
endpoint := baseURL + "?client=gtx&sl=auto&tl=en&dt=t&q=" + neturl.QueryEscape(query)
|
||||
targetLanguage = strings.TrimSpace(targetLanguage)
|
||||
if targetLanguage == "" {
|
||||
targetLanguage = "en"
|
||||
}
|
||||
endpoint := baseURL + "?client=gtx&sl=auto&tl=" + neturl.QueryEscape(targetLanguage) + "&dt=t&q=" + neturl.QueryEscape(query)
|
||||
resp, err := g.Client.Get(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestTranslateQueryFallsBackToGoogleWithoutGeminiKey(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
@@ -41,6 +41,35 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[[["도시에서 웃는 커플","smiling couple in city",null,null,1]],null,"en"]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
first, err := service.TranslateSummaryToKorean("smiling couple in city")
|
||||
if err != nil {
|
||||
t.Fatalf("expected translation to succeed, got error: %v", err)
|
||||
}
|
||||
second, err := service.TranslateSummaryToKorean("smiling couple in city")
|
||||
if err != nil {
|
||||
t.Fatalf("expected cached translation to succeed, got error: %v", err)
|
||||
}
|
||||
if first != "도시에서 웃는 커플" || second != first {
|
||||
t.Fatalf("unexpected translated summary values: %q %q", first, second)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("expected one upstream translation request due to cache, got %d", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKnownMediaPhrases(t *testing.T) {
|
||||
translated := translateKoreanMediaTerms("사이버 펑크 도시")
|
||||
if translated != "cyberpunk city" {
|
||||
@@ -48,6 +77,94 @@ func TestNormalizeKnownMediaPhrases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"authentic couple city walk\ncandid couple park footage\nnatural lifestyle b-roll"}]}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
|
||||
queries, err := service.BuildSupplementalQueries("다정한 커플", []string{"friendly couple"}, []AIRecommendation{
|
||||
{Assessment: "irrelevant", SearchHint: "authentic lifestyle couple"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected supplemental query generation to succeed, got %v", err)
|
||||
}
|
||||
if len(queries) < 3 || queries[0] != "authentic couple city walk" {
|
||||
t.Fatalf("unexpected supplemental queries: %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandImageQueriesReturnsExactlyFiveEnglishQueries(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"funny cat gif\",\"cute cat reaction\",\"cat meme gif\",\"animated cat sticker\"]}"}]}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
|
||||
queries, err := service.ExpandImageQueries("웃긴 고양이")
|
||||
if err != nil {
|
||||
t.Fatalf("expected image query expansion to succeed, got %v", err)
|
||||
}
|
||||
if len(queries) != 5 {
|
||||
t.Fatalf("expected exactly 5 queries, got %#v", queries)
|
||||
}
|
||||
if queries[0] != "funny cat" || queries[4] != "animated cat sticker" {
|
||||
t.Fatalf("unexpected image queries: %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandImageQueriesFallsBackWhenGeminiFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
queries, err := service.ExpandImageQueries("happy dog")
|
||||
if err == nil {
|
||||
t.Fatal("expected fallback warning error when gemini expansion fails")
|
||||
}
|
||||
if len(queries) != 5 {
|
||||
t.Fatalf("expected fallback to still provide 5 queries, got %#v", queries)
|
||||
}
|
||||
if queries[0] != "happy dog" {
|
||||
t.Fatalf("expected original query to be preserved in fallback, got %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandImageQueriesAcceptsLoosePlainTextList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"Here is the JSON requested\n1. cute cat\n2. cute cat gif\n3. cat reaction gif\n4. cat meme gif\n5. animated cat sticker"}]}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
|
||||
queries, err := service.ExpandImageQueries("고양이")
|
||||
if err != nil {
|
||||
t.Fatalf("expected loose plain-text list to be accepted, got %v", err)
|
||||
}
|
||||
if len(queries) != 5 || queries[0] != "cute cat" || queries[4] != "animated cat sticker" {
|
||||
t.Fatalf("unexpected loose parsed queries: %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) {
|
||||
ranked := []SearchResult{
|
||||
{Link: "https://a.example"},
|
||||
@@ -78,7 +195,7 @@ func TestRemainingGeminiCapacityShrinksWithReviewedItems(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.setCachedVisual("image\nhttps://example.com/thumb.jpg", "abc", "image/jpeg", time.Minute)
|
||||
|
||||
data, mimeType, ok := service.getCachedVisual("image\nhttps://example.com/thumb.jpg")
|
||||
@@ -91,7 +208,7 @@ func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute)
|
||||
|
||||
value, ok := service.getCachedTranslation("비 오는 도시")
|
||||
@@ -104,7 +221,7 @@ func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiExpansionCacheRoundTrip(t *testing.T) {
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute)
|
||||
|
||||
value, ok := service.getCachedExpansion("city rain")
|
||||
@@ -143,3 +260,22 @@ func TestRankSearchResultsPrefersUsableVisuals(t *testing.T) {
|
||||
t.Fatalf("expected usable thumbnail result first, got %#v", ranked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRecommendationsExcludesIrrelevantAndPendingFiller(t *testing.T) {
|
||||
recommended := []AIRecommendation{
|
||||
{Title: "keep", Link: "https://a.example", Recommended: true, Assessment: "positive", ThumbnailURL: "https://example.com/a.jpg"},
|
||||
{Title: "drop", Link: "https://b.example", Recommended: false, Assessment: "irrelevant", ThumbnailURL: "https://example.com/b.jpg", Reason: "관련이 없습니다."},
|
||||
}
|
||||
ranked := []SearchResult{
|
||||
{Title: "keep", Link: "https://a.example", ThumbnailURL: "https://example.com/a.jpg"},
|
||||
{Title: "extra", Link: "https://c.example", ThumbnailURL: "https://example.com/c.jpg"},
|
||||
}
|
||||
|
||||
merged := MergeRecommendations(recommended, ranked, 16)
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("expected only the positive recommendation without pending filler, got %#v", merged)
|
||||
}
|
||||
if merged[0].Link != "https://a.example" {
|
||||
t.Fatalf("unexpected merged result: %#v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGiphyAPIBaseURL = "https://api.giphy.com"
|
||||
defaultGiphyMaxResults = 100
|
||||
giphyBatchSize = 20
|
||||
giphyDownloadSizeLimit = 50 * 1024 * 1024
|
||||
)
|
||||
|
||||
type GiphyConfig struct {
|
||||
Enabled bool
|
||||
APIKey string
|
||||
MaxResults int
|
||||
Rating string
|
||||
Lang string
|
||||
DownloadDir string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type GiphyService struct {
|
||||
Config GiphyConfig
|
||||
Client *http.Client
|
||||
Gemini *GeminiService
|
||||
Debug func(message string, data any)
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type GiphyResult struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Link string `json:"link,omitempty"`
|
||||
Title string `json:"title"`
|
||||
SearchQuery string `json:"searchQuery"`
|
||||
OriginalQuery string `json:"originalQuery,omitempty"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
PreviewStillURL string `json:"previewStillUrl"`
|
||||
FullURL string `json:"fullUrl"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Rating string `json:"rating"`
|
||||
SourcePageURL string `json:"sourcePageUrl"`
|
||||
OpenURL string `json:"openUrl,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
ActionLabel string `json:"actionLabel,omitempty"`
|
||||
ActionType string `json:"actionType,omitempty"`
|
||||
SecondaryActionLabel string `json:"secondaryActionLabel,omitempty"`
|
||||
PreviewBlockedReason string `json:"previewBlockedReason,omitempty"`
|
||||
Raw map[string]any `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type GiphySearchResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
OriginalQuery string `json:"originalQuery"`
|
||||
ExpandedQueries []string `json:"expandedQueries"`
|
||||
Total int `json:"total"`
|
||||
Items []GiphyResult `json:"items"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type GiphyDownloadRequest struct {
|
||||
ProviderID string `json:"providerId"`
|
||||
Title string `json:"title"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
OriginalQuery string `json:"originalQuery"`
|
||||
SelectedExpansionQuery string `json:"selectedExpansionQuery"`
|
||||
}
|
||||
|
||||
type GiphyDownloadResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
SavedPath string `json:"savedPath,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type giphySearchAPIResponse struct {
|
||||
Data []giphyAPIItem `json:"data"`
|
||||
}
|
||||
|
||||
type giphyAPIItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Rating string `json:"rating"`
|
||||
URL string `json:"url"`
|
||||
Images struct {
|
||||
Original giphyRendition `json:"original"`
|
||||
OriginalStill giphyRendition `json:"original_still"`
|
||||
FixedWidth giphyRendition `json:"fixed_width"`
|
||||
FixedWidthStill giphyRendition `json:"fixed_width_still"`
|
||||
FixedWidthDownsample giphyRendition `json:"fixed_width_downsampled"`
|
||||
FixedHeight giphyRendition `json:"fixed_height"`
|
||||
PreviewGIF giphyRendition `json:"preview_gif"`
|
||||
Downsized giphyRendition `json:"downsized"`
|
||||
DownsizedLarge giphyRendition `json:"downsized_large"`
|
||||
} `json:"images"`
|
||||
}
|
||||
|
||||
type giphyRendition struct {
|
||||
URL string `json:"url"`
|
||||
MP4 string `json:"mp4"`
|
||||
WebP string `json:"webp"`
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
}
|
||||
|
||||
func NewGiphyService(config GiphyConfig, gemini *GeminiService) *GiphyService {
|
||||
if config.MaxResults <= 0 {
|
||||
config.MaxResults = defaultGiphyMaxResults
|
||||
}
|
||||
if strings.TrimSpace(config.Rating) == "" {
|
||||
config.Rating = "g"
|
||||
}
|
||||
if strings.TrimSpace(config.Lang) == "" {
|
||||
config.Lang = "en"
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
config.BaseURL = defaultGiphyAPIBaseURL
|
||||
}
|
||||
return &GiphyService{
|
||||
Config: config,
|
||||
BaseURL: strings.TrimRight(config.BaseURL, "/"),
|
||||
Client: &http.Client{Timeout: 20 * time.Second},
|
||||
Gemini: gemini,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GiphyService) SearchImages(query string, requestedMax int) (GiphySearchResponse, error) {
|
||||
response := GiphySearchResponse{
|
||||
Provider: "giphy",
|
||||
OriginalQuery: strings.TrimSpace(query),
|
||||
}
|
||||
if !s.Config.Enabled {
|
||||
return response, fmt.Errorf("giphy is disabled")
|
||||
}
|
||||
if response.OriginalQuery == "" {
|
||||
return response, fmt.Errorf("query is required")
|
||||
}
|
||||
if strings.TrimSpace(s.Config.APIKey) == "" {
|
||||
return response, fmt.Errorf("giphy api key is not configured")
|
||||
}
|
||||
|
||||
target := s.Config.MaxResults
|
||||
if requestedMax > 0 {
|
||||
target = minInt(requestedMax, s.Config.MaxResults)
|
||||
}
|
||||
if target <= 0 {
|
||||
target = minInt(defaultGiphyMaxResults, s.Config.MaxResults)
|
||||
}
|
||||
|
||||
expandedQueries, expansionErr := s.expandQueries(response.OriginalQuery)
|
||||
response.ExpandedQueries = expandedQueries
|
||||
if expansionErr != nil {
|
||||
s.debug("giphy:query_expansion_fallback", map[string]any{
|
||||
"query": response.OriginalQuery,
|
||||
"queries": expandedQueries,
|
||||
"error": expansionErr.Error(),
|
||||
})
|
||||
} else {
|
||||
s.debug("giphy:query_expansion", map[string]any{
|
||||
"query": response.OriginalQuery,
|
||||
"queries": expandedQueries,
|
||||
})
|
||||
}
|
||||
|
||||
type queryState struct {
|
||||
query string
|
||||
offset int
|
||||
enabled bool
|
||||
}
|
||||
|
||||
states := make([]queryState, 0, len(expandedQueries))
|
||||
for _, item := range expandedQueries {
|
||||
states = append(states, queryState{query: item, enabled: true})
|
||||
}
|
||||
|
||||
items := make([]GiphyResult, 0, target)
|
||||
seen := map[string]bool{}
|
||||
var allErrs []string
|
||||
var successfulCalls int
|
||||
|
||||
fetchRound := func(limit int) bool {
|
||||
progress := false
|
||||
for idx := range states {
|
||||
if !states[idx].enabled || len(items) >= target {
|
||||
continue
|
||||
}
|
||||
batchLimit := minInt(limit, target-len(items))
|
||||
found, err := s.searchQuery(states[idx].query, batchLimit, states[idx].offset, response.OriginalQuery)
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, err.Error())
|
||||
s.debug("giphy:query_error", map[string]any{
|
||||
"query": states[idx].query,
|
||||
"offset": states[idx].offset,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
successfulCalls++
|
||||
s.debug("giphy:query_results", map[string]any{
|
||||
"query": states[idx].query,
|
||||
"offset": states[idx].offset,
|
||||
"count": len(found),
|
||||
})
|
||||
if len(found) == 0 {
|
||||
states[idx].enabled = false
|
||||
continue
|
||||
}
|
||||
states[idx].offset += len(found)
|
||||
before := len(items)
|
||||
for _, item := range found {
|
||||
if len(items) >= target {
|
||||
break
|
||||
}
|
||||
if mergeUniqueGiphyResult(&items, item, seen) {
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
if len(found) < batchLimit && len(items) == before {
|
||||
states[idx].enabled = false
|
||||
}
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
progress := fetchRound(minInt(giphyBatchSize, target))
|
||||
for round := 0; len(items) < target && progress && round < 3; round++ {
|
||||
progress = fetchRound(minInt(giphyBatchSize, target-len(items)))
|
||||
}
|
||||
|
||||
response.Items = items
|
||||
response.Total = len(items)
|
||||
s.debug("giphy:search_complete", map[string]any{
|
||||
"query": response.OriginalQuery,
|
||||
"expanded": response.ExpandedQueries,
|
||||
"total": response.Total,
|
||||
"target": target,
|
||||
"warning": response.Warning,
|
||||
"successCalls": successfulCalls,
|
||||
})
|
||||
|
||||
if response.Total == 0 && len(allErrs) > 0 {
|
||||
return response, fmt.Errorf("giphy search failed: %s", strings.Join(uniqueStrings(allErrs, 3), "; "))
|
||||
}
|
||||
if len(allErrs) > 0 && response.Warning == "" {
|
||||
response.Warning = "Some GIPHY requests failed, showing partial results."
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *GiphyService) DownloadMedia(req GiphyDownloadRequest) (GiphyDownloadResponse, error) {
|
||||
if !s.Config.Enabled {
|
||||
return GiphyDownloadResponse{OK: false, Error: "GIPHY_DISABLED", Message: "GIPHY is disabled"}, fmt.Errorf("giphy is disabled")
|
||||
}
|
||||
if strings.TrimSpace(req.ProviderID) == "" || strings.TrimSpace(req.DownloadURL) == "" {
|
||||
return GiphyDownloadResponse{OK: false, Error: "INVALID_REQUEST", Message: "providerId and downloadUrl are required"}, fmt.Errorf("providerId and downloadUrl are required")
|
||||
}
|
||||
if !isAllowedGiphyDownloadURL(req.DownloadURL) {
|
||||
return GiphyDownloadResponse{OK: false, Error: "INVALID_DOWNLOAD_URL", Message: "Only approved GIPHY media URLs are allowed"}, fmt.Errorf("download url is not on an approved giphy host")
|
||||
}
|
||||
if err := os.MkdirAll(s.Config.DownloadDir, 0o755); err != nil {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_DIR_FAILED", Message: "Failed to prepare GIPHY download directory"}, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodGet, req.DownloadURL, nil)
|
||||
if err != nil {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to build GIPHY download request"}, err
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "AI-Media-Hub/1.0")
|
||||
resp, err := s.Client.Do(httpReq)
|
||||
if err != nil {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, fmt.Errorf("giphy download returned status %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > giphyDownloadSizeLimit {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download too large: %d", resp.ContentLength)
|
||||
}
|
||||
|
||||
contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
extension := determineGiphyExtension(req.DownloadURL, contentType)
|
||||
fileName := buildGiphyFilename(req.ProviderID, req.Title, extension, time.Now())
|
||||
targetPath := filepath.Join(s.Config.DownloadDir, fileName)
|
||||
cleanTargetPath := filepath.Clean(targetPath)
|
||||
cleanBaseDir := filepath.Clean(s.Config.DownloadDir)
|
||||
if !strings.HasPrefix(cleanTargetPath, cleanBaseDir) {
|
||||
return GiphyDownloadResponse{OK: false, Error: "INVALID_PATH", Message: "Resolved download path is invalid"}, fmt.Errorf("resolved giphy download path escaped base directory")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, giphyDownloadSizeLimit+1))
|
||||
if err != nil {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to read media from GIPHY"}, err
|
||||
}
|
||||
if int64(len(data)) > giphyDownloadSizeLimit {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download exceeded size limit during read")
|
||||
}
|
||||
if err := os.WriteFile(cleanTargetPath, data, 0o644); err != nil {
|
||||
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to save media from GIPHY"}, err
|
||||
}
|
||||
|
||||
s.debug("giphy:download_success", map[string]any{
|
||||
"providerId": req.ProviderID,
|
||||
"fileName": fileName,
|
||||
"savedPath": cleanTargetPath,
|
||||
})
|
||||
return GiphyDownloadResponse{
|
||||
OK: true,
|
||||
FileName: fileName,
|
||||
SavedPath: cleanTargetPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *GiphyService) expandQueries(query string) ([]string, error) {
|
||||
if s.Gemini == nil {
|
||||
return buildFallbackImageQueries(query, strings.TrimSpace(query)), fmt.Errorf("gemini service is not configured")
|
||||
}
|
||||
return s.Gemini.ExpandImageQueries(query)
|
||||
}
|
||||
|
||||
func (s *GiphyService) searchQuery(query string, limit, offset int, originalQuery string) ([]GiphyResult, error) {
|
||||
endpoint, err := neturl.Parse(s.BaseURL + "/v1/gifs/search")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := endpoint.Query()
|
||||
params.Set("api_key", s.Config.APIKey)
|
||||
params.Set("q", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
params.Set("offset", strconv.Itoa(max(offset, 0)))
|
||||
params.Set("rating", s.Config.Rating)
|
||||
params.Set("lang", s.Config.Lang)
|
||||
endpoint.RawQuery = params.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("giphy returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
var payload giphySearchAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]GiphyResult, 0, len(payload.Data))
|
||||
for _, item := range payload.Data {
|
||||
mapped := mapGiphyItem(item, query, originalQuery)
|
||||
if mapped.ProviderID == "" || mapped.FullURL == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, mapped)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mapGiphyItem(item giphyAPIItem, searchQuery, originalQuery string) GiphyResult {
|
||||
previewURL := firstNonEmpty(
|
||||
item.Images.FixedWidthDownsample.URL,
|
||||
item.Images.FixedWidth.URL,
|
||||
item.Images.FixedHeight.URL,
|
||||
item.Images.PreviewGIF.URL,
|
||||
item.Images.Downsized.URL,
|
||||
item.Images.Original.URL,
|
||||
)
|
||||
stillURL := firstNonEmpty(
|
||||
item.Images.FixedWidthStill.URL,
|
||||
item.Images.OriginalStill.URL,
|
||||
item.Images.FixedWidth.URL,
|
||||
item.Images.Downsized.URL,
|
||||
item.Images.Original.URL,
|
||||
)
|
||||
fullURL := firstNonEmpty(
|
||||
item.Images.Original.URL,
|
||||
item.Images.DownsizedLarge.URL,
|
||||
item.Images.Downsized.URL,
|
||||
item.Images.FixedWidth.URL,
|
||||
)
|
||||
downloadURL := firstNonEmpty(
|
||||
item.Images.Original.URL,
|
||||
item.Images.DownsizedLarge.URL,
|
||||
item.Images.FixedWidth.URL,
|
||||
item.Images.Original.MP4,
|
||||
)
|
||||
width := atoiOrZero(firstNonEmpty(item.Images.Original.Width, item.Images.FixedWidth.Width, item.Images.DownsizedLarge.Width))
|
||||
height := atoiOrZero(firstNonEmpty(item.Images.Original.Height, item.Images.FixedWidth.Height, item.Images.DownsizedLarge.Height))
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
title = strings.ReplaceAll(strings.TrimSpace(item.Slug), "-", " ")
|
||||
}
|
||||
if title == "" {
|
||||
title = "Untitled GIPHY"
|
||||
}
|
||||
return GiphyResult{
|
||||
Provider: "giphy",
|
||||
ProviderID: strings.TrimSpace(item.ID),
|
||||
Link: strings.TrimSpace(item.URL),
|
||||
Title: title,
|
||||
SearchQuery: searchQuery,
|
||||
OriginalQuery: originalQuery,
|
||||
PreviewURL: previewURL,
|
||||
PreviewStillURL: stillURL,
|
||||
FullURL: fullURL,
|
||||
DownloadURL: downloadURL,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Rating: strings.TrimSpace(item.Rating),
|
||||
SourcePageURL: strings.TrimSpace(item.URL),
|
||||
OpenURL: strings.TrimSpace(item.URL),
|
||||
Source: "GIPHY",
|
||||
ActionLabel: "Download",
|
||||
ActionType: "giphy_download",
|
||||
SecondaryActionLabel: "Open Original",
|
||||
}
|
||||
}
|
||||
|
||||
func mergeUniqueGiphyResult(items *[]GiphyResult, candidate GiphyResult, seen map[string]bool) bool {
|
||||
for _, key := range giphyDedupKeys(candidate) {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if seen[key] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, key := range giphyDedupKeys(candidate) {
|
||||
if key != "" {
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
*items = append(*items, candidate)
|
||||
return true
|
||||
}
|
||||
|
||||
func giphyDedupKeys(item GiphyResult) []string {
|
||||
keys := []string{}
|
||||
if item.ProviderID != "" {
|
||||
keys = append(keys, "id:"+strings.ToLower(item.ProviderID))
|
||||
}
|
||||
if item.FullURL != "" {
|
||||
keys = append(keys, "full:"+strings.ToLower(strings.TrimSpace(item.FullURL)))
|
||||
}
|
||||
if item.SourcePageURL != "" {
|
||||
keys = append(keys, "source:"+strings.ToLower(strings.TrimSpace(item.SourcePageURL)))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func buildFallbackImageQueries(originalQuery, englishBase string) []string {
|
||||
base := strings.TrimSpace(englishBase)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(originalQuery)
|
||||
}
|
||||
candidates := []string{
|
||||
base,
|
||||
base + " gif",
|
||||
base + " reaction gif",
|
||||
base + " meme gif",
|
||||
base + " animated gif",
|
||||
base + " reaction image",
|
||||
base + " sticker",
|
||||
}
|
||||
queries := normalizeImageExpansionQueries(candidates)
|
||||
if len(queries) > 5 {
|
||||
return queries[:5]
|
||||
}
|
||||
for len(queries) < 5 {
|
||||
queries = append(queries, fmt.Sprintf("%s gif %d", base, len(queries)+1))
|
||||
}
|
||||
return queries[:5]
|
||||
}
|
||||
|
||||
func normalizeImageExpansionQueries(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
queries := make([]string, 0, 5)
|
||||
for _, item := range items {
|
||||
normalized := sanitizePlainEnglishLine(item)
|
||||
normalized = strings.Join(strings.Fields(normalized), " ")
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if !looksMostlyASCII(normalized) {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(normalized)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
queries = append(queries, normalized)
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
func isAllowedGiphyDownloadURL(rawURL string) bool {
|
||||
parsed, err := neturl.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return false
|
||||
}
|
||||
return host == "giphy.com" || strings.HasSuffix(host, ".giphy.com")
|
||||
}
|
||||
|
||||
func buildGiphyFilename(providerID, title, extension string, now time.Time) string {
|
||||
slug := sanitizeGiphyFilenameComponent(title)
|
||||
if slug == "" {
|
||||
slug = "untitled"
|
||||
}
|
||||
providerID = sanitizeGiphyFilenameComponent(providerID)
|
||||
if providerID == "" {
|
||||
providerID = "unknown"
|
||||
}
|
||||
ext := extension
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
return fmt.Sprintf("giphy_%s_%s_%s%s", providerID, slug, now.Format("20060102_150405"), ext)
|
||||
}
|
||||
|
||||
func sanitizeGiphyFilenameComponent(value string) string {
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = re.ReplaceAllString(normalized, "-")
|
||||
return strings.Trim(normalized, "-")
|
||||
}
|
||||
|
||||
func determineGiphyExtension(rawURL, contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
}
|
||||
parsed, err := neturl.Parse(rawURL)
|
||||
if err == nil {
|
||||
ext := strings.ToLower(path.Ext(parsed.Path))
|
||||
switch ext {
|
||||
case ".gif", ".mp4", ".webp", ".png", ".jpg", ".jpeg":
|
||||
if ext == ".jpeg" {
|
||||
return ".jpg"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
}
|
||||
return ".gif"
|
||||
}
|
||||
|
||||
func atoiOrZero(value string) int {
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func uniqueStrings(items []string, limit int) []string {
|
||||
seen := map[string]bool{}
|
||||
unique := make([]string, 0, minInt(len(items), limit))
|
||||
for _, item := range items {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed == "" || seen[trimmed] {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = true
|
||||
unique = append(unique, trimmed)
|
||||
if len(unique) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func (s *GiphyService) debug(message string, data any) {
|
||||
if s != nil && s.Debug != nil {
|
||||
s.Debug(message, data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewGiphyServiceAppliesDefaults(t *testing.T) {
|
||||
service := NewGiphyService(GiphyConfig{Enabled: true}, nil)
|
||||
if service.Config.MaxResults != 100 {
|
||||
t.Fatalf("expected default max results 100, got %d", service.Config.MaxResults)
|
||||
}
|
||||
if service.Config.Rating != "g" || service.Config.Lang != "en" {
|
||||
t.Fatalf("unexpected defaults: %#v", service.Config)
|
||||
}
|
||||
if service.BaseURL != defaultGiphyAPIBaseURL {
|
||||
t.Fatalf("expected default base url %q, got %q", defaultGiphyAPIBaseURL, service.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedGiphyDownloadURL(t *testing.T) {
|
||||
if !isAllowedGiphyDownloadURL("https://media2.giphy.com/media/test/giphy.gif") {
|
||||
t.Fatal("expected media.giphy.com host to be allowed")
|
||||
}
|
||||
if isAllowedGiphyDownloadURL("https://example.com/file.gif") {
|
||||
t.Fatal("expected non-giphy host to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGiphyFilenameSanitizesInput(t *testing.T) {
|
||||
got := buildGiphyFilename("ABC123", "Funny Cat!!!", ".gif", time.Date(2026, 3, 24, 15, 32, 12, 0, time.UTC))
|
||||
want := "giphy_abc123_funny-cat_20260324_153212.gif"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiphySearchAggregatesDedupesAndCapsAt100(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"happy cat gif\",\"cat reaction\",\"cat meme\",\"animated cat sticker\"]}"}]}}]}`))
|
||||
})
|
||||
mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
limit := atoiOrZero(r.URL.Query().Get("limit"))
|
||||
offset := atoiOrZero(r.URL.Query().Get("offset"))
|
||||
data := make([]map[string]any, 0, limit)
|
||||
for idx := 0; idx < limit; idx++ {
|
||||
id := fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx)
|
||||
if idx == 0 {
|
||||
id = fmt.Sprintf("shared-%d", offset)
|
||||
}
|
||||
data = append(data, map[string]any{
|
||||
"id": id,
|
||||
"title": fmt.Sprintf("%s %d", query, offset+idx),
|
||||
"slug": fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx),
|
||||
"rating": "g",
|
||||
"url": "https://giphy.com/gifs/" + id,
|
||||
"images": map[string]any{
|
||||
"original": map[string]any{
|
||||
"url": fmt.Sprintf("https://media.giphy.com/media/%s/giphy.gif", id),
|
||||
"width": "480",
|
||||
"height": "270",
|
||||
},
|
||||
"fixed_width": map[string]any{
|
||||
"url": fmt.Sprintf("https://media.giphy.com/media/%s/200w.gif", id),
|
||||
"width": "200",
|
||||
"height": "113",
|
||||
},
|
||||
"fixed_width_still": map[string]any{
|
||||
"url": fmt.Sprintf("https://media.giphy.com/media/%s/200w_s.gif", id),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
gemini := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
gemini.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
gemini.GenerateEndpoint = server.URL + "/generate"
|
||||
|
||||
service := NewGiphyService(GiphyConfig{
|
||||
Enabled: true,
|
||||
APIKey: "test-key",
|
||||
MaxResults: 100,
|
||||
BaseURL: server.URL,
|
||||
}, gemini)
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
resp, err := service.SearchImages("웃긴 고양이", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("expected giphy search to succeed, got %v", err)
|
||||
}
|
||||
if len(resp.ExpandedQueries) != 5 {
|
||||
t.Fatalf("expected 5 expanded queries, got %#v", resp.ExpandedQueries)
|
||||
}
|
||||
if resp.Total != 100 || len(resp.Items) != 100 {
|
||||
t.Fatalf("expected capped 100 unique items, got total=%d len=%d", resp.Total, len(resp.Items))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range resp.Items {
|
||||
if seen[item.ProviderID] {
|
||||
t.Fatalf("found duplicate providerId %q in aggregated results", item.ProviderID)
|
||||
}
|
||||
seen[item.ProviderID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMediaHappyPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
_, _ = w.Write([]byte("GIF89a"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse server url: %v", err)
|
||||
}
|
||||
tempDir := t.TempDir()
|
||||
service := NewGiphyService(GiphyConfig{
|
||||
Enabled: true,
|
||||
APIKey: "test-key",
|
||||
DownloadDir: tempDir,
|
||||
}, nil)
|
||||
service.Client = &http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
if strings.HasSuffix(clone.URL.Host, "giphy.com") {
|
||||
clone.URL.Scheme = serverURL.Scheme
|
||||
clone.URL.Host = serverURL.Host
|
||||
clone.Host = serverURL.Host
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(clone)
|
||||
}),
|
||||
}
|
||||
|
||||
resp, err := service.DownloadMedia(GiphyDownloadRequest{
|
||||
ProviderID: "abc123",
|
||||
Title: "Funny Cat",
|
||||
DownloadURL: "https://media.giphy.com/media/abc123/giphy.gif",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected download to succeed, got %v", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
t.Fatalf("expected ok response, got %#v", resp)
|
||||
}
|
||||
if filepath.Ext(resp.FileName) != ".gif" {
|
||||
t.Fatalf("expected gif extension, got %q", resp.FileName)
|
||||
}
|
||||
if _, err := os.Stat(resp.SavedPath); err != nil {
|
||||
t.Fatalf("expected saved file at %q: %v", resp.SavedPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-36
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다."
|
||||
const FallbackPreviewReason = "Fallback due to missing provider preview."
|
||||
const PendingVisualReason = "Ranked candidate pending stronger visual evidence."
|
||||
const SupplementalFallbackReason = "추가 탐색 후에도 충분한 확신 후보가 부족해 시각 자산이 있는 후보를 제한적으로 보강했습니다."
|
||||
|
||||
type GeminiBatchStats struct {
|
||||
CandidateCap int `json:"candidateCap"`
|
||||
@@ -22,6 +22,7 @@ type GeminiBatchStats struct {
|
||||
SequentialRetried int `json:"sequentialRetried"`
|
||||
RecommendedCount int `json:"recommendedCount"`
|
||||
VisualRejectCount int `json:"visualRejectCount"`
|
||||
DeadlineLimited bool `json:"deadlineLimited,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
@@ -176,6 +177,9 @@ func EvaluateAllCandidatesWithGeminiWithDeadline(service *GeminiService, query s
|
||||
seen := map[string]bool{}
|
||||
for _, batch := range results {
|
||||
if batch.err != nil {
|
||||
if strings.Contains(batch.err.Error(), "due to deadline") {
|
||||
stats.DeadlineLimited = true
|
||||
}
|
||||
if service != nil && service.Debug != nil {
|
||||
service.Debug("ranker:gemini_batch_error", map[string]any{
|
||||
"batchIndex": batch.index,
|
||||
@@ -254,6 +258,7 @@ func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason strin
|
||||
Source: item.Source,
|
||||
Reason: reason,
|
||||
Recommended: false,
|
||||
Assessment: "unclear",
|
||||
}))
|
||||
}
|
||||
return fallback
|
||||
@@ -366,18 +371,22 @@ func NeedsSupplementalExploration(items []AIRecommendation) bool {
|
||||
|
||||
recommendedCount := 0
|
||||
negativeCount := 0
|
||||
unclearCount := 0
|
||||
for _, item := range items {
|
||||
if item.Recommended {
|
||||
if item.Recommended && item.Assessment == "positive" {
|
||||
recommendedCount++
|
||||
}
|
||||
if looksNegativeReason(item.Reason) {
|
||||
if IsExcludedAssessment(item.Assessment) || looksNegativeReason(item.Reason) {
|
||||
negativeCount++
|
||||
}
|
||||
if item.Assessment == "unclear" {
|
||||
unclearCount++
|
||||
}
|
||||
}
|
||||
if recommendedCount >= 3 {
|
||||
if recommendedCount >= 4 {
|
||||
return false
|
||||
}
|
||||
return negativeCount >= max(2, len(items)/2)
|
||||
return negativeCount >= max(2, len(items)/3) || unclearCount >= max(2, len(items)/2)
|
||||
}
|
||||
|
||||
func looksNegativeReason(reason string) bool {
|
||||
@@ -399,11 +408,9 @@ func looksNegativeReason(reason string) bool {
|
||||
func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation {
|
||||
merged := make([]AIRecommendation, 0, min(limit, len(ranked)))
|
||||
seen := map[string]bool{}
|
||||
fillerCount := 0
|
||||
maxFiller := min(4, limit)
|
||||
|
||||
for _, item := range recommended {
|
||||
if !item.Recommended {
|
||||
if !item.Recommended || item.Assessment != "positive" {
|
||||
continue
|
||||
}
|
||||
if item.Link == "" || seen[item.Link] {
|
||||
@@ -417,7 +424,10 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
|
||||
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit {
|
||||
continue
|
||||
}
|
||||
if looksNegativeReason(item.Reason) || strings.Contains(item.Reason, GeminiFallbackReason) {
|
||||
if IsExcludedAssessment(item.Assessment) || looksNegativeReason(item.Reason) || strings.Contains(item.Reason, GeminiFallbackReason) {
|
||||
continue
|
||||
}
|
||||
if item.Assessment == "unclear" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.PreviewVideoURL) == "" && !hasUsableThumbnail(item.ThumbnailURL) {
|
||||
@@ -426,32 +436,6 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
|
||||
seen[item.Link] = true
|
||||
merged = append(merged, DecorateRecommendationMedia(item))
|
||||
}
|
||||
|
||||
if len(merged) < min(16, limit) {
|
||||
for _, item := range ranked {
|
||||
if len(merged) >= min(16, limit) || item.Link == "" || seen[item.Link] {
|
||||
continue
|
||||
}
|
||||
if fillerCount >= maxFiller {
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(item.PreviewVideoURL) == "" && !hasUsableThumbnail(item.ThumbnailURL) {
|
||||
continue
|
||||
}
|
||||
seen[item.Link] = true
|
||||
merged = append(merged, DecorateRecommendationMedia(AIRecommendation{
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
Snippet: item.Snippet,
|
||||
ThumbnailURL: item.ThumbnailURL,
|
||||
PreviewVideoURL: item.PreviewVideoURL,
|
||||
Source: item.Source,
|
||||
Reason: PendingVisualReason,
|
||||
Recommended: false,
|
||||
}))
|
||||
fillerCount++
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -485,14 +469,24 @@ func BackfillRecommendations(existing []AIRecommendation, ranked []SearchResult,
|
||||
ThumbnailURL: item.ThumbnailURL,
|
||||
PreviewVideoURL: item.PreviewVideoURL,
|
||||
Source: item.Source,
|
||||
Reason: firstNonEmpty(strings.TrimSpace(reason), FallbackPreviewReason),
|
||||
Reason: firstNonEmpty(strings.TrimSpace(reason), SupplementalFallbackReason),
|
||||
Recommended: false,
|
||||
Assessment: "unclear",
|
||||
}))
|
||||
fillerCount++
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func IsExcludedAssessment(assessment string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(assessment)) {
|
||||
case "irrelevant", "inappropriate":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
|
||||
@@ -47,7 +47,7 @@ func (artgridCollector) Enrich(searcher *SearchService, result SearchResult) Sea
|
||||
type googleVideoCollector struct{}
|
||||
|
||||
func (googleVideoCollector) Name() string { return "Google Video" }
|
||||
func (googleVideoCollector) MaxResults() int { return 8 }
|
||||
func (googleVideoCollector) MaxResults() int { return 12 }
|
||||
func (googleVideoCollector) Enabled(enabledPlatforms map[string]bool) bool {
|
||||
return len(enabledPlatforms) == 0 || enabledPlatforms["google video"]
|
||||
}
|
||||
|
||||
+357
-24
@@ -5,6 +5,11 @@ const searchQuery = document.getElementById("searchQuery");
|
||||
const searchResults = document.getElementById("searchResults");
|
||||
const searchWarning = document.getElementById("searchWarning");
|
||||
const queryVariants = document.getElementById("queryVariants");
|
||||
const searchModeTitle = document.getElementById("searchModeTitle");
|
||||
const searchModeHint = document.getElementById("searchModeHint");
|
||||
const searchSubmitButton = document.getElementById("searchSubmitButton");
|
||||
const searchResultsViewport = document.getElementById("searchResultsViewport");
|
||||
const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-toggle]"));
|
||||
const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
@@ -13,6 +18,7 @@ const downloadForm = document.getElementById("downloadForm");
|
||||
const downloadUrl = document.getElementById("downloadUrl");
|
||||
const downloadResult = document.getElementById("downloadResult");
|
||||
const cardTemplate = document.getElementById("searchCardTemplate");
|
||||
const imageCardTemplate = document.getElementById("imageCardTemplate");
|
||||
const previewModal = document.getElementById("previewModal");
|
||||
const previewMediaFrame = document.getElementById("previewMediaFrame");
|
||||
const previewTitle = document.getElementById("previewTitle");
|
||||
@@ -40,6 +46,8 @@ const debugSummary = document.getElementById("debugSummary");
|
||||
const resultModal = document.getElementById("resultModal");
|
||||
const resultModalTitle = document.getElementById("resultModalTitle");
|
||||
const resultModalSource = document.getElementById("resultModalSource");
|
||||
const resultModalReasonLabel = document.getElementById("resultModalReasonLabel");
|
||||
const resultModalSnippetLabel = document.getElementById("resultModalSnippetLabel");
|
||||
const resultModalSnippet = document.getElementById("resultModalSnippet");
|
||||
const resultModalReason = document.getElementById("resultModalReason");
|
||||
const resultModalFrame = document.getElementById("resultModalFrame");
|
||||
@@ -58,6 +66,8 @@ const resultModalReady = Boolean(
|
||||
resultModal &&
|
||||
resultModalTitle &&
|
||||
resultModalSource &&
|
||||
resultModalReasonLabel &&
|
||||
resultModalSnippetLabel &&
|
||||
resultModalSnippet &&
|
||||
resultModalReason &&
|
||||
resultModalFrame &&
|
||||
@@ -80,9 +90,16 @@ let cropEnd = 0;
|
||||
let cropMax = 0;
|
||||
let activeThumb = null;
|
||||
let activeResultItem = null;
|
||||
let activeResultModalSummaryRequest = 0;
|
||||
const activePlatforms = new Set(["envato", "artgrid", "google video"]);
|
||||
const hlsInstances = new WeakMap();
|
||||
const debugEntries = [];
|
||||
const summaryTranslationCache = new Map();
|
||||
const summaryTranslationInflight = new Map();
|
||||
const resultPreviewCache = new Map();
|
||||
const resultPreviewInflight = new Map();
|
||||
let cardSummaryObserver = null;
|
||||
let activeMediaType = "video";
|
||||
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
|
||||
|
||||
function proxiedPreviewURL(src) {
|
||||
@@ -92,6 +109,25 @@ function proxiedPreviewURL(src) {
|
||||
return `/api/preview/stream?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
function transcodedPreviewURL(src) {
|
||||
if (!src) {
|
||||
return "";
|
||||
}
|
||||
return `/api/preview/transcode?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
function buildPlayablePreviewURL(src, source = "") {
|
||||
const trimmed = String(src || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes(".m3u8") && (String(source || "").toLowerCase() === "artgrid" || lower.includes("artgrid") || lower.includes("artlist"))) {
|
||||
return transcodedPreviewURL(trimmed);
|
||||
}
|
||||
return proxiedPreviewURL(trimmed);
|
||||
}
|
||||
|
||||
function isLowValueThumbnailURL(src) {
|
||||
const lower = String(src || "").toLowerCase();
|
||||
if (!lower) {
|
||||
@@ -118,9 +154,6 @@ function summarizeReason(reason) {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
if (text === "Ranked candidate pending stronger visual evidence.") {
|
||||
return "Preview evidence pending";
|
||||
}
|
||||
if (text === "Fallback due to missing provider preview.") {
|
||||
return "Provider preview missing";
|
||||
}
|
||||
@@ -134,6 +167,9 @@ function setStatus(label, progress) {
|
||||
}
|
||||
|
||||
function setHidden(element, hidden, visibleDisplayClass = "flex") {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.toggle("hidden", hidden);
|
||||
if (visibleDisplayClass) {
|
||||
element.classList.toggle(visibleDisplayClass, !hidden);
|
||||
@@ -259,6 +295,70 @@ function renderQueryVariants(queries = []) {
|
||||
queryVariants.classList.add("hidden");
|
||||
}
|
||||
|
||||
function syncMediaTypeButtons() {
|
||||
for (const button of mediaTypeToggles) {
|
||||
const type = button.dataset.mediaTypeToggle;
|
||||
const active = type === activeMediaType;
|
||||
button.classList.toggle("bg-white", active);
|
||||
button.classList.toggle("text-black", active);
|
||||
button.classList.toggle("text-zinc-300", !active);
|
||||
}
|
||||
}
|
||||
|
||||
function renderImageEmptyState(message) {
|
||||
searchResults.innerHTML = "";
|
||||
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">${message}</div>`;
|
||||
}
|
||||
|
||||
function renderImageResults(items = []) {
|
||||
searchResults.innerHTML = "";
|
||||
searchResults.classList.remove("xl:grid-cols-3");
|
||||
searchResults.classList.add("xl:grid-cols-4");
|
||||
if (!items.length) {
|
||||
renderImageEmptyState("GIPHY에서 표시할 이미지/GIF를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const node = imageCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
const image = node.querySelector("img");
|
||||
image.loading = "lazy";
|
||||
image.src = item.previewStillUrl || item.previewUrl || item.fullUrl || PREVIEW_PLACEHOLDER;
|
||||
image.alt = item.title;
|
||||
node.querySelector(".image-card-tag").textContent = `GIPHY / ${item.searchQuery || "query"}`;
|
||||
node.querySelector(".image-card-title").textContent = item.title;
|
||||
node.querySelector(".image-card-caption").textContent = item.title || "Untitled GIPHY result";
|
||||
node.querySelector(".image-card-meta").textContent = `${item.rating || "unrated"} / ${item.width || "?"}x${item.height || "?"}`;
|
||||
node.addEventListener("click", () => openResultModal(item));
|
||||
searchResults.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMediaTypeUI() {
|
||||
const isImageMode = activeMediaType === "image";
|
||||
syncMediaTypeButtons();
|
||||
setHidden(queryVariants, true, "");
|
||||
showWarning("");
|
||||
searchResultsViewport.classList.toggle("image-results-scroll", isImageMode);
|
||||
searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery";
|
||||
searchModeHint.textContent = isImageMode
|
||||
? "GIPHY 이미지/GIF 검색 결과를 그대로 보여주는 모드입니다. 최대 100개 결과를 내부 스크롤로 탐색할 수 있습니다."
|
||||
: "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.";
|
||||
searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요";
|
||||
searchSubmitButton.textContent = isImageMode ? "Search GIPHY" : "AI Search";
|
||||
for (const button of platformToggles) {
|
||||
button.classList.toggle("hidden", isImageMode);
|
||||
}
|
||||
if (isImageMode) {
|
||||
setStatus("giphy image mode", 0);
|
||||
renderImageEmptyState("GIPHY 검색어를 입력하면 여기에 최대 100개의 이미지/GIF 결과가 표시됩니다.");
|
||||
} else {
|
||||
searchResults.classList.add("xl:grid-cols-3");
|
||||
searchResults.classList.remove("xl:grid-cols-4");
|
||||
searchResults.innerHTML = "";
|
||||
setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPlatformButtons() {
|
||||
for (const button of platformToggles) {
|
||||
const platform = button.dataset.platformToggle;
|
||||
@@ -458,6 +558,10 @@ function resetResultModalMedia() {
|
||||
setHidden(resultModalGooglePanel, true, "flex");
|
||||
}
|
||||
|
||||
function isGiphyResult(item) {
|
||||
return String(item?.provider || "").toLowerCase() === "giphy" || String(item?.source || "").toLowerCase() === "giphy";
|
||||
}
|
||||
|
||||
function showResultModalFrame(src) {
|
||||
if (!src) {
|
||||
return;
|
||||
@@ -470,7 +574,7 @@ function showResultModalVideo(src) {
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
attachVideoSource(resultModalVideo, proxiedPreviewURL(src));
|
||||
attachVideoSource(resultModalVideo, src);
|
||||
setHidden(resultModalVideo, false, "");
|
||||
}
|
||||
|
||||
@@ -484,10 +588,122 @@ function showResultModalGooglePanel(item, message = "") {
|
||||
resultModalFallbackLabel.textContent = item.source || "Preview Fallback";
|
||||
resultModalGoogleImage.src = hasUsableThumbnail(item.thumbnailUrl) ? item.thumbnailUrl : PREVIEW_PLACEHOLDER;
|
||||
resultModalGoogleImage.alt = item.title || "";
|
||||
resultModalGoogleText.textContent = message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action.";
|
||||
resultModalGoogleText.textContent = summarizeReason(message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action.");
|
||||
setHidden(resultModalGooglePanel, false, "flex");
|
||||
}
|
||||
|
||||
async function translateSummaryForModal(item, originalText, requestId) {
|
||||
const translated = await translateSummaryText(originalText);
|
||||
if (!translated) {
|
||||
return;
|
||||
}
|
||||
if (activeResultItem?.link === item.link && activeResultModalSummaryRequest === requestId) {
|
||||
resultModalSnippet.textContent = translated;
|
||||
logEvent("result:modal:summary_translated", { title: item.title, source: item.source });
|
||||
}
|
||||
}
|
||||
|
||||
async function translateSummaryText(originalText) {
|
||||
const trimmed = String(originalText || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (summaryTranslationCache.has(trimmed)) {
|
||||
return summaryTranslationCache.get(trimmed);
|
||||
}
|
||||
if (summaryTranslationInflight.has(trimmed)) {
|
||||
return summaryTranslationInflight.get(trimmed);
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const data = await api("/api/translate/summary", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: trimmed }),
|
||||
});
|
||||
const translated = String(data.translatedText || "").trim();
|
||||
if (translated) {
|
||||
summaryTranslationCache.set(trimmed, translated);
|
||||
}
|
||||
return translated;
|
||||
} catch {
|
||||
return "";
|
||||
} finally {
|
||||
summaryTranslationInflight.delete(trimmed);
|
||||
}
|
||||
})();
|
||||
summaryTranslationInflight.set(trimmed, request);
|
||||
|
||||
try {
|
||||
return await request;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function translateCardSummary(node) {
|
||||
if (!node || node.dataset.summaryTranslated === "true") {
|
||||
return;
|
||||
}
|
||||
node.dataset.summaryTranslated = "true";
|
||||
const originalText = node.dataset.summaryOriginal || "";
|
||||
const translated = await translateSummaryText(originalText);
|
||||
if (!translated) {
|
||||
return;
|
||||
}
|
||||
const summaryNode = node.querySelector(".result-reason");
|
||||
if (summaryNode) {
|
||||
summaryNode.textContent = translated;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchResultPreview(item) {
|
||||
const key = String(item?.link || "").trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (resultPreviewCache.has(key)) {
|
||||
return resultPreviewCache.get(key);
|
||||
}
|
||||
if (resultPreviewInflight.has(key)) {
|
||||
return resultPreviewInflight.get(key);
|
||||
}
|
||||
const request = (async () => {
|
||||
try {
|
||||
const preview = await api("/api/download/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: key }),
|
||||
});
|
||||
resultPreviewCache.set(key, preview);
|
||||
return preview;
|
||||
} catch (error) {
|
||||
logEvent("result:preview:fetch_failed", { link: key, source: item?.source || "", message: error.message });
|
||||
return null;
|
||||
} finally {
|
||||
resultPreviewInflight.delete(key);
|
||||
}
|
||||
})();
|
||||
resultPreviewInflight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function ensureCardSummaryObserver() {
|
||||
if (cardSummaryObserver || typeof IntersectionObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
cardSummaryObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
cardSummaryObserver.unobserve(entry.target);
|
||||
void translateCardSummary(entry.target);
|
||||
}
|
||||
}, { rootMargin: "160px 0px" });
|
||||
}
|
||||
|
||||
function fallbackResultModalMedia(item, reason) {
|
||||
logEvent("result:modal:fallback", { title: item.title, source: item.source, reason, mediaMode: item.mediaMode });
|
||||
if (item.previewVideoUrl) {
|
||||
@@ -503,6 +719,7 @@ function fallbackResultModalMedia(item, reason) {
|
||||
|
||||
function renderResults(results) {
|
||||
searchResults.innerHTML = "";
|
||||
ensureCardSummaryObserver();
|
||||
if (!results.length) {
|
||||
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">No results matched the current search sources.</div>`;
|
||||
return;
|
||||
@@ -528,17 +745,27 @@ function renderResults(results) {
|
||||
mediaFallback.textContent = item.source === "Envato" || item.source === "Artgrid" ? `${item.source} preview unavailable` : "Preview unavailable";
|
||||
}
|
||||
node.querySelector("h3").textContent = item.title;
|
||||
node.querySelector(".result-snippet").textContent = summarizeReason(item.reason) || item.snippet || item.source || "";
|
||||
node.querySelector(".result-reason").textContent = item.snippet ? `Source: ${item.snippet}` : (item.previewBlockedReason || "");
|
||||
node.querySelector(".result-snippet").textContent = summarizeReason(item.reason) || item.source || "";
|
||||
node.querySelector(".result-reason").textContent = item.snippet || item.previewBlockedReason || "";
|
||||
node.querySelector(".source-badge").textContent = item.source;
|
||||
node.dataset.summaryOriginal = item.snippet || "";
|
||||
node.dataset.summaryTranslated = "false";
|
||||
node.addEventListener("click", () => openResultModal(item));
|
||||
previewVideo.poster = usableThumbnail ? item.thumbnailUrl : "";
|
||||
if (item.previewVideoUrl) {
|
||||
const mediaArea = node.querySelector(".relative");
|
||||
mediaArea.addEventListener("mouseenter", () => {
|
||||
logEvent("preview:hover:start", { title: item.title, source: item.source, previewVideoUrl: item.previewVideoUrl });
|
||||
const mediaArea = node.querySelector(".relative");
|
||||
if (item.previewVideoUrl || item.source === "Google Video") {
|
||||
mediaArea.addEventListener("mouseenter", async () => {
|
||||
let previewURL = item.previewVideoUrl || "";
|
||||
if (!previewURL && item.source === "Google Video") {
|
||||
const preview = await fetchResultPreview(item);
|
||||
previewURL = preview?.previewStreamUrl || "";
|
||||
}
|
||||
logEvent("preview:hover:start", { title: item.title, source: item.source, previewVideoUrl: previewURL });
|
||||
if (!previewURL) {
|
||||
return;
|
||||
}
|
||||
overlays.forEach((overlay) => overlay.classList.add("hidden"));
|
||||
startHoverPreview(previewVideo, proxiedPreviewURL(item.previewVideoUrl));
|
||||
startHoverPreview(previewVideo, buildPlayablePreviewURL(previewURL, item.source));
|
||||
});
|
||||
mediaArea.addEventListener("mouseleave", () => {
|
||||
logEvent("preview:hover:end", { title: item.title, source: item.source });
|
||||
@@ -549,6 +776,11 @@ function renderResults(results) {
|
||||
overlays.forEach((overlay) => overlay.classList.remove("hidden"));
|
||||
});
|
||||
}
|
||||
if (cardSummaryObserver && item.snippet) {
|
||||
cardSummaryObserver.observe(node);
|
||||
} else if (item.snippet) {
|
||||
void translateCardSummary(node);
|
||||
}
|
||||
searchResults.appendChild(node);
|
||||
}
|
||||
}
|
||||
@@ -576,26 +808,57 @@ async function prepareDirectDownload(targetUrl) {
|
||||
downloadResult.textContent = "preview loaded";
|
||||
}
|
||||
|
||||
function openResultModal(item) {
|
||||
async function openResultModal(item) {
|
||||
if (!resultModalReady) {
|
||||
logEvent("result:modal:error", { message: "result modal is not fully initialized" });
|
||||
return;
|
||||
}
|
||||
const giphyItem = isGiphyResult(item);
|
||||
activeResultItem = item;
|
||||
activeResultModalSummaryRequest += 1;
|
||||
const summaryRequestId = activeResultModalSummaryRequest;
|
||||
resultModalTitle.textContent = item.title || "Untitled";
|
||||
resultModalSource.textContent = item.source || "";
|
||||
resultModalReason.textContent = summarizeReason(item.reason) || "AI 노트가 없습니다.";
|
||||
resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
||||
resultModalOpenExternal.href = item.link || "#";
|
||||
resultModalReasonLabel.textContent = giphyItem ? "Result Info" : "AI Note";
|
||||
resultModalSnippetLabel.textContent = giphyItem ? "Source" : "Source Summary";
|
||||
resultModalReason.textContent = giphyItem
|
||||
? [
|
||||
`Rating: ${item.rating || "unrated"}`,
|
||||
`Size: ${item.width || "?"} x ${item.height || "?"}`,
|
||||
`Provider ID: ${item.providerId || "-"}`,
|
||||
].join("\n")
|
||||
: (summarizeReason(item.reason) || "AI 노트가 없습니다.");
|
||||
const originalSummary = giphyItem
|
||||
? [
|
||||
"Powered by GIPHY",
|
||||
item.sourcePageUrl || item.openUrl || item.link || "",
|
||||
`Rating: ${item.rating || "unrated"}`,
|
||||
].filter(Boolean).join("\n")
|
||||
: (item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.");
|
||||
resultModalSnippet.textContent = originalSummary;
|
||||
resultModalOpenExternal.href = item.openUrl || item.sourcePageUrl || item.link || "#";
|
||||
resultModalDownload.classList.toggle("hidden", !item.actionType);
|
||||
resultModalDownload.textContent = item.actionLabel || "Open Source";
|
||||
const showSecondary = Boolean(item.secondaryActionLabel && item.link);
|
||||
const showSecondary = Boolean(item.secondaryActionLabel && (item.openUrl || item.link));
|
||||
resultModalSecondaryAction.classList.toggle("hidden", !showSecondary);
|
||||
resultModalSecondaryAction.textContent = item.secondaryActionLabel || "Open Source";
|
||||
resetResultModalMedia();
|
||||
if (giphyItem) {
|
||||
showResultModalThumbnail(item.fullUrl || item.previewUrl || item.previewStillUrl, item.title || "");
|
||||
showModal(resultModal);
|
||||
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link, provider: item.provider });
|
||||
return;
|
||||
}
|
||||
const embedURL = buildResultModalEmbedURL(item);
|
||||
const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview.";
|
||||
if (item.source === "Google Video" && item.mediaMode === "thumbnail") {
|
||||
let resolvedPreviewURL = item.previewVideoUrl || "";
|
||||
if (!resolvedPreviewURL && item.source === "Google Video") {
|
||||
const preview = await fetchResultPreview(item);
|
||||
resolvedPreviewURL = preview?.previewStreamUrl || "";
|
||||
}
|
||||
if (resolvedPreviewURL) {
|
||||
showResultModalVideo(buildPlayablePreviewURL(resolvedPreviewURL, item.source));
|
||||
} else if (item.source === "Google Video" && item.mediaMode === "thumbnail") {
|
||||
showResultModalGooglePanel(item, item.snippet || "Open source page or download directly.");
|
||||
} else if (item.mediaMode === "embed" && embedURL && embedURL !== "about:blank") {
|
||||
showResultModalFrame(embedURL);
|
||||
@@ -610,13 +873,14 @@ function openResultModal(item) {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
} else if (item.mediaMode === "preview_video" && item.previewVideoUrl) {
|
||||
showResultModalVideo(item.previewVideoUrl);
|
||||
showResultModalVideo(buildPlayablePreviewURL(item.previewVideoUrl, item.source));
|
||||
} else if (item.mediaMode === "thumbnail" && hasUsableThumbnail(item.thumbnailUrl)) {
|
||||
showResultModalThumbnail(item.thumbnailUrl, item.title || "");
|
||||
} else {
|
||||
fallbackResultModalMedia(item, fallbackReason);
|
||||
}
|
||||
showModal(resultModal);
|
||||
void translateSummaryForModal(item, item.snippet, summaryRequestId);
|
||||
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
|
||||
}
|
||||
|
||||
@@ -627,6 +891,7 @@ function closeResultViewer() {
|
||||
if (!resultModal.classList.contains("hidden")) {
|
||||
logEvent("result:modal:close", { title: activeResultItem?.title || "" });
|
||||
}
|
||||
activeResultModalSummaryRequest += 1;
|
||||
activeResultItem = null;
|
||||
resetResultModalMedia();
|
||||
hideModal(resultModal);
|
||||
@@ -634,6 +899,26 @@ function closeResultViewer() {
|
||||
|
||||
searchForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (activeMediaType === "image") {
|
||||
setStatus("searching GIPHY", 10);
|
||||
showWarning("");
|
||||
try {
|
||||
const data = await api("/api/giphy/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: searchQuery.value, maxResults: 100 }),
|
||||
});
|
||||
renderImageResults(data.items || []);
|
||||
showWarning("");
|
||||
logEvent("image-search:completed", { query: data.originalQuery || searchQuery.value, total: data.total || 0, expandedQueries: data.expandedQueries || [] });
|
||||
setStatus("giphy search complete", 100);
|
||||
} catch (error) {
|
||||
renderImageEmptyState("GIPHY 검색 결과를 불러오지 못했습니다.");
|
||||
showWarning(error.message);
|
||||
setStatus("giphy search failed", 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStatus("preparing search", 5);
|
||||
showWarning("");
|
||||
try {
|
||||
@@ -654,6 +939,14 @@ searchForm.addEventListener("submit", async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const button of mediaTypeToggles) {
|
||||
button.addEventListener("click", () => {
|
||||
activeMediaType = button.dataset.mediaTypeToggle || "video";
|
||||
applyMediaTypeUI();
|
||||
logEvent("media-type:update", { active: activeMediaType });
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
@@ -706,6 +999,35 @@ function closeModal() {
|
||||
pendingDownload = null;
|
||||
}
|
||||
|
||||
async function downloadGiphyItem(item) {
|
||||
resultModalDownload.disabled = true;
|
||||
const originalLabel = resultModalDownload.textContent;
|
||||
resultModalDownload.textContent = "Downloading...";
|
||||
try {
|
||||
const data = await api("/api/giphy/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: item.providerId,
|
||||
title: item.title,
|
||||
downloadUrl: item.downloadUrl,
|
||||
originalQuery: item.originalQuery,
|
||||
selectedExpansionQuery: item.searchQuery,
|
||||
}),
|
||||
});
|
||||
downloadResult.textContent = `${data.fileName} saved to ${data.savedPath}`;
|
||||
setStatus("giphy download complete", 100);
|
||||
logEvent("giphy:download:completed", { providerId: item.providerId, fileName: data.fileName, savedPath: data.savedPath });
|
||||
} catch (error) {
|
||||
downloadResult.textContent = error.message;
|
||||
setStatus("giphy download failed", 100);
|
||||
logEvent("giphy:download:error", { providerId: item.providerId, message: error.message, data: error.data || null });
|
||||
} finally {
|
||||
resultModalDownload.disabled = false;
|
||||
resultModalDownload.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("border-white/60", "bg-white/[0.08]");
|
||||
@@ -774,10 +1096,14 @@ if (resultModalReady) {
|
||||
}
|
||||
});
|
||||
resultModalDownload.addEventListener("click", async () => {
|
||||
if (!activeResultItem?.link) {
|
||||
if (!activeResultItem) {
|
||||
return;
|
||||
}
|
||||
const currentItem = activeResultItem;
|
||||
if (currentItem.actionType === "giphy_download") {
|
||||
await downloadGiphyItem(currentItem);
|
||||
return;
|
||||
}
|
||||
if (currentItem.actionType === "download") {
|
||||
try {
|
||||
closeResultViewer();
|
||||
@@ -788,13 +1114,13 @@ if (resultModalReady) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.open(currentItem.link, "_blank", "noopener,noreferrer");
|
||||
window.open(currentItem.openUrl || currentItem.sourcePageUrl || currentItem.link, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
resultModalSecondaryAction.addEventListener("click", () => {
|
||||
if (!activeResultItem?.link) {
|
||||
if (!activeResultItem) {
|
||||
return;
|
||||
}
|
||||
window.open(activeResultItem.link, "_blank", "noopener,noreferrer");
|
||||
window.open(activeResultItem.openUrl || activeResultItem.sourcePageUrl || activeResultItem.link, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
}
|
||||
previewModal.addEventListener("click", (event) => {
|
||||
@@ -910,8 +1236,15 @@ window.addEventListener("error", (event) => {
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
logEvent("window:unhandledrejection", { reason: String(event.reason) });
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
closeResultViewer();
|
||||
});
|
||||
|
||||
connectWS();
|
||||
syncPlatformButtons();
|
||||
setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0);
|
||||
applyMediaTypeUI();
|
||||
logEvent("app:ready", { activePlatforms: Array.from(activePlatforms) });
|
||||
|
||||
+46
-20
@@ -33,9 +33,16 @@
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone A</p>
|
||||
<h2 class="text-2xl font-semibold text-white">AI Smart Discovery</h2>
|
||||
<h2 id="searchModeTitle" class="text-2xl font-semibold text-white">AI Smart Discovery</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div class="inline-flex rounded-full border border-white/10 bg-black/30 p-1">
|
||||
<button data-media-type-toggle="video" class="media-type-toggle rounded-full bg-white px-4 py-2 text-sm font-medium text-black transition">Video</button>
|
||||
<button data-media-type-toggle="image" class="media-type-toggle rounded-full px-4 py-2 text-sm font-medium text-zinc-300 transition">Image</button>
|
||||
</div>
|
||||
<p id="searchModeHint" class="text-sm text-zinc-400">비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.</p>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-wrap gap-3">
|
||||
<button data-platform-toggle="envato" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Envato</button>
|
||||
<button data-platform-toggle="artgrid" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Artgrid</button>
|
||||
@@ -43,11 +50,13 @@
|
||||
</div>
|
||||
<form id="searchForm" class="flex flex-col gap-3 md:flex-row">
|
||||
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" />
|
||||
<button class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
|
||||
<button id="searchSubmitButton" class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
|
||||
</form>
|
||||
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
|
||||
<div id="queryVariants" class="hidden"></div>
|
||||
<div id="searchResults" class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
|
||||
<div id="searchResultsViewport" class="mt-6">
|
||||
<div id="searchResults" class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="grid gap-8">
|
||||
@@ -149,9 +158,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
|
||||
<div class="flex w-full max-w-7xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-950 shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div id="resultModal" class="fixed inset-0 z-50 hidden items-start justify-center overflow-y-auto bg-black/80 px-2 py-2 sm:px-4 sm:py-4">
|
||||
<div class="result-modal-shell flex w-full max-w-6xl min-h-0 flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-950 shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div class="min-w-0">
|
||||
<p id="resultModalSource" class="text-xs uppercase tracking-[0.25em] text-zinc-500"></p>
|
||||
<h3 id="resultModalTitle" class="mt-1 truncate text-xl font-semibold text-white"></h3>
|
||||
@@ -161,8 +170,8 @@
|
||||
<button id="closeResultModal" type="button" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-b border-white/10 bg-black/40 p-4">
|
||||
<div id="resultModalMediaFrame" class="aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<div class="border-b border-white/10 bg-black/40 p-2 sm:p-3">
|
||||
<div id="resultModalMediaFrame" class="result-modal-media-frame aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<iframe id="resultModalFrame" class="hidden h-full w-full bg-white" referrerpolicy="no-referrer" allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen></iframe>
|
||||
<video id="resultModalVideo" class="hidden h-full w-full bg-black object-contain" controls playsinline></video>
|
||||
<img id="resultModalThumbnail" class="hidden h-full w-full object-contain" alt="" />
|
||||
@@ -179,13 +188,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-5 px-5 py-5 lg:grid-cols-[1.6fr_0.8fr]">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
||||
<p id="resultModalReason" class="mt-3 whitespace-pre-wrap text-sm leading-7 text-zinc-200"></p>
|
||||
<div class="result-modal-details grid min-h-0 gap-3 px-3 py-3 sm:gap-4 sm:px-4 sm:py-4 lg:grid-cols-[1.5fr_0.8fr]">
|
||||
<div class="flex min-h-[180px] min-w-0 flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<p id="resultModalReasonLabel" class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
||||
<div class="result-panel-scroll mt-3 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||
<p id="resultModalReason" class="whitespace-pre-wrap text-xs leading-6 text-zinc-200 sm:text-sm"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-[260px] flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<div class="mb-4 flex flex-col gap-3">
|
||||
<div class="flex min-h-[200px] min-w-0 flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div class="mb-3 flex flex-col gap-2">
|
||||
<button id="resultModalDownload" type="button" class="hidden w-full rounded-2xl border border-white bg-white px-4 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">
|
||||
Primary Action
|
||||
</button>
|
||||
@@ -194,9 +205,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">Source Summary</p>
|
||||
<p id="resultModalSnippetLabel" class="text-xs uppercase tracking-[0.25em] text-zinc-500">Source Summary</p>
|
||||
<div class="result-summary-scroll mt-3 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||
<p id="resultModalSnippet" class="text-sm leading-7 text-zinc-300"></p>
|
||||
<p id="resultModalSnippet" class="text-xs leading-6 text-zinc-300 sm:text-sm"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,17 +223,32 @@
|
||||
<div class="media-fallback absolute inset-0 hidden items-center justify-center bg-[radial-gradient(circle_at_top,#2b3342,transparent_60%),linear-gradient(180deg,#111827,#05070b)] p-5 text-center text-xs uppercase tracking-[0.24em] text-zinc-300">
|
||||
Preview unavailable
|
||||
</div>
|
||||
<div class="preview-overlay absolute left-3 top-3 rounded-full border border-white/20 bg-black/60 px-3 py-1 text-[11px] uppercase tracking-[0.25em] text-white">AI Recommended</div>
|
||||
<div class="source-badge preview-overlay absolute bottom-3 left-3 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-black"></div>
|
||||
</div>
|
||||
<div class="space-y-2 p-5">
|
||||
<h3 class="line-clamp-2 text-base font-medium text-white"></h3>
|
||||
<p class="result-snippet line-clamp-3 text-sm text-zinc-400"></p>
|
||||
<p class="result-reason line-clamp-2 text-xs tracking-[0.02em] text-zinc-500"></p>
|
||||
<p class="result-snippet line-clamp-2 text-sm text-zinc-300"></p>
|
||||
<p class="result-reason line-clamp-3 text-xs tracking-[0.02em] text-zinc-500"></p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script src="/app.js?v=20260316i" defer></script>
|
||||
<template id="imageCardTemplate">
|
||||
<button type="button" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 text-left transition hover:border-white/30">
|
||||
<div class="relative aspect-[4/3] overflow-hidden bg-zinc-900">
|
||||
<img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" />
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent p-4">
|
||||
<p class="image-card-tag text-[11px] uppercase tracking-[0.25em] text-zinc-300"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 p-5">
|
||||
<h3 class="image-card-title line-clamp-2 text-base font-medium text-white"></h3>
|
||||
<p class="image-card-caption line-clamp-3 text-sm text-zinc-300"></p>
|
||||
<p class="image-card-meta text-[11px] uppercase tracking-[0.22em] text-zinc-500"></p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script src="/app.js?v=20260324b" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,6 +26,45 @@ body {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
.media-type-toggle {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.image-prompt-chip {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.image-prompt-chip:hover {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
#searchResultsViewport {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
#searchResultsViewport.image-results-scroll {
|
||||
height: min(62dvh, 58rem);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
#searchResultsViewport.image-results-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
#searchResultsViewport.image-results-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.dual-slider__thumb {
|
||||
touch-action: none;
|
||||
cursor: ew-resize;
|
||||
@@ -50,6 +89,41 @@ body {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.result-panel-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.result-panel-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.result-modal-shell {
|
||||
height: min(calc(100dvh - 0.5rem), 860px);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.result-modal-shell > * {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.result-modal-media-frame {
|
||||
max-height: min(34dvh, 22rem);
|
||||
}
|
||||
|
||||
.result-modal-details {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#resultModalSnippet,
|
||||
#resultModalReason {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.debug-entry {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 10px 8px;
|
||||
@@ -72,3 +146,19 @@ body {
|
||||
word-break: break-word;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.result-modal-media-frame {
|
||||
max-height: min(28dvh, 18rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 720px) {
|
||||
.result-modal-shell {
|
||||
height: min(calc(100dvh - 0.25rem), 700px);
|
||||
}
|
||||
|
||||
.result-modal-media-frame {
|
||||
max-height: min(22dvh, 11rem);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -15,9 +15,16 @@
|
||||
<Icon>https://raw.githubusercontent.com/selfhst/icons/main/png/google-gemini-light.png</Icon>
|
||||
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="Dashboard port" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
|
||||
<Config Name="Downloads" Target="/app/downloads" Default="/mnt/user/appdata/ai-media-hub/downloads" Mode="rw" Description="Media output directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/downloads</Config>
|
||||
<Config Name="GIPHY Downloads" Target="/app/downloads/giphy" Default="/mnt/user/appdata/ai-media-hub/giphy" Mode="rw" Description="Directory for downloaded GIPHY images and GIFs" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/giphy</Config>
|
||||
<Config Name="Database" Target="/app/db" Default="/mnt/user/appdata/ai-media-hub/db" Mode="rw" Description="SQLite database directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/db</Config>
|
||||
<Config Name="SearXNG Base URL" Target="SEARXNG_BASE_URL" Default="http://searxng:8080" Mode="" Description="Base URL for the SearXNG instance" Type="Variable" Display="always" Required="true" Mask="false">http://searxng:8080</Config>
|
||||
<Config Name="SearXNG Google Video Engine" Target="SEARXNG_GOOGLE_VIDEO_ENGINE" Default="google videos" Mode="" Description="Engine name used for Google video searches" Type="Variable" Display="always" Required="true" Mask="false">google videos</Config>
|
||||
<Config Name="SearXNG Web Engine" Target="SEARXNG_WEB_ENGINE" Default="google" Mode="" Description="Engine name used for Envato and Artgrid searches" Type="Variable" Display="always" Required="true" Mask="false">google</Config>
|
||||
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="Gemini API key" Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
<Config Name="GIPHY Enabled" Target="GIPHY_ENABLED" Default="true" Mode="" Description="Enable GIPHY image and GIF search" Type="Variable" Display="always" Required="true" Mask="false">true</Config>
|
||||
<Config Name="GIPHY API Key" Target="GIPHY_API_KEY" Default="" Mode="" Description="GIPHY API key" Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||
<Config Name="GIPHY Max Results" Target="GIPHY_MAX_RESULTS" Default="100" Mode="" Description="Maximum number of aggregated GIPHY results to return" Type="Variable" Display="always" Required="true" Mask="false">100</Config>
|
||||
<Config Name="GIPHY Rating" Target="GIPHY_RATING" Default="g" Mode="" Description="GIPHY content rating filter" Type="Variable" Display="always" Required="true" Mask="false">g</Config>
|
||||
<Config Name="GIPHY Lang" Target="GIPHY_LANG" Default="en" Mode="" Description="Language hint sent to GIPHY search" Type="Variable" Display="always" Required="true" Mask="false">en</Config>
|
||||
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="Gemini API key" Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||
<Config Name="Gemini Model" Target="GEMINI_MODEL" Default="gemini-2.5-flash" Mode="" Description="Gemini model used for multilingual query expansion" Type="Variable" Display="always" Required="true" Mask="false">gemini-2.5-flash</Config>
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user