Commit graph

62 commits

Author SHA1 Message Date
dd293ec117
Merge pull request #18 from richardnixondev/chore/remove-nsfw-blur-and-discreet
chore: remove NSFW blur gate and Discreet mode (UI only)
2026-05-18 09:12:46 +01:00
896b42f935 chore: remove NSFW blur gate and Discreet mode (UI only)
User reported the NSFW toggle was not appearing, then asked to remove
it entirely. For a solo home-NAS deploy a global blur layer adds no
value — if the user wants to filter, the existing `/api/media?nsfw=`
query param still works.

Removed
- Two header buttons: NSFW 👁/🙈 and Discreto 🤫/👁
- The discreet-banner above the alerts
- body x-data + x-init (no longer needed without the store)
- filter-nsfw select from the gallery header
- nsfwFilter param building from loadGallery's URL
- isNsfw / nsfwBadge / thumbClass branching in appendToGallery
  (every gallery thumb now just gets class "gallery-thumb")
- Alpine.store('prefs') registration and the autoDiscreet IIFE
- CSS blocks: .nsfw-thumb, .show-nsfw, .nsfw-badge, .discreet, and
  .discreet-banner

Kept
- posts.nsfw column populated from Reddit's over_18
- GET /api/media?nsfw=all|hide|only query param + corresponding
  Database filtering
- over18=1 cookie on the requests Session
- PIN lock and 🔒 Lock header button (independent feature)

Bump 1.5.3 -> 1.5.4 (patch — UI removal, no backend or contract
change).

151 tests + ruff + mypy still green; grep confirms zero showNsfw /
nsfw-thumb / discreet / autoDiscreet references left in src/web/.
2026-05-18 00:15:41 +01:00
74c6c4d759
Merge pull request #17 from richardnixondev/chore/remove-cmdk-palette
chore: remove Cmd+K command palette (kept blocking navigation)
2026-05-17 23:42:08 +01:00
0875e1932f chore: remove Cmd+K command palette (kept blocking navigation)
After three attempted fixes (1.4.3 ESC, 1.5.1/1.5.2 cache-bust +
force-close paths), the palette was still leaving users stuck on the
NAS — even in a clean browser. Removing the UI is the right call;
nothing else in the app depends on it.

Removed
- div#cmdk-overlay from index.html
- cmdkPalette() + cmdkForceClose() + document-level keydown listener
  from app.js
- .cmdk-overlay/.cmdk-card/.cmdk-close/.cmdk-results/.cmdk-group-label/
  .cmdk-item/.cmdk-hint blocks from app.css
- The [x-cloak] global stays — other Alpine components may need it.

Kept
- GET /api/search and routers/search.py: cheap, no UI dependency.
  Useful for shell scripts or future re-introduction of a palette
  via a different approach.

Bump 1.5.2 -> 1.5.3 (patch).

151 tests + ruff + mypy still green; grep for "cmdk" in src/web is
empty.
2026-05-17 23:41:48 +01:00
d49e2df0f6
Merge pull request #16 from richardnixondev/fix/cmdk-cherry-pick
Fix/cmdk cherry pick
2026-05-17 23:29:51 +01:00
1d0444fa6f fix(cmdk): cache-bust static assets + force-close path (re-apply lost commit)
The original commit (23e7e7b) landed on the PR branch but the PR #15
merge in main only included the previous commit, leaving the Cmd+K
palette fix orphaned. This cherry-picks 23e7e7b on top of the
post-merge main and bumps to 1.5.2 (1.5.1 already burned a release
slot without the fix).

What this restores:
- Static URLs carry ?v={app_version} so browsers refetch on every
  release. The earlier symptom — \"my Esc still does not close\" —
  was almost entirely cached app.js from before 1.4.3.
- cmdkForceClose() does display:none directly on the DOM, used by:
  the X button (plain onclick), an onkeydown handler on the input
  itself, and a document-level keydown listener with capture:true.
  Three independent close paths so something always works even if
  Alpine is wedged.
2026-05-17 23:29:33 +01:00
d983944850 fix(cmdk): bypass Alpine entirely for close + cache-bust static assets
Two layered fixes for the palette getting stuck open even after the
1.4.3 ESC fix:

1) Static asset cache busting
   - /static/css/app.css and /static/js/app.js now include ?v={app_version}
     in their <script>/<link> URLs. Container Manager Rebuild was pulling
     the new image but browsers were still using the cached JS/CSS from
     a previous version — making any fix to app.js invisible until the
     user hit Cmd+Shift+R. The version-suffixed URL forces a refetch on
     every release. _app_version is now passed in the index() context.

2) Direct DOM bypass for the palette close path
   - New cmdkForceClose() does the close via plain DOM (display:none + set
     Alpine.$data().open=false if Alpine is alive). Independent of Alpine
     reactivity timing.
   - Document-level keydown listener with capture:true installed at
     DOMContentLoaded. Runs before any focused element captures Esc /
     Cmd+K, including the palette's own input. Calls cmdkForceClose()
     directly — does not rely on the Alpine event going through.
   - X button and outer overlay onclick now invoke cmdkForceClose() via
     onclick=, not Alpine @click. The Alpine @click handlers are kept
     side-by-side as belt-and-suspenders.
   - Input keeps a literal onkeydown="" Escape handler too — three
     independent ways to close, so something always works.

