Skip to content

Commit 166c88a

Browse files
appaKappaKclaude
andcommitted
v1.7.0: multi-path serving, custom filenames, serve from Library
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 22a5b59 commit 166c88a

8 files changed

Lines changed: 327 additions & 23 deletions

File tree

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ A Python desktop app that fetches, parses, and deduplicates multiple Pi-hole blo
1717
- Progress bar and per-source status during combine
1818
- Save combined lists to a local library organized in folders
1919
- Load saved lists back into the combiner to merge with new sources
20-
- **Serve List** — hosts the combined list over HTTP on your LAN so Pi-hole can pull it directly via gravity
20+
- **Serve List** — hosts combined lists over HTTP on your LAN so Pi-hole can pull them directly via gravity; name each served file (e.g. `general.txt`, `tvs.txt`) so multiple lists can be served simultaneously at different URLs for Pi-hole group management
21+
- **Serve from Library** — serve any saved list directly from the Library tab without re-combining
2122
- Dark mode desktop GUI (customtkinter)
2223
- Window and taskbar icon
2324
- Install desktop shortcut / launcher entry (Linux)
@@ -45,13 +46,16 @@ The app opens with three tabs:
4546
### Pushing to Pi-hole
4647

4748
1. Build your combined list in the Combine tab
48-
2. Click **Serve List** — the `` indicator turns green and a URL appears (e.g. `http://YOUR.IP.GO.HERE:8765/blocklist.txt`)
49-
3. Copy the URL and paste it into Pi-hole's **Adlists** page
50-
4. Run **Update Gravity** in Pi-hole — it fetches and caches the list
51-
5. Click **Stop Serving** or close the app — Pi-hole retains the list from its gravity cache
49+
2. Optionally type a filename (e.g. `general`) — leave blank for the default `blocklist.txt`
50+
3. Click **Serve List** — the `` indicator turns green and a URL appears (e.g. `http://YOUR.IP.GO.HERE:8765/general.txt`)
51+
4. Copy the URL and paste it into Pi-hole's **Adlists** page
52+
5. Run **Update Gravity** in Pi-hole — it fetches and caches the list
53+
6. Click **Stop Serving** or close the app — Pi-hole retains the list from its gravity cache
5254

5355
> Pi-hole and your PC just need to be on the same local network. The server defaults to port **8765**.
5456
57+
> **Tip:** To use Pi-hole's group management, build separate lists (e.g. one for general devices, one for smart TVs) and serve each with a different filename. Each URL is a separate adlist entry in Pi-hole that can be assigned to different groups.
58+
5559
### Output format
5660

