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/.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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).
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).
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.
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.
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.
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
- 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