kryten-webqueue 0.16.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/CHANGELOG.md +9 -0
  2. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/db.py +42 -2
  4. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/catalog.py +8 -4
  5. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/pages.py +5 -4
  6. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/catalog/browse.html +34 -6
  7. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/pyproject.toml +1 -1
  8. kryten_webqueue-0.17.0/tests/test_search_facets.py +81 -0
  9. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/.gitignore +0 -0
  12. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/README.md +0 -0
  13. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/config.example.json +0 -0
  14. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/deploy/kryten-webqueue.service +0 -0
  15. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/deploy/nginx-queue.conf +0 -0
  16. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  17. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/IMPL_API_GATE.md +0 -0
  18. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/IMPL_ECONOMY.md +0 -0
  19. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  20. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/IMPL_ROBOT.md +0 -0
  21. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  22. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/PRE_PLAN_GAPS.md +0 -0
  23. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/PRODUCT_PLAN.md +0 -0
  24. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  25. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/docs/UX_POLISH_PLAN.md +0 -0
  26. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/__init__.py +0 -0
  27. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/__main__.py +0 -0
  28. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  29. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/api_gate/client.py +0 -0
  30. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/app.py +0 -0
  31. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/__init__.py +0 -0
  32. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/otp.py +0 -0
  33. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  34. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/session.py +0 -0
  35. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/__init__.py +0 -0
  36. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/images.py +0 -0
  37. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  38. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/sync.py +0 -0
  39. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/config.py +0 -0
  40. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/__init__.py +0 -0
  41. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  42. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  43. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  44. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  45. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  46. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  47. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  48. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  49. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/__init__.py +0 -0
  50. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  51. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/manager.py +0 -0
  52. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/tasks.py +0 -0
  53. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/logging_config.py +0 -0
  54. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/__init__.py +0 -0
  55. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  56. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/fire.py +0 -0
  57. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/importer.py +0 -0
  58. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/ordering.py +0 -0
  59. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  60. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/promos/__init__.py +0 -0
  61. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/promos/director.py +0 -0
  62. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/__init__.py +0 -0
  63. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/ordering.py +0 -0
  64. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/poller.py +0 -0
  65. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/presence.py +0 -0
  66. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/shadow.py +0 -0
  67. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/__init__.py +0 -0
  68. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  69. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  70. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  71. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  72. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  73. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  74. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/auth.py +0 -0
  75. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/queue.py +0 -0
  76. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/user.py +0 -0
  77. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/static/css/main.css +0 -0
  78. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/static/js/main.js +0 -0
  79. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/index.html +0 -0
  80. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  81. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  82. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  83. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  84. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/auth/login.html +0 -0
  85. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/base.html +0 -0
  86. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  90. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_promo_director.py +0 -0
  103. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_promo_pool_exclusion.py +0 -0
  104. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_queue_announce.py +0 -0
  105. {kryten_webqueue-0.16.0 → kryten_webqueue-0.17.0}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.17.0] — 2026-06-17