5761
When you click *Combine All*, the app produces a plain-text file with a short comment header followed by one domain per line, sorted alphabetically:
@@ -106,6 +110,7 @@ tests/
106110
test_fetcher.py
107111
test_combiner.py
108112
test_database.py
113+
test_server.py
109114
```
110115

111116
## Data storage
@@ -135,6 +140,11 @@ pytest tests/
135140

136141
## Recent updates
137142

143+
**v1.7.0**
144+
- **Multi-path serving** — the HTTP server now supports serving multiple lists simultaneously at different URL paths; enables Pi-hole group management with separate lists per device group
145+
- **Custom serve filename** — name the served file in the Combine tab (e.g. `general``/general.txt`) instead of the fixed `/blocklist.txt`
146+
- **Serve from Library** — serve any saved list directly from the Library tab with its own URL, without needing to re-combine
147+
138148
**v1.6.0**
139149
- **Settings persistence** — port and Blocklist/Allowlist choice now saved to the local database; restored automatically on next launch
140150
- **Source metadata** — when saving a combined list to the library, the source URLs are stored alongside it; loading the list back into the Combine tab restores the individual URLs (not just the content blob) so you can see where it came from and re-fetch fresh data

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "piholecombinelist"
7-
version = "1.6.0"
7+
version = "1.7.0"
88
requires-python = ">=3.9"
99
dependencies = [
1010
"requests>=2.28.0",

src/piholecombinelist/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Pi-hole Combined Blocklist Generator."""
2-
# v1.6.0
2+
# v1.7.0
33

4-
__version__ = "1.6.0"
4+
__version__ = "1.7.0"
55

66
from .combiner import ListCombiner
77
from .database import Database

src/piholecombinelist/gui/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(self) -> None:
6767
self._db,
6868
get_combine_tab_cb=lambda: self._combine_tab,
6969
switch_to_combine_cb=lambda: self._tabs.set("Combine"),
70+
server=self._server,
7071
)
7172
self._library_tab.pack(fill="both", expand=True)
7273

src/piholecombinelist/gui/combine_tab.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ def __init__(self, parent, db: Database, switch_to_library_cb, server: ListServe
142142
self._server = server
143143
self._list_type_var = list_type_var
144144

145+
# Whether the Combine tab is currently serving; tracks the active path
146+
self._serving: bool = False
147+
self._serving_path: str = ""
148+
145149
# url → credit name, populated by _extract_urls()
146150
self._url_credits: dict[str, str] = {}
147151

@@ -276,6 +280,13 @@ def _build_ui(self) -> None:
276280
serve_row, text="Serve List", width=110, command=self._toggle_serve
277281
)
278282
self._serve_btn.pack(side="left", padx=(0, 8))
283+
self._serve_name_entry = ctk.CTkEntry(
284+
serve_row, placeholder_text="blocklist", width=120
285+
)
286+
self._serve_name_entry.pack(side="left", padx=(0, 4))
287+
ctk.CTkLabel(serve_row, text=".txt", text_color="gray60").pack(
288+
side="left", padx=(0, 8)
289+
)
279290
self._serve_url_var = ctk.StringVar()
280291
self._serve_url_entry = ctk.CTkEntry(
281292
serve_row, textvariable=self._serve_url_var, width=280, state="disabled",
@@ -473,24 +484,42 @@ def _save_to_library(self) -> None:
473484

474485
# ── Serve over HTTP ──────────────────────────────────────────────
475486

487+
def _serve_path_from_name(self) -> str:
488+
"""Build a URL path from the filename entry, defaulting to ``/blocklist.txt``."""
489+
raw = self._serve_name_entry.get().strip()
490+
if not raw:
491+
return "/blocklist.txt"
492+
# Strip .txt if user typed it, we add it ourselves
493+
if raw.lower().endswith(".txt"):
494+
raw = raw[:-4]
495+
slug = re.sub(r'[^a-zA-Z0-9_-]+', '-', raw).strip('-')
496+
return f"/{slug or 'blocklist'}.txt"
497+
476498
def _toggle_serve(self) -> None:
477-
if self._server.is_running:
478-
self._server.stop()
499+
if self._serving:
500+
self._server.remove_path(self._serving_path)
501+
self._serving = False
502+
self._serving_path = ""
479503
self._serve_indicator.configure(text_color="#C0392B")
480504
self._serve_btn.configure(text="Serve List", fg_color=["#3B8ED0", "#1F6AA5"])
505+
self._serve_name_entry.configure(state="normal")
481506
self._serve_url_entry.pack_forget()
482507
self._serve_copy_btn.pack_forget()
483508
else:
484509
content = self._output_box.get("1.0", "end").strip()
485510
if not content:
486511
messagebox.showwarning("Nothing to serve", "Combine sources first.")
487512
return
513+
path = self._serve_path_from_name()
488514
try:
489-
url = self._server.start(content)
515+
url = self._server.add_path(path, content)
490516
except OSError as exc:
491517
messagebox.showerror("Server error", f"Could not start server:\n{exc}")
492518
return
519+
self._serving = True
520+
self._serving_path = path
493521
self._serve_url_var.set(url)
522+
self._serve_name_entry.configure(state="disabled")
494523
self._serve_url_entry.pack(side="left", padx=(0, 8))
495524
self._serve_copy_btn.pack(side="left")
496525
self._serve_indicator.configure(text_color="#27AE60")

src/piholecombinelist/gui/library_tab.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
"""Library tab: browse folders and saved lists, load back into combiner."""
22

33
import json
4+
import re as _re
45
from pathlib import Path
56
from tkinter import filedialog, messagebox, simpledialog
67
from typing import Optional
78

89
import customtkinter as ctk
910

1011
from ..database import Database
12+
from ..server import ListServer
13+
14+
15+
def _slugify(name: str) -> str:
16+
"""Convert a list name to a URL-safe path component."""
17+
slug = name.lower()
18+
slug = _re.sub(r'[^a-z0-9]+', '-', slug)
19+
slug = slug.strip('-')
20+
return slug or "list"
1121

1222

1323
class LibraryTab(ctk.CTkFrame):
1424
"""The Library tab: browse folders and saved lists, load back into combiner."""
1525

16-
def __init__(self, parent, db: Database, get_combine_tab_cb, switch_to_combine_cb) -> None:
26+
def __init__(self, parent, db: Database, get_combine_tab_cb, switch_to_combine_cb,
27+
server: ListServer) -> None:
1728
super().__init__(parent, fg_color="transparent")
1829
self._db = db
1930
self._get_combine_tab = get_combine_tab_cb
2031
self._switch_to_combine = switch_to_combine_cb
32+
self._server = server
2133
self._selected_folder_id: Optional[int] = None # None = root
2234
self._selected_list_id: Optional[int] = None
35+
# list_id → URL path currently being served (e.g. "/my-general-list.txt")
36+
self._served_paths: dict[int, str] = {}
2337

2438
self._build_ui()
2539
self.refresh()
@@ -104,7 +118,7 @@ def _build_ui(self) -> None:
104118

105119
# Move-to row
106120
move_row = ctk.CTkFrame(right, fg_color="transparent")
107-
move_row.grid(row=4, column=0, sticky="ew", padx=10, pady=(0, 10))
121+
move_row.grid(row=4, column=0, sticky="ew", padx=10, pady=(0, 4))
108122
ctk.CTkLabel(move_row, text="Move to folder:").pack(side="left", padx=(0, 8))
109123
self._move_folder_var = ctk.StringVar(value="🏠 Root")
110124
self._move_menu = ctk.CTkOptionMenu(
@@ -115,6 +129,26 @@ def _build_ui(self) -> None:
115129
side="left"
116130
)
117131

132+
# Serve row — host a saved list over HTTP for Pi-hole
133+
serve_row = ctk.CTkFrame(right, fg_color="transparent")
134+
serve_row.grid(row=5, column=0, sticky="ew", padx=10, pady=(0, 10))
135+
self._lib_serve_indicator = ctk.CTkLabel(
136+
serve_row, text="●", text_color="#C0392B", width=16
137+
)
138+
self._lib_serve_indicator.pack(side="left", padx=(0, 4))
139+
self._lib_serve_btn = ctk.CTkButton(
140+
serve_row, text="Serve", width=90, command=self._toggle_lib_serve
141+
)
142+
self._lib_serve_btn.pack(side="left", padx=(0, 8))
143+
self._lib_serve_url_var = ctk.StringVar()
144+
self._lib_serve_url_entry = ctk.CTkEntry(
145+
serve_row, textvariable=self._lib_serve_url_var, width=280, state="disabled",
146+
)
147+
self._lib_serve_copy_btn = ctk.CTkButton(
148+
serve_row, text="Copy URL", width=80, command=self._copy_lib_serve_url
149+
)
150+
# URL entry + copy button hidden until a list is being served
151+
118152
# ── Refresh helpers ──────────────────────────────────────────────
119153

120154
def refresh(self) -> None:
@@ -232,12 +266,27 @@ def _select_list(self, list_id: int) -> None:
232266
self._lib_dupes_label.configure(
233267
text=f"Duplicates removed: {row['duplicates_removed']}"
234268
)
269+
# Reflect serve state for this list
270+
if list_id in self._served_paths:
271+
self._lib_serve_indicator.configure(text_color="#27AE60")
272+
self._lib_serve_btn.configure(text="Stop Serving", fg_color=["#C0392B", "#922B21"])
273+
self._lib_serve_url_var.set(self._server.url_for(self._served_paths[list_id]))
274+
self._lib_serve_url_entry.pack(side="left", padx=(0, 8))
275+
self._lib_serve_copy_btn.pack(side="left")
276+
else:
277+
self._lib_serve_indicator.configure(text_color="#C0392B")
278+
self._lib_serve_btn.configure(text="Serve", fg_color=["#3B8ED0", "#1F6AA5"])
279+
self._lib_serve_url_entry.pack_forget()
280+
self._lib_serve_copy_btn.pack_forget()
235281

236282
def _delete_list(self) -> None:
237283
if self._selected_list_id is None:
238284
messagebox.showinfo("Select a list", "Select a list to delete.")
239285
return
240286
if messagebox.askyesno("Delete list", "Delete this list?", parent=self):
287+
# Stop serving this list if it's active
288+
if self._selected_list_id in self._served_paths:
289+
self._server.remove_path(self._served_paths.pop(self._selected_list_id))
241290
self._db.delete_list(self._selected_list_id)
242291
self._selected_list_id = None
243292
self._content_box.configure(state="normal")
@@ -285,6 +334,52 @@ def _load_into_combiner(self) -> None:
285334
combine_tab.load_content_as_source(f"[library] {row['name']}", row["content"])
286335
self._switch_to_combine()
287336

337+
# ── Serve from Library ─────────────────────────────────────────
338+
339+
def _make_path(self, list_id: int, name: str) -> str:
340+
"""Return a unique ``/slug.txt`` path for *list_id*, avoiding collisions."""
341+
base = _slugify(name)
342+
candidate = f"/{base}.txt"
343+
for lid, p in self._served_paths.items():
344+
if p == candidate and lid != list_id:
345+
candidate = f"/{base}-{list_id}.txt"
346+
break
347+
return candidate
348+
349+
def _toggle_lib_serve(self) -> None:
350+
if self._selected_list_id is None:
351+
messagebox.showinfo("Select a list", "Select a list to serve.")
352+
return
353+
lid = self._selected_list_id
354+
if lid in self._served_paths:
355+
# Stop serving this list
356+
self._server.remove_path(self._served_paths.pop(lid))
357+
self._lib_serve_indicator.configure(text_color="#C0392B")
358+
self._lib_serve_btn.configure(text="Serve", fg_color=["#3B8ED0", "#1F6AA5"])
359+
self._lib_serve_url_entry.pack_forget()
360+
self._lib_serve_copy_btn.pack_forget()
361+
else:
362+
row = self._db.get_list(lid)
363+
if not row:
364+
return
365+
path = self._make_path(lid, row["name"])
366+
try:
367+
url = self._server.add_path(path, row["content"])
368+
except OSError as exc:
369+
messagebox.showerror("Server error", f"Could not start server:\n{exc}")
370+
return
371+
self._served_paths[lid] = path
372+
self._lib_serve_url_var.set(url)
373+
self._lib_serve_url_entry.pack(side="left", padx=(0, 8))
374+
self._lib_serve_copy_btn.pack(side="left")
375+
self._lib_serve_indicator.configure(text_color="#27AE60")
376+
self._lib_serve_btn.configure(text="Stop Serving", fg_color=["#C0392B", "#922B21"])
377+
378+
def _copy_lib_serve_url(self) -> None:
379+
self.clipboard_clear()
380+
self.clipboard_append(self._lib_serve_url_var.get())
381+
messagebox.showinfo("Copied", "URL copied — paste it into Pi-hole's Adlists page,\nthen run gravity.")
382+
288383
def _move_list(self) -> None:
289384
if self._selected_list_id is None:
290385
messagebox.showinfo("Select a list", "Select a list to move.")

src/piholecombinelist/server.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""Minimal HTTP server that serves the combined blocklist as plain text."""
2-
# v1.0.0
1+
"""Minimal HTTP server that serves combined blocklists as plain text."""
2+
# v1.7.0
33

44
import http.server
55
import socket
@@ -25,29 +25,63 @@ class _ReuseAddrServer(socketserver.TCPServer):
2525

2626

2727
class ListServer:
28-
"""Serves a blocklist as plain text over HTTP on /blocklist.txt."""
28+
"""Serves one or more blocklists as plain text over HTTP on distinct paths."""
2929

3030
DEFAULT_PORT = 8765
3131

3232
def __init__(self, port: int = DEFAULT_PORT) -> None:
3333
self._port = port
3434
self._server: Optional[_ReuseAddrServer] = None
3535
self._thread: Optional[threading.Thread] = None
36+
self._paths: dict[str, bytes] = {}
3637

3738
@property
3839
def is_running(self) -> bool:
3940
return self._server is not None
4041

42+
# ── Public API ────────────────────────────────────────────────
43+
44+
def add_path(self, path: str, content: str) -> str:
45+
"""Register *path* with *content*. Starts the server if needed.
46+
47+
Returns the full URL for that path.
48+
"""
49+
self._paths[path] = content.encode("utf-8")
50+
if not self._server:
51+
self._start_server()
52+
return f"http://{_local_ip()}:{self._port}{path}"
53+
54+
def remove_path(self, path: str) -> None:
55+
"""Remove *path*. Stops the server when no paths remain."""
56+
self._paths.pop(path, None)
57+
if not self._paths:
58+
self._stop_server()
59+
4160
def start(self, content: str) -> str:
42-
"""Start serving *content*. Returns the URL to add to Pi-hole's Adlists."""
43-
if self._server:
44-
self.stop()
61+
"""Compatibility wrapper: serve *content* at ``/blocklist.txt``."""
62+
return self.add_path("/blocklist.txt", content)
4563

46-
payload = content.encode("utf-8")
64+
def stop(self) -> None:
65+
"""Full shutdown: clear all paths and stop the server."""
66+
self._paths.clear()
67+
self._stop_server()
68+
69+
def has_path(self, path: str) -> bool:
70+
return path in self._paths
71+
72+
def url_for(self, path: str) -> str:
73+
"""Return the full URL for an already-registered *path*."""
74+
return f"http://{_local_ip()}:{self._port}{path}"
75+
76+
# ── Internal ──────────────────────────────────────────────────
77+
78+
def _start_server(self) -> None:
79+
paths = self._paths # reference — handler always sees latest entries
4780

4881
class _Handler(http.server.BaseHTTPRequestHandler):
4982
def do_GET(self): # noqa: N802
50-
if self.path in ("/", "/blocklist.txt"):
83+
payload = paths.get(self.path)
84+
if payload is not None:
5185
self.send_response(200)
5286
self.send_header("Content-Type", "text/plain; charset=utf-8")
5387
self.send_header("Content-Length", str(len(payload)))
@@ -65,9 +99,8 @@ def log_message(self, *_):
6599
target=self._server.serve_forever, daemon=True, name="phlist-server"
66100
)
67101
self._thread.start()
68-
return f"http://{_local_ip()}:{self._port}/blocklist.txt"
69102

70-
def stop(self) -> None:
103+
def _stop_server(self) -> None:
71104
if self._server:
72105
self._server.shutdown()
73106
self._server.server_close()

0 commit comments

Comments
 (0)