diff --git a/docs/cookie-import.md b/docs/cookie-import.md index 0fe54fb..4f7d8e9 100644 --- a/docs/cookie-import.md +++ b/docs/cookie-import.md @@ -2,6 +2,18 @@ ArcHive stores cookies encrypted per site for browse and yt-dlp downloads. +## Recommended: Log in with browser (desktop + Android) + +1. In **Settings → Cookies**, select the site (or open a cookie-required site in Browse). +2. Press **Log in with browser**. ArcHive opens the site in its own browser: + - **Desktop:** a separate window. Log in, then press **Save cookies** in ArcHive (or just close the window). + - **Android:** a full-screen browser with **Cancel** / **Save cookies** buttons at the top. The system back button navigates back inside the page. +3. The captured cookies, including HttpOnly session cookies that page scripts cannot read, are converted to Netscape format and stored in the vault (on the LAN host when the app is in Remote LAN mode). + +Android does not let one app read another app's cookies (for example Chrome's), so the login has to happen inside ArcHive. Google sign-in may refuse embedded browsers; for YouTube, import cookies with Cookie-Editor instead. + +Implementation: `src-tauri/src/cookie_login.rs` (desktop window + Android bridge) and `src-tauri/android-overlays/CookieBrowserPlugin.kt` (installed by `scripts/patch-android-ytdlp.ps1` / `.sh`). + ## Android: in-app capture (fastest) On Android you can harvest a session without a browser or extension: @@ -114,6 +126,8 @@ capturing a jar. `thothub.tv` is intentionally absent — it is a parked ad domain now. +Lines exported as `#HttpOnly_` are treated as cookies, not comments. + ## Security - Cookies are encrypted at rest (AES-256-GCM). diff --git a/docs/mobile-android.md b/docs/mobile-android.md index 42e50a6..7168479 100644 --- a/docs/mobile-android.md +++ b/docs/mobile-android.md @@ -177,20 +177,21 @@ Video playback uses `GET /api/scenes/{id}/media` and `GET /api/files/stream` wit ## Troubleshooting -| Issue | Fix | -| -------------------------------------- | ---------------------------------------------------------------------------------------- | -| `FrameInsert open fail` in logcat | Usually MIUI noise — see [troubleshooting-android.md](troubleshooting-android.md) | -| `om.archhive.app` in logs | Truncated `com.archhive.app` — verify with `android:regen` if UI is broken | -| -------------------------------------- | ---------------------------------------------------------------------------------------- | -| `yt-dlp-aarch64-linux-android` missing | Sidecars are desktop-only; run `bun run android:regen` | -| `Unresolved reference: TauriActivity` | Stale `gen/android` after identifier change; run `bun run android:regen` | -| App runs but `invoke` is undefined | Set Engine → **Remote LAN**; ensure desktop LAN is on. Rebuild after capability changes. | -| Read-only filesystem on Android | Mobile uses app data dir for DB; downloads go via Remote LAN desktop host | -| `ERR_CLEARTEXT_NOT_PERMITTED` | Run `scripts/patch-android-lan.ps1` and rebuild | -| Connection refused | Firewall: allow TCP **8787** on PC (API). `:1420` is dev UI only. | -| Used `:1420` in Remote LAN | Remote LAN API is port **8787**, not the Vite dev port 1420 | -| 401 Unauthorized | Verify API token matches desktop LAN token | -| Empty browse results | Ensure desktop app is running and cookies are configured | +| Issue | Fix | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FrameInsert open fail` in logcat | Usually MIUI noise — see [troubleshooting-android.md](troubleshooting-android.md) | +| `om.archhive.app` in logs | Truncated `com.archhive.app` — verify with `android:regen` if UI is broken | +| -------------------------------------- | ---------------------------------------------------------------------------------------- | +| `yt-dlp-aarch64-linux-android` missing | Sidecars are desktop-only; run `bun run android:regen` | +| `Unresolved reference: TauriActivity` | Stale `gen/android` after identifier change; run `bun run android:regen` | +| App runs but `invoke` is undefined | Set Engine → **Remote LAN**; ensure desktop LAN is on. Rebuild after capability changes. | +| Read-only filesystem on Android | Mobile uses app data dir for DB; downloads go via Remote LAN desktop host | +| `ERR_CLEARTEXT_NOT_PERMITTED` | Run `scripts/patch-android-lan.ps1` and rebuild | +| Connection refused | Firewall: allow TCP **8787** on PC (API). `:1420` is dev UI only. | +| Used `:1420` in Remote LAN | Remote LAN API is port **8787**, not the Vite dev port 1420 | +| 401 Unauthorized | Verify API token matches desktop LAN token | +| Empty browse results | Ensure desktop app is running and cookies are configured | +| `YoutubeDL/FFmpeg not initialized`, no versions in Settings | APK was packaged with compressed native libs (`extractNativeLibs=false`). `patch-android-ytdlp` sets `jniLibs.useLegacyPackaging = true`; run `bun run android:patches` and rebuild | ## Scripts diff --git a/docs/sites.md b/docs/sites.md index ce750f6..e7d5387 100644 --- a/docs/sites.md +++ b/docs/sites.md @@ -2,21 +2,21 @@ ## Tier A — yt-dlp (browse + download) -| Site ID | Display Name | Browse kinds | Cookies | -| --------- | ------------ | -------------------------------------------- | -------------------- | -| youtube | YouTube | channel, search, video | Optional | -| tiktok | TikTok | channel, search, video | No | -| twitter | Twitter / X | channel, video | Recommended | -| instagram | Instagram | channel, video | Yes | -| thisvid | ThisVid | tag, search, video | Yes | -| thothub | ThotHub | tag, model, search, video | Yes for gated videos | -| pornhub | PornHub | category, tag, search, channel, model, video | Yes | -| youporn | YouPorn | category, model, search, video | Optional | -| xnxx | XNXX | search, tag, channel, video | Optional | -| xhamster | xHamster | tag, search, channel, video | Yes | -| xvideos | XVIDEOS | tag, search, channel, video | Yes | -| reddit | Reddit | channel, search, video | OAuth for subreddits | -| eporner | Eporner | search, tag, category, video | No | +| Site ID | Display Name | Browse kinds | Cookies | +| --------- | ------------ | -------------------------------------------- | -------------------------------------------------------------- | +| youtube | YouTube | channel, search, video | Optional | +| tiktok | TikTok | channel, search, video | No | +| twitter | Twitter / X | channel, video | Recommended | +| instagram | Instagram | channel, video | Yes | +| thisvid | ThisVid | tag, search, video | Yes | +| thothub | ThotHub | tag, model, search, video | Yes for gated videos (`thethothub.com`) | +| pornhub | PornHub | category, tag, search, channel, model, video | Yes | +| youporn | YouPorn | category, model, search, video | Optional | +| xnxx | XNXX | search, tag, channel, video | Optional | +| xhamster | xHamster | tag, search, channel, video | Yes | +| xvideos | XVIDEOS | tag, search, channel, video | Yes | +| reddit | Reddit | channel, search, video | Login cookies (Reddit hides listings from logged-out visitors) | +| eporner | Eporner | search, tag, category, video | No | ## Tier B — Live Cam Sites (scraping + webview bridge) @@ -27,9 +27,9 @@ ## Tier C — gallery-dl -| Site ID | Display Name | Notes | -| ------- | ------------ | ---------------------------- | -| redgifs | RedGifs | Uses gallery-dl for download | +| Site ID | Display Name | Notes | +| ------- | ------------ | -------------------------------------------------------------------- | +| redgifs | RedGifs | Browse via the RedGifs v2 API (temporary token); direct MP4 download | ## Cookie Requirements @@ -79,6 +79,33 @@ desktop host. the StashDB id, merges aliases (deduped, capped), and sets the portrait only when none is stored. +## Sorting, filters and categories + +What each site can strictly combine (tags, categories, search + category, +exclusions) is documented in [tag-combining.md](tag-combining.md). + +Adapters advertise what they support via `SiteAdapter::browse_options()` +(returned in `SiteInfo.browse_options`); the browse page shows matching +Sort / Date / Length / Quality selects and passes the chosen ids as +`BrowseQuery.filters`. Unknown ids are dropped before they reach a URL. +Every page also has on-device filters (words, `-exclude`, length) over +loaded results. Category / tag indexes come from `list_categories()` +(`list_site_categories` command, `GET /api/sites/{id}/categories`). + +| Site | Sort | Date / Length / Quality | Categories | +| ---------- | --------------------------------------- | ---------------------------- | --------------------- | +| PornHub | newest, views, rating, longest, hottest | period, duration, HD | JSON-embedded catalog | +| XVIDEOS | relevance, date, rating, views, length | period, duration, HD/1080p | `/tags` (top 400) | +| XNXX | views, random | period, duration, HD/Full HD | `/tags` (top 400) | +| xHamster | search: 5 sorts; category: newest/best | duration, 720p/1080p/4K | `/categories` | +| YouPorn | relevance, date, views, rating, length | — | `/categories/` | +| Eporner | 7 API `order` values | — | `/cats/` | +| ThotHub | KVS `sort_by` (5) | — | — | +| ThisVid | KVS `sort_by` (5) | — | `/categories/` | +| Reddit | hot, new, top, rising | period (top) | — | +| RedGifs | trending, latest, top | — | — | +| Chaturbate | — | — | top hashtags API | + ## SCrawler Parity Roadmap Future adapters: Threads, Pinterest, OnlyFans (cookie + DRM limitations), Bluesky, LPSG. diff --git a/docs/tag-combining.md b/docs/tag-combining.md new file mode 100644 index 0000000..f20857d --- /dev/null +++ b/docs/tag-combining.md @@ -0,0 +1,203 @@ +# Combining tags, categories and searches + +What each site can **strictly** combine in one listing, i.e. results that must +match _every_ term, versus what only works as a relevance-ranked word search. +Everything marked strict was verified against the live sites (October 2026) +by opening result videos and reading **their own** tag / category lists (not +titles, and not the site-wide menus on video pages). URL schemes change, so +re-check before relying on an entry for automation. + +**Strict** = the site intersects the terms (each result has all of them). +**Word search** = the terms are keywords; results are ranked by relevance and +are not guaranteed to match every term. + +## Summary + +| Site | Tag + tag | Category + category | Search + category | Exclude | +| -------- | --------------------------- | --------------------- | ------------------------------------ | ---------------------- | +| XVIDEOS | ❌ (keyword search) | ❌ | ❌ | ❌ (`-tag` is ignored) | +| PornHub | ❌ (word search) | ✅ strict, exactly 2 | ✅ strict, 1 category | ✅ up to 10 categories | +| Eporner | ❌ (word search) | ✅ strict, any number | ✅ strict (search/tag + categories) | ✅ categories | +| xHamster | ❌ (word search) | ❌ | ❌ | ❌ | +| XNXX | ❌ (word search) | — (no categories) | ❌ | ❌ | +| ThisVid | ❌ (one tag only) | ❌ | ❌ (search + category is not strict) | ❌ | +| YouPorn | ❌ (word search) | ❌ | ❌ | ❌ | +| Others | not supported (word search) | not supported | not supported | not supported | + +ThisVid's one strict combination — **one category + one tag** — does not fit +the columns above; see its section. + +On every site, the in-app **Filter results** box (`word`, `-word`) can further +narrow the _loaded_ results on the device; that is a local text filter on +titles/tags, not a site query. + +## XVIDEOS + +- **No strict tag matching — not even for a single multi-word tag.** Tag pages + are a keyword search over titles and tags: + - `/tags/petite-brunette` lists **73,466** videos — more than `petite` + (46,462) or `brunette` (50,095) alone, which a real tag could not. Of 8 + results opened, **none** carried a `petite-brunette` tag, only 2 had both + `petite` and `brunette` tags, and some matched only through the title. + - `/tags/milf+anal` and `/tags/redhead+big-tits+milf` behave the same: results + were missing one or more of the tags (none of the 5 checked had all three). + - `/tags/milf/anal` just redirects to the `anal` tag; `-tag` exclusion + (`/tags/milf+anal+-lesbian`) is ignored. + - The counts confirm it. Results-page counts: `milf` 48,587, `anal` 59,259, + `milf+anal` **40,951** — that would make 84% of all MILF videos also anal; + a real intersection would be a small fraction of either. `petite-brunette` + (= `petite+brunette`) shows 26,078 against `petite` 32,679 (80%). The + tag-index badges are worse: `milf-anal` 122,495 is larger than `milf` + (73,089) and `anal` (95,337) put together. The search box is OR-like: + `/?k=milf` 82,413 → `/?k=milf+anal` 135,138. + - Single-word tags (`/tags/milf`) are the closest to a real tag listing. +- **Categories** (`/c/Femdom-235`, ~38 of them) cannot be combined with each + other, with tags, or with a search: `?c=`, `?tag=` and `?k=` on a category + page are ignored, `/c/X/tags/Y` is a 404. Category pages do support sort / + length / quality as path segments: `/c/s:uploaddate/d:10-20min/q:hd/Femdom-235`. +- **Search + tag**: not possible (`/?k=…&tag=…` and `/tags/x?k=…` ignore the + extra parameter). + +**In the app:** Combine runs a word search (`/?k=milf+anal`); results are +relevance-ranked, not strict. Use the **Filter results** box to drop loaded +results by word. + +## PornHub + +- **Categories — strict, exactly 2 (straight, gay and trans).** + `/video/incategories/milf/anal` (paging `?page=N`, sort `&o=mv` etc. work). + Verified: 10 of 10 results (from the page's `#incategoryVideos` grid) list + both MILF and Anal in their own categories. + - Three categories (`/incategories/a/b/c`) → 404. + - Gay: `/gay/video/incategories/amateur/bareback` (catalog slugs without + their `-gay` suffix). Verified: 8 of 8 results list both categories. + - Trans has no category index of its own (`/transgender/categories` is a + 404); a trans category is `/video/incategories//transgender`. + Verified: 8 of 8 `milf/transgender` results list both MILF and + Transgender. Most of the 111 straight categories have such a page; a few + pairs (BBW, Casting, Bisexual Male, Scissoring, Described Video) are 404s, + and the app falls back to a word search for those. + - Lesbian "categories" are not categories: every entry on + `/lesbian/categories` links to a search (`/lesbian/video/search?search=anal`), + so lesbian combinations are word searches. +- **Search + 1 category, minus up to 10 categories — strict.** The search + page's own filter (the category include / exclude lists) uses numeric + category ids: + `/video/search?search=redhead&filter_category=29&exclude_category=35-8` + (29 MILF, 35 Anal, 8 Big Tits). + - Verified: 8 of 8 results list MILF and neither Anal nor Big Tits in their + own categories; the same holds on page 2 with a sort (`&o=mv&page=2`). + - Several exclusions are **dash**-separated (`35-8`), at most 10. A comma + (`35,8`) keeps only the first one. + - Only **one** `filter_category`. + - `?search=` on a category page is a 404. +- **Category page minus categories — strict.** `exclude_category` also works + on category listings, `/video?c=29&exclude_category=8` (8 of 8 MILF results + without Big Tits, where the unfiltered page is mostly Big Tits), and on + `/video/incategories/milf/anal?exclude_category=8`. `filter_category` is + ignored on category pages, and `incategories` only takes slugs (ids 404). +- **Tags**: no tag-intersection URL; multiple tags are a word search. + +**In the app:** + +- Two categories: PornHub category picker → **Combine** → pick 2 (Straight or + Gay) → _Browse 2 together_. On Lesbian and Trans, Combine runs a word search. +- The picker loads PornHub's live catalog (111 straight, 53 gay, 73 lesbian + entries). The **Trans** tab lists the straight categories, each opened as + that category ∩ Transgender; _All trans_ is the whole trans section. +- Search + categories: run a search, open the **Categories** panel under the + filters, tap a category once to require it (one at most), and tap others + twice to exclude them (up to 10). +- On a category page the same panel only excludes. + +## Eporner + +- **Search or tag + categories — strict, include and exclude.** + `/tag/redhead/?include=milf,anal&exclude=lesbian` (also on category pages, + `/cat/japanese/?include=milf`). + - Paging and sort: the page number goes **before** the sort segment — + `/tag/redhead/2/longest/?include=milf`. (`/longest/2/` redirects to page 1.) + - Verified: 8 of 8 `?include=milf` results and 5 of 5 + `?include=milf&exclude=anal` results had the right categories on their + own pages. + - Only **categories** (the ~86 on `/cats/`) work in `include`/`exclude`; + tag slugs (`femdom`, `pantyhose`) are silently dropped. +- **Tag + tag**: not strict. Every multi-tag form (`/tag/redhead+milf/`, + `/search/redhead milf/`, `/tag/redhead-milf/`) lands on a keyword-search page + (`/tag/redhead-milf/`). The search narrows with each word (API totals: + `redhead` 100k+, `redhead milf` 52,597, `redhead milf anal` 16,993), so it + behaves AND-like, but it is keyword matching, not tag intersection. +- Tag slugs are case-sensitive: `/tag/czech-ouQ9EM84/` works, lowercase 404s. + +**For a strict Eporner match:** search or open a tag, then use the +**Categories** panel on the results page — tap once to require a category, +twice to exclude it. Required categories are strict; the search term itself is +a keyword match. + +## xHamster + +- **No strict combination of any kind.** + - `/categories/milf/anal` → 404; there is no multi-tag or multi-category URL. + - Multiple terms only work as a word search: `/search/redhead+milf` + (the form xHamster's own "related searches" use). + - A search can only be narrowed by orientation (`?orientations=gay`), plus + sort/length/quality. +- Single tags (`/tags/`) and categories (`/categories/`) are real + listings with sort (`/newest`, `/best`), HD (`/hd`) and paging (`/2`). + +**In the app:** Combine runs a word search; results are relevance-ranked. + +## XNXX + +- **No strict combination.** XNXX has no tag pages: `/tags/milf` redirects to + the search `/search/milf`, and `/tags/milf/anal` redirects to `/search/anal`. +- `/search/big-tits` (hyphenated, as XNXX's own tag links use) ranks + differently from `/search/big+tits` (separate words); neither is a strict + match. The app uses the hyphenated form for a single tag. + +**In the app:** Combine runs a word search. + +## ThisVid + +- **Category + one tag — strict.** `/categories/amateurs/?tag=anal` (or the + other way round, `/tags/anal/?category=amateurs`) returns only videos in that + category **and** with that tag. Checked by opening result pages: every one + listed both (amateurs + anal, asian + massage). Paging works + (`/categories/amateurs/2/?tag=anal`). +- **Only one tag.** `?tag=anal&tag=bdsm` keeps just the last tag; comma or `+` + lists (`?tag=anal,bdsm`) are 404s; `?tag[]=…` is a server error. +- **Tag + tag**: no. `/tags/anal/?tag=bdsm` _replaces_ the tag (results are + bdsm, not anal); `/tags/a,b/`, `/tags/a+b/`, `/tags/a/b/` are 404s. +- **Search + category**: not strict. `/search/?q=wife&category=amateurs` + reorders the results but still returns other categories. + +**In the app:** open a ThisVid category, type a tag in **Narrow by tag** +(autocomplete from ThisVid's A–Z tag index) and tap Apply — a strict +category + tag listing, with sort and paging. Combining several ThisVid tags +in the tag browser still runs a word search. + +## YouPorn + +- **No strict combination.** `/category/milf/anal/` is a 403; `category_id=` and + `tag=` on search, tag or category pages are stripped by a redirect. +- Its "porn tags" are search aliases: `/porntags/redhead-milf/` returns exactly + the same videos as `/search/?query=redhead+milf`, and `/porntags/redhead/` + the same as `/search/?query=redhead`. + +**In the app:** Combine runs a word search. + +## Other sites + +ThotHub, RedGifs, Reddit and the cam sites were **not tested** for combined +queries. In the app, combining tags on them runs a word search (the site's +normal search with all the words). + +## How the app decides + +Each site advertises `BrowseOptions.combine_tags` (how many tags one URL can +intersect exactly: PornHub 2, others 0) and `include_exclude` (Eporner, +PornHub). `max_include` / `max_exclude` cap the panel (PornHub 1 / 10), and +`exclude_only_kinds` lists listings that take only exclusions (PornHub +category pages). A combined tag slug is sent as `a,b,…`; when the count exceeds the +site's exact limit, `AppState::browse` turns it into a word search, and the +results page shows a note that the combination is not exact. diff --git a/public/site-icons/chaturbate.png b/public/site-icons/chaturbate.png new file mode 100644 index 0000000..32054a3 Binary files /dev/null and b/public/site-icons/chaturbate.png differ diff --git a/public/site-icons/eporner.png b/public/site-icons/eporner.png new file mode 100644 index 0000000..ecf9357 Binary files /dev/null and b/public/site-icons/eporner.png differ diff --git a/public/site-icons/instagram.png b/public/site-icons/instagram.png new file mode 100644 index 0000000..2d1ee27 Binary files /dev/null and b/public/site-icons/instagram.png differ diff --git a/public/site-icons/pornhub.png b/public/site-icons/pornhub.png new file mode 100644 index 0000000..7278cc7 Binary files /dev/null and b/public/site-icons/pornhub.png differ diff --git a/public/site-icons/reddit.png b/public/site-icons/reddit.png new file mode 100644 index 0000000..fe0fefd Binary files /dev/null and b/public/site-icons/reddit.png differ diff --git a/public/site-icons/redgifs.png b/public/site-icons/redgifs.png new file mode 100644 index 0000000..09a6cc3 Binary files /dev/null and b/public/site-icons/redgifs.png differ diff --git a/public/site-icons/stripchat.png b/public/site-icons/stripchat.png new file mode 100644 index 0000000..fdb6145 Binary files /dev/null and b/public/site-icons/stripchat.png differ diff --git a/public/site-icons/tiktok.png b/public/site-icons/tiktok.png new file mode 100644 index 0000000..dd836d3 Binary files /dev/null and b/public/site-icons/tiktok.png differ diff --git a/public/site-icons/twitter.png b/public/site-icons/twitter.png new file mode 100644 index 0000000..7ecf1ca Binary files /dev/null and b/public/site-icons/twitter.png differ diff --git a/public/site-icons/xhamster.png b/public/site-icons/xhamster.png new file mode 100644 index 0000000..3e0cd4a Binary files /dev/null and b/public/site-icons/xhamster.png differ diff --git a/public/site-icons/xnxx.png b/public/site-icons/xnxx.png new file mode 100644 index 0000000..6706f7c Binary files /dev/null and b/public/site-icons/xnxx.png differ diff --git a/public/site-icons/xvideos.png b/public/site-icons/xvideos.png new file mode 100644 index 0000000..951ea46 Binary files /dev/null and b/public/site-icons/xvideos.png differ diff --git a/public/site-icons/youporn.png b/public/site-icons/youporn.png new file mode 100644 index 0000000..adf4049 Binary files /dev/null and b/public/site-icons/youporn.png differ diff --git a/public/site-icons/youtube.png b/public/site-icons/youtube.png new file mode 100644 index 0000000..2dac66c Binary files /dev/null and b/public/site-icons/youtube.png differ diff --git a/scripts/patch-android-lan.ps1 b/scripts/patch-android-lan.ps1 index b22b92b..e377193 100644 --- a/scripts/patch-android-lan.ps1 +++ b/scripts/patch-android-lan.ps1 @@ -13,10 +13,14 @@ if (-not (Test-Path $GradlePath)) { } $content = Get-Content $GradlePath -Raw -if ($content -notmatch 'manifestPlaceholders\["usesCleartextTraffic"\] = "true"') { - $content = $content -replace 'manifestPlaceholders\["usesCleartextTraffic"\] = "false"', 'manifestPlaceholders\["usesCleartextTraffic"\] = "true"' +# The generated defaultConfig sets "false" and only the debug build type sets +# "true", so test for a remaining "false" (not for any "true"): otherwise +# release APKs keep cleartext off and the WebView cannot reach the on-device +# 127.0.0.1 media server or a Remote LAN host. +if ($content -match 'manifestPlaceholders\["usesCleartextTraffic"\] = "false"') { + $content = $content -replace 'manifestPlaceholders\["usesCleartextTraffic"\] = "false"', 'manifestPlaceholders["usesCleartextTraffic"] = "true"' Set-Content -Path $GradlePath -Value $content -NoNewline - Write-Host "Patched $GradlePath for cleartext HTTP (Remote LAN)." + Write-Host "Patched $GradlePath for cleartext HTTP (loopback media server + Remote LAN)." } else { Write-Host "Android LAN cleartext patch already applied." } diff --git a/scripts/patch-android-ytdlp.ps1 b/scripts/patch-android-ytdlp.ps1 index 8730bc5..91eecdc 100644 --- a/scripts/patch-android-ytdlp.ps1 +++ b/scripts/patch-android-ytdlp.ps1 @@ -47,6 +47,19 @@ if (Test-Path $resumeWorkerSrc) { Copy-Item -Path $resumeWorkerSrc -Destination (Join-Path $pluginDestDir "PendingResumeWorker.kt") -Force Write-Host "Installed PendingResumeWorker.kt" } +$cookieBrowserSrc = Join-Path $Overlays "CookieBrowserPlugin.kt" +if (Test-Path $cookieBrowserSrc) { + Copy-Item -Path $cookieBrowserSrc -Destination (Join-Path $pluginDestDir "CookieBrowserPlugin.kt") -Force + Write-Host "Installed CookieBrowserPlugin.kt" +} +# FileProvider roots: shared storage + app-private dirs ("Open with default app"). +$filePathsSrc = Join-Path $Overlays "file_paths.xml" +$filePathsDest = Join-Path $GenAndroid "app\src\main\res\xml\file_paths.xml" +if (Test-Path $filePathsSrc) { + New-Item -ItemType Directory -Force -Path (Split-Path $filePathsDest) | Out-Null + Copy-Item -Path $filePathsSrc -Destination $filePathsDest -Force +} + # Ensure youtubedl-android + WorkManager dependencies $gradle = Get-Content $gradlePath -Raw @@ -166,6 +179,41 @@ if (Test-Path $proguardPath) { Write-Host "Created $proguardPath with YtDlp keep rules" } +# Projects patched before these rules existed: add the library keeps that +# stop R8 from breaking YoutubeDL.init / FFmpeg.init in release builds. +$proguardNow = Get-Content $proguardPath -Raw +if ($proguardNow -notmatch 'org\.apache\.commons\.compress') { + $libKeeps = @" +-keep class org.apache.commons.compress.** { *; } +-keep class org.apache.commons.io.** { *; } +-keep class com.fasterxml.jackson.** { *; } +-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod +-dontwarn org.apache.commons.compress.** +-dontwarn org.apache.commons.io.** +-dontwarn com.fasterxml.jackson.** +"@ + $proguardNow = $proguardNow.TrimEnd() + "`r`n" + $libKeeps + Set-Content -Path $proguardPath -Value $proguardNow -NoNewline + Write-Host "Added youtubedl-android library ProGuard keeps." +} + +# Projects patched before the cookie login browser existed: add its keeps. +$proguardNow = Get-Content $proguardPath -Raw +if ($proguardNow -notmatch 'CookieBrowserPlugin') { + $cookieKeeps = "-keep class com.archhive.app.CookieBrowserPlugin { *; }`r`n-keep class com.archhive.app.CookieBrowserPlugin`$OpenArgs { *; }`r`n" + Set-Content -Path $proguardPath -Value ($proguardNow.TrimEnd() + "`r`n" + $cookieKeeps) -NoNewline + Write-Host "Added CookieBrowserPlugin ProGuard keeps." +} + +# Projects patched before the plugin Args keep existed: R8 stripped the +# nested *Args classes, breaking every yt-dlp invoke in release builds. +$proguardNow = Get-Content $proguardPath -Raw +if ($proguardNow -notmatch [regex]::Escape('com.archhive.app.**Args')) { + $argsKeep = "-keep class com.archhive.app.**Args { *; }`r`n" + Set-Content -Path $proguardPath -Value ($proguardNow.TrimEnd() + "`r`n" + $argsKeep) -NoNewline + Write-Host "Added plugin Args ProGuard keeps." +} + Write-Host "Android YtDlp overlay patch complete (youtubedl-android $YoutubeDlAndroidVersion)." # Remove stale Linux BtbN ffmpeg/ffprobe assets left from older tauri.android.conf.json diff --git a/scripts/patch-android-ytdlp.sh b/scripts/patch-android-ytdlp.sh index 7a9ef44..a388f1b 100644 --- a/scripts/patch-android-ytdlp.sh +++ b/scripts/patch-android-ytdlp.sh @@ -45,6 +45,10 @@ if [[ -f "$OVERLAYS/PendingResumeWorker.kt" ]]; then cp "$OVERLAYS/PendingResumeWorker.kt" "$PLUGIN_DEST_DIR/PendingResumeWorker.kt" echo "Installed PendingResumeWorker.kt" fi +if [[ -f "$OVERLAYS/CookieBrowserPlugin.kt" ]]; then + cp "$OVERLAYS/CookieBrowserPlugin.kt" "$PLUGIN_DEST_DIR/CookieBrowserPlugin.kt" + echo "Installed CookieBrowserPlugin.kt" +fi if ! grep -q 'youtubedl-android:library' "$GRADLE"; then python3 - "$GRADLE" "$YOUTUBE_DL_ANDROID_VERSION" "$WORK_RUNTIME_KTX_VERSION" <<'PY' @@ -143,6 +147,28 @@ if [[ ! -f "$PROGUARD_OVERLAY" ]]; then exit 1 fi +# youtubedl-android unpacks libpython.zip.so / libffmpeg.zip.so from the installed +# native library dir; modern AGP leaves .so files inside the APK +# (extractNativeLibs=false) and YoutubeDL.init fails. Use legacy packaging. +if ! grep -q 'useLegacyPackaging' "$GRADLE"; then + python3 - "$GRADLE" <<'PY' +import re, sys +from pathlib import Path +path = Path(sys.argv[1]) +text = path.read_text() +block = ''' packaging { + // youtubedl-android needs extracted native libs (libpython.zip.so etc.) + jniLibs.useLegacyPackaging = true + } +''' +new, n = re.subn(r'(?m)^(android \{)\n', lambda m: m.group(1) + "\n" + block, text, count=1) +if n == 0: + raise SystemExit("Could not find android block for useLegacyPackaging") +path.write_text(new) +print("Enabled jniLibs.useLegacyPackaging (extractNativeLibs) for youtubedl-android") +PY +fi + if [[ -f "$PROGUARD" ]] && grep -q 'YtDlpPlugin' "$PROGUARD"; then echo "ProGuard YtDlp keep rules already present." else @@ -158,6 +184,28 @@ else echo "Wrote ProGuard keep rules to $PROGUARD" fi +# Projects patched before these rules existed: keep the libraries +# youtubedl-android drives reflectively (R8 broke release-build init). +if ! grep -q 'org.apache.commons.compress' "$PROGUARD"; then + cat >> "$PROGUARD" <<'PG' + +-keep class org.apache.commons.compress.** { *; } +-keep class org.apache.commons.io.** { *; } +-keep class com.fasterxml.jackson.** { *; } +-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod +-dontwarn org.apache.commons.compress.** +-dontwarn org.apache.commons.io.** +-dontwarn com.fasterxml.jackson.** +PG + echo "Added youtubedl-android library ProGuard keeps." +fi + +# Projects patched before the cookie login browser existed: add its keeps. +if ! grep -q 'CookieBrowserPlugin' "$PROGUARD"; then + printf '\n-keep class com.archhive.app.CookieBrowserPlugin { *; }\n-keep class com.archhive.app.CookieBrowserPlugin$OpenArgs { *; }\n' >> "$PROGUARD" + echo "Added CookieBrowserPlugin ProGuard keeps." +fi + echo "Android YtDlp overlay patch complete (youtubedl-android $YOUTUBE_DL_ANDROID_VERSION)." STALE_BINARIES="$GEN_ANDROID/app/src/main/assets/binaries" diff --git a/scripts/validate-android-build.ps1 b/scripts/validate-android-build.ps1 index fa39bc1..e92932e 100644 --- a/scripts/validate-android-build.ps1 +++ b/scripts/validate-android-build.ps1 @@ -38,6 +38,7 @@ $termuxPlugin = Join-Path $gen "app\src\main\java\com\archhive\app\TermuxBridgeP $mainActivity = Join-Path $gen "app\src\main\java\com\archhive\app\MainActivity.kt" $fgService = Join-Path $gen "app\src\main\java\com\archhive\app\DownloadForegroundService.kt" $resumeWorker = Join-Path $gen "app\src\main\java\com\archhive\app\PendingResumeWorker.kt" +$cookieBrowser = Join-Path $gen "app\src\main\java\com\archhive\app\CookieBrowserPlugin.kt" $gradle = Join-Path $gen "app\build.gradle.kts" $proguard = Join-Path $gen "app\proguard-rules.pro" $manifest = Join-Path $gen "app\src\main\AndroidManifest.xml" @@ -64,6 +65,9 @@ if (-not (Test-Path $fgService)) { if (-not (Test-Path $resumeWorker)) { Add-Fail "Missing PendingResumeWorker.kt" } +if (-not (Test-Path $cookieBrowser)) { + Add-Fail "Missing CookieBrowserPlugin.kt (run scripts/apply-android-patches.ps1)" +} if (-not (Test-Path $gradle)) { Add-Fail "Missing app/build.gradle.kts" @@ -78,8 +82,11 @@ if (-not (Test-Path $gradle)) { if ($gradleText -notmatch 'work-runtime-ktx') { Add-Warn "build.gradle.kts missing work-runtime-ktx (PendingResumeWorker)" } - if ($gradleText -notmatch 'manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*"true"') { - Add-Fail "build.gradle.kts missing usesCleartextTraffic=true placeholder (LAN HTTP)" + if ($gradleText -notmatch 'useLegacyPackaging\s*=\s*true') { + Add-Fail "build.gradle.kts missing jniLibs.useLegacyPackaging = true (youtubedl-android cannot init without extracted native libs)" + } + if ($gradleText -match 'manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*"false"') { + Add-Fail "build.gradle.kts still sets usesCleartextTraffic=false (release WebView cannot reach the 127.0.0.1 media server)" } } diff --git a/src-tauri/android-overlays/CookieBrowserPlugin.kt b/src-tauri/android-overlays/CookieBrowserPlugin.kt new file mode 100644 index 0000000..6588bc9 --- /dev/null +++ b/src-tauri/android-overlays/CookieBrowserPlugin.kt @@ -0,0 +1,193 @@ +package com.archhive.app + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Dialog +import android.graphics.Bitmap +import android.graphics.Color +import android.util.Log +import android.view.Gravity +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import app.tauri.annotation.Command +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin + +/** + * In-app login browser used to capture site cookies on Android. + * + * Android apps cannot read Chrome's cookie jar, so ArcHive opens its own + * full-screen WebView on the site's login page. When the user taps "Save + * cookies", every cookie the WebView holds for the requested URLs, including + * HttpOnly session cookies, is read from the process-wide [CookieManager] + * and returned to Rust, which stores them in the encrypted cookie vault. + * + * Args: { url: string, cookieUrls: string[], title: string } + * Returns: { cookies: [{ url: string, header: string }] } + * Rejects with "cancelled" when the user backs out. + */ +@TauriPlugin +class CookieBrowserPlugin(private val activity: Activity) : Plugin(activity) { + + companion object { + private const val TAG = "CookieBrowserPlugin" + } + + class OpenArgs @JvmOverloads constructor( + var url: String = "", + var cookieUrls: Array = emptyArray(), + var title: String = "Log in", + ) + + @Volatile private var dialog: Dialog? = null + + @SuppressLint("SetJavaScriptEnabled") + @Command + fun open(invoke: Invoke) { + val args = invoke.parseArgs(OpenArgs::class.java) + if (!args.url.startsWith("https://") && !args.url.startsWith("http://")) { + invoke.reject("Invalid login URL") + return + } + val cookieUrls = if (args.cookieUrls.isEmpty()) arrayOf(args.url) else args.cookieUrls + + activity.runOnUiThread { + try { + dialog?.dismiss() + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + + val dlg = Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen) + var settled = false + + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.parseColor("#111114")) + } + val bar = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(16, 12, 16, 12) + } + val cancel = Button(activity).apply { text = "Cancel" } + val title = TextView(activity).apply { + text = args.title + setTextColor(Color.WHITE) + textSize = 15f + maxLines = 2 + setPadding(16, 0, 16, 0) + } + val save = Button(activity).apply { text = "Save cookies" } + bar.addView(cancel) + bar.addView(title, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + bar.addView(save) + + val progress = ProgressBar(activity, null, android.R.attr.progressBarStyleHorizontal).apply { + isIndeterminate = false + max = 100 + } + + val webView = WebView(activity).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.setSupportMultipleWindows(false) + // Drop the "; wv" token: some login pages (Google, X) + // refuse embedded WebViews that advertise themselves. + settings.userAgentString = settings.userAgentString.replace("; wv", "") + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val scheme = request.url.scheme ?: return true + // Stay inside the WebView for web pages; ignore app deep links. + return scheme != "http" && scheme != "https" + } + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + title.text = "${args.title}\n${url ?: ""}" + } + } + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView, newProgress: Int) { + progress.progress = newProgress + progress.visibility = if (newProgress >= 100) android.view.View.GONE else android.view.View.VISIBLE + } + } + } + cookieManager.setAcceptThirdPartyCookies(webView, true) + + root.addView(bar, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + root.addView(progress, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 6)) + root.addView(webView, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + dlg.setContentView(root) + + fun finish(saveCookies: Boolean) { + if (settled) return + settled = true + try { + if (saveCookies) { + cookieManager.flush() + val list = JSArray() + for (u in cookieUrls) { + val header = cookieManager.getCookie(u) + if (!header.isNullOrBlank()) { + val entry = JSObject() + entry.put("url", u) + entry.put("header", header) + list.put(entry) + } + } + val ret = JSObject() + ret.put("cookies", list) + invoke.resolve(ret) + } else { + invoke.reject("cancelled") + } + } catch (e: Exception) { + Log.w(TAG, "cookie capture failed", e) + invoke.reject("Cookie capture failed: ${e.message}") + } finally { + webView.stopLoading() + webView.destroy() + dlg.dismiss() + dialog = null + } + } + + save.setOnClickListener { finish(true) } + cancel.setOnClickListener { finish(false) } + dlg.setOnCancelListener { finish(false) } + dlg.setOnKeyListener { _, keyCode, event -> + if (keyCode == android.view.KeyEvent.KEYCODE_BACK && + event.action == android.view.KeyEvent.ACTION_UP && + webView.canGoBack() + ) { + webView.goBack() + true + } else { + false + } + } + + dialog = dlg + dlg.show() + webView.loadUrl(args.url) + } catch (e: Exception) { + Log.e(TAG, "failed to open login browser", e) + invoke.reject("Could not open login browser: ${e.message}") + } + } + } +} diff --git a/src-tauri/android-overlays/YtDlpPlugin.kt b/src-tauri/android-overlays/YtDlpPlugin.kt index 3c099bd..8ddbee3 100644 --- a/src-tauri/android-overlays/YtDlpPlugin.kt +++ b/src-tauri/android-overlays/YtDlpPlugin.kt @@ -12,6 +12,7 @@ import app.tauri.plugin.Plugin import com.yausername.ffmpeg.FFmpeg import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDLRequest +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,11 +25,19 @@ import java.util.concurrent.TimeUnit @TauriPlugin class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { - private val scope = CoroutineScope(Dispatchers.IO) + // Last-resort guard: an uncaught Throwable in a plugin coroutine (e.g. a + // LinkageError from youtubedl-android) must not take the whole app down. + private val scope = CoroutineScope( + Dispatchers.IO + CoroutineExceptionHandler { _, e -> + Log.e("YtDlpPlugin", "Uncaught error in plugin coroutine", e) + } + ) @Volatile private var initialized = false @Volatile private var ffmpegPath: String? = null @Volatile private var ffprobePath: String? = null @Volatile private var ffmpegLibDir: String? = null + /** Last init failure, surfaced in "not initialized" responses for diagnosis. */ + @Volatile private var initError: String? = null /** * Lazily initialize YoutubeDL + FFmpeg (idempotent, thread-safe). @@ -44,7 +53,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { YoutubeDL.getInstance().init(activity) try { FFmpeg.getInstance().init(activity) - } catch (e: Exception) { + } catch (e: Throwable) { // FFmpeg module may be absent in some builds; yt-dlp can still run. Log.w("YtDlpPlugin", "FFmpeg.init failed (media tools may be unavailable)", e) } @@ -52,12 +61,18 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { resolveMediaToolsLocked() initialized = true true - } catch (e: Exception) { + } catch (e: Throwable) { Log.e("YtDlpPlugin", "Failed to init YoutubeDL", e) + initError = "${e.javaClass.simpleName}: ${e.message ?: ""}" false } } + private fun notInitialized(what: String): String { + val cause = initError ?: return "$what not initialized" + return "$what not initialized ($cause)" + } + /** FFmpeg.init is async; wait until libavdevice appears in the unpacked tree. */ private fun waitForFfmpegLibsUnpack() { val libDir = File( @@ -184,7 +199,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() if (!ensureInitialized()) { ret.put("ok", false) - ret.put("message", "YoutubeDL/FFmpeg not initialized") + ret.put("message", notInitialized("YoutubeDL/FFmpeg")) invoke.resolve(ret) return@launch } @@ -253,7 +268,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { if (!ensureInitialized()) { val ret = JSObject() ret.put("stdout", "") - ret.put("stderr", "YoutubeDL/FFmpeg not initialized") + ret.put("stderr", notInitialized("YoutubeDL/FFmpeg")) ret.put("exitCode", -1) invoke.resolve(ret) return@launch @@ -276,7 +291,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { ret.put("stderr", stderr) ret.put("exitCode", code) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("stdout", "") ret.put("stderr", e.message ?: "Unknown error") @@ -298,7 +313,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { if (!ensureInitialized()) { val ret = JSObject() ret.put("stdout", "") - ret.put("stderr", "YoutubeDL not initialized") + ret.put("stderr", notInitialized("YoutubeDL")) ret.put("exitCode", -1) invoke.resolve(ret) return @@ -315,7 +330,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { ret.put("stderr", result.err) ret.put("exitCode", 0) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("stdout", "") ret.put("stderr", e.message ?: "Unknown error") @@ -346,7 +361,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("version", ver ?: "") invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("version", "") invoke.resolve(ret) @@ -363,7 +378,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { if (!ensureInitialized()) { val ret = JSObject() ret.put("success", false) - ret.put("message", "YoutubeDL not initialized") + ret.put("message", notInitialized("YoutubeDL")) invoke.resolve(ret) return } @@ -377,7 +392,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { ret.put("success", true) ret.put("message", "Updated successfully") invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("success", false) ret.put("message", e.message ?: "Update failed") @@ -400,7 +415,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("ok", true) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("ok", false) ret.put("message", e.message ?: "startKeepAlive failed") @@ -421,7 +436,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("ok", true) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("ok", false) ret.put("message", e.message ?: "updateKeepAlive failed") @@ -436,7 +451,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("ok", true) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("ok", false) ret.put("message", e.message ?: "stopKeepAlive failed") @@ -444,6 +459,87 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { } } + /** + * Open a local file in another app (video player, gallery, …) via + * ACTION_VIEW. Android forbids file:// URIs across apps, so the file is + * shared through the app's FileProvider (`${applicationId}.fileprovider`). + */ + @Command + fun openFile(invoke: Invoke) { + val args = invoke.parseArgs(OpenFileArgs::class.java) + val ret = JSObject() + try { + val file = File(args.path) + if (!file.isFile) { + ret.put("ok", false) + ret.put("message", "File not found: ${args.path}") + invoke.resolve(ret) + return + } + val uri = androidx.core.content.FileProvider.getUriForFile( + activity, + "${activity.packageName}.fileprovider", + file, + ) + val mime = args.mime.ifBlank { + android.webkit.MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(file.extension.lowercase()) ?: "video/*" + } + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + activity.startActivity(android.content.Intent.createChooser(intent, "Open with")) + ret.put("ok", true) + } catch (e: Throwable) { + Log.w("YtDlpPlugin", "openFile failed for ${args.path}", e) + ret.put("ok", false) + ret.put("message", e.message ?: e.javaClass.simpleName) + } + invoke.resolve(ret) + } + + /** + * Video fullscreen. wry's WebChromeClient cancels HTML5 fullscreen + * (onShowCustomView), so the web player expands itself and asks for an + * immersive window here: system bars hidden (swipe to peek) and, for + * landscape video, a sensor-landscape orientation lock. + */ + @Command + fun setImmersive(invoke: Invoke) { + val args = invoke.parseArgs(ImmersiveArgs::class.java) + activity.runOnUiThread { + val ret = JSObject() + try { + val window = activity.window + val controller = androidx.core.view.WindowCompat.getInsetsController( + window, + window.decorView, + ) + if (args.enabled) { + controller.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat + .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + activity.requestedOrientation = if (args.landscape) { + android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } else { + android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } else { + controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + activity.requestedOrientation = + android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + ret.put("ok", true) + } catch (e: Throwable) { + Log.w("YtDlpPlugin", "setImmersive failed", e) + ret.put("ok", false) + ret.put("message", e.message ?: e.javaClass.simpleName) + } + invoke.resolve(ret) + } + } + /** Schedule WorkManager to wake the app when network is available. */ @Command fun schedulePendingResume(invoke: Invoke) { @@ -453,7 +549,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("ok", true) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("ok", false) ret.put("message", e.message ?: "schedulePendingResume failed") @@ -468,7 +564,7 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("ok", true) invoke.resolve(ret) - } catch (e: Exception) { + } catch (e: Throwable) { val ret = JSObject() ret.put("ok", false) ret.put("message", e.message ?: "cancelPendingResume failed") @@ -533,6 +629,16 @@ class YtDlpPlugin(private val activity: Activity) : Plugin(activity) { var requireUnmetered: Boolean = false, ) + class ImmersiveArgs @JvmOverloads constructor( + var enabled: Boolean = false, + var landscape: Boolean = true, + ) + + class OpenFileArgs @JvmOverloads constructor( + var path: String = "", + var mime: String = "", + ) + class ShareFileArgs @JvmOverloads constructor( var filename: String = "archhive-backup.json", var text: String = "", diff --git a/src-tauri/android-overlays/file_paths.xml b/src-tauri/android-overlays/file_paths.xml new file mode 100644 index 0000000..a99fa9b --- /dev/null +++ b/src-tauri/android-overlays/file_paths.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src-tauri/android-overlays/proguard-ytdlp.pro b/src-tauri/android-overlays/proguard-ytdlp.pro index 49701ac..02c7780 100644 --- a/src-tauri/android-overlays/proguard-ytdlp.pro +++ b/src-tauri/android-overlays/proguard-ytdlp.pro @@ -8,6 +8,12 @@ -keep class com.archhive.app.** { *; } -keepattributes Signature,InnerClasses,EnclosingMethod +# Plugin argument classes (YtDlpPlugin$Args, $MediaArgs, CookieBrowserPlugin$OpenArgs, ...): +# Tauri builds them reflectively from JSON via Jackson, so R8 must keep their +# no-arg constructors and fields. Without this, release builds fail with +# "Cannot construct instance of YtDlpPlugin$Args (no Creators ...)". +-keep class com.archhive.app.**Args { *; } + # Tauri plugin framework (annotations + Plugin base used by register_android_plugin) -keep class app.tauri.** { *; } -keep @app.tauri.annotation.TauriPlugin class * { *; } @@ -26,3 +32,10 @@ # with "class ...AsiExtraField is not a concrete class" (seen v0.6.0, OnePlus 7T). -keep class org.apache.commons.compress.** { *; } -dontwarn org.apache.commons.compress.** +# ...and the libraries it drives reflectively during YoutubeDL.init / +# FFmpeg.init (zip unpacking, JSON). +-keep class org.apache.commons.io.** { *; } +-keep class com.fasterxml.jackson.** { *; } +-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod +-dontwarn org.apache.commons.io.** +-dontwarn com.fasterxml.jackson.** diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c0acd7b..91c250d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -38,14 +38,31 @@ pub async fn browse( slug: String, page: Option, orientation: Option, + filters: Option, ) -> CmdResult { map_err( state - .browse(&site_id, kind, &slug, page.unwrap_or(1), orientation) + .browse( + &site_id, + kind, + &slug, + page.unwrap_or(1), + orientation, + filters.unwrap_or_default(), + ) .await, ) } +#[tauri::command] +pub async fn list_site_categories( + state: State<'_, Arc>, + site_id: String, + group: Option, +) -> CmdResult> { + map_err(state.list_site_categories(&site_id, group.as_deref()).await) +} + #[tauri::command] pub fn put_browse_cache( state: State<'_, Arc>, @@ -417,6 +434,27 @@ pub fn save_site_cookies( map_err(state.save_site_cookies(&site_id, &cookies)) } +/// Open the in-app login browser for a site and return the cookies captured +/// once the user finishes (the frontend stores them via `save_site_cookies`, +/// which also covers Remote LAN hosts). Async: reading webview cookies from a +/// synchronous command deadlocks on Windows. +#[tauri::command] +pub async fn cookie_login( + app: AppHandle, + state: State<'_, Arc>, + site_id: String, +) -> CmdResult { + let (display_name, base_url) = map_err(state.site_login_info(&site_id))?; + let target = crate::cookie_login::login_target(&site_id, &base_url); + map_err(crate::cookie_login::capture(&app, &site_id, &display_name, &target).await) +} + +/// Desktop: save (`save = true`) or cancel the open login window. +#[tauri::command] +pub fn cookie_login_finish(app: AppHandle, save: bool) -> CmdResult<()> { + map_err(crate::cookie_login::finish(&app, save)) +} + #[tauri::command] pub fn delete_site_cookies(state: State<'_, Arc>, site_id: String) -> CmdResult<()> { map_err(state.delete_site_cookies(&site_id)) @@ -431,7 +469,9 @@ pub fn catch_login_cookies(app: AppHandle, site_id: String) -> CmdResult<()> { .ok_or_else(|| { format!("No known login page for '{site_id}'. Use the DevTools / JSON import instead.") })?; - map_err(crate::mobile::cookie_harvest::open_login(&app, &site_id, &url)) + map_err(crate::mobile::cookie_harvest::open_login( + &app, &site_id, &url, + )) } /// Take + clear the in-app login window state. @@ -596,14 +636,23 @@ pub async fn open_scene_with_default( state: State<'_, Arc>, id: String, ) -> CmdResult<()> { - use tauri_plugin_opener::OpenerExt; let scene = map_err(state.get_scene(&id))?; let path = scene .path .ok_or_else(|| "Scene has no file on disk".to_string())?; - app.opener() - .open_path(&path, None::<&str>) - .map_err(|e| e.to_string()) + // The opener plugin's open_path does nothing useful on Android (needs a + // content:// URI); hand the file to other apps via our FileProvider. + #[cfg(target_os = "android")] + { + map_err(crate::mobile::ytdlp_bridge::open_file(&app, &path)) + } + #[cfg(not(target_os = "android"))] + { + use tauri_plugin_opener::OpenerExt; + app.opener() + .open_path(&path, None::<&str>) + .map_err(|e| e.to_string()) + } } #[tauri::command] @@ -1354,3 +1403,12 @@ pub async fn load_demo_scene(state: State<'_, Arc>) -> CmdResult CmdResult<()> { + map_err(crate::mobile::ytdlp_bridge::set_immersive( + &app, enabled, landscape, + )) +} diff --git a/src-tauri/src/cookie_login.rs b/src-tauri/src/cookie_login.rs new file mode 100644 index 0000000..dcae345 --- /dev/null +++ b/src-tauri/src/cookie_login.rs @@ -0,0 +1,507 @@ +//! In-app browser login that captures a site's cookies. +//! +//! Instead of exporting cookies from an external browser, the user logs in +//! inside a browser ArcHive controls and the resulting cookies (including +//! HttpOnly session cookies that page JavaScript cannot see) are converted to +//! Netscape format for the cookie vault / yt-dlp `--cookies`. +//! +//! * Desktop: a secondary `WebviewWindow` (no IPC capability) is opened on the +//! site; closing it, or pressing "Save cookies" in the app, reads the +//! cookies via `Webview::cookies_for_url`. +//! * Android: the `CookieBrowserPlugin` Kotlin overlay shows a full-screen +//! WebView and returns `CookieManager` cookie headers when the user taps +//! "Save cookies". Other apps' cookie jars (e.g. Chrome's) are not readable +//! on Android, which is why the login happens in-app. + +use crate::error::{AppError, AppResult}; +use serde::Serialize; +use tauri::plugin::{Builder, TauriPlugin}; +use tauri::{AppHandle, Runtime}; + +/// Lifetime given to session / undated cookies so yt-dlp and the vault keep them. +const SESSION_COOKIE_TTL_SECS: i64 = 30 * 24 * 3600; +/// Fallback lifetime for header-only cookies (Android `CookieManager` hides expiry). +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +const HEADER_COOKIE_TTL_SECS: i64 = 365 * 24 * 3600; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedCookie { + pub domain: String, + pub include_subdomains: bool, + pub path: String, + pub secure: bool, + pub expires: i64, + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CookieLoginResult { + pub site_id: String, + /// Netscape cookie file contents, ready for `save_site_cookies`. + pub netscape: String, + pub count: usize, +} + +/// Where the login browser starts and which URLs' cookies to collect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoginTarget { + pub start_url: String, + pub cookie_urls: Vec, +} + +pub fn login_target(site_id: &str, base_url: &str) -> LoginTarget { + let base = format!("{}/", base_url.trim_end_matches('/')); + let (start, extra): (&str, &[&str]) = match site_id { + "reddit" => ( + "https://www.reddit.com/login/", + &[ + "https://www.reddit.com/", + "https://old.reddit.com/", + "https://reddit.com/", + ], + ), + "twitter" => ( + "https://x.com/i/flow/login", + &["https://x.com/", "https://twitter.com/"], + ), + "instagram" => ("https://www.instagram.com/accounts/login/", &[]), + "tiktok" => ("https://www.tiktok.com/login", &[]), + "youtube" => ( + "https://accounts.google.com/ServiceLogin?service=youtube&continue=https%3A%2F%2Fwww.youtube.com%2F", + &["https://www.youtube.com/"], + ), + "redgifs" => ("https://www.redgifs.com/login", &[]), + _ => ("", &[]), + }; + let mut cookie_urls = vec![base.clone()]; + for u in extra { + if !cookie_urls.iter().any(|c| c == u) { + cookie_urls.push((*u).to_string()); + } + } + LoginTarget { + start_url: if start.is_empty() { + base + } else { + start.to_string() + }, + cookie_urls, + } +} + +/// Netscape cookie file text. HttpOnly cookies are written as ordinary lines +/// (the `#HttpOnly_` prefix would be dropped by simple parsers). +pub fn to_netscape(cookies: &[CapturedCookie]) -> String { + let mut out = + String::from("# Netscape HTTP Cookie File\n# Captured by ArcHive login browser\n\n"); + for c in cookies { + out.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\n", + c.domain, + if c.include_subdomains { + "TRUE" + } else { + "FALSE" + }, + c.path, + if c.secure { "TRUE" } else { "FALSE" }, + c.expires, + c.name, + c.value + )); + } + out +} + +/// Drop duplicates (same domain/path/name) keeping the first occurrence, and +/// cookies whose name/value would corrupt the tab-separated format. +pub fn dedupe(cookies: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + cookies + .into_iter() + .filter(|c| { + !c.name.is_empty() + && !c.name.contains(['\t', '\n', '\r', ';', '=']) + && !c.value.contains(['\t', '\n', '\r']) + }) + .filter(|c| seen.insert((c.domain.clone(), c.path.clone(), c.name.clone()))) + .collect() +} + +/// Cookies from a `Cookie:`-style header (`a=1; b=2`) captured for `url`. +/// The header carries no domain/expiry, so cookies are scoped to the site's +/// registrable-ish host (`www.` stripped, subdomains included). +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub fn cookies_from_header(url: &str, header: &str, now: i64) -> Vec { + let Ok(parsed) = url::Url::parse(url) else { + return Vec::new(); + }; + let Some(host) = parsed.host_str() else { + return Vec::new(); + }; + let domain = format!(".{}", host.trim_start_matches("www.")); + let secure = parsed.scheme() == "https"; + header + .split(';') + .filter_map(|pair| { + let (name, value) = pair.split_once('=')?; + let name = name.trim(); + (!name.is_empty()).then(|| CapturedCookie { + domain: domain.clone(), + include_subdomains: true, + path: "/".to_string(), + secure, + expires: now + HEADER_COOKIE_TTL_SECS, + name: name.to_string(), + value: value.trim().to_string(), + }) + }) + .collect() +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Open the login browser for `site_id`, wait for the user to finish, and +/// return the captured cookies as a Netscape file. +pub async fn capture( + app: &AppHandle, + site_id: &str, + display_name: &str, + target: &LoginTarget, +) -> AppResult { + let title = format!("Log in to {display_name}, then save cookies"); + let cookies = dedupe(platform::capture(app, &title, target).await?); + if cookies.is_empty() { + return Err(AppError::Site(format!( + "No cookies were captured for {display_name}. Make sure the page finished loading and you are logged in." + ))); + } + Ok(CookieLoginResult { + site_id: site_id.to_string(), + count: cookies.len(), + netscape: to_netscape(&cookies), + }) +} + +/// Desktop: finish (save=true) or cancel the open login window. +pub fn finish(app: &AppHandle, save: bool) -> AppResult<()> { + platform::finish(app, save) +} + +pub fn init() -> TauriPlugin { + Builder::new("cookie-browser") + .setup(|app, api| { + platform::setup(app, api); + Ok(()) + }) + .build() +} + +#[cfg(desktop)] +mod platform { + use super::*; + use std::sync::Mutex; + use tauri::{Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; + use tokio::sync::mpsc; + + const LABEL: &str = "cookie-login"; + + #[derive(Default)] + pub struct DesktopLoginState { + signal: Mutex>>, + } + + pub fn setup(app: &AppHandle, _api: tauri::plugin::PluginApi) { + app.manage(DesktopLoginState::default()); + } + + fn send(app: &AppHandle, save: bool) -> bool { + app.try_state::() + .and_then(|s| s.signal.lock().ok().and_then(|g| g.clone())) + .is_some_and(|tx| tx.send(save).is_ok()) + } + + pub fn finish(app: &AppHandle, save: bool) -> AppResult<()> { + if send(app, save) { + Ok(()) + } else { + Err(AppError::Other("No login window is open.".into())) + } + } + + pub async fn capture( + app: &AppHandle, + title: &str, + target: &LoginTarget, + ) -> AppResult> { + if let Some(existing) = app.get_webview_window(LABEL) { + send(app, false); + let _ = existing.destroy(); + } + let start: url::Url = target + .start_url + .parse() + .map_err(|e| AppError::InvalidInput(format!("bad login url: {e}")))?; + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let state = app + .try_state::() + .ok_or_else(|| AppError::Other("cookie-browser plugin not initialized".into()))?; + if let Ok(mut slot) = state.signal.lock() { + *slot = Some(tx.clone()); + } + + let window = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::External(start)) + .title(title) + .inner_size(1024.0, 780.0) + .focused(true) + .build() + .map_err(|e| AppError::Other(format!("could not open login window: {e}")))?; + // Closing the window means "done": keep it alive until cookies are read. + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = tx.send(true); + } + }); + + let save = rx.recv().await.unwrap_or(false); + if let Ok(mut slot) = state.signal.lock() { + *slot = None; + } + + let mut out = Vec::new(); + if save { + let now = now_unix(); + for u in &target.cookie_urls { + let Ok(parsed) = url::Url::parse(u) else { + continue; + }; + let host = parsed.host_str().unwrap_or_default().to_string(); + // Must run off the main thread (Windows deadlocks otherwise); + // this is an async command task, so that holds. + let cookies = match window.cookies_for_url(parsed) { + Ok(c) => c, + Err(e) => { + tracing::warn!("[cookie-login] cookies_for_url({u}) failed: {e}"); + continue; + } + }; + for c in cookies { + let (domain, include_subdomains) = match c.domain() { + Some(d) if !d.is_empty() => { + let d = if d.starts_with('.') || d == host { + d.to_string() + } else { + format!(".{d}") + }; + let sub = d.starts_with('.'); + (d, sub) + } + _ => (host.clone(), false), + }; + let expires = c + .expires_datetime() + .map(|t| t.unix_timestamp()) + .filter(|t| *t > now) + .unwrap_or(now + SESSION_COOKIE_TTL_SECS); + out.push(CapturedCookie { + domain, + include_subdomains, + path: c + .path() + .filter(|p| !p.is_empty()) + .unwrap_or("/") + .to_string(), + secure: c.secure().unwrap_or(false), + expires, + name: c.name().to_string(), + value: c.value().to_string(), + }); + } + } + } + let _ = window.destroy(); + if !save { + return Err(AppError::Other("Login cancelled.".into())); + } + Ok(out) + } +} + +#[cfg(target_os = "android")] +mod platform { + use super::*; + use serde::Deserialize; + use tauri::plugin::PluginHandle; + use tauri::Manager; + + pub struct AndroidLoginState { + handle: Option>, + } + + #[derive(Deserialize)] + struct Entry { + url: String, + header: String, + } + + #[derive(Deserialize)] + struct Response { + #[serde(default)] + cookies: Vec, + } + + pub fn setup(app: &AppHandle, api: tauri::plugin::PluginApi) { + let handle = match api.register_android_plugin("com.archhive.app", "CookieBrowserPlugin") { + Ok(h) => Some(h), + Err(e) => { + tracing::warn!("CookieBrowserPlugin registration failed: {e}"); + None + } + }; + app.manage(AndroidLoginState { handle }); + } + + pub fn finish(_app: &AppHandle, _save: bool) -> AppResult<()> { + // The native dialog has its own Save / Cancel buttons. + Ok(()) + } + + pub async fn capture( + app: &AppHandle, + title: &str, + target: &LoginTarget, + ) -> AppResult> { + let handle = app + .try_state::>() + .and_then(|s| s.handle.clone()) + .ok_or_else(|| { + AppError::Other( + "Login browser unavailable (CookieBrowserPlugin not registered). Re-run android:patches and rebuild the APK." + .into(), + ) + })?; + let payload = serde_json::json!({ + "url": target.start_url, + "cookieUrls": target.cookie_urls, + "title": title, + }); + // Blocks until the user taps Save / Cancel: keep it off the async workers. + let response: Response = tokio::task::spawn_blocking(move || { + handle.run_mobile_plugin::("open", payload) + }) + .await + .map_err(|e| AppError::Other(format!("login browser task failed: {e}")))? + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("cancelled") { + AppError::Other("Login cancelled.".into()) + } else { + AppError::Other(format!("Login browser failed: {msg}")) + } + })?; + let now = now_unix(); + Ok(response + .cookies + .iter() + .flat_map(|e| cookies_from_header(&e.url, &e.header, now)) + .collect()) + } +} + +#[cfg(all(mobile, not(target_os = "android")))] +mod platform { + use super::*; + + pub fn setup(_app: &AppHandle, _api: tauri::plugin::PluginApi) {} + + pub fn finish(_app: &AppHandle, _save: bool) -> AppResult<()> { + Ok(()) + } + + pub async fn capture( + _app: &AppHandle, + _title: &str, + _target: &LoginTarget, + ) -> AppResult> { + Err(AppError::Other( + "The login browser is not available on this platform yet.".into(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_cookies_scope_to_site_domain() { + let c = cookies_from_header( + "https://www.reddit.com/", + "reddit_session=abc; token_v2=x=y", + 100, + ); + assert_eq!(c.len(), 2); + assert_eq!(c[0].domain, ".reddit.com"); + assert!(c[0].include_subdomains && c[0].secure); + assert_eq!(c[0].expires, 100 + HEADER_COOKIE_TTL_SECS); + assert_eq!(c[1].name, "token_v2"); + assert_eq!(c[1].value, "x=y"); + } + + #[test] + fn netscape_output_round_trips_through_vault_format() { + let cookies = dedupe(vec![ + CapturedCookie { + domain: ".example.com".into(), + include_subdomains: true, + path: "/".into(), + secure: true, + expires: 2_000_000_000, + name: "sid".into(), + value: "v1".into(), + }, + CapturedCookie { + domain: ".example.com".into(), + include_subdomains: true, + path: "/".into(), + secure: true, + expires: 2_000_000_000, + name: "sid".into(), + value: "dupe".into(), + }, + CapturedCookie { + domain: "example.com".into(), + include_subdomains: false, + path: "/".into(), + secure: false, + expires: 1, + name: "bad\tname".into(), + value: "x".into(), + }, + ]); + assert_eq!(cookies.len(), 1); + let text = to_netscape(&cookies); + assert!(text.starts_with("# Netscape HTTP Cookie File")); + assert!(text.contains(".example.com\tTRUE\t/\tTRUE\t2000000000\tsid\tv1\n")); + } + + #[test] + fn login_targets() { + let r = login_target("reddit", "https://old.reddit.com"); + assert_eq!(r.start_url, "https://www.reddit.com/login/"); + assert!(r + .cookie_urls + .contains(&"https://old.reddit.com/".to_string())); + let x = login_target("xvideos", "https://www.xvideos.com"); + assert_eq!(x.start_url, "https://www.xvideos.com/"); + assert_eq!(x.cookie_urls, vec!["https://www.xvideos.com/".to_string()]); + } +} diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index c8934bb..5c67df0 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -1,279 +1,285 @@ -pub const MIGRATION_001: &str = r#" -CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS download_jobs ( - id TEXT PRIMARY KEY, - url TEXT NOT NULL, - adapter TEXT NOT NULL, - status TEXT NOT NULL, - progress REAL NOT NULL DEFAULT 0, - output_path TEXT, - error TEXT, - title TEXT, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS performers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - aliases TEXT NOT NULL DEFAULT '[]', - image TEXT, - favorite INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - parent_id TEXT -); - -CREATE TABLE IF NOT EXISTS scenes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - path TEXT, - source_url TEXT, - thumb TEXT, - phash TEXT, - oshash TEXT, - duration INTEGER, - studio_id TEXT, - date TEXT, - rating INTEGER, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS scene_performers ( - scene_id TEXT NOT NULL, - performer_id TEXT NOT NULL, - PRIMARY KEY (scene_id, performer_id) -); - -CREATE TABLE IF NOT EXISTS scene_tags ( - scene_id TEXT NOT NULL, - tag_id TEXT NOT NULL, - PRIMARY KEY (scene_id, tag_id) -); - -CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5( - title, - notes, - content='scenes', - content_rowid='rowid' -); - -CREATE TRIGGER IF NOT EXISTS scenes_ai AFTER INSERT ON scenes BEGIN - INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); -END; - -CREATE TRIGGER IF NOT EXISTS scenes_ad AFTER DELETE ON scenes BEGIN - INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); -END; - -CREATE TRIGGER IF NOT EXISTS scenes_au AFTER UPDATE ON scenes BEGIN - INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); - INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); -END; -"#; - -pub const MIGRATION_002: &str = r#" -CREATE TABLE IF NOT EXISTS site_cookies ( - site_id TEXT PRIMARY KEY, - encrypted_data BLOB NOT NULL, - updated_at TEXT NOT NULL -); -"#; - -pub const MIGRATION_003: &str = r#" -ALTER TABLE download_jobs ADD COLUMN metadata TEXT; -"#; - -pub const MIGRATION_004: &str = r#" -ALTER TABLE scenes ADD COLUMN channel TEXT; -"#; - -pub const MIGRATION_005: &str = r#" -ALTER TABLE scenes ADD COLUMN file_size INTEGER; -"#; - -pub const MIGRATION_006: &str = r#" -ALTER TABLE scenes ADD COLUMN notes TEXT; -"#; - -pub const MIGRATION_007: &str = r#" -ALTER TABLE download_jobs ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0; -ALTER TABLE download_jobs ADD COLUMN last_retry_at TEXT; -"#; - -pub const MIGRATION_008: &str = r#" -ALTER TABLE scenes ADD COLUMN width INTEGER; -ALTER TABLE scenes ADD COLUMN height INTEGER; -"#; - -pub const MIGRATION_009: &str = r#" -CREATE TABLE IF NOT EXISTS watch_history ( - scene_id TEXT PRIMARY KEY, - position_secs REAL NOT NULL DEFAULT 0, - duration_secs REAL NOT NULL DEFAULT 0, - watched INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL -); -"#; - -pub const MIGRATION_010: &str = r#" -CREATE TABLE IF NOT EXISTS saved_searches ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - site_id TEXT NOT NULL, - kind TEXT NOT NULL, - slug TEXT NOT NULL, - orientation TEXT, - last_checked_at TEXT, - last_item_keys TEXT NOT NULL DEFAULT '[]', - new_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL -); -"#; - -pub const MIGRATION_011: &str = r#" -ALTER TABLE saved_searches ADD COLUMN auto_queue INTEGER NOT NULL DEFAULT 0; -"#; - -pub const MIGRATION_012: &str = r#" -CREATE TABLE IF NOT EXISTS watchlist_poll_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - finished_at TEXT NOT NULL, - checked INTEGER NOT NULL DEFAULT 0, - queued INTEGER NOT NULL DEFAULT 0, - errors INTEGER NOT NULL DEFAULT 0 -); -"#; - -pub const MIGRATION_013: &str = r#" -DROP TRIGGER IF EXISTS scenes_ai; -DROP TRIGGER IF EXISTS scenes_ad; -DROP TRIGGER IF EXISTS scenes_au; -DROP TABLE IF EXISTS scenes_fts; - -CREATE VIRTUAL TABLE scenes_fts USING fts5( - title, - notes, - content='scenes', - content_rowid='rowid' -); - -CREATE TRIGGER scenes_ai AFTER INSERT ON scenes BEGIN - INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); -END; - -CREATE TRIGGER scenes_ad AFTER DELETE ON scenes BEGIN - INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); -END; - -CREATE TRIGGER scenes_au AFTER UPDATE ON scenes BEGIN - INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); - INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); -END; - -INSERT INTO scenes_fts(rowid, title, notes) -SELECT rowid, title, notes FROM scenes; -"#; - -pub const MIGRATION_014: &str = r#" -CREATE TABLE IF NOT EXISTS collections ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'collection', - description TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS collection_scenes ( - collection_id TEXT NOT NULL, - scene_id TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL, - PRIMARY KEY (collection_id, scene_id), - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, - FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_collection_scenes_scene ON collection_scenes(scene_id); -CREATE INDEX IF NOT EXISTS idx_collection_scenes_order ON collection_scenes(collection_id, position); -"#; - -pub const MIGRATION_015: &str = r#" -ALTER TABLE collections ADD COLUMN filter_json TEXT; - -CREATE INDEX IF NOT EXISTS idx_collections_type ON collections(type); -"#; - -pub const MIGRATION_016: &str = r#" -CREATE TABLE IF NOT EXISTS browse_cache ( - cache_key TEXT PRIMARY KEY, - site_id TEXT NOT NULL, - kind TEXT NOT NULL, - slug TEXT NOT NULL, - page INTEGER NOT NULL, - orientation TEXT, - payload TEXT NOT NULL, - fetched_at TEXT NOT NULL, - ttl_secs INTEGER NOT NULL DEFAULT 86400 -); - -CREATE INDEX IF NOT EXISTS idx_browse_cache_fetched ON browse_cache(fetched_at); -"#; - -/// V2 BodyMatch: performer body attributes (cup size + hair color). -/// Values are validated in Rust (see `normalize_cup_size` / `normalize_hair_color`); -/// `NULL` means "unset". Old DBs get the columns via `column_exists` gating in -/// `Database::new` (bundled SQLite may not support `ADD COLUMN IF NOT EXISTS`). -pub const MIGRATION_017: &str = r#" -ALTER TABLE performers ADD COLUMN cup_size TEXT; -ALTER TABLE performers ADD COLUMN hair_color TEXT; - -CREATE INDEX IF NOT EXISTS idx_performers_cup ON performers(cup_size); -CREATE INDEX IF NOT EXISTS idx_performers_hair ON performers(hair_color); -"#; - -/// StashDB enrichment: remote stash-box ids + local studio cache. -/// `scenes.studio_name` mirrors the matched studio (kept denormalized so -/// library lists don't need a join). `vault_secrets` holds stash-box API -/// keys encrypted (same AES-256-GCM cipher as cookies). -pub const MIGRATION_018: &str = r#" -ALTER TABLE scenes ADD COLUMN stash_id TEXT; -ALTER TABLE scenes ADD COLUMN stashdb_updated_at TEXT; -ALTER TABLE scenes ADD COLUMN studio_name TEXT; -ALTER TABLE performers ADD COLUMN stash_id TEXT; - -CREATE TABLE IF NOT EXISTS studios ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - stash_id TEXT, - url TEXT, - image TEXT -); - -CREATE TABLE IF NOT EXISTS vault_secrets ( - key TEXT PRIMARY KEY, - encrypted_data BLOB NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_scenes_stash_id ON scenes(stash_id); -CREATE INDEX IF NOT EXISTS idx_performers_stash_id ON performers(stash_id); -CREATE INDEX IF NOT EXISTS idx_studios_stash_id ON studios(stash_id); -"#; - -/// StashDB fingerprint fix: `scenes.md5` (full-file MD5, StashDB's most common -/// fingerprint — previously never computed) plus cleanup of bogus `oshash` -/// rows. The old `compute_oshash` wrote a 64-char SHA-256 digest; real -/// OSHASH values are always exactly 16 hex chars, so anything else is -/// unreachable junk that would break batch Identify counts. -pub const MIGRATION_019: &str = r#" -ALTER TABLE scenes ADD COLUMN md5 TEXT; -UPDATE scenes SET oshash = NULL WHERE oshash IS NOT NULL AND length(oshash) != 16; -"#; +pub const MIGRATION_001: &str = r#" +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS download_jobs ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + adapter TEXT NOT NULL, + status TEXT NOT NULL, + progress REAL NOT NULL DEFAULT 0, + output_path TEXT, + error TEXT, + title TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS performers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + aliases TEXT NOT NULL DEFAULT '[]', + image TEXT, + favorite INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + parent_id TEXT +); + +CREATE TABLE IF NOT EXISTS scenes ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + path TEXT, + source_url TEXT, + thumb TEXT, + phash TEXT, + oshash TEXT, + duration INTEGER, + studio_id TEXT, + date TEXT, + rating INTEGER, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS scene_performers ( + scene_id TEXT NOT NULL, + performer_id TEXT NOT NULL, + PRIMARY KEY (scene_id, performer_id) +); + +CREATE TABLE IF NOT EXISTS scene_tags ( + scene_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (scene_id, tag_id) +); + +CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5( + title, + notes, + content='scenes', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS scenes_ai AFTER INSERT ON scenes BEGIN + INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); +END; + +CREATE TRIGGER IF NOT EXISTS scenes_ad AFTER DELETE ON scenes BEGIN + INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); +END; + +CREATE TRIGGER IF NOT EXISTS scenes_au AFTER UPDATE ON scenes BEGIN + INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); + INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); +END; +"#; + +pub const MIGRATION_002: &str = r#" +CREATE TABLE IF NOT EXISTS site_cookies ( + site_id TEXT PRIMARY KEY, + encrypted_data BLOB NOT NULL, + updated_at TEXT NOT NULL +); +"#; + +pub const MIGRATION_003: &str = r#" +ALTER TABLE download_jobs ADD COLUMN metadata TEXT; +"#; + +pub const MIGRATION_004: &str = r#" +ALTER TABLE scenes ADD COLUMN channel TEXT; +"#; + +pub const MIGRATION_005: &str = r#" +ALTER TABLE scenes ADD COLUMN file_size INTEGER; +"#; + +pub const MIGRATION_006: &str = r#" +ALTER TABLE scenes ADD COLUMN notes TEXT; +"#; + +pub const MIGRATION_007: &str = r#" +ALTER TABLE download_jobs ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE download_jobs ADD COLUMN last_retry_at TEXT; +"#; + +pub const MIGRATION_008: &str = r#" +ALTER TABLE scenes ADD COLUMN width INTEGER; +ALTER TABLE scenes ADD COLUMN height INTEGER; +"#; + +pub const MIGRATION_009: &str = r#" +CREATE TABLE IF NOT EXISTS watch_history ( + scene_id TEXT PRIMARY KEY, + position_secs REAL NOT NULL DEFAULT 0, + duration_secs REAL NOT NULL DEFAULT 0, + watched INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +); +"#; + +pub const MIGRATION_010: &str = r#" +CREATE TABLE IF NOT EXISTS saved_searches ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + site_id TEXT NOT NULL, + kind TEXT NOT NULL, + slug TEXT NOT NULL, + orientation TEXT, + last_checked_at TEXT, + last_item_keys TEXT NOT NULL DEFAULT '[]', + new_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +"#; + +pub const MIGRATION_011: &str = r#" +ALTER TABLE saved_searches ADD COLUMN auto_queue INTEGER NOT NULL DEFAULT 0; +"#; + +pub const MIGRATION_012: &str = r#" +CREATE TABLE IF NOT EXISTS watchlist_poll_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + finished_at TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 0, + queued INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0 +); +"#; + +pub const MIGRATION_013: &str = r#" +DROP TRIGGER IF EXISTS scenes_ai; +DROP TRIGGER IF EXISTS scenes_ad; +DROP TRIGGER IF EXISTS scenes_au; +DROP TABLE IF EXISTS scenes_fts; + +CREATE VIRTUAL TABLE scenes_fts USING fts5( + title, + notes, + content='scenes', + content_rowid='rowid' +); + +CREATE TRIGGER scenes_ai AFTER INSERT ON scenes BEGIN + INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); +END; + +CREATE TRIGGER scenes_ad AFTER DELETE ON scenes BEGIN + INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); +END; + +CREATE TRIGGER scenes_au AFTER UPDATE ON scenes BEGIN + INSERT INTO scenes_fts(scenes_fts, rowid, title, notes) VALUES('delete', old.rowid, old.title, old.notes); + INSERT INTO scenes_fts(rowid, title, notes) VALUES (new.rowid, new.title, new.notes); +END; + +INSERT INTO scenes_fts(rowid, title, notes) +SELECT rowid, title, notes FROM scenes; +"#; + +pub const MIGRATION_014: &str = r#" +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'collection', + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS collection_scenes ( + collection_id TEXT NOT NULL, + scene_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, scene_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, + FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_collection_scenes_scene ON collection_scenes(scene_id); +CREATE INDEX IF NOT EXISTS idx_collection_scenes_order ON collection_scenes(collection_id, position); +"#; + +pub const MIGRATION_015: &str = r#" +ALTER TABLE collections ADD COLUMN filter_json TEXT; + +CREATE INDEX IF NOT EXISTS idx_collections_type ON collections(type); +"#; + +pub const MIGRATION_016: &str = r#" +CREATE TABLE IF NOT EXISTS browse_cache ( + cache_key TEXT PRIMARY KEY, + site_id TEXT NOT NULL, + kind TEXT NOT NULL, + slug TEXT NOT NULL, + page INTEGER NOT NULL, + orientation TEXT, + payload TEXT NOT NULL, + fetched_at TEXT NOT NULL, + ttl_secs INTEGER NOT NULL DEFAULT 86400 +); + +CREATE INDEX IF NOT EXISTS idx_browse_cache_fetched ON browse_cache(fetched_at); +"#; + +/// V2 BodyMatch: performer body attributes (cup size + hair color). +/// Values are validated in Rust (see `normalize_cup_size` / `normalize_hair_color`); +/// `NULL` means "unset". Old DBs get the columns via `column_exists` gating in +/// `Database::new` (bundled SQLite may not support `ADD COLUMN IF NOT EXISTS`). +pub const MIGRATION_017: &str = r#" +ALTER TABLE performers ADD COLUMN cup_size TEXT; +ALTER TABLE performers ADD COLUMN hair_color TEXT; + +CREATE INDEX IF NOT EXISTS idx_performers_cup ON performers(cup_size); +CREATE INDEX IF NOT EXISTS idx_performers_hair ON performers(hair_color); +"#; + +/// StashDB enrichment: remote stash-box ids + local studio cache. +/// `scenes.studio_name` mirrors the matched studio (kept denormalized so +/// library lists don't need a join). `vault_secrets` holds stash-box API +/// keys encrypted (same AES-256-GCM cipher as cookies). +pub const MIGRATION_018: &str = r#" +ALTER TABLE scenes ADD COLUMN stash_id TEXT; +ALTER TABLE scenes ADD COLUMN stashdb_updated_at TEXT; +ALTER TABLE scenes ADD COLUMN studio_name TEXT; +ALTER TABLE performers ADD COLUMN stash_id TEXT; + +CREATE TABLE IF NOT EXISTS studios ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + stash_id TEXT, + url TEXT, + image TEXT +); + +CREATE TABLE IF NOT EXISTS vault_secrets ( + key TEXT PRIMARY KEY, + encrypted_data BLOB NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_scenes_stash_id ON scenes(stash_id); +CREATE INDEX IF NOT EXISTS idx_performers_stash_id ON performers(stash_id); +CREATE INDEX IF NOT EXISTS idx_studios_stash_id ON studios(stash_id); +"#; + +/// StashDB fingerprint fix: `scenes.md5` (full-file MD5, StashDB's most common +/// fingerprint — previously never computed) plus cleanup of bogus `oshash` +/// rows. The old `compute_oshash` wrote a 64-char SHA-256 digest; real +/// OSHASH values are always exactly 16 hex chars, so anything else is +/// unreachable junk that would break batch Identify counts. +pub const MIGRATION_019: &str = r#" +ALTER TABLE scenes ADD COLUMN md5 TEXT; +UPDATE scenes SET oshash = NULL WHERE oshash IS NOT NULL AND length(oshash) != 16; +"#; + +/// Saved searches remember the listing's sort / filters (JSON `BrowseFilters`), +/// so checks and re-opens run the same listing that was saved. +pub const MIGRATION_020: &str = r#" +ALTER TABLE saved_searches ADD COLUMN filters TEXT; +"#; diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 08e88a6..29254b1 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -4,7 +4,7 @@ use crate::db::migrations::{ MIGRATION_001, MIGRATION_002, MIGRATION_003, MIGRATION_004, MIGRATION_005, MIGRATION_006, MIGRATION_007, MIGRATION_008, MIGRATION_009, MIGRATION_010, MIGRATION_011, MIGRATION_012, MIGRATION_013, MIGRATION_014, MIGRATION_015, MIGRATION_016, MIGRATION_017, MIGRATION_018, - MIGRATION_019, + MIGRATION_019, MIGRATION_020, }; use crate::error::{AppError, AppResult}; use crate::models::{ @@ -166,6 +166,9 @@ impl Database { if !column_exists(&conn, "scenes", "md5") { conn.execute_batch(MIGRATION_019)?; } + if !column_exists(&conn, "saved_searches", "filters") { + conn.execute_batch(MIGRATION_020)?; + } // Backfill the default StashDB endpoint for DBs created before it // existed (fresh DBs get it via AppSettings::default). { @@ -1069,6 +1072,15 @@ impl Database { BrowseOrientation::Lesbian => "lesbian", BrowseOrientation::Transgender => "transgender", }); + let filters = req.filters.clone().normalized(); + let filters_json = if filters.is_empty() { + None + } else { + Some( + serde_json::to_string(&filters) + .map_err(|e| AppError::Other(format!("filters serialize: {e}")))?, + ) + }; let conn = self .conn .lock() @@ -1076,8 +1088,8 @@ impl Database { let id = uuid::Uuid::new_v4().to_string(); let created_at = chrono::Utc::now().to_rfc3339(); conn.execute( - "INSERT INTO saved_searches (id, name, site_id, kind, slug, orientation, last_checked_at, last_item_keys, new_count, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '[]', 0, ?7)", + "INSERT INTO saved_searches (id, name, site_id, kind, slug, orientation, last_checked_at, last_item_keys, new_count, created_at, filters) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '[]', 0, ?7, ?8)", rusqlite::params![ id, name, @@ -1085,7 +1097,8 @@ impl Database { kind_str, req.slug.trim(), orientation_str, - created_at + created_at, + filters_json ], )?; Ok(crate::models::SavedSearch { @@ -1095,6 +1108,7 @@ impl Database { kind: req.kind, slug: req.slug.trim().to_string(), orientation: req.orientation, + filters, last_checked_at: None, new_count: 0, auto_queue: false, @@ -1134,6 +1148,11 @@ impl Database { new_count: row.get::<_, i64>(8).unwrap_or(0).max(0) as u32, auto_queue: row.get::<_, i64>(10).unwrap_or(0) != 0, created_at: row.get(9)?, + // Unreadable filter JSON degrades to the site defaults. + filters: row + .get::<_, Option>(11)? + .and_then(|j| serde_json::from_str(&j).ok()) + .unwrap_or_default(), }) } @@ -1143,7 +1162,7 @@ impl Database { .lock() .map_err(|e| AppError::Other(e.to_string()))?; let mut stmt = conn.prepare( - "SELECT id, name, site_id, kind, slug, orientation, last_checked_at, last_item_keys, new_count, created_at, auto_queue + "SELECT id, name, site_id, kind, slug, orientation, last_checked_at, last_item_keys, new_count, created_at, auto_queue, filters FROM saved_searches ORDER BY created_at DESC", )?; let rows = stmt.query_map([], Self::map_saved_search_row)?; @@ -1157,7 +1176,7 @@ impl Database { .map_err(|e| AppError::Other(e.to_string()))?; let row = conn .query_row( - "SELECT id, name, site_id, kind, slug, orientation, last_checked_at, last_item_keys, new_count, created_at, auto_queue + "SELECT id, name, site_id, kind, slug, orientation, last_checked_at, last_item_keys, new_count, created_at, auto_queue, filters FROM saved_searches WHERE id = ?1", rusqlite::params![id], Self::map_saved_search_row, @@ -2733,27 +2752,42 @@ impl Database { let new_notes = notes.unwrap_or(existing.notes.as_deref().unwrap_or("")); let mut new_path = existing.path.clone(); + let mut new_thumb: Option = None; if rename_file { - if let Some(ref old_path) = existing.path { - let old = std::path::Path::new(old_path); - if old.exists() { - let ext = old.extension().and_then(|e| e.to_str()).unwrap_or("mp4"); - let safe: String = new_title - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ' { - c - } else { - '_' - } - }) - .collect(); - let safe = safe.trim().replace(' ', "_"); - let parent = old.parent().unwrap_or_else(|| std::path::Path::new(".")); - let target = parent.join(format!("{safe}.{ext}")); - if target != old { - std::fs::rename(old, &target)?; - new_path = Some(target.to_string_lossy().to_string()); + let old_path = existing + .path + .as_deref() + .ok_or_else(|| AppError::InvalidInput("scene has no file to rename".into()))?; + let old = std::path::Path::new(old_path); + if !old.is_file() { + return Err(AppError::NotFound(format!("file not found: {old_path}"))); + } + let ext = old.extension().and_then(|e| e.to_str()).unwrap_or("mp4"); + let target = old.with_file_name(format!("{}.{ext}", safe_file_stem(new_title))); + if target != old { + // fs::rename silently replaces an existing file; never clobber + // another video that already has this name. + if target.exists() { + return Err(AppError::InvalidInput(format!( + "a file named {} already exists", + target.file_name().and_then(|n| n.to_str()).unwrap_or("") + ))); + } + std::fs::rename(old, &target) + .map_err(|e| AppError::Other(format!("could not rename {old_path}: {e}")))?; + new_path = Some(target.to_string_lossy().to_string()); + // Keep the `.jpg` thumbnail sidecar next to the video. + if let Some(thumb) = existing.thumb.as_deref() { + let thumb = std::path::Path::new(thumb); + if thumb.parent() == old.parent() + && thumb.file_stem() == old.file_stem() + && thumb.is_file() + { + let thumb_ext = thumb.extension().and_then(|e| e.to_str()).unwrap_or("jpg"); + let thumb_target = target.with_extension(thumb_ext); + if !thumb_target.exists() && std::fs::rename(thumb, &thumb_target).is_ok() { + new_thumb = Some(thumb_target.to_string_lossy().to_string()); + } } } } @@ -2764,8 +2798,8 @@ impl Database { .lock() .map_err(|e| AppError::Other(e.to_string()))?; conn.execute( - "UPDATE scenes SET title = ?2, path = COALESCE(?3, path), notes = ?4, rating = COALESCE(?5, rating) WHERE id = ?1", - params![id, new_title, new_path, new_notes, rating], + "UPDATE scenes SET title = ?2, path = COALESCE(?3, path), notes = ?4, rating = COALESCE(?5, rating), thumb = COALESCE(?6, thumb) WHERE id = ?1", + params![id, new_title, new_path, new_notes, rating, new_thumb], )?; drop(conn); @@ -2983,6 +3017,27 @@ fn parse_status(s: &str) -> DownloadStatus { } } +/// File-name stem for "rename file to match title": ASCII letters, digits, +/// `-` and `_` kept, everything else `_`, spaces collapsed to `_`. +fn safe_file_stem(title: &str) -> String { + let safe: String = title + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ' { + c + } else { + '_' + } + }) + .collect(); + let safe = safe.trim().replace(' ', "_"); + if safe.is_empty() { + "untitled".to_string() + } else { + safe + } +} + #[cfg(test)] mod tests { use super::*; @@ -3005,6 +3060,64 @@ mod tests { .unwrap() } + #[test] + fn rename_file_moves_thumb_and_never_overwrites() { + let dir = tempdir().unwrap(); + let db = Database::new(dir.path().to_path_buf()).unwrap(); + let video = dir.path().join("abc123.mp4"); + let thumb = dir.path().join("abc123.jpg"); + std::fs::write(&video, b"v").unwrap(); + std::fs::write(&thumb, b"t").unwrap(); + let id = db + .insert_scene( + "abc123", + Some(video.to_str().unwrap()), + None, + &[], + &[], + Some(thumb.to_str().unwrap()), + None, + None, + None, + None, + None, + ) + .unwrap(); + + let scene = db + .update_scene(&id, Some("My Clip!"), None, None, true, None, None) + .unwrap(); + let renamed = dir.path().join("My_Clip_.mp4"); + assert_eq!(scene.path.as_deref(), renamed.to_str()); + assert!(renamed.is_file() && !video.exists()); + assert!(dir.path().join("My_Clip_.jpg").is_file()); + + // A second scene renamed to the same title must not clobber the first. + let other = dir.path().join("other.mp4"); + std::fs::write(&other, b"o").unwrap(); + let id2 = db + .insert_scene( + "other", + Some(other.to_str().unwrap()), + None, + &[], + &[], + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + let err = db + .update_scene(&id2, Some("My Clip!"), None, None, true, None, None) + .unwrap_err(); + assert!(err.to_string().contains("already exists")); + assert_eq!(std::fs::read(&renamed).unwrap(), b"v"); + assert!(other.is_file()); + } + #[test] fn watch_progress_round_trip() { let dir = tempdir().unwrap(); @@ -3083,9 +3196,20 @@ mod tests { kind: crate::models::BrowseKind::Tag, slug: "abc".to_string(), orientation: None, + filters: crate::models::BrowseFilters { + sort: Some("mv".into()), + exclude: Some("35".into()), + period: Some(" ".into()), + ..Default::default() + }, }) .unwrap(); assert_eq!(saved.name, "test"); + // Filters round-trip (blank values dropped). + let stored = db.get_saved_search(&saved.id).unwrap().unwrap(); + assert_eq!(stored.filters.sort.as_deref(), Some("mv")); + assert_eq!(stored.filters.exclude.as_deref(), Some("35")); + assert_eq!(stored.filters.period, None); assert!(!saved.auto_queue); assert_eq!(saved.new_count, 0); @@ -3097,6 +3221,7 @@ mod tests { kind: crate::models::BrowseKind::Tag, slug: "abc".to_string(), orientation: None, + filters: Default::default(), }) .is_err()); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index afd4672..2c3959f 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -1,25 +1,64 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum AppError { - #[error("database error: {0}")] - Db(#[from] rusqlite::Error), - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("image error: {0}")] - Image(#[from] image::ImageError), - #[error("not found: {0}")] - NotFound(String), - #[error("invalid input: {0}")] - InvalidInput(String), - #[error("download error: {0}")] - Download(String), - #[error("site error: {0}")] - Site(String), - #[error("{0}")] - Other(String), -} - -pub type AppResult = Result; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("database error: {0}")] + Db(#[from] rusqlite::Error), + #[error("http error: {}", reqwest_chain(.0))] + Http(#[from] reqwest::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("image error: {0}")] + Image(#[from] image::ImageError), + #[error("not found: {0}")] + NotFound(String), + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("download error: {0}")] + Download(String), + #[error("site error: {0}")] + Site(String), + #[error("{0}")] + Other(String), +} + +pub type AppResult = Result; + +/// reqwest's own message ("error sending request for url (…)") hides the +/// cause; append the source chain (DNS / connect / TLS / timeout detail) so +/// field reports say what actually failed. +fn reqwest_chain(e: &reqwest::Error) -> String { + let mut out = e.to_string(); + let mut source = std::error::Error::source(e); + while let Some(cause) = source { + let text = cause.to_string(); + if !out.contains(&text) { + out.push_str(": "); + out.push_str(&text); + } + source = cause.source(); + } + out +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn http_errors_include_the_cause() { + // Port 9 on localhost refuses connections: the message must say so. + let err = reqwest::Client::new() + .get("http://127.0.0.1:9/") + .send() + .await + .unwrap_err(); + let msg = super::AppError::from(err).to_string(); + assert!( + msg.starts_with("http error: error sending request"), + "{msg}" + ); + assert!( + msg.len() > "http error: error sending request for url (http://127.0.0.1:9/)".len(), + "{msg}" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d88526..0a19e9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod cookie_login; mod db; mod discovery; mod downloads; @@ -96,6 +97,12 @@ fn bootstrap_mobile_settings(db: &Database, data_dir: &std::path::Path) -> Resul settings.engine_mode = EngineMode::Local; changed = true; } + // "Standalone" (YouTube + direct links only) is retired: the on-device + // engine runs full yt-dlp, so Local covers everything it did. + if settings.engine_mode == EngineMode::Standalone { + settings.engine_mode = EngineMode::Local; + changed = true; + } if settings.library_path.is_empty() { let downloads = data_dir.join("downloads"); std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?; @@ -179,6 +186,8 @@ pub fn run() { #[cfg(mobile)] let builder = builder.plugin(mobile::share::init()); + let builder = builder.plugin(cookie_login::init()); + #[cfg(mobile)] let builder = builder.plugin(mobile::cookie_harvest::init()); @@ -295,6 +304,9 @@ pub fn run() { commands::merge_duplicates, commands::list_cookie_sites, commands::save_site_cookies, + commands::cookie_login, + commands::list_site_categories, + commands::cookie_login_finish, commands::delete_site_cookies, commands::catch_login_cookies, commands::poll_cookie_harvest, @@ -316,6 +328,7 @@ pub fn run() { commands::open_scene_with_default, commands::batch_update_scenes, commands::list_pornhub_categories, + commands::set_player_fullscreen, commands::probe_scene_metadata, commands::probe_library_durations, commands::ffmpeg_status, diff --git a/src-tauri/src/metadata/stashbox.rs b/src-tauri/src/metadata/stashbox.rs index e12b3e4..85cb07f 100644 --- a/src-tauri/src/metadata/stashbox.rs +++ b/src-tauri/src/metadata/stashbox.rs @@ -25,14 +25,15 @@ pub const STASHDB_ENDPOINT: &str = "https://stashdb.org/graphql"; /// Shared scene selection — every field here exists on stash-box `Scene`: /// `images` (not `image`), `performers` as appearances wrapping `performer`, -/// `date` (present on all box versions; `release_date` only on newer ones). +/// `release_date` (StashDB removed the old `date` field; boxes that only +/// know `date` are retried via [`StashBoxClient::graphql_scenes`]). /// Kept in one place and interpolated into each query so the field lists /// can't drift apart (unknown fields fail the whole call server-side). /// `concat!` can't take a const, so the full documents are built once via /// `LazyLock` instead of per call. const SCENE_SELECTION: &str = r#"id title - date + release_date details duration images { url } @@ -161,6 +162,15 @@ impl StashBoxClient { .filter_map(|e| e.get("message").and_then(|m| m.as_str())) .collect::>() .join("; "); + // StashDB reports a missing/invalid key as a GraphQL error + // with HTTP 200, not a 401. + if msg.to_ascii_lowercase().contains("not authorized") { + return Err(AppError::InvalidInput(format!( + "stash-box rejected the request as not authorized — check the API key \ + for {} in Settings → Metadata", + self.endpoint + ))); + } return Err(AppError::Site(format!( "stash-box GraphQL error: {}", if msg.is_empty() { @@ -174,6 +184,18 @@ impl StashBoxClient { Ok(body.get("data").cloned().unwrap_or(Value::Null)) } + /// Scene queries select `release_date`; older stash-box versions only + /// have `date`, so retry once with that field when the box rejects it. + async fn graphql_scenes(&self, query: &str, variables: Value) -> AppResult { + match self.graphql(query, variables.clone()).await { + Err(AppError::Site(msg)) if msg.contains("release_date") => { + self.graphql(&query.replace("release_date", "date"), variables) + .await + } + other => other, + } + } + /// Test credentials with a cheap query. Returns box name when known. pub async fn test_connection(&self) -> AppResult { let data = self.graphql("query Me { me { name } }", json!({})).await?; @@ -201,7 +223,7 @@ impl StashBoxClient { )); } let data = self - .graphql( + .graphql_scenes( FIND_BY_FINGERPRINTS_QUERY.as_str(), json!({ "fingerprints": [inputs] }), ) @@ -229,7 +251,7 @@ impl StashBoxClient { return Err(AppError::InvalidInput("stash_id is required".into())); } let data = self - .graphql(FIND_SCENE_QUERY.as_str(), json!({ "id": stash_id })) + .graphql_scenes(FIND_SCENE_QUERY.as_str(), json!({ "id": stash_id })) .await?; let raw = data.get("findScene").cloned().unwrap_or(Value::Null); if raw.is_null() { @@ -249,7 +271,7 @@ impl StashBoxClient { return Err(AppError::InvalidInput("title is required".into())); } let data = self - .graphql(SEARCH_SCENES_QUERY.as_str(), json!({ "title": title })) + .graphql_scenes(SEARCH_SCENES_QUERY.as_str(), json!({ "title": title })) .await?; let raw = data .get("queryScenes") diff --git a/src-tauri/src/mobile/ytdlp_bridge.rs b/src-tauri/src/mobile/ytdlp_bridge.rs index e17b434..b735802 100644 --- a/src-tauri/src/mobile/ytdlp_bridge.rs +++ b/src-tauri/src/mobile/ytdlp_bridge.rs @@ -305,6 +305,36 @@ pub fn ensure_media_tools(app: &AppHandle) -> AppResult { } } +/// Open a local file in another Android app (system chooser) through the +/// FileProvider; `file://` URIs are rejected across apps on Android 7+. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub fn open_file(app: &AppHandle, path: &str) -> AppResult<()> { + #[cfg(target_os = "android")] + { + use serde_json::json; + with_plugin_state(app, |state| { + let response: PluginResponse = state + .handle()? + .run_mobile_plugin("openFile", json!({ "path": path, "mime": "" })) + .map_err(|e| AppError::Other(format!("openFile failed: {e}")))?; + if response.ok.unwrap_or(false) { + Ok(()) + } else { + Err(AppError::Other( + response + .message + .unwrap_or_else(|| "no app can open this file".into()), + )) + } + }) + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, path); + Err(AppError::Other("open_file is Android-only".into())) + } +} + /// Run ffmpeg or ffprobe through the Kotlin plugin (Android-native binaries). /// Returns combined stdout/stderr text on success. pub fn execute_media(app: &AppHandle, tool: &str, args: &[String]) -> AppResult { @@ -409,3 +439,37 @@ pub fn cancel_pending_resume(app: &AppHandle) { let _ = app; } } + +/// Immersive video fullscreen on Android: hide the system bars and (for +/// landscape video) lock to sensor-landscape. No-op elsewhere — desktop and +/// browsers use the HTML Fullscreen API. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub fn set_immersive(app: &AppHandle, enabled: bool, landscape: bool) -> AppResult<()> { + #[cfg(target_os = "android")] + { + use serde_json::json; + with_plugin_state(app, |state| { + let response: PluginResponse = state + .handle()? + .run_mobile_plugin( + "setImmersive", + json!({ "enabled": enabled, "landscape": landscape }), + ) + .map_err(|e| AppError::Other(format!("setImmersive failed: {e}")))?; + if response.ok.unwrap_or(false) { + Ok(()) + } else { + Err(AppError::Other( + response + .message + .unwrap_or_else(|| "setImmersive failed".into()), + )) + } + }) + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, enabled, landscape); + Ok(()) + } +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 1f889d3..23032d9 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,1193 +1,1363 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum BrowseKind { - Tag, - Model, - Channel, - Search, - Video, - Category, - Livestream, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "lowercase")] -pub enum BrowseOrientation { - #[default] - Straight, - Gay, - Lesbian, - Transgender, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MediaItem { - pub id: String, - pub title: String, - pub url: String, - pub thumbnail: Option, - pub duration: Option, - pub site_id: String, - pub performers: Vec, - pub tags: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub is_live: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub viewers: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub age: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gender: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stream_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub embed_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BrowsePage { - pub items: Vec, - pub page: u32, - pub has_more: bool, - pub total: Option, - /// True when this page was served from the local browse_cache table. - #[serde(default)] - pub from_cache: bool, - /// Age of the cached payload in seconds when `from_cache` is true. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_age_secs: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BrowseQuery { - pub kind: BrowseKind, - pub slug: String, - pub page: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub orientation: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SiteInfo { - pub id: String, - pub display_name: String, - pub base_url: String, - pub supported_kinds: Vec, - pub requires_cookies: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum DownloadStatus { - Pending, - Active, - Paused, - WaitingForWifi, - Completed, - Failed, - Cancelled, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BulkImportResult { - pub queued: u32, - pub expanded: u32, - pub skipped: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DownloadJob { - pub id: String, - pub url: String, - pub adapter: String, - pub status: DownloadStatus, - pub progress: f32, - pub output_path: Option, - pub error: Option, - pub title: Option, - pub created_at: String, - #[serde(default)] - pub retry_count: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_retry_at: Option, - /// 1-based position in the active queue (pending + active jobs ahead, - /// plus one). `None` when the backend hasn't computed it yet. Mobile - /// surfaces this as "#N in queue" so users know how long they'll wait. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub queue_position: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DownloadPlan { - pub url: String, - pub output_template: String, - pub tool: DownloadTool, - pub title: Option, - pub performers: Vec, - pub tags: Vec, - pub adapter_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub thumbnail_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, - /// HTTP Referer to send for [`DownloadTool::DirectHttp`] downloads. - /// Needed by Referer-gated CDNs (e.g. PornHub's phncdn). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub referer: Option, - /// Initial download status (e.g., WaitingForWifi). If None, defaults to Pending. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub initial_status: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DownloadTool { - YtDlp, - GalleryDl, - DirectHttp, - /// HLS playlist saved to MP4 via ffmpeg stream-copy. Used when only a - /// gated `.m3u8` exists (e.g. PornHub): ffmpeg sends Referer/Cookie - /// headers that plain HTTP and `