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>
This commit is contained in:
Mariotaku 2022-11-19 01:07:22 +09:00 committed by GitHub
parent 66615a0be0
commit 01b8ba353a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 314 additions and 12 deletions

View File

@ -383,6 +383,7 @@ jobs:
libboost-log-dev \
libboost-thread-dev \
libcap-dev \
libcurl4-openssl-dev \
libdrm-dev \
libevdev-dev \
libpulse-dev \
@ -548,7 +549,7 @@ jobs:
- name: Setup Dependencies MacOS
run: |
# install dependencies using homebrew
brew install boost cmake ffmpeg opus
brew install boost cmake curl ffmpeg opus
# fix openssl header not found
ln -sf /usr/local/opt/openssl/include/openssl /usr/local/include/openssl
@ -846,6 +847,7 @@ jobs:
mingw-w64-x86_64-binutils
mingw-w64-x86_64-boost
mingw-w64-x86_64-cmake
mingw-w64-x86_64-curl
mingw-w64-x86_64-nsis
mingw-w64-x86_64-openssl
mingw-w64-x86_64-opus

View File

@ -69,6 +69,9 @@ include_directories(third-party/miniupnp)
find_package(Threads REQUIRED)
find_package(OpenSSL REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules (CURL REQUIRED libcurl)
if(NOT APPLE)
set(Boost_USE_STATIC_LIBS ON)
endif()
@ -88,6 +91,7 @@ if(WIN32)
INPUT "${CMAKE_CURRENT_BINARY_DIR}/pre-compiled.zip"
DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/pre-compiled)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${CURL_STATIC_LDFLAGS} ${CURL_STATIC_CFLAGS}")
if(NOT DEFINED SUNSHINE_PREPARED_BINARIES)
set(SUNSHINE_PREPARED_BINARIES "${CMAKE_CURRENT_BINARY_DIR}/pre-compiled/windows")
@ -149,6 +153,7 @@ if(WIN32)
d3d11 dxgi D3DCompiler
setupapi
dwmapi
${CURL_STATIC_LIBRARIES}
)
set_source_files_properties(third-party/ViGEmClient/src/ViGEmClient.cpp PROPERTIES COMPILE_DEFINITIONS "UNICODE=1;ERROR_INVALID_DEVICE_OBJECT_PARAMETER=650")
@ -451,6 +456,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
${FFMPEG_LIBRARIES}
${Boost_LIBRARIES}
${OPENSSL_LIBRARIES}
${CURL_LIBRARIES}
${PLATFORM_LIBRARIES})
if(NOT WIN32)
@ -458,6 +464,11 @@ if(NOT WIN32)
endif()
add_executable(sunshine ${SUNSHINE_TARGET_FILES})
if(WIN32)
set_target_properties(sunshine PROPERTIES LINK_SEARCH_START_STATIC 1)
endif()
target_link_libraries(sunshine ${SUNSHINE_EXTERNAL_LIBRARIES} ${EXTRA_LIBS})
target_compile_definitions(sunshine PUBLIC ${SUNSHINE_DEFINITIONS})
set_target_properties(sunshine PROPERTIES CXX_STANDARD 17
@ -647,8 +658,8 @@ elseif(UNIX)
# Dependencies
set(CPACK_DEB_COMPONENT_INSTALL ON)
set(CPACK_DEBIAN_PACKAGE_DEPENDS "openssl, libavdevice58, libboost-thread1.67.0 | libboost-thread1.71.0 | libboost-thread1.74.0, libboost-filesystem1.67.0 | libboost-filesystem1.71.0 | libboost-filesystem1.74.0, libboost-log1.67.0 | libboost-log1.71.0 | libboost-log1.74.0, libpulse0, libopus0, libxcb-shm0, libxcb-xfixes0, libxtst6, libevdev2, libdrm2, libcap2")
set(CPACK_RPM_PACKAGE_REQUIRES "openssl >= 1.1, libavdevice >= 4.3, boost-thread >= 1.67.0, boost-filesystem >= 1.67.0, boost-log >= 1.67.0, pulseaudio-libs >= 10.0, libopusenc >= 0.2.1, libxcb >= 1.13, libXtst >= 1.2.3, libevdev >= 1.5.6, libdrm >= 2.4.97, libcap >= 2.22")
set(CPACK_DEBIAN_PACKAGE_DEPENDS "openssl, libavdevice58, libboost-thread1.67.0 | libboost-thread1.71.0 | libboost-thread1.74.0, libboost-filesystem1.67.0 | libboost-filesystem1.71.0 | libboost-filesystem1.74.0, libboost-log1.67.0 | libboost-log1.71.0 | libboost-log1.74.0, libcurl4, libpulse0, libopus0, libxcb-shm0, libxcb-xfixes0, libxtst6, libevdev2, libdrm2, libcap2")
set(CPACK_RPM_PACKAGE_REQUIRES "openssl >= 1.1, libavdevice >= 4.3, boost-thread >= 1.67.0, boost-filesystem >= 1.67.0, boost-log >= 1.67.0, libcurl >= 7.0, pulseaudio-libs >= 10.0, libopusenc >= 0.2.1, libxcb >= 1.13, libXtst >= 1.2.3, libevdev >= 1.5.6, libdrm >= 2.4.97, libcap >= 2.22")
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) # This should automatically figure out dependencies, doesn't work with the current config
endif()
endif()

