feat(snot): add search history and purge command
This commit is contained in:
1 parent
afdb0ac636
commit
7e8541d5d7
2 files changed
+87
-2
No files matched your search
+72
-1
@@ -162,7 +162,12 @@
|
||||
<div x-data="app()" class="gapped" @keydown.window="onKeydown">
|
||||
<form @submit.prevent="search" class="search-form">
|
||||
<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" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -225,8 +230,38 @@
|
||||
|
||||
<script defer src="./alpine@3.x.x.cdn.min.js"></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 = () => ({
|
||||
results: [],
|
||||
searchHistory: [],
|
||||
errorMessage: "",
|
||||
form: {
|
||||
query: "",
|
||||
@@ -236,9 +271,17 @@
|
||||
minSizeMB: 0,
|
||||
},
|
||||
sort: { by: null, dir: "asc" },
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string} The Alfred trigger URL.
|
||||
*/
|
||||
buildAlfredURL(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) {
|
||||
if (this.sort.by === field) {
|
||||
this.sort.dir = this.sort.dir === "asc" ? "desc" : "asc";
|
||||
@@ -247,6 +290,10 @@
|
||||
this.sort.dir = "asc";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onKeydown(e) {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
|
||||
return;
|
||||
@@ -256,6 +303,7 @@
|
||||
this.$refs.filterInput.focus();
|
||||
}
|
||||
},
|
||||
/** @returns {object[]} The filtered and sorted results. */
|
||||
get visibleResults() {
|
||||
const isEmpty = !this.filter.query && this.filter.minSizeMB === 0;
|
||||
let rows = isEmpty
|
||||
@@ -284,6 +332,10 @@
|
||||
{ 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 },
|
||||
],
|
||||
/**
|
||||
* @param {string} actor
|
||||
* @returns {string} The actor filter token.
|
||||
*/
|
||||
actorToken(actor) {
|
||||
const escaped = actor.replaceAll('"', '\\"');
|
||||
if (escaped.includes(" ")) {
|
||||
@@ -291,11 +343,20 @@
|
||||
}
|
||||
return `actor:${escaped}`;
|
||||
},
|
||||
/**
|
||||
* @param {string} actor
|
||||
* @returns {boolean} Whether the actor is already in the query.
|
||||
*/
|
||||
hasActorFilter(actor) {
|
||||
const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll('"', '\\"');
|
||||
const pattern = new RegExp(`(^|\\s|\\|)actor:(?:"${escaped}"|${escaped})(?=\\s|\\||$)`, "i");
|
||||
return pattern.test(this.form.query);
|
||||
},
|
||||
/**
|
||||
* @param {string} actor
|
||||
* @param {MouseEvent} event
|
||||
* @returns {void}
|
||||
*/
|
||||
addActorFilter(actor, event) {
|
||||
if (!actor) {
|
||||
return;
|
||||
@@ -315,12 +376,15 @@
|
||||
this.form.query = this.form.query ? `${this.form.query} ${token}` : token;
|
||||
this.search();
|
||||
},
|
||||
/** @returns {Promise<void>} */
|
||||
async init() {
|
||||
this.searchHistory = searchHistory.history;
|
||||
if (window.ENV?.query) {
|
||||
this.form.query = window.ENV?.query ?? "";
|
||||
await this.search();
|
||||
}
|
||||
},
|
||||
/** @returns {string[] | null} Playable URLs, if available. */
|
||||
get playableURLs() {
|
||||
const playable = this.visibleResults.some((item) => item.file_path.startsWith("/mnt/box/files"));
|
||||
if (!playable) {
|
||||
@@ -332,6 +396,7 @@
|
||||
.sort(() => Math.random() - 0.5);
|
||||
return urls;
|
||||
},
|
||||
/** @returns {string | null} The playlist trigger URL, if available. */
|
||||
get playlistLink() {
|
||||
const urls = this.playableURLs;
|
||||
if (!urls) {
|
||||
@@ -341,6 +406,7 @@
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/mpv/?argument=${playlistArg}`;
|
||||
},
|
||||
|
||||
/** @returns {string | null} The grid trigger URL, if available. */
|
||||
get gridLink() {
|
||||
const urls = this.playableURLs;
|
||||
if (!urls) {
|
||||
@@ -350,8 +416,10 @@
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/gridvid/?argument=${playlistArg}`;
|
||||
},
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async search() {
|
||||
this.errorMessage = "";
|
||||
const query = this.form.query.trim();
|
||||
const response = await fetch("/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -363,6 +431,9 @@
|
||||
return;
|
||||
}
|
||||
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}"`);
|
||||
},
|
||||
});
|
||||
|
||||
+15
-1
@@ -31,9 +31,10 @@ import click
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
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
|
||||
@@ -747,6 +748,19 @@ def scan(paths: list[Path]):
|
||||
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")
|
||||
@click.argument("paths", nargs=-1, type=Path)
|
||||
def mark_deletion(paths: list[Path]):
|
||||
|
||||
Reference in new issue
Block a user