feat(snot): add search history and purge command

This commit is contained in:
abdus committed 2026-10-28 08:37:23 +00:00
1 parent afdb0ac636
commit 7e8541d5d7
2 files changed
+87 -2

No files matched your search

+72 -1
View File
@@ -162,7 +162,12 @@
<div x-data="app()" class="gapped" @keydown.window="onKeydown"> <div x-data="app()" class="gapped" @keydown.window="onKeydown">
<form @submit.prevent="search" class="search-form"> <form @submit.prevent="search" class="search-form">
<div class="row"> <div class="row">
<input class="input" type="text" x-ref="query" x-model="form.query" placeholder="Search videos" style="width: 300px" /> <input class="input" type="text" x-ref="query" x-model="form.query" list="search-history" placeholder="Search videos" style="width: 300px" />
<datalist id="search-history">
<template x-for="query in searchHistory" :key="query">
<option :value="query"></option>
</template>
</datalist>
<input class="button" type="submit" /> <input class="button" type="submit" />
</div> </div>
</form> </form>
@@ -225,8 +230,38 @@
<script defer src="./alpine@3.x.x.cdn.min.js"></script> <script defer src="./alpine@3.x.x.cdn.min.js"></script>
<script> <script>
const searchHistory = {
/** @returns {string[]} The most recent searches. */
get history() {
try {
const history = JSON.parse(localStorage.getItem("snot.searchHistory") || "[]");
return Array.isArray(history) ? history.filter((query) => typeof query === "string" && query.trim()).slice(0, 40) : [];
} catch (error) {
console.warn("Could not load search history", error);
return [];
}
},
/**
* @param {string} value
* @returns {string[]} The updated searches.
*/
add(value) {
const query = value.trim();
if (!query) return this.history;
const history = [query, ...this.history.filter((item) => item !== query)].slice(0, 40);
try {
localStorage.setItem("snot.searchHistory", JSON.stringify(history));
} catch (error) {
console.warn("Could not save search history", error);
}
return history;
},
};
/** @returns {object} The Alpine application state and methods. */
const app = () => ({ const app = () => ({
results: [], results: [],
searchHistory: [],
errorMessage: "", errorMessage: "",
form: { form: {
query: "", query: "",
@@ -236,9 +271,17 @@
minSizeMB: 0, minSizeMB: 0,
}, },
sort: { by: null, dir: "asc" }, sort: { by: null, dir: "asc" },
/**
* @param {string} url
* @returns {string} The Alfred trigger URL.
*/
buildAlfredURL(url) { buildAlfredURL(url) {
return `alfred://runtrigger/dev.abdus.alfred.media/video/?argument=${encodeURIComponent(url)}`; return `alfred://runtrigger/dev.abdus.alfred.media/video/?argument=${encodeURIComponent(url)}`;
}, },
/**
* @param {string} field The result field to sort by.
* @returns {void}
*/
toggleSort(field) { toggleSort(field) {
if (this.sort.by === field) { if (this.sort.by === field) {
this.sort.dir = this.sort.dir === "asc" ? "desc" : "asc"; this.sort.dir = this.sort.dir === "asc" ? "desc" : "asc";
@@ -247,6 +290,10 @@
this.sort.dir = "asc"; this.sort.dir = "asc";
} }
}, },
/**
* @param {KeyboardEvent} e
* @returns {Promise<void>}
*/
async onKeydown(e) { async onKeydown(e) {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
return; return;
@@ -256,6 +303,7 @@
this.$refs.filterInput.focus(); this.$refs.filterInput.focus();
} }
}, },
/** @returns {object[]} The filtered and sorted results. */
get visibleResults() { get visibleResults() {
const isEmpty = !this.filter.query && this.filter.minSizeMB === 0; const isEmpty = !this.filter.query && this.filter.minSizeMB === 0;
let rows = isEmpty let rows = isEmpty
@@ -284,6 +332,10 @@
{ key: "file_path", value: (it) => it.file_path, label: "File Path" }, { key: "file_path", value: (it) => it.file_path, label: "File Path" },
{ key: "created_at", value: (it) => (it.created_at ? it.created_at.slice(0, 10) : ""), label: "Created At", sortable: true }, { key: "created_at", value: (it) => (it.created_at ? it.created_at.slice(0, 10) : ""), label: "Created At", sortable: true },
], ],
/**
* @param {string} actor
* @returns {string} The actor filter token.
*/
actorToken(actor) { actorToken(actor) {
const escaped = actor.replaceAll('"', '\\"'); const escaped = actor.replaceAll('"', '\\"');
if (escaped.includes(" ")) { if (escaped.includes(" ")) {
@@ -291,11 +343,20 @@
} }
return `actor:${escaped}`; return `actor:${escaped}`;
}, },
/**
* @param {string} actor
* @returns {boolean} Whether the actor is already in the query.
*/
hasActorFilter(actor) { hasActorFilter(actor) {
const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll('"', '\\"'); const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll('"', '\\"');
const pattern = new RegExp(`(^|\\s|\\|)actor:(?:"${escaped}"|${escaped})(?=\\s|\\||$)`, "i"); const pattern = new RegExp(`(^|\\s|\\|)actor:(?:"${escaped}"|${escaped})(?=\\s|\\||$)`, "i");
return pattern.test(this.form.query); return pattern.test(this.form.query);
}, },
/**
* @param {string} actor
* @param {MouseEvent} event
* @returns {void}
*/
addActorFilter(actor, event) { addActorFilter(actor, event) {
if (!actor) { if (!actor) {
return; return;
@@ -315,12 +376,15 @@
this.form.query = this.form.query ? `${this.form.query} ${token}` : token; this.form.query = this.form.query ? `${this.form.query} ${token}` : token;
this.search(); this.search();
}, },
/** @returns {Promise<void>} */
async init() { async init() {
this.searchHistory = searchHistory.history;
if (window.ENV?.query) { if (window.ENV?.query) {
this.form.query = window.ENV?.query ?? ""; this.form.query = window.ENV?.query ?? "";
await this.search(); await this.search();
} }
}, },
/** @returns {string[] | null} Playable URLs, if available. */
get playableURLs() { get playableURLs() {
const playable = this.visibleResults.some((item) => item.file_path.startsWith("/mnt/box/files")); const playable = this.visibleResults.some((item) => item.file_path.startsWith("/mnt/box/files"));
if (!playable) { if (!playable) {
@@ -332,6 +396,7 @@
.sort(() => Math.random() - 0.5); .sort(() => Math.random() - 0.5);
return urls; return urls;
}, },
/** @returns {string | null} The playlist trigger URL, if available. */
get playlistLink() { get playlistLink() {
const urls = this.playableURLs; const urls = this.playableURLs;
if (!urls) { if (!urls) {
@@ -341,6 +406,7 @@
return `alfred://runtrigger/dev.abdus.alfred.media/mpv/?argument=${playlistArg}`; return `alfred://runtrigger/dev.abdus.alfred.media/mpv/?argument=${playlistArg}`;
}, },
/** @returns {string | null} The grid trigger URL, if available. */
get gridLink() { get gridLink() {
const urls = this.playableURLs; const urls = this.playableURLs;
if (!urls) { if (!urls) {
@@ -350,8 +416,10 @@
return `alfred://runtrigger/dev.abdus.alfred.media/gridvid/?argument=${playlistArg}`; return `alfred://runtrigger/dev.abdus.alfred.media/gridvid/?argument=${playlistArg}`;
}, },
/** @returns {Promise<void>} */
async search() { async search() {
this.errorMessage = ""; this.errorMessage = "";
const query = this.form.query.trim();
const response = await fetch("/search", { const response = await fetch("/search", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -363,6 +431,9 @@
return; return;
} }
this.results = await response.json(); this.results = await response.json();
if (query) {
this.searchHistory = searchHistory.add(query);
}
window.app.setTitle(`s·m·u·t - ${this.results.length} results for "${this.form.query}"`); window.app.setTitle(`s·m·u·t - ${this.results.length} results for "${this.form.query}"`);
}, },
}); });
+15 -1
View File
@@ -31,9 +31,10 @@ import click
import httpx import httpx
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
HandlerFunc = Callable[["Request"], "Response"] HandlerFunc = Callable[["Request"], "Response"]
client = httpx.Client(base_url="http://klein:18543", timeout=30) client = httpx.Client(base_url="http://klein.talpa-carat.ts.net:18543", timeout=30)
@dataclasses.dataclass @dataclasses.dataclass
@@ -747,6 +748,19 @@ def scan(paths: list[Path]):
scan_videos(video_paths=list(paths)) scan_videos(video_paths=list(paths))
@cli.command()
def purge() -> None:
"""Remove box entries not seen in the last 24 hours."""
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=24)
response = client.delete(
"/videos",
params={"category": "eq.box", "last_seen_at": f"lt.{cutoff.isoformat()}"},
headers={"Prefer": "return=representation"},
)
response.raise_for_status()
logging.info(f"Purged {len(response.json())} box entries")
@cli.command("mark-deletion") @cli.command("mark-deletion")
@click.argument("paths", nargs=-1, type=Path) @click.argument("paths", nargs=-1, type=Path)
def mark_deletion(paths: list[Path]): def mark_deletion(paths: list[Path]):