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">
|
<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
@@ -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]):
|
||||||
|
|||||||
Reference in new issue
Block a user