feat(smartlist): added smartlists as requested in #24
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,'"')+'"').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
@@ -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
|
||||
|
||||

|
||||
Reference in New Issue
Block a user