Bump 1.5.0 -> 1.5.1 (patch on top of the pending 1.5.0 PR; release
will publish a single 1.5.1 tag covering both the relations editor
and the palette hardening).

Verified locally: HTML response carries ?v= on static URLs,
cmdkForceClose function is present, onkeydown handler on input is in.
151 tests + lint + types + format still green.
2026-05-17 23:28:45 +01:00
e69d6128e6
Merge pull request #15 from richardnixondev/feat/tag-relations-editor
feat(tags): visual editor for tag relations (Hydrus-style)
2026-05-17 23:11:58 +01:00
32f8e4940a feat(tags): visual editor for tag relations (Hydrus-style)
Closes the loop on the tag taxonomy: backend / schema / API for
relations already shipped in 1.4.0, now there is a UI for them.

UI additions
- New "Tags" tab in the header (between Fontes and Configuracoes).
  Shows every tag as a clickable chip colored by category, with the
  post count as a small badge. Filter dropdown by category + text
  filter by name (debounced 200ms).
- Tag modal (_modal_tag.html): clicking any tag opens a modal that
  splits its relations into four labeled panels — Parents (up arrow),
  Children (down arrow), Siblings (synonyms, two-way arrow), Implies
  (right arrow). Each relation is a colored chip that, when clicked,
  removes the relation after confirmation.
- "Adicionar relacao" form at the bottom: select the kind, type
  the other tag name (with datalist autocomplete from the existing
  tags cache), Adicionar. The form maps the UI kinds to the API
  payload, including a "parent-inverse" choice that flips parent/
  child so the user does not need to think about direction.

Frontend wiring
- app.js: loadTagsList / renderTagsGrid / openTagRelationsModal /
  refreshTagRelations / addTagRelation / removeTagRelation. Reuses
  the existing /api/tags, /api/tags/{id}/relations, POST and DELETE
  /api/tags/relations from 1.4.0 — zero new backend.
- switchTab triggers loadTagsList when the user enters the tab.
- Removal of a sibling tries both directions because the relation can
  have been stored either way.

Styling
- .tags-grid, .tag-explorer-chip with hover/border accent.
- .tag-modal-content with .tag-relations-panels grid (two columns,
  collapses to one on narrow screens) and a separate .tag-rel-add
  block for the form.

Bump 1.4.3 -> 1.5.0 (minor — new UI tab, no schema or contract
changes; backend untouched).

Verified: 151 tests still green, ruff + mypy + format clean, docker
build OK. Dev server smoke confirmed the tab renders, the modal
opens and the four panels populate.
2026-05-17 23:08:37 +01:00
3b6798e1f0
Merge pull request #14 from richardnixondev/fix/cmdk-esc-and-close-button
fix(cmdk): ESC closes the palette, plus visible X button
2026-05-17 22:52:34 +01:00
a7bad36090
Merge branch 'main' into fix/cmdk-esc-and-close-button 2026-05-17 22:52:27 +01:00
a320d58269
Merge pull request #13 from richardnixondev/fix/yaml-encoding-resilience
fix(config): resilient YAML loader (cp1252 fallback) + ASCII-only example
2026-05-17 22:51:57 +01:00
c09d3a4acb fix(cmdk): ESC closes the palette, plus visible X button
The Alpine @keydown.escape.window modifier was racing with the
focused input — once the palette opened and the input took focus,
ESC events were captured by the input element first and the
window-level handler didn't always fire. Result: palette stuck open
covering the page.

Fix
- cmdkPalette.init() registers a window keydown listener directly in
  JS. Survives x-show toggling display:none, doesn't depend on the
  input keeping focus, handles both Escape (when open) and Cmd/Ctrl+K
  (toggle from anywhere).
- The Alpine modifier @keydown.escape.window is dropped; kept a local
  @keydown.escape on the input element (with .prevent.stop) as a
  belt-and-suspenders fallback when focus is in the input.
- Added a visible "X" close button in the top-right of the palette
  card so there's always an obvious manual escape route, even if a
  weird keyboard layout swallows Esc.
- Updated the hint line to mention the new exit options.

Bump 1.4.1 -> 1.4.3 (patch — UX bugfix; jumps 1.4.2 since the
in-flight YAML encoding fix also claims that slot).
2026-05-17 22:49:01 +01:00
f825a638cd fix(config): resilient YAML loader (cp1252 fallback) + ASCII-only example
Cause
- v1.4.1's config.yaml.example had an em-dash (U+2014, 3-byte UTF-8 sequence)
  inside the skip_nsfw comment. When DSM File Station / classic Notepad
  re-saves the file, they convert it to a single 0x97 byte (Windows-1252
  em-dash) — which is invalid UTF-8 and crashes yaml.safe_load with
  UnicodeDecodeError, returning 500 on every GET / after PIN unlock.