View File

@ -14,6 +14,7 @@ RUN apt-get update -y \
libboost-log-dev=1.74.0* \
libboost-thread-dev=1.74.0* \
libcap-dev=1:2.44* \
libcurl4-openssl-dev=7.81.0* \
libdrm-dev=2.4.110* \
libevdev-dev=1.12.1* \
libpulse-dev=1:15.99.1* \

View File

@ -9,7 +9,7 @@ arch=('x86_64' 'i686')
url=@PROJECT_HOMEPAGE_URL@
license=('GPL3')
depends=('avahi' 'boost-libs' 'ffmpeg4.4' 'libevdev' 'libpulse' 'libx11' 'libxcb' 'libxfixes' 'libxrandr' 'libxtst' 'openssl' 'opus' 'udev')
depends=('avahi' 'boost-libs' 'curl' 'ffmpeg4.4' 'libevdev' 'libpulse' 'libx11' 'libxcb' 'libxfixes' 'libxrandr' 'libxtst' 'openssl' 'opus' 'udev')
makedepends=('boost' 'cmake' 'git' 'make')
optdepends=('cuda' 'libcap' 'libdrm')

View File

@ -32,6 +32,7 @@ post-fetch {
}
depends_lib port:avahi \
port:curl \
port:ffmpeg \
port:libopus

View File

