feat(smartlist): added smartlists as requested in #24

This commit is contained in:
2026-01-13 13:33:14 +01:00
parent a30b461a52
commit 41d4e6d7f0
4 changed files with 331 additions and 68 deletions
+52
View File
@@ -588,6 +588,58 @@ def smartlist_count(conn: sqlite3.Connection, groups: List[Dict[str, Any]]) -> i
""", (*params, *fts_params)).fetchone()
return int(row[0]) if row else 0
def get_distinct_field_values(
conn: sqlite3.Connection,
field: str,
groups: Optional[List[Dict[str, Any]]] = None
) -> List[Tuple[str, int]]:
"""
Get distinct non-empty values for a metadata field, optionally filtered by smart list groups.
Returns list of (value, count) tuples sorted by value.
Supported fields: series, writer, publisher, format, year, tags, characters, teams, genre
"""
# Map field names to their table column
meta_fields = {
'series', 'writer', 'publisher', 'format', 'year', 'month', 'day',
'tags', 'characters', 'teams', 'genre', 'title', 'volume', 'number'
}
items_fields = {'ext', 'name'}
if field not in meta_fields and field not in items_fields:
return []
# Build column reference
table_prefix = 'm' if field in meta_fields else 'i'
col = f"{table_prefix}.{field}"
# Build WHERE clause
where_parts = ["i.is_dir=0", f"{col} IS NOT NULL", f"TRIM({col})!=''"]
params: List[Any] = []
fts_params: List[Any] = []
if groups:
group_where, group_params = build_smartlist_where(groups)
where_parts.append(f"({group_where})")
params.extend(group_params)
fts_sql, fts_params = _build_fts_prefilter(groups)
else:
fts_sql = ""
where_clause = " AND ".join(where_parts)
sql = f"""
SELECT TRIM({col}) AS value, COUNT(*) AS cnt
FROM items i
LEFT JOIN meta m ON m.rel = i.rel
WHERE {where_clause}{fts_sql}
GROUP BY value
ORDER BY value COLLATE NOCASE
"""
rows = conn.execute(sql, (*params, *fts_params)).fetchall()
return [(row[0], row[1]) for row in rows]
# ----------------------------- Stats ------------------------------------------
def stats(conn: sqlite3.Connection) -> Dict[str, Any]:
+164 -64
View File
@@ -497,7 +497,171 @@ def _entry_xml_from_row(row) -> str:
def health():
return PlainTextResponse("ok")
# IMPORTANT: Specific routes must come before catch-all routes
# Smart list routes MUST be defined before /opds/{path:path}
@app.get("/opds/smart", response_class=Response)
def opds_smart_lists(_=Depends(require_basic)):
lists = _load_smartlists()
tpl = env.get_template("entry.xml.j2")
entries = []
for sl in lists:
href = f"/opds/smart/{quote(sl['slug'])}"
entries.append(
tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
updated=now_rfc3339(),
title=sl["name"],
is_dir=True,
href_abs=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
)
)
xml = _feed(entries, title="Smart Lists", self_href="/opds/smart")
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/smart/{slug}/{value:path}", response_class=Response)
def opds_smart_list_grouped(slug: str, value: str, page: int = 1, _=Depends(require_basic)):
"""Show comics from a dynamic smart list filtered by a specific field value."""
lists = _load_smartlists()
sl = next((x for x in lists if x.get("slug") == slug), None)
if not sl:
raise HTTPException(404, "Smart list not found")
group_by_field = (sl.get("group_by") or "").strip()
if not group_by_field:
raise HTTPException(400, "This smart list is not configured for grouping")
# Build modified groups that include the field=value filter
base_groups = sl.get("groups") or []
# Add a new group with a single rule: field equals value
additional_rule = {
"not": False,
"field": group_by_field,
"op": "equals",
"value": value
}
# Combine with existing groups using AND logic
# If there are existing groups, we need to ensure they're all satisfied along with the new filter
if base_groups:
# Add the field filter to each existing group
modified_groups = []
for grp in base_groups:
modified_grp = {"rules": grp.get("rules", []) + [additional_rule]}
modified_groups.append(modified_grp)
else:
# No existing groups, just filter by the field value
modified_groups = [{"rules": [additional_rule]}]
sort = (sl.get("sort") or "issued_desc").lower()
distinct_by = (sl.get("distinct_by") or "").strip().lower()
distinct_mode = (sl.get("distinct_mode") or "latest").strip().lower()
distinct_flag = distinct_mode if distinct_by == "series_volume" else False
sl_limit = int(sl.get("limit") or 0)
# paging
page = max(1, int(page))
page_size = PAGE_SIZE
start = (page - 1) * page_size
effective_page_size = page_size if sl_limit == 0 else max(0, min(page_size, sl_limit - start))
conn = db.connect()
try:
rows = db.smartlist_query(conn, modified_groups, sort, effective_page_size, start, distinct_flag)
total = db.smartlist_count(conn, modified_groups)
finally:
conn.close()
total_for_nav = min(total, sl_limit) if sl_limit > 0 else total
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/smart/{quote(slug)}/{quote(value, safe='')}?page={page}"
next_href = None
if (start + len(rows)) < total_for_nav:
next_href = f"/opds/smart/{quote(slug)}/{quote(value, safe='')}?page={page+1}"
title = f"{sl['name']}{value}"
xml = _feed(entries_xml, title=title, self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/smart/{slug}", response_class=Response)
def opds_smart_list(slug: str, page: int = 1, _=Depends(require_basic)):
lists = _load_smartlists()
sl = next((x for x in lists if x.get("slug") == slug), None)
if not sl:
raise HTTPException(404, "Smart list not found")
groups = sl.get("groups") or []
# Check if this is a dynamic smart list (group_by is set)
group_by_field = (sl.get("group_by") or "").strip()
if group_by_field:
# Dynamic mode: show distinct values as navigation folders
conn = db.connect()
try:
distinct_values = db.get_distinct_field_values(conn, group_by_field, groups)
finally:
conn.close()
tpl = env.get_template("entry.xml.j2")
entries = []
for value, count in distinct_values:
# URL-encode the value for the path
encoded_value = quote(value, safe='')
href = f"/opds/smart/{quote(slug)}/{encoded_value}"
title = f"{value} ({count})"
entries.append(
tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
updated=now_rfc3339(),
title=title,
is_dir=True,
href_abs=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
)
)
xml = _feed(entries, title=sl["name"], self_href=f"/opds/smart/{quote(slug)}")
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# Normal mode: show comics directly
sort = (sl.get("sort") or "issued_desc").lower()
# Distinct handling (series+volume) + mode
distinct_by = (sl.get("distinct_by") or "").strip().lower()
distinct_mode = (sl.get("distinct_mode") or "latest").strip().lower()
distinct_flag = distinct_mode if distinct_by == "series_volume" else False # db.smartlist_query expects False | "latest" | "oldest"
# Hard cap per list
sl_limit = int(sl.get("limit") or 0)
# paging
page = max(1, int(page))
page_size = PAGE_SIZE
start = (page - 1) * page_size
# effective page size when a hard cap exists
effective_page_size = page_size if sl_limit == 0 else max(0, min(page_size, sl_limit - start))
conn = db.connect()
try:
rows = db.smartlist_query(conn, groups, sort, effective_page_size, start, distinct_flag)
total = db.smartlist_count(conn, groups)
finally:
conn.close()
# Total for navigation honors the hard cap
total_for_nav = min(total, sl_limit) if sl_limit > 0 else total
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/smart/{quote(slug)}?page={page}"
next_href = None
if (start + len(rows)) < total_for_nav:
next_href = f"/opds/smart/{quote(slug)}?page={page + 1}"
xml = _feed(entries_xml, title=sl["name"], self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# Catch-all browse route - MUST come after specific /opds routes
@app.get("/opds/{path:path}", response_class=Response)
def browse(path: str, page: int = 1, _=Depends(require_basic)):
path = path.strip("/")
@@ -912,70 +1076,6 @@ def _save_smartlists(lists: list[dict]) -> None:
SMARTLISTS_PATH.parent.mkdir(parents=True, exist_ok=True)
SMARTLISTS_PATH.write_text(json.dumps(lists, ensure_ascii=False, indent=0), encoding="utf-8")
@app.get("/opds/smart", response_class=Response)
def opds_smart_lists(_=Depends(require_basic)):
lists = _load_smartlists()
tpl = env.get_template("entry.xml.j2")
entries = []
for sl in lists:
href = f"/opds/smart/{quote(sl['slug'])}"
entries.append(
tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
updated=now_rfc3339(),
title=sl["name"],
is_dir=True,
href_abs=f"{SERVER_BASE.rstrip('/')}{_abs_url(href)}",
)
)
xml = _feed(entries, title="Smart Lists", self_href="/opds/smart")
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/smart/{slug}", response_class=Response)
def opds_smart_list(slug: str, page: int = 1, _=Depends(require_basic)):
lists = _load_smartlists()
sl = next((x for x in lists if x.get("slug") == slug), None)
if not sl:
raise HTTPException(404, "Smart list not found")
groups = sl.get("groups") or []
sort = (sl.get("sort") or "issued_desc").lower()
# Distinct handling (series+volume) + mode
distinct_by = (sl.get("distinct_by") or "").strip().lower()
distinct_mode = (sl.get("distinct_mode") or "latest").strip().lower()
distinct_flag = distinct_mode if distinct_by == "series_volume" else False # db.smartlist_query expects False | "latest" | "oldest"
# Hard cap per list
sl_limit = int(sl.get("limit") or 0)
# paging
page = max(1, int(page))
page_size = PAGE_SIZE
start = (page - 1) * page_size
# effective page size when a hard cap exists
effective_page_size = page_size if sl_limit == 0 else max(0, min(page_size, sl_limit - start))
conn = db.connect()
try:
rows = db.smartlist_query(conn, groups, sort, effective_page_size, start, distinct_flag)
total = db.smartlist_count(conn, groups)
finally:
conn.close()
# Total for navigation honors the hard cap
total_for_nav = min(total, sl_limit) if sl_limit > 0 else total
entries_xml = [_entry_xml_from_row(r) for r in rows]
self_href = f"/opds/smart/{quote(slug)}?page={page}"
next_href = None
if (start + len(rows)) < total_for_nav:
next_href = f"/opds/smart/{quote(slug)}?page={page + 1}"
xml = _feed(entries_xml, title=sl["name"], self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/search", response_class=HTMLResponse)
def smartlists_page(_=Depends(require_basic)):
tpl = env.get_template("smartlists.html")
+26 -3
View File
@@ -39,6 +39,22 @@
<hr class="my-3">
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label">Group By (Dynamic)</label>
<select id="groupBy" class="form-select">
<option value="">None (regular smart list)</option>
<option value="writer">Writer</option>
<option value="publisher">Publisher</option>
<option value="series">Series</option>
<option value="format">Format</option>
<option value="year">Year</option>
<option value="tags">Tags</option>
<option value="characters">Characters</option>
<option value="teams">Teams</option>
<option value="genre">Genre</option>
</select>
<div class="form-text small">Auto-create sub-lists per value</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Sort</label>
<select id="sort" class="form-select">
@@ -53,6 +69,9 @@
<input id="limit" class="form-control" type="number" min="0" value="0" />
<div class="form-text">0 = unlimited</div>
</div>
</div>
<div class="row g-2 mt-2" id="distinctWrap">
<div class="col-6 col-md-3">
<label class="form-label">Distinct</label>
<select id="distinctBy" class="form-select">
@@ -60,7 +79,7 @@
<option value="series_volume">Series + Volume</option>
</select>
</div>
<div class="col-12 col-md-3" id="distinctModeWrap" style="display:none;">
<div class="col-6 col-md-3" id="distinctModeWrap" style="display:none;">
<label class="form-label">Pick</label>
<div class="d-flex gap-2">
<div class="form-check">
@@ -191,6 +210,7 @@
function resetForm() {
document.getElementById('listName').value = '';
document.getElementById('groupBy').value = '';
document.getElementById('sort').value = 'issued_desc';
document.getElementById('limit').value = '0';
distinctBySel.value = '';
@@ -208,11 +228,12 @@
const col = document.createElement('div');
col.className = 'col-12';
const distinctTxt = l.distinct_by ? `${l.distinct_by} (${l.distinct_mode || 'latest'})` : '—';
const groupByTxt = l.group_by ? `<span class="badge bg-info">Dynamic: ${l.group_by}</span>` : '';
col.innerHTML = `
<div class="card h-100">
<div class="card-body d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${l.name}</div>
<div class="fw-semibold">${l.name} ${groupByTxt}</div>
<div class="small text-secondary">
${(l.groups||[]).map((g,i)=>'Group '+(i+1)+': '+g.rules.map(r => (r.not?'NOT ':'')+r.field+' '+r.op+' "'+String(r.value||'').replace(/"/g,'&quot;')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'}
</div>
@@ -227,6 +248,7 @@
</div>`;
col.querySelector('.edit').onclick = () => {
document.getElementById('listName').value = l.name;
document.getElementById('groupBy').value = l.group_by || '';
document.getElementById('sort').value = l.sort || 'issued_desc';
document.getElementById('limit').value = l.limit || 0;
distinctBySel.value = l.distinct_by || '';
@@ -264,6 +286,7 @@
const name = document.getElementById('listName').value.trim();
const groups = readGroups();
if (!name || groups.length === 0) { alert('Please provide a name and at least one rule.'); return; }
const group_by = document.getElementById('groupBy').value;
const sort = document.getElementById('sort').value;
const limit = parseInt(document.getElementById('limit').value || '0', 10);
const distinct_by = distinctBySel.value; // '' or 'series_volume'
@@ -272,7 +295,7 @@
const lists = await loadLists();
const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'list');
const existing = lists.find(l => l.slug === slug) || lists.find(l => l.name === name);
const record = { name, slug, groups, sort, limit, distinct_by, distinct_mode };
const record = { name, slug, groups, sort, limit, distinct_by, distinct_mode, group_by };
if (existing) Object.assign(existing, record); else lists.push(record);
await saveLists(lists);
resetForm();
+89 -1
View File
@@ -127,9 +127,97 @@ So you get a de-duplicated "what's the newest issue for each series?" view.
- "Latest Image series":
- Rules: `publisher = "Image Comics"`, `year >= 2018`
- Distinct by series: on
→ One newest issue per Image series since 2018.
### Dynamic Smart Lists (Auto-Grouping)
Dynamic Smart Lists automatically create sub-folders based on distinct values in a metadata field. Instead of manually creating one smart list per writer, publisher, or series, you can create a single dynamic smart list that generates them automatically.
#### How It Works
When you set the **Group By** field to a metadata field (e.g., `writer`, `publisher`, `format`):
1. The smart list becomes a navigation folder showing all distinct values for that field
2. Each value appears as a sub-folder with a count (e.g., "Brian K. Vaughan (47)")
3. Clicking a sub-folder shows all comics matching both:
- Your smart list filters (if any)
- That specific field value
#### Example: Browse by Writer
Create a smart list named "All Writers":
- **Group By**: `writer`
- **Rules**: (optional) Add filters to narrow down, e.g., `year >= 2020`
- **Sort**: `series_number` or `issued_desc`
Result: You get a folder structure like:
```
All Writers/
├── Brian K. Vaughan (47)
├── Ed Brubaker (92)
├── Grant Morrison (156)
├── Jeff Lemire (73)
└── ...
```
#### Example: Browse Trade Paperbacks by Series
Create "TPB by Series":
- **Group By**: `series`
- **Rules**: `format equals "TPB"`
- **Sort**: `series_number`
Result: Only TPBs, organized by series name.
#### Supported Group By Fields
- `writer` - Group by writer/author
- `publisher` - Group by publisher
- `series` - Group by series name
- `format` - Group by format (TPB, Hardcover, etc.)
- `year` - Group by publication year
- `tags` - Group by tags
- `characters` - Group by character appearances
- `teams` - Group by team appearances
- `genre` - Group by genre
#### Combining with Regular Filters
Dynamic smart lists work with all regular smart list features:
- **Filters**: Pre-filter comics before grouping (e.g., only 2020+ or only Image Comics)
- **Distinct**: De-duplicate by series+volume
- **Limit**: Cap results per sub-folder
- **Sort**: Control ordering within each group
#### JSON Configuration Example
```json
{
"name": "Recent Comics by Writer",
"slug": "recent-by-writer",
"group_by": "writer",
"groups": [
{
"rules": [
{
"not": false,
"field": "year",
"op": ">=",
"value": "2020"
}
]
}
],
"sort": "issued_desc",
"limit": 0,
"distinct_by": "",
"distinct_mode": "latest"
}
```
This creates a dynamic list showing all writers who published comics since 2020, with each writer's comics sorted by newest first.
### Screenshot
![](img/smartlists.PNG)