Mariotaku 01b8ba353a
Cover Finder (#216)
Adds functionality to search and add game cover images automatically.

Co-authored-by: Conn O'Griofa <connogriofa@gmail.com>
Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
2022-11-18 11:07:22 -05:00

441 lines
14 KiB
HTML

<div id="app" class="container">
<div class="my-4">
<h1>Applications</h1>
<div>Applications are refreshed only when Client is restarted</div>
</div>
<div class="card p-4">
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="(app,i) in apps" :key="i">
<td>{{app.name}}</td>
<td>
<button class="btn btn-primary" @click="editApp(i)">Edit</button>
<button class="btn btn-danger" @click="showDeleteForm(i)">
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="edit-form card mt-2" v-if="showEditForm">
<div class="p-4">
<!--name-->
<div class="mb-3">
<label for="appName" class="form-label">Application Name</label>
<input
type="text"
class="form-control"
id="appName"
aria-describedby="appNameHelp"
v-model="editForm.name"
/>
<div id="appNameHelp" class="form-text">
Application Name, as shown on Moonlight
</div>
</div>
<!--output-->
<div class="mb-3">
<label for="appOutput" class="form-label">Output</label>
<input
type="text"
class="form-control monospace"
id="appOutput"
aria-describedby="appOutputHelp"
v-model="editForm.output"
/>
<div id="appOutputHelp" class="form-text">
The file where the output of the command is stored, if it is not
specified, the output is ignored
</div>
</div>
<!--prep-cmd-->
<div class="mb-3 d-flex flex-column">
<label for="appName" class="form-label">Command Preparations</label>
<div class="form-text">
A list of commands to be run before/after the application. <br />
If any of the prep-commands fail, starting the application is aborted
</div>
<table v-if="editForm['prep-cmd'].length > 0">
<thead>
<th class="precmd-head">Do</th>
<th class="precmd-head">Undo</th>
<th style="width: 48px"></th>
</thead>
<tbody>
<tr v-for="(c,i) in editForm['prep-cmd']">
<td>
<input
type="text"
class="form-control monospace"
v-model="c.do"
/>
</td>
<td>
<input
type="text"
class="form-control monospace"
v-model="c.undo"
/>
</td>
<td>
<button
class="btn btn-danger"
@click="editForm['prep-cmd'].splice(i,1)"
>
&times;
</button>
</td>
</tr>
</tbody>
</table>
<button
class="mt-2 btn btn-success"
style="margin: 0 auto"
@click="addPrepCmd"
>
&plus; Add
</button>
</div>
<!--detatched-->
<div class="mb-3">
<label for="appName" class="form-label">Detached Commands</label>
<div
v-for="(c,i) in editForm.detached"
class="d-flex justify-content-between my-2"
>
<pre>{{c}}</pre>
<button
class="btn btn-danger mx-2"
@click="editForm.detached.splice(i,1)"
>
&times;
</button>
</div>
<div class="d-flex justify-content-between">
<input
type="text"
class="form-control monospace"
v-model="detachedCmd"
/>
<button
class="btn btn-success mx-2"
@click="editForm.detached.push(detachedCmd);detachedCmd = '';"
>
+
</button>
</div>
<div class="form-text">
A list of commands to be run and forgotten about
</div>
</div>
<!--command-->
<div class="mb-3">
<label for="appCmd" class="form-label">Command</label>
<input
type="text"
class="form-control monospace"
id="appCmd"
aria-describedby="appCmdHelp"
v-model="editForm.cmd"
/>
<div id="appCmdHelp" class="form-text">
The main application, if it is not specified, a processs is started
that sleeps indefinitely
</div>
</div>
<!--working dir-->
<div class="mb-3">
<label for="appWorkingDir" class="form-label">Working Directory</label>
<input
type="text"
class="form-control monospace"
id="appWorkingDir"
aria-describedby="appWorkingDirHelp"
v-model="editForm['working-dir']"
/>
<div id="appWorkingDirHelp" class="form-text">
The working directory that should be passed to the process.
For example, some applications use the working directory to search for configuration files.
If not set, Sunshine will default to the parent directory of the command
</div>
</div>
<!-- Image path -->
<div class="mb-3">
<label for="appImagePath" class="form-label">Image</label>
<div class="input-group dropup">
<input
type="text"
class="form-control monospace"
id="appImagePath"
aria-describedby="appImagePathHelp"
v-model="editForm['image-path']"
/>
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false" v-dropdown-show="showCoverFinder"
ref="coverFinderDropdown">
Find Cover
</button>
<div class="dropdown-menu dropdown-menu-end w-50 cover-finder overflow-hidden"
aria-labelledby="findCoverToggle">
<div class="modal-header">
<h4 class="modal-title">Covers Found</h4>
<button type="button" class="btn-close" aria-label="Close" @click="closeCoverFinder"></button>
</div>
<div class="modal-body cover-results px-3 pt-3" :class="{ busy: coverFinderBusy }">
<div class="row">
<div v-if="coverSearching" class="col-12 col-sm-6 col-lg-4 mb-3">
<div class="cover-container">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<div v-for="(cover,i) in coverCandidates" :key="i" class="col-12 col-sm-6 col-lg-4 mb-3"
@click="useCover(cover)">
<div class="cover-container result">
<img class="rounded" :src="cover.url"/>
</div>
<label class="d-block text-nowrap text-center text-truncate">
{{cover.name}}
</label>
</div>
</div>
</div>
</div>
</div>
<div id="appImagePathHelp" class="form-text">
Application icon/picture/image path that will be sent to client. Image must be a PNG file.
If not set, Sunshine will send default box image.
</div>
</div>
<!--buttons-->
<div class="d-flex">
<button @click="showEditForm = false" class="btn btn-secondary m-2">
Cancel
</button>
<button class="btn btn-primary m-2" @click="save">Save</button>
</div>
</div>
</div>
<div class="mt-2" v-else>
<button class="btn btn-primary" @click="newApp">+ Add New</button>
</div>
</div>
<script>
Vue.directive('dropdown-show', {
bind: function (el, binding) {
el.addEventListener('show.bs.dropdown', binding.value);
}
});
new Vue({
el: "#app",
data() {
return {
apps: [],
showEditForm: false,
editForm: null,
detachedCmd: "",
coverSearching: false,
coverFinderBusy: false,
coverCandidates: [],
};
},
created() {
fetch("/api/apps")
.then((r) => r.json())
.then((r) => {
console.log(r);
this.apps = r.apps;
});
},
methods: {
newApp() {
this.editForm = {
name: "",
output: "",
cmd: [],
index: -1,
"prep-cmd": [],
detached: [],
"image-path": ""
};
this.editForm.index = -1;
this.showEditForm = true;
},
editApp(id) {
this.editForm = JSON.parse(JSON.stringify(this.apps[id]));
this.$set(this.editForm, "index", id);
if (this.editForm["prep-cmd"] === undefined)
this.$set(this.editForm, "prep-cmd", []);
if (this.editForm["detached"] === undefined)
this.$set(this.editForm, "detached", []);
this.showEditForm = true;
},
showDeleteForm(id) {
let resp = confirm(
"Are you sure to delete " + this.apps[id].name + "?"
);
if (resp) {
fetch("/api/apps/" + id, { method: "DELETE" }).then((r) => {
if (r.status == 200) document.location.reload();
});
}
},
addPrepCmd() {
this.editForm["prep-cmd"].push({
do: "",
undo: "",
});
},
showCoverFinder($event) {
this.coverCandidates = [];
this.coverSearching = true;
function getSearchBucket(name) {
let bucket = name.substring(0, Math.min(name.length, 2)).toLowerCase().replaceAll(/[^a-z\d]/g, '');
if (!bucket) {
return '@';
}
return bucket;
}
function searchCovers(name) {
if (!name) {
return Promise.resolve([]);
}
let searchName = name.replaceAll(/\s+/g, '.').toLowerCase();
let bucket = getSearchBucket(name);
return fetch("https://db.lizardbyte.dev/buckets/" + bucket + ".json").then(function (r) {
if (!r.ok) throw new Error("Failed to search covers");
return r.json();
}).then(maps => Promise.all(Object.keys(maps).map(id => {
let item = maps[id];
if (item.name.replaceAll(/\s+/g, '.').toLowerCase().startsWith(searchName)) {
return fetch("https://db.lizardbyte.dev/games/" + id + ".json").then(function (r) {
return r.json();
}).catch(() => null);
}
return null;
}).filter(item => item)))
.then(results => results
.filter(item => item && item.cover && item.cover.url)
.map(game => {
const thumb = game.cover.url;
const dotIndex = thumb.lastIndexOf('.');
const slashIndex = thumb.lastIndexOf('/');
if (dotIndex < 0 || slashIndex < 0) {
return null;
}
const hash = thumb.substring(slashIndex + 1, dotIndex);
return {
name: game.name,
key: "igdb_" + game.id,
url: "https://images.igdb.com/igdb/image/upload/t_cover_big/" + hash + ".jpg",
saveUrl: "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/" + hash + ".png",
}
}).filter(item => item));
}
searchCovers(this.editForm["name"].toString())
.then(list => this.coverCandidates = list)
.finally(() => this.coverSearching = false);
},
closeCoverFinder() {
const ref = this.$refs.coverFinderDropdown;
if (!ref) {
return;
}
const dropdown = this.coverFinderDropdown = bootstrap.Dropdown.getInstance(ref);
if (!dropdown) {
return;
}
dropdown.hide();
},
useCover(cover) {
this.coverFinderBusy = true;
fetch("/api/covers/upload", {
method: "POST",
body: JSON.stringify({
key: cover.key,
url: cover.saveUrl,
})
}).then(r => {
if (!r.ok) throw new Error("Failed to download covers");
return r.json();
}).then(body => this.$set(this.editForm, "image-path", body.path))
.then(() => this.closeCoverFinder())
.finally(() => this.coverFinderBusy = false);
},
save() {
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
fetch("/api/apps", {
method: "POST",
body: JSON.stringify(this.editForm),
}).then((r) => {
if (r.status == 200) document.location.reload();
});
},
},
});
</script>
<style>
.precmd-head {
width: 200px;
}
.monospace {
font-family: monospace;
}
.cover-finder {
}
.cover-finder .cover-results {
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
}
.cover-finder .cover-results.busy * {
cursor: wait !important;
pointer-events: none;
}
.cover-container {
padding-top: 133.33%;
position: relative;
}
.cover-container.result {
cursor: pointer;
}
.spinner-border {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
margin: auto;
}
.cover-container img {
display: block;
position: absolute;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>