10
+
11
+ ### Added
12
+
13
+ - **Search now combines with category/tag filters.** A free-text search ANDs with the selected category and/or tag instead of ignoring them. `db.search()` / `db.search_count()` accept `category` and `tag`; the `/catalog/search` page and JSON route pass them through and keep the dropdowns populated/selected; `applyFacets()` includes the active facets when a query is present. (Shared `_facet_filter()` SQL helper keeps browse and search behavior identical.)
14
+ - **Clear empty-results messaging.** When a search and/or facet filter returns nothing, the catalog page now explains *why* — naming the active query and filters — and offers one-click escapes ("Search without filters", "Clear filters", "Back to browse") instead of a bare "No items found."
15
+
16
+ [0.17.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.17.0
17
+
9
18
  ## [0.16.0] — 2026-06-17
10
19
 
11
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -59,6 +59,36 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
59
59
  return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
60
60
 
61
61
 
62
+ def _facet_filter(alias: str, category: str | None, tag: str | None) -> tuple[str, list]:
63
+ """SQL fragment (+ params) AND-filtering by a category slug and/or tag name.
64
+
65
+ Each filter is an ``AND friendly_token IN (...)`` subquery on the catalog row
66
+ under ``alias``; an absent filter contributes nothing. Shared by browse() and
67
+ search() so the two paths narrow results identically.
68
+ """
69
+ sql = ""
70
+ params: list = []
71
+ if category:
72
+ sql += f"""
73
+ AND {alias}.friendly_token IN (
74
+ SELECT cc.friendly_token FROM catalog_categories cc
75
+ JOIN categories cat ON cc.category_id = cat.id
76
+ WHERE cat.slug = ?
77
+ )
78
+ """
79
+ params.append(category)
80
+ if tag:
81
+ sql += f"""
82
+ AND {alias}.friendly_token IN (
83
+ SELECT ct.friendly_token FROM catalog_tags ct
84
+ JOIN tags t ON ct.tag_id = t.id
85
+ WHERE t.name = ?
86
+ )
87
+ """
88
+ params.append(tag)
89
+ return sql, params
90
+
91
+
62
92
  # Default quality-weighted ordering (see browse() for rationale).
63
93
  _DEFAULT_ORDER = """
64
94
  ORDER BY
@@ -446,7 +476,8 @@ class Database:
446
476
  row = await self._fetch_one(query, params)
447
477
  return row["cnt"] if row else 0
448
478
 
449
- async def search(self, query_text: str, *, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
479
+ async def search(self, query_text: str, *, category: str | None = None, tag: str | None = None,
480
+ page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
450
481
  sql = """
451
482
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
452
483
  rank AS relevance
@@ -464,6 +495,11 @@ class Database:
464
495
  excl_sql, excl_params = _hidden_exclusion("c")
465
496
  sql += excl_sql
466
497
  params.extend(excl_params)
498
+ # Category/tag facets AND with the text match (same subqueries browse()
499
+ # uses), so a search can be narrowed by the selected facets.
500
+ facet_sql, facet_params = _facet_filter("c", category, tag)
501
+ sql += facet_sql
502
+ params.extend(facet_params)
467
503
  # Relevance is the natural default for a text query; other sort keys let
468
504
  # the user reorder the matched set explicitly.
469
505
  sql += " ORDER BY rank " if (sort or "default") == "default" else _browse_order_clause(sort)
@@ -471,7 +507,8 @@ class Database:
471
507
  params.extend([per_page, (page - 1) * per_page])
472
508
  return await self._fetch_all(sql, params)
473
509
 
474
- async def search_count(self, query_text: str, *, show_hidden: bool = False) -> int:
510
+ async def search_count(self, query_text: str, *, category: str | None = None, tag: str | None = None,
511
+ show_hidden: bool = False) -> int:
475
512
  sql = """
476
513
  SELECT COUNT(*) as cnt
477
514
  FROM catalog_fts fts
@@ -488,6 +525,9 @@ class Database:
488
525
  excl_sql, excl_params = _hidden_exclusion("c")
489
526
  sql += excl_sql
490
527
  params.extend(excl_params)
528
+ facet_sql, facet_params = _facet_filter("c", category, tag)
529
+ sql += facet_sql
530
+ params.extend(facet_params)
491
531
  row = await self._fetch_one(sql, params)
492
532
  return row["cnt"] if row else 0
493
533
 
@@ -19,15 +19,19 @@ async def browse(request: Request, category: str | None = None, tag: str | None
19
19
 
20
20
 
21
21
  @router.get("/search")
22
- async def search(request: Request, q: str = "", page: int = 1, show_hidden: int = 0,
22
+ async def search(request: Request, q: str = "", category: str | None = None, tag: str | None = None,
23
+ page: int = 1, show_hidden: int = 0,
23
24
  sort: str = "default", user: dict = Depends(get_current_user)):
24
- """Full-text search of catalog."""
25
+ """Full-text search of catalog, optionally narrowed by category/tag."""
25
26
  if not q.strip():
26
27
  raise HTTPException(400, "Query required")
27
28
  db = request.app.state.db
28
29
  show_hidden = bool(show_hidden) and (user.get("rank") or 0) >= 3
29
- items = await db.search(q, page=page, show_hidden=show_hidden, sort=sort)
30
- return {"items": items, "query": q, "page": page, "sort": sort}
30
+ items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort)
31
+ categories = await db.get_categories(show_hidden=show_hidden)
32
+ tags = await db.get_tags(show_hidden=show_hidden)
33
+ return {"items": items, "categories": categories, "tags": tags,
34
+ "query": q, "active_category": category, "active_tag": tag, "page": page, "sort": sort}
31
35
 
32
36
 
33
37
  @router.get("/item/{friendly_token}")
@@ -107,6 +107,7 @@ async def catalog_browse_page(request: Request, category: str | None = None,
107
107
 
108
108
  @router.get("/catalog/search", response_class=HTMLResponse)
109
109
  async def catalog_search_page(request: Request, q: str = "", page: int = 1,
110
+ category: str | None = None, tag: str | None = None,
110
111
  show_hidden: int = 0, sort: str = "default"):
111
112
  user = _get_user_or_none(request)
112
113
  if not user:
@@ -117,8 +118,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
117
118
  is_admin = (user.get("rank") or 0) >= 3
118
119
  show_hidden = bool(show_hidden) and is_admin
119
120
  sort = sort if sort in _VALID_SORTS else "default"
120
- items = await db.search(q, page=page, show_hidden=show_hidden, sort=sort)
121
- total = await db.search_count(q, show_hidden=show_hidden)
121
+ items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort)
122
+ total = await db.search_count(q, category=category, tag=tag, show_hidden=show_hidden)
122
123
  total_pages = max(1, (total + 23) // 24)
123
124
  categories = await db.get_categories(show_hidden=show_hidden)
124
125
  tags = await db.get_tags(show_hidden=show_hidden)
@@ -130,8 +131,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
130
131
  "tags": tags,
131
132
  "page": page,
132
133
  "total_pages": total_pages,
133
- "active_category": None,
134
- "active_tag": None,
134
+ "active_category": category,
135
+ "active_tag": tag,
135
136
  "query": q,
136
137
  "is_admin": is_admin,
137
138
  "show_hidden": show_hidden,
@@ -101,13 +101,41 @@
101
101
  {% endfor %}
102
102
  </div>
103
103
 
104
+ {% set sort_q = ('&sort=' ~ sort) if (sort and sort != 'default') else '' %}
104
105
  {% if not items %}
105
106
  <div class="empty-state">
106
- <p>No items found.</p>
107
+ {% if query and (active_category or active_tag) %}
108
+ <p>No results for "{{ query }}" within the selected filter{% if active_category and active_tag %}s{% endif %}.</p>
109
+ <p class="muted">
110
+ Active:
111
+ {% if active_category %}<strong>category</strong>{% endif %}
112
+ {% if active_category and active_tag %} and {% endif %}
113
+ {% if active_tag %}<strong>tag “{{ active_tag }}”</strong>{% endif %}.
114
+ Try widening your search.
115
+ </p>
116
+ <p>
117
+ <a class="btn btn-sm" href="/catalog/search?q={{ query | urlencode }}{{ sort_q }}">Search without filters</a>
118
+ <a class="btn btn-sm" href="/catalog/browse">Clear search</a>
119
+ </p>
120
+ {% elif query %}
121
+ <p>No results for "{{ query }}".</p>
122
+ <p class="muted">Check the spelling or try fewer / different words.</p>
123
+ <p><a class="btn btn-sm" href="/catalog/browse">Back to browse</a></p>
124
+ {% elif active_category or active_tag %}
125
+ <p>No items match the selected filter{% if active_category and active_tag %}s{% endif %}.</p>
126
+ <p class="muted">
127
+ Active:
128
+ {% if active_category %}<strong>category</strong>{% endif %}
129
+ {% if active_category and active_tag %} and {% endif %}
130
+ {% if active_tag %}<strong>tag “{{ active_tag }}”</strong>{% endif %}.
131
+ </p>
132
+ <p><a class="btn btn-sm" href="/catalog/browse">Clear filters</a></p>
133
+ {% else %}
134
+ <p>No items found.</p>
135
+ {% endif %}
107
136
  </div>
108
137
  {% endif %}
109
138
 
110
- {% set sort_q = ('&sort=' ~ sort) if (sort and sort != 'default') else '' %}
111
139
  <div class="pagination">
112
140
  {% if page > 1 %}
113
141
  <a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}{{ sort_q }}" class="btn btn-sm">&larr; Prev</a>
@@ -141,13 +169,13 @@ function applyFacets() {
141
169
  const sort = document.getElementById('sort-select').value;
142
170
  try { localStorage.setItem('browse_sort', sort); } catch (e) { /* ignore */ }
143
171
  const params = new URLSearchParams();
144
- // On a search results page, preserve the query and stay on /catalog/search.
172
+ // On a search results page keep the query AND apply the facets so search
173
+ // results are narrowed by category/tag (they AND together server-side).
145
174
  if (CURRENT_QUERY) {
146
175
  params.set('q', CURRENT_QUERY);
147
- } else {
148
- if (cat) params.set('category', cat);
149
- if (tag) params.set('tag', tag);
150
176
  }
177
+ if (cat) params.set('category', cat);
178
+ if (tag) params.set('tag', tag);
151
179
  if (sort && sort !== 'default') params.set('sort', sort);
152
180
  const base = CURRENT_QUERY ? '/catalog/search' : '/catalog/browse';
153
181
  const qs = params.toString();
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,81 @@
1
+ """Search combines with category/tag facets (Item 4 of the UX polish plan).
2
+
3
+ A free-text search now ANDs with the selected category and/or tag, mirroring how
4
+ browse() already filters. These tests pin that behavior at the DB layer.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from kryten_webqueue.catalog.db import Database
10
+
11
+ MEDIACMS = "https://www.dropsugar.com"
12
+
13
+
14
+ @pytest.fixture
15
+ async def db(tmp_path):
16
+ database = Database(str(tmp_path / "search_facets.db"))
17
+ await database.connect()
18
+ await database.run_migrations()
19
+ yield database
20
+ await database.close()
21
+
22
+
23
+ async def _add_catalog(db, token, title):
24
+ await db.insert_catalog({
25
+ "friendly_token": token,
26
+ "title": title,
27
+ "description": "",
28
+ "duration_sec": 600,
29
+ "manifest_url": f"{MEDIACMS}/api/v1/media/cytube/{token}.json?format=json",
30
+ "thumbnail_url": "",
31
+ "synced_at": "2026-01-01T00:00:00+00:00",
32
+ })
33
+
34
+
35
+ async def test_search_ands_with_category_and_tag(db):
36
+ # Three items all matching the query "Dragon", differentiated by facets.
37
+ await _add_catalog(db, "tok_action", "Dragon Action")
38
+ await _add_catalog(db, "tok_comedy", "Dragon Comedy")
39
+ await _add_catalog(db, "tok_plain", "Dragon Plain")
40
+
41
+ action_id = await db.upsert_category("Action")
42
+ comedy_id = await db.upsert_category("Comedy")
43
+ await db.set_catalog_categories("tok_action", [action_id])
44
+ await db.set_catalog_categories("tok_comedy", [comedy_id])
45
+
46
+ epic_tag = await db.upsert_tag("Epic")
47
+ await db.set_catalog_tags("tok_action", [epic_tag])
48
+
49
+ # Resolve the Action slug (upsert derives it).
50
+ action_slug = next(c["slug"] for c in await db.get_categories() if c["name"] == "Action")
51
+
52
+ # Plain query: all three match.
53
+ assert {r["friendly_token"] for r in await db.search("Dragon")} == {
54
+ "tok_action", "tok_comedy", "tok_plain",
55
+ }
56
+ assert await db.search_count("Dragon") == 3
57
+
58
+ # Query + category: only the Action item.
59
+ cat_results = {r["friendly_token"] for r in await db.search("Dragon", category=action_slug)}
60
+ assert cat_results == {"tok_action"}
61
+ assert await db.search_count("Dragon", category=action_slug) == 1
62
+
63
+ # Query + tag: only the Epic-tagged item.
64
+ tag_results = {r["friendly_token"] for r in await db.search("Dragon", tag="Epic")}
65
+ assert tag_results == {"tok_action"}
66
+ assert await db.search_count("Dragon", tag="Epic") == 1
67
+
68
+ # Query + category + tag that don't co-occur: empty (true intersection).
69
+ assert await db.search("Dragon", category=action_slug, tag="Nonexistent") == []
70
+ assert await db.search_count("Dragon", category=action_slug, tag="Nonexistent") == 0
71
+
72
+
73
+ async def test_search_facet_with_no_text_match_is_empty(db):
74
+ await _add_catalog(db, "tok1", "Comedy Night")
75
+ comedy_id = await db.upsert_category("Comedy")
76
+ await db.set_catalog_categories("tok1", [comedy_id])
77
+ comedy_slug = next(c["slug"] for c in await db.get_categories() if c["name"] == "Comedy")
78
+
79
+ # The category matches the item, but the query does not -> no results.
80
+ assert await db.search("Horror", category=comedy_slug) == []
81
+ assert await db.search_count("Horror", category=comedy_slug) == 0