Fix
- src/web/config_manager.load_config(): try utf-8 first; on
  UnicodeDecodeError, retry with cp1252 (which maps 0x97 → U+2014
  correctly so PyYAML accepts the text). Log a warning so the next
  save_config() normalizes the file back to UTF-8.
- config.yaml.example: replace em-dash with comma in the skip_nsfw comment
  so the canonical example is pure ASCII and cannot trigger this again.

Bump 1.4.1 -> 1.4.2 (patch — bugfix to a regression introduced by
1.4.1's own example file).

Verified
- 151 tests still green.
- Manual smoke: a YAML file containing raw 0x97 byte loads correctly
  via the cp1252 fallback, with warning logged. UTF-8 files unchanged.

For existing users hit by this on the NAS, the immediate workaround
is to remove the em-dash from their config.yaml's skip_nsfw comment
line (or just delete the comment entirely). After 1.4.2 the loader
handles it transparently.
2026-05-17 22:44:38 +01:00
52c82c376c
Merge pull request #12 from richardnixondev/fix/nsfw-default-and-cookie
fix(nsfw): default skip_nsfw=False + over18 cookie for hardening
2026-05-17 22:21:19 +01:00
b9ccc925cc
Merge branch 'main' into fix/nsfw-default-and-cookie 2026-05-17 22:21:12 +01:00
6f25ee74aa
Merge pull request #11 from richardnixondev/feat/tag-power-features
feat(tags): bulk editor, in-modal editor, relations, Cmd+K palette
2026-05-17 22:20:01 +01:00
b360825d7a fix(nsfw): default skip_nsfw=False + over18 cookie for hardening
The Reddit JSON API still serves NSFW content anonymously; the silent
skip users were seeing came from OUR own default — DownloadConfig had
skip_nsfw=True both as dataclass field default and as load_config()
fallback. New installs / configs without the key explicit got NSFW
filtered out without warning.

Changes
- src/config.py: DownloadConfig.skip_nsfw default → False; load_config
  fallback → False. Existing configs with `skip_nsfw: true` continue
  to filter; only the implicit/default path becomes permissive.
- src/reddit_client.py: Session now sets over18=1 cookie on
  .reddit.com. Anonymous access works without it today, but some
  quarantined / gated subs honor it and the cost is zero.
- config.yaml.example: skip_nsfw line shows the new default explicitly.

Bump 1.4.0 -> 1.4.1 (patch — behavior change is bugfix to a misleading
default).

Smoke vs the wild (Reddit prod):
- r/nsfw.json anonymous + our UA → HTTP 200 with full media URLs ✓
- Same with cookie over18=1 → identical response ✓
- Without User-Agent → HTTP 403 (Reddit bot guard, already protected
  by our UA header).

151 tests still green; the existing test_load_valid_config asserts
skip_nsfw=True because the fixture config_file explicitly sets it —
that test continues to validate the YAML override path.
2026-05-17 22:17:18 +01:00
6364a84610 feat(tags): bulk editor, in-modal editor, relations, Cmd+K palette
Quatro features que destravam o uso pratico da tag taxonomy criada
na Fase 2.

Backend
- Schema novo: tag_relations(parent_id, child_id, kind) com kinds
  sibling/parent/implies (estilo Hydrus). Indexes nos dois lados.
- Database.bulk_tag_posts(): add ou remove uma tag em N posts numa
  unica transacao; reusa _get_or_create_tag.
- Database.add_tag_relation / remove_tag_relation / list_tag_relations
  com validacao de kind e self-relation.
- Database.search_universal(): typeahead retornando authors+
  subreddits+tags filtrados por LIKE; usado pelo Cmd+K.
- routers/tags.py ganha 4 endpoints: POST /api/posts/tags/bulk,
  GET /api/tags/{id}/relations, POST /api/tags/relations,
  DELETE /api/tags/relations/{parent}/{child}/{kind}.
- routers/search.py (novo): GET /api/search?q= com cap 20.

Frontend
- _modal_media.html ganha .modal-tags-editor: chips removiveis (click)
  + input de adicionar + select de categoria. loadModalTags() roda
  ao abrir o modal; chama /api/posts/{id}/tags. addTagToCurrentPost
  / removeTagFromCurrentPost atualizam in-place.
- tab_gallery.html: selection-bar agora tem .selection-bulk-tag com
  input + datalist + select categoria + botoes "+ Tag" e "- Tag".
  bulkTag(action) chama /api/posts/tags/bulk. Datalist alimentada
  pela cache _tagsCache (TTL 60s).
- index.html: novo overlay #cmdk-overlay com Alpine x-data="cmdkPalette()".
  Atalho global Cmd+K / Ctrl+K alterna; setas navegam, Enter seleciona,
  Esc fecha. Selecionar autor/subreddit filtra galeria direto; tag
  alimenta o input de bulk para acelerar workflow de tagging.
- CSS: .modal-tags-editor, .selection-bulk-tag, .cmdk-overlay/.cmdk-card/
  .cmdk-results responsivos. [x-cloak] global para evitar flash.

Bump 1.3.1 -> 1.4.0 (minor — 4 features novas, schema novo, novo
router, sem quebra de contrato existente).

Verificado: 151 testes verdes, ruff/mypy/format OK, docker build OK,
smoke manual confirma /api/search retorna mix correto, bulk add
afeta N posts, relation sibling persiste, Cmd+K abre.
2026-05-17 22:10:59 +01:00
4a540ef379
Merge pull request #10 from richardnixondev/docs/refresh-readme-v1.3.1
docs: refresh README covering v1.1.0 → v1.3.1 features
2026-05-17 21:56:19 +01:00
a6ee86c4bb docs: refresh README covering v1.1.0 → v1.3.1
A documentacao estava colada na v1.0; cinco releases depois precisa
de uma atualizacao geral.

Features
- Reorganiza em Collection / Web dashboard / Operations.
- Inclui Tag taxonomy, NSFW gate, Discreet mode, PIN lock, keyboard
  shortcuts, last_error banner, /health endpoint, modular HTMX+Alpine
  frontend.

Web Features
- Cobre os novos toggles do header (NSFW, Discreto, Lock), filtros
  NSFW na galeria, atalhos de teclado no modal, tag chips.

Security & Limits
- Nova subsection PIN lock com semantica do cookie HMAC, idle timeout,
  bypass de /health e /static.
- Tabela de rate limits ganha linha para mutacoes de config + tags
  (60/min por IP+path).

Env vars (Docker)
- RMC_PIN, RMC_PIN_TIMEOUT documentados.

Synology DSM
- Passo 4 menciona PIN como segundo fator se exposto publicamente.
- Novo passo 6: curl POST /api/tags/backfill para taggear retroativo
  uma biblioteca pre-existente.

API Reference
- Nova secao Tags (5 endpoints) com nota de que /api/media ja inclui
  tags por post.
- Nova secao Health & Auth (/health, /unlock, /lock).
- Collector ganha /api/collector/clear-error.
- Gallery filters ganham nsfw=all|hide|only.

Database Schema
- Adiciona coluna posts.nsfw, tabelas tags + post_tags + scheduler_history
  com comentarios de categoria/source.
- Nota sobre PRAGMA journal_mode=WAL.

Project Structure
- Atualiza arvore: routers/health.py, routers/tags.py, session.py,
  static/css/app.css, static/js/app.js, templates/partials/,
  unlock.html, docker-compose.synology.yml, .github/workflows/release.yml.
- Comentarios curtos por arquivo.

Apenas docs — sem mudanca de codigo, sem bump.
2026-05-17 21:54:32 +01:00
9f21aa0751
Merge pull request #9 from richardnixondev/feat/tag-chips-on-cards
feat(gallery): render tag chips on each media card
2026-05-17 21:50:36 +01:00
e532b55678 feat(gallery): render tag chips on each media card
Conclui a Fase 2 que ja tinha schema + auto-tagger + API: agora as tags
aparecem visualmente como chips coloridos abaixo do meta de cada card,
sem nova chamada por item.

Backend
- Database._attach_tags(): helper que, dado N rows de posts, faz UMA
  query batch em post_tags+tags com IN(...) e mescla in-place o campo
  `tags=[{name, category}, ...]`. Custo O(1) em queries, nao O(N).
- get_media_files, get_media_by_authors, get_favorites e
  get_recent_downloads agora chamam _attach_tags antes de retornar.
- get_favorites tambem ganha flair/source_type/nsfw que faltavam na
  projecao (consistencia com get_media_files).

Frontend
- renderTagChips(tags) gera <div class="tag-chips"> com no maximo 4
  chips visiveis + "+N" para o resto. Classe `cat-{category}` aplica
  a cor por categoria (performer azul, source laranja, genre verde,
  meta roxo — CSS ja preparado desde a Fase 2).
- appendToGallery inclui os chips no rodape de cada card, abaixo de
  flair-badge. escapeHtml em todos os valores.

Bump 1.3.0 -> 1.3.1 (patch — feature visual sem mudanca de contrato).
Backend testes mantem 151 verdes; smoke confirma /api/media retorna
tags populados (depois de /api/tags/backfill se a biblioteca for
pre-existente).
2026-05-17 21:47:47 +01:00
c5dbbbe4b1
Merge pull request #8 from richardnixondev/feat/fase1-nsfw-gate
feat: phases 1-4 — NSFW gate, tag taxonomy, PIN lock, enriched gallery
2026-05-17 21:37:58 +01:00
d3f31fe9ab feat: phases 1-4 — NSFW gate, tag taxonomy, PIN lock, enriched gallery
Quatro fases do roadmap do refactor de frontend em uma unica entrega.

Fase 1 — NSFW gate visual
- get_media_files / get_total_media_count / get_recent_downloads /
  get_media_by_authors retornam nsfw (+ flair, source_type, created_utc
  ja persistidos).
- GET /api/media aceita ?nsfw=all|hide|only com regex validation.
- Dropdown "NSFW: tudo / ocultar / apenas" na barra de filtros da
  galeria.
- appendToGallery aplica .nsfw-thumb (blur 22px) quando item.nsfw.
- Badge "NSFW" vermelho no canto do thumbnail.
- Toggle 👁/🙈 no header (Alpine.store.prefs.showNsfw) revela o blur
  globalmente; clase .show-nsfw no body.

Fase 2 — Tag taxonomy estilo Stash
- Schema novo: tabelas tags (id, name, category, is_nsfw, description)
  e post_tags (post_id, tag_id, source). Indexes em ambas direcoes.
- auto_tag_post() popula 4 categorias automaticas a cada add_post:
  subreddit -> 'source', author quando source_type=user -> 'performer',
  flair -> 'genre', nsfw=1 -> 'meta'. Idempotente; preserva tags
  criadas pelo usuario (source='user').
- Novo router src/web/routers/tags.py:
  - GET /api/tags (com ?category)
  - GET /api/posts/{id}/tags
  - POST /api/posts/{id}/tags (rate-limited)
  - DELETE /api/posts/{id}/tags/{tag_id}
  - POST /api/tags/backfill — corre auto_tag_post em todos os posts
    existentes (idempotente).
- main.py: process_post agora chama db.auto_tag_post(record).

Fase 3 — PIN lock + session cookie
- Novo src/web/session.py: cookie 'rmc_unlock' assinado por HMAC-SHA256
  com chave de 32 bytes gerada por boot (in-memory; restart invalida
  sessoes). Format: '{issued_unix}.{hex_sig}'.
- Env vars novas: RMC_PIN (4-6 digitos, se ausente desliga o lock
  inteiro), RMC_PIN_TIMEOUT (segundos, default 600).
- PinLockMiddleware: gateia tudo exceto /health, /static, /unlock,
  /favicon. GET sem cookie redirect 303 /unlock; non-GET sem cookie
  retornam 401.
- Endpoints: GET /unlock renderiza tela com input password OTP-friendly;
  POST /unlock valida via secrets.compare_digest e seta cookie httponly
  samesite=lax (secure auto quando https). POST /lock invalida cookie.
- Template unlock.html (sem auth/cookie), CSS .unlock-wrap/.unlock-card.
- Header ganha botao "🔒 Lock" quando pin_enabled (passado via context).
- Nova dep python-multipart (necessaria pra Form() do FastAPI).

Fase 4 — Galeria enriquecida + atalhos
- Badges Reddit-specific no card: NSFW, source_type icon (👤/📂),
  tempo relativo created_utc (agora/Xm/Xh/Xd/Xmes/Xa), chip de flair
  com estilo proprio.
- Keyboard shortcuts no modal: j/→ proximo, k/← anterior, f favoritar,
  b blacklist autor, Esc fecha. Inativo quando focus em input.
- Modo discreto turbinado: grids compactas (.gallery-grid e
  .authors-grid em minmax(110px)), thumbs menores em recent, info
  reduzida, flair-badge escondida. Discreet-banner discreto no topo.
- Auto-discreet: 60s sem mouse/keyboard/click/scroll → ativa
  discreet automaticamente. Privacidade hands-off.

Plus
- escapeHtml() helper para titulos e nomes nos templates JS.
- CSS .tag-chip preparado para Fase 5 (chips de tag no card visual);
  estilizado por categoria (performer/source/genre/meta).

Bump 1.2.1 → 1.3.0 (minor — 4 features novas, schema novo, nova env
var, nova dep). Backwards-compat preservado: clientes JSON antigos
continuam recebendo JSON; APIs nao quebraram contratos.

Verificado: 151 testes verdes, ruff/mypy/format OK, docker build OK,
smoke manual cobre PIN lock (redirect, 401, cookie issue, bypass de
health/static), backfill (11 posts tagged), nsfw filter (hide retorna
so nsfw=0), keyboard shortcuts.
2026-05-17 21:27:47 +01:00
3802707c3d
Merge pull request #7 from richardnixondev/refactor/htmx-all-config-forms
refactor(frontend): migrate remaining 13 config forms to HTMX
2026-05-17 21:13:58 +01:00
efd968c947 refactor(frontend): migrate remaining 13 config forms to HTMX
Sequencia natural da Fase 0b — os outros 4 forms (user + 3 blacklist)
e 6 botoes de Remover agora seguem o mesmo padrao do subreddit que ja
estava demonstrado.

Backend
- src/web/routers/config.py: helpers _wants_html, _empty_html_or_json
  e _tag_fragment centralizam a logica. Cada POST agora aceita
  `Accept: text/html` e responde o fragmento Jinja correspondente
  (_item_user.html / _tag.html); cada DELETE responde 200 com body
  vazio para HTMX swap. Backwards-compat: clientes que mandam
  `Accept: application/json` (ou nenhum) continuam recebendo JSON
  identico ao antes.

Templates
- Novos partials: _item_user.html, _tag.html (generico, parametrizado
  por prefix / value / delete_url / confirm).
- _item_subreddit.html: botao agora usa hx-delete (era onclick).
- tab_sources.html: 5 forms restantes ganham hx-post / hx-ext /
  hx-target / hx-swap / hx-on::after-request. Loops Jinja renderizam
  via {% include %} dos partials, e tudo ja vem com hx-delete no
  primeiro carregamento.

JS
- 11 funcoes mortas removidas de app.js (handle*Submit, removeSubreddit,
  removeUser, removeBlacklist*). attachFormHandlers() saiu do boot.
- ~225 linhas de JS deletadas. O app.js agora foca no que ainda nao
  migrou (gallery, modal, scheduler, settings, individual collection).

UX
- Adicionar/remover qualquer entry de config nao recarrega mais a
  pagina. A interacao fica instantanea, mantendo scroll position.

Bump 1.2.0 → 1.2.1 (patch — nao mexe em API publica nem contrato).
151 testes verdes, lint/mypy/docker OK, smoke manual confirma fragments
em todos os endpoints.
2026-05-17 21:10:48 +01:00
8fae4d9fb8
Merge pull request #6 from richardnixondev/refactor/frontend-foundation-0b
refactor(frontend): foundation 0b — Jinja partials, JS extracted, HTMX/Alpine in
2026-05-17 20:59:46 +01:00
6d7c7debd4 refactor(frontend): foundation phase 0b — Jinja partials + HTMX/Alpine
Sequencia da Fase 0a. Sem mudanca de comportamento visivel alem dos
dois toggles novos no header; estrutura agora ja suporta as Fases 1-4
sem cirurgia.

Template
- index.html monolitico (2575 linhas) → 41 linhas, so composicao.
- 5 parciais Jinja (tab_dashboard / tab_sources / tab_gallery /
  tab_authors / tab_settings) e 2 modais (_modal_media / _modal_author)
  em templates/partials/.

JS
- Bloco <script> de 1933 linhas inline → src/web/static/js/app.js
  (carregado com defer, ordenado depois de api.js).
- attachFormHandlers() roda em DOMContentLoaded, substitui os
  addEventListener-no-meio-do-script que dependiam da ordem inline.
- Alpine.store('prefs') com showNsfw + discreet, persistido em
  localStorage, alimenta CSS .show-nsfw / .discreet ja preparados na
  Fase 0a — pronto pra Fase 1.

HTMX (POC)
- htmx 2.0.4 + htmx-ext-json-enc carregados via CDN com defer.
- Form add-subreddit migrado para hx-post; endpoint
  POST /api/subreddits detecta Accept: text/html e responde com o
  fragmento _item_subreddit.html, adicionado ao DOM via hx-swap
  beforeend sem reload. Demonstra o padrao que os outros 13 forms
  podem migrar individualmente.
- Backwards-compat: clientes JSON existentes recebem JSON normalmente.

Alpine
- Dois novos toggles no header (NSFW 👁/🙈 e Discreto 🤫/👁) ligados
  ao store; clicar em qualquer um persiste no localStorage e adiciona
  classe no <body> que o CSS ja sabe reagir.

Bump 1.1.0 → 1.2.0 (minor: ganho de Alpine como dependencia visivel
no DOM, ainda nao quebra contrato).

Verificado local: 151 testes verdes, ruff + mypy + docker build OK,
dev server respondendo HTTP 200 em todas as abas, POST /api/subreddits
testado nos dois modos (JSON e HTML fragment).
2026-05-17 20:45:09 +01:00
b92d99f59e
Merge pull request #5 from richardnixondev/refactor/frontend-foundation
refactor(frontend): foundation 0a — CSS extracted, NSFW persisted, last_error banner
2026-05-17 20:22:10 +01:00
e4639365a4 refactor(frontend): foundation phase 0a — CSS extraction + NSFW persistence + last_error banner
Primeira parte do refactor frontend planejado. Sem mudar funcionalidade
visivel pro usuario, prepara o terreno pra fase 0b (parciais Jinja +
HTMX + Alpine) na proxima iteracao.

Backend foundation
- Persist over_18 do Reddit como coluna `nsfw` em posts (migration
  ALTER TABLE). Antes vinha em RedditPost mas era jogado fora; sem
  isso o NSFW gate da Fase 1 nao tem dado pra operar.
- POST /api/collector/clear-error pra dispensar o banner que mostra
  o `last_error` no header da UI.

CSS extraction (1063 → 0 inline)
- Move o bloco <style> inteiro do index.html (linhas 7-1069) para
  src/web/static/css/app.css. Index ja serve via <link>.
- Adiciona CSS custom properties no :root (cores principais) como
  base para tema; uso opcional, mantem cores hardcoded onde ja
  estavam pra evitar visual diff.
- Novos placeholders .error-banner, .nsfw-thumb, .discreet pra
  destravar Fase 1 sem nova mudanca de CSS.

UI observability
- Banner vermelho discreto no topo da pagina quando
  collector_status.last_error esta preenchido. updateStatus()
  alimenta; botao "Limpar" chama /api/collector/clear-error.
- Novo helper global showApiError(err, fallback) que LE o `detail`
  real do response (Response ou Error). Substitui as 22 chamadas
  de `showAlert('Erro de conexao', 'error')` espalhadas pelos
  handlers. Agora um 500 do server aparece com a mensagem real,
  nao com o generico que escondia o bug do compose :ro.

Resultado: index.html caiu de 3620 para 2575 linhas, sem refactor de
JS/HTML ainda. Cobertura e CI continuam verdes (151 testes).
2026-05-17 19:53:36 +01:00
5682d2a844 chore: bump version to 1.0.3 2026-05-17 18:38:15 +01:00
8349ed0d48
Merge pull request #4 from richardnixondev/chore/stability-hardening
chore(stability): runtime/observability hardening + scheduler tests + /health
2026-05-17 18:35:09 +01:00
a2727a9ac4 chore(stability): harden runtime, observability, and test coverage
Pacote de endurecimento focado em rodar 24/7 no NAS sem surpresas.

DB / persistence
- Register a datetime adapter so Python 3.12+ stops warning about the
  deprecated default; emits "YYYY-MM-DD HH:MM:SS[.ffffff]" to preserve
  existing string-based comparisons in get_enhanced_stats.
- PRAGMA journal_mode=WAL on init (concurrency + crash safety on the
  NAS) and synchronous=NORMAL per connection (cheap, WAL-safe).
- Two composite indexes: (author, subreddit) and (media_type,
  downloaded_at) — speed up the gallery and cleanup queries.

API surface
- New /health router with no auth dep, returns booleans for db /
  ffmpeg / downloads_writable / scheduler plus the package version and
  whether Basic Auth is configured. Reachable even when
  RMC_AUTH_USER/PASS are set, so it can be wired into Container
  Manager / external monitors.
- Refactor: move require_auth() from app-level dependency to per-router
  include_router(..., dependencies=[require_auth()]); /health is the
  only route that opts out.
- Rate limit (60/min per IP+path) on every POST/PUT/DELETE in config:
  subreddits, users, blacklist.*, settings.* — protects config.yaml
  from runaway loops or spam.

Observability
- Replace silent `except Exception` in main and scheduler with
  exc_info=True logging and a new collector_status["last_error"] field
  surfaced via /api/collector/status, so a failed 3am scheduled run
  isn't invisible.
- RotatingFileHandler (10 MB × 5 backups) caps collector.log around
  60 MB on the NAS volume.

Quality
- tests/test_router_scheduler.py covers status / config (interval +
  cron) / history / run-now / collector run / individual collect
  validation. scheduler.py coverage 25% → 55%, total 55% → 58%
  (135 → 151 tests).
- Type extractors.gfycat (cast yt-dlp Any to str), removed from the
  mypy ignore list. First module destravado — pattern for the rest.

Verified locally: ruff + mypy + 151 pytest green; docker container
boots healthy in 6s; PRAGMA journal_mode reports `wal`; 70 POSTs to
/api/subreddits returned 60 OK + 10 HTTP 429.
2026-05-17 18:11:45 +01:00
6e7aba37db chore: bump version to 1.0.2 2026-05-17 17:19:36 +01:00
0299dd4384
Merge pull request #3 from richardnixondev/fix/frontend-performance-and-bugs
fix(compose): mount config.yaml writable so UI can persist changes
2026-05-17 17:12:30 +01:00
c0dffb39a4 fix(compose): mount config.yaml writable so UI can persist changes
The UI mutates /app/config.yaml on every add/remove of subreddits, users
or blacklist entries. With the :ro flag the write fails with OSError and
the route returns 500 (the frontend then shows "Erro de conexão"
because the plain-text 500 body is not JSON-parseable).

Drop :ro from both compose files and from the README volumes table.
2026-05-17 17:12:01 +01:00
331eb8311c
Merge pull request #2 from richardnixondev/fix/frontend-performance-and-bugs
feat(deploy): publish to GHCR, document Synology DSM setup, fix index 500
2026-05-17 15:47:45 +01:00
562b8697ca chore: bump version to 1.0.1 2026-05-17 14:56:28 +01:00
d02e071ddf feat(deploy): publish image to GHCR and document Synology DSM setup
Add a release workflow that publishes a linux/amd64 image to
ghcr.io/richardnixondev/reddit-media-collector on every v* tag, plus a
ready-to-paste docker-compose for Synology Container Manager.

Persist scheduler state across container restarts by reading the DB and
config paths from RMC_SCHEDULER_DB / RMC_SCHEDULER_CONFIG (previously
written to /app, which is not a volume).

Drive-by fixes uncovered while smoke-testing the image:
- Dockerfile now copies README.md (required by pyproject) and drops the
  single-file VOLUME entry that breaks bind mounts; adds an OCI source
  label and a socket-based HEALTHCHECK so Container Manager reflects real
  liveness.
- src/web/app.py uses the new Starlette TemplateResponse signature so the
  index page does not 500 under fresh dependency pins.
2026-05-17 14:48:02 +01:00
f536b01dba
Merge pull request #1 from richardnixondev/fix/frontend-performance-and-bugs
Fix/frontend performance and bugs
2026-05-17 13:13:10 +01:00
b7265f0582 docs: refresh README — pyproject install, dev workflow, security, full API ref
- Drop requirements.txt references; install path is now 'pip install -e ".[dev]"'
- Add Development section: pre-commit, pytest, mypy, ruff
- Add Security & Limits section: optional Basic auth via env, rate limits per endpoint, gallery eviction
- Update Web Features to include Authors tab and Scheduler
- Expand API Reference to cover all routers (config, media, blacklist, favorites, authors, stats, collector, scheduler) — was missing ~15 endpoints
- Update Project Structure to reflect routers/, deps.py, auth.py, rate_limit.py, static/js, tests/, pyproject.toml, .github/, .pre-commit-config.yaml
- Add CI badge
2026-05-17 12:00:28 +01:00
b4a2f303ac ci: add mypy job + pre-commit hook, tighten type annotations
- .github/workflows/ci.yml: new 'types' job runs mypy src/; docker now needs lint+types+test
- .pre-commit-config.yaml: add mirrors-mypy hook on src/ with types-requests, types-PyYAML
- pyproject.toml: add per-module overrides ignoring legacy modules (main, downloader, reddit_client, database, extractors.{reddit,gfycat}) while keeping src/web/ strict
- src/web/deps.py: type DOWNLOADS_DIR explicitly as Path
- src/web/config_manager.py: type guards on get_subreddits/users/blacklist returns
- src/web/routers/favorites.py: dict[str, object] for result, avoid in-place + on object
- src/web/routers/media.py: dict[str, object] for delete result
- src/web/rate_limit.py: move Awaitable/Callable under TYPE_CHECKING
- ruff format + auto-fix on src/ + tests/
2026-05-17 11:58:40 +01:00
34c2b16e19 test: cover routers, auth, rate limit, config_manager, imgur extractor
- tests/test_auth.py: enable/disable via env, valid/invalid creds
- tests/test_rate_limit.py: bucket math + window expiry
- tests/test_router_media.py: path traversal (file/thumb), paginated subreddits/authors, blacklist preview shape, DELETE flow, cleanup-by-type 400 paths
- tests/test_router_stats.py: cache TTL, .json exclusion, missing dir
- tests/test_router_favorites.py: paginated favorites/authors, no N+1 (query count), favorites_only filter
- tests/test_router_config.py: CRUD subreddits/users/blacklist
- tests/test_config_manager.py: case-insensitive dedupe, blacklist removes from collection, domain normalization
- tests/test_extractors_imgur.py: i.imgur direct, gifv->mp4, albums/galleries -> None

src/database.py: resolve RMC_DB_PATH lazily in Database() so tests can monkeypatch the env per case (no more module-import capture)

Result: 135 passed (was 77). Coverage 55% global; 100% on auth/imgur, 96% rate_limit, 84% stats router, 80% config_manager.
2026-05-17 11:54:40 +01:00
5f9c6023c1 perf: async I/O, caching, pagination, frontend fluidity, optional auth, rate limits
Backend:
- src/web/routers/stats.py: filesystem scan via asyncio.to_thread with 30s TTL cache
- src/web/routers/media.py: async thumbnail generation with per-file lock + semaphore(2)
- src/database.py: consolidate get_authors_with_stats (was N+1 favorites lookup) into a single query with EXISTS subquery
- src/web/routers/{media,favorites}.py: optional pagination for subreddits/authors/blacklist-preview/favorites-authors
- src/web/rate_limit.py (new): per-IP+path token bucket dependency
- Rate limits applied to /api/stats, /api/stats/enhanced (30/min), cleanup-blacklist, cleanup-by-type (5/min)
- src/web/auth.py (new): optional HTTP Basic auth, activated when RMC_AUTH_USER and RMC_AUTH_PASS are both set
- src/web/app.py: mount StaticFiles at /static, register auth dependency app-wide

Frontend:
- src/web/static/js/api.js (new): fetchJSON/debounce/throttle helpers on window.api
- index.html: debounced gallery filter onchange handlers (200ms)
- index.html: window eviction at 500 items in galleryItems (keeps DOM lean during long sessions)
- index.html: pause status polling on visibilitychange (no fetches when tab is hidden)
- index.html: loadStats() now throttled to 2s, with force=true used after delete/cleanup
- index.html: parallel batch DELETE with 5 workers + progress UI
- index.html: adapt to new response shape from paginated subreddits/authors endpoints
2026-05-17 11:50:29 +01:00
0402aeb06e fix(security): block path traversal in file/thumb endpoints
- Add _safe_resolve helper validating that resolved path stays within DOWNLOADS_DIR
- Apply to get_media_file and get_video_thumbnail
- Reject null bytes and overlong filenames
- Replace bare except in magic-byte fallback with OSError + logging
2026-05-17 11:43:21 +01:00
986f1dfef4 chore: checkpoint baseline (routers, tests, pyproject) 2026-05-17 11:40:35 +01:00
a58498a315
Update README.md
typos from en_us to en_uk
2026-02-11 19:59:41 +00:00
23e841e8d3 Update user-agent string to match project name 2025-09-20 14:30:00 -03:00
614da5b3c5 Add project documentation 2025-09-20 12:15:00 -03:00