mirror of
https://github.com/LizardByte/Sunshine.git
synced 2025-03-29 22:20:24 +00:00
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:
parent
66615a0be0
commit
01b8ba353a
4
.github/workflows/CI.yml
vendored
4
.github/workflows/CI.yml
vendored
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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* \
|
||||
|
@ -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')
|
||||
|
||||
|
@ -32,6 +32,7 @@ post-fetch {
|
||||
}
|
||||
|
||||
depends_lib port:avahi \
|
||||
port:curl \
|
||||
port:ffmpeg \
|
||||
port:libopus
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user