@ -13,6 +13,8 @@
#include <boost/asio/ssl/context.hpp>
#include <boost/filesystem.hpp>
#include <Simple-Web-Server/crypto.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <boost/asio/ssl/context_base.hpp>
@ -164,9 +166,12 @@ void getAppsPage(resp_https_t response, req_https_t request) {
print_req(request);
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Access-Control-Allow-Origin", "https://images.igdb.com/");
std::string header = read_file(WEB_DIR "header.html");
std::string content = read_file(WEB_DIR "apps.html");
response->write(header + content);
response->write(header + content, headers);
}
void getClientsPage(resp_https_t response, req_https_t request) {
@ -411,6 +416,67 @@ void deleteApp(resp_https_t response, req_https_t request) {
proc::refresh(config::stream.file_apps);
}
void uploadCover(resp_https_t response, req_https_t request) {
if(!authenticate(response, request)) return;
std::stringstream ss;
std::stringstream configStream;
ss << request->content.rdbuf();
pt::ptree outputTree;
auto g = util::fail_guard([&]() {
std::ostringstream data;
SimpleWeb::StatusCode code = SimpleWeb::StatusCode::success_ok;
if(outputTree.get_child_optional("error").has_value()) {
code = SimpleWeb::StatusCode::client_error_bad_request;
}
pt::write_json(data, outputTree);
response->write(code, data.str());
});
pt::ptree inputTree;
try {
pt::read_json(ss, inputTree);
}
catch(std::exception &e) {
BOOST_LOG(warning) << "UploadCover: "sv << e.what();
outputTree.put("status", "false");
outputTree.put("error", e.what());
return;
}
auto key = inputTree.get("key", "");
if(key.empty()) {
outputTree.put("error", "Cover key is required");
return;
}
auto url = inputTree.get("url", "");
const std::string coverdir = platf::appdata().string() + "/covers/";
if(!boost::filesystem::exists(coverdir)) {
boost::filesystem::create_directory(coverdir);
}
std::basic_string path = coverdir + http::url_escape(key) + ".png";
if(!url.empty()) {
if(http::url_get_host(url) != "images.igdb.com") {
outputTree.put("error", "Only images.igdb.com is allowed");
return;
}
if(!http::download_file(url, path)) {
outputTree.put("error", "Failed to download cover");
return;
}
}
else {
auto data = SimpleWeb::Crypto::Base64::decode(inputTree.get<std::string>("data"));
std::ofstream imgfile(path);
imgfile.write(data.data(), (int)data.size());
}
outputTree.put("path", path);
}
void getConfig(resp_https_t response, req_https_t request) {
if(!authenticate(response, request)) return;
@ -616,6 +682,7 @@ void start() {
server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp;
server.resource["^/api/clients/unpair$"]["POST"] = unpairAll;
server.resource["^/api/apps/close"]["POST"] = closeApp;
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
server.resource["^/images/favicon.ico$"]["GET"] = getFaviconImage;
server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage;
server.resource["^/third_party/bootstrap.min.css$"]["GET"] = getBootstrapCss;

View File

@ -13,6 +13,7 @@
#include <Simple-Web-Server/server_http.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <boost/asio/ssl/context_base.hpp>
#include <curl/curl.h>
#include "config.h"
#include "crypto.h"
@ -180,4 +181,55 @@ int create_creds(const std::string &pkey, const std::string &cert) {
return 0;
}
bool download_file(const std::string &url, const std::string &file) {
CURL *curl = curl_easy_init();
if(!curl) {
BOOST_LOG(error) << "Couldn't create CURL instance";
return false;
}
FILE *fp = fopen(file.c_str(), "wb");
if(!fp) {
BOOST_LOG(error) << "Couldn't open ["sv << file << ']';
curl_easy_cleanup(curl);
return false;
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fwrite);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
#ifdef _WIN32
curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA);
#endif
CURLcode result = curl_easy_perform(curl);
if(result != CURLE_OK) {
BOOST_LOG(error) << "Couldn't download ["sv << url << ", code:" << result << ']';
}
curl_easy_cleanup(curl);
fclose(fp);
return result == CURLE_OK;
}
std::string url_escape(const std::string &url) {
CURL *curl = curl_easy_init();
char *string = curl_easy_escape(curl, url.c_str(), url.length());
std::string result(string);
curl_free(string);
curl_easy_cleanup(curl);
return result;
}
std::string url_get_host(const std::string &url) {
CURLU *curlu = curl_url();
curl_url_set(curlu, CURLUPART_URL, url.c_str(), url.length());
char *host;
if(curl_url_get(curlu, CURLUPART_HOST, &host, 0) != CURLUE_OK) {
curl_url_cleanup(curlu);
return "";
}
std::string result(host);
curl_free(host);
curl_url_cleanup(curlu);
return result;
}
} // namespace http

View File

@ -12,6 +12,10 @@ int save_user_creds(
bool run_our_mouth = false);
int reload_user_creds(const std::string &file);
bool download_file(const std::string &url, const std::string &file);
std::string url_escape(const std::string &url);
std::string url_get_host(const std::string &url);
extern std::string unique_id;
extern net::net_e origin_pin_allowed;
extern net::net_e origin_web_ui_allowed;

View File

@ -169,13 +169,47 @@
<!-- Image path -->
<div class="mb-3">
<label for="appImagePath" class="form-label">Image</label>
<input
type="text"
class="form-control monospace"
id="appImagePath"
aria-describedby="appImagePathHelp"
v-model="editForm['image-path']"
/>
<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.
@ -196,6 +230,12 @@
</div>
<script>
Vue.directive('dropdown-show', {
bind: function (el, binding) {
el.addEventListener('show.bs.dropdown', binding.value);
}
});
new Vue({
el: "#app",
data() {
@ -204,6 +244,9 @@
showEditForm: false,
editForm: null,
detachedCmd: "",
coverSearching: false,
coverFinderBusy: false,
coverCandidates: [],
};
},
created() {
@ -253,6 +296,85 @@
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", {
@ -274,4 +396,46 @@
.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>