diff --git a/Makefile.common b/Makefile.common index 927232a40669..4aa4bd1751f0 100644 --- a/Makefile.common +++ b/Makefile.common @@ -411,6 +411,7 @@ OBJ += \ $(LIBRETRO_COMM_DIR)/time/rtime.o \ manual_content_scan.o \ tasks/task_database.o \ + network/screenscraper_meta.o \ disk_control_interface.o ifeq ($(HAVE_CONFIGFILE), 1) @@ -2465,6 +2466,8 @@ ifeq ($(HAVE_NETWORKING), 1) tasks/task_netplay_lan_scan.o \ tasks/task_netplay_nat_traversal.o \ tasks/task_pl_thumbnail_download.o \ + network/screenscraper.o \ + tasks/task_screenscraper.o \ tasks/task_netplay_find_content.o ifeq ($(HAVE_EMSCRIPTEN), 1) diff --git a/config.def.h b/config.def.h index 94c2f3f3bc8d..069cc17171c0 100644 --- a/config.def.h +++ b/config.def.h @@ -739,6 +739,8 @@ #define DEFAULT_OZONE_COLLAPSE_SIDEBAR false #define DEFAULT_OZONE_SHOW_SIDEBAR true #define DEFAULT_OZONE_SCROLL_CONTENT_METADATA false +/* 0 == OZONE_METADATA_SCROLL_HORIZONTAL, i.e. upstream behaviour */ +#define DEFAULT_OZONE_METADATA_SCROLL_STYLE 0 #define DEFAULT_OZONE_THUMBNAIL_SCALE_FACTOR 1.0f #define DEFAULT_OZONE_FONT_SCALE 0 #define DEFAULT_OZONE_FONT_SCALE_FACTOR_GLOBAL 1.0f @@ -1582,6 +1584,11 @@ #define DEFAULT_NETWORK_ON_DEMAND_THUMBNAILS false +/* Default state for the classic thumbnail media types */ +#define DEFAULT_SCREENSCRAPER_MEDIA_ON true +/* Default state for the extended media types (videos, manuals, ...) */ +#define DEFAULT_SCREENSCRAPER_MEDIA_OFF false + /* Number of entries that will be kept in content history playlist file. */ #define DEFAULT_CONTENT_HISTORY_SIZE 200 diff --git a/configuration.c b/configuration.c index e5e639e9106a..f4bc7d84bf23 100644 --- a/configuration.c +++ b/configuration.c @@ -1637,6 +1637,10 @@ static struct config_array_setting *populate_settings_array( #endif #ifdef HAVE_NETWORKING + SETTING_ARRAY("screenscraper_username", settings->arrays.screenscraper_username, false, NULL, true); + SETTING_ARRAY("screenscraper_password", settings->arrays.screenscraper_password, false, NULL, true); + SETTING_ARRAY("screenscraper_devid", settings->arrays.screenscraper_devid, false, NULL, true); + SETTING_ARRAY("screenscraper_devpassword", settings->arrays.screenscraper_devpassword, false, NULL, true); SETTING_ARRAY("netplay_mitm_server", settings->arrays.netplay_mitm_server, false, NULL, true); #ifdef HAVE_CLOUDSYNC SETTING_ARRAY("webdav_url", settings->arrays.webdav_url, false, NULL, true); @@ -1974,6 +1978,8 @@ static struct config_bool_setting *populate_settings_bool( #include "settings/settings_def_widget_scale.h" #endif #include "settings/settings_def_cheevos_account.h" +#include "settings/settings_def_screenscraper.h" +#include "settings/settings_def_screenscraper_account.h" #include "settings/settings_def_menu_show_restart.h" #include "settings/settings_def_quit_restart.h" #include "settings/settings_def_menu_throttle.h" @@ -2686,6 +2692,8 @@ static struct config_float_setting *populate_settings_float( #include "settings/settings_def_widget_scale.h" #endif #include "settings/settings_def_cheevos_account.h" +#include "settings/settings_def_screenscraper.h" +#include "settings/settings_def_screenscraper_account.h" #include "settings/settings_def_menu_show_restart.h" #include "settings/settings_def_quit_restart.h" #include "settings/settings_def_menu_throttle.h" @@ -3350,6 +3358,8 @@ static struct config_uint_setting *populate_settings_uint( #include "settings/settings_def_widget_scale.h" #endif #include "settings/settings_def_cheevos_account.h" +#include "settings/settings_def_screenscraper.h" +#include "settings/settings_def_screenscraper_account.h" #include "settings/settings_def_menu_show_restart.h" #include "settings/settings_def_quit_restart.h" #include "settings/settings_def_menu_throttle.h" @@ -4062,6 +4072,8 @@ static struct config_int_setting *populate_settings_int( #include "settings/settings_def_widget_scale.h" #endif #include "settings/settings_def_cheevos_account.h" +#include "settings/settings_def_screenscraper.h" +#include "settings/settings_def_screenscraper_account.h" #include "settings/settings_def_menu_show_restart.h" #include "settings/settings_def_quit_restart.h" #include "settings/settings_def_menu_throttle.h" @@ -4655,6 +4667,8 @@ static struct config_int_setting *populate_settings_int( #include "settings/settings_def_widget_scale.h" #endif #include "settings/settings_def_cheevos_account.h" +#include "settings/settings_def_screenscraper.h" +#include "settings/settings_def_screenscraper_account.h" #include "settings/settings_def_menu_show_restart.h" #include "settings/settings_def_quit_restart.h" #include "settings/settings_def_menu_throttle.h" diff --git a/configuration.h b/configuration.h index e4d0be7b268e..920013eaa930 100644 --- a/configuration.h +++ b/configuration.h @@ -270,6 +270,12 @@ typedef struct settings unsigned menu_timedate_date_separator; unsigned gfx_thumbnails; unsigned menu_left_thumbnails; + unsigned menu_dynamic_wallpaper_mode; + unsigned menu_dynamic_wallpaper_type; + unsigned screenscraper_region; + unsigned screenscraper_language; + unsigned screenscraper_quota_action; + unsigned screenscraper_primary_scraper; unsigned menu_icon_thumbnails; unsigned gfx_thumbnail_upscale_threshold; unsigned menu_rgui_thumbnail_downscaler; @@ -295,6 +301,9 @@ typedef struct settings unsigned menu_ozone_header_icon; unsigned menu_ozone_header_separator; unsigned menu_ozone_font_scale; + /* enum ozone_metadata_scroll_style - how Ozone's content + * metadata panel presents the scraped synopsis */ + unsigned ozone_metadata_scroll_style; unsigned menu_font_color_red; unsigned menu_font_color_green; unsigned menu_font_color_blue; @@ -764,6 +773,9 @@ typedef struct settings bool menu_xmb_entry_icons; bool menu_xmb_switch_icons; bool menu_xmb_vertical_thumbnails; + /* Draws the scraped facts for the selected entry in place of + * the left thumbnail */ + bool menu_xmb_show_metadata_panel; bool menu_content_show_settings; bool menu_content_show_favorites; bool menu_content_show_favorites_first; @@ -863,6 +875,21 @@ typedef struct settings bool network_on_demand_thumbnails; bool core_updater_auto_backup; + /* ScreenScraper */ + bool screenscraper_media_boxarts; + bool screenscraper_media_snaps; + bool screenscraper_media_titles; + bool screenscraper_media_logos; + bool screenscraper_media_boxarts3d; + bool screenscraper_media_fanarts; + bool screenscraper_media_marquees; + bool screenscraper_media_videos; + bool screenscraper_media_manuals; + bool screenscraper_media_bezels; + bool screenscraper_metadata; + bool screenscraper_overwrite; + bool screenscraper_use_crc; + /* UI */ bool ui_menubar_enable; bool ui_suspend_screensaver_enable; @@ -1093,6 +1120,10 @@ typedef struct settings char cheevos_username[32]; char cheevos_token[32]; char cheevos_leaderboards_enable[32]; + char screenscraper_username[32]; + char screenscraper_password[32]; + char screenscraper_devid[32]; + char screenscraper_devpassword[32]; char video_context_driver[32]; char audio_driver[32]; char audio_resampler[32]; diff --git a/gfx/gfx_animation.c b/gfx/gfx_animation.c index d6f89cb8988a..c416d1d78073 100644 --- a/gfx/gfx_animation.c +++ b/gfx/gfx_animation.c @@ -23,8 +23,10 @@ #include #include #include +#include #include "gfx_animation.h" +#include "../msg_hash.h" #include "../performance_counters.h" #define TICKER_SLOW_SPEED 1666666 @@ -1724,6 +1726,691 @@ bool gfx_animation_ticker_smooth(gfx_animation_ctx_ticker_smooth_t *ticker) return is_active; } +/* Line ticker: scrolls a wrapped block of text vertically inside a + * fixed field. Lives here rather than in a menu driver because both + * XMB (entry sublabels, metadata panel) and Ozone (metadata panel) + * drive it. */ +static size_t gfx_animation_line_ticker_loop(uint64_t idx, + size_t line_len, size_t num_lines) +{ + size_t line_ticks = TICKER_LINE_DISPLAY_TICKS(line_len); + size_t ticker_period = num_lines + 1; + size_t phase = (idx / line_ticks) % ticker_period; + /* In this case, line_offset is simply equal to the phase */ + return phase; +} + +static size_t gfx_animation_line_ticker_generic(uint64_t idx, + size_t line_len, size_t max_lines, size_t num_lines) +{ + size_t line_ticks = TICKER_LINE_DISPLAY_TICKS(line_len); + /* Note: This function is only called if num_lines > max_lines */ + size_t excess_lines = num_lines - max_lines; + /* Ticker will pause for one line duration when the first + * or last line is reached (this is mostly required for the + * case where num_lines == (max_lines + 1), since otherwise + * the text flicks rapidly up and down in disconcerting + * fashion...) */ + size_t ticker_period = (excess_lines * 2) + 2; + size_t phase = (idx / line_ticks) % ticker_period; + + /* Pause on first line */ + if (phase > 0) + phase--; + /* Pause on last line */ + if (phase > excess_lines) + phase--; + + /* Lines scrolling upwards */ + if (phase <= excess_lines) + return phase; + /* Lines scrolling downwards */ + return (excess_lines * 2) - phase; +} + +bool gfx_animation_line_ticker(gfx_animation_t *p_anim, gfx_animation_ctx_line_ticker_t *line_ticker) +{ + char *wrapped_str = NULL; + size_t wrapped_str_len = 0; + size_t line_ticker_str_len = 0; + size_t line_offset = 0; + size_t total_lines = 0; + bool ret = false; + bool is_active = false; + + /* Sanity check */ + if (!line_ticker) + return false; + if ( (!line_ticker->str || !*line_ticker->str) + || (line_ticker->line_len < 1) + || (line_ticker->max_lines < 1) + || (line_ticker->len < 1)) + goto end; + + /* Line wrap input string */ + line_ticker_str_len = strlen(line_ticker->str); + wrapped_str_len = line_ticker_str_len + 1 + 10; /* 10 bytes use for inserting '\n' */ + if (!(wrapped_str = (char*)malloc(wrapped_str_len))) + goto end; + + wrapped_str[0] = '\0'; + + word_wrap( + wrapped_str, + wrapped_str_len, + line_ticker->str, + line_ticker_str_len, + (int)line_ticker->line_len, + 100, 0); + + if (!wrapped_str || !*wrapped_str) + goto end; + + /* Count total number of lines */ + { + size_t slen = strlen(wrapped_str); + const char *p = wrapped_str; + size_t remaining = slen; + total_lines = 1; + + while (remaining > 0) + { + const char *nl = (const char *)memchr(p, '\n', remaining); + if (!nl) + break; + total_lines++; + nl++; + remaining -= (size_t)(nl - p); + p = nl; + } + } + + /* Check whether total number of lines fits within + * the set limit */ + if (total_lines <= line_ticker->max_lines) + { + strlcpy(line_ticker->s, wrapped_str, line_ticker->len); + ret = true; + goto end; + } + + /* Determine offset of first line in wrapped string */ + switch (line_ticker->type_enum) + { + case TICKER_TYPE_LOOP: + line_offset = gfx_animation_line_ticker_loop( + line_ticker->idx, + line_ticker->line_len, + total_lines); + break; + case TICKER_TYPE_BOUNCE: + default: + line_offset = gfx_animation_line_ticker_generic( + line_ticker->idx, + line_ticker->line_len, + line_ticker->max_lines, + total_lines); + break; + } + + /* Build output string from required lines */ + { + const char *p = wrapped_str; + size_t remaining = strlen(wrapped_str); + size_t cur_line = 0; + + /* Skip to the starting line */ + while (cur_line < line_offset && remaining > 0) + { + const char *nl = (const char *)memchr(p, '\n', remaining); + if (!nl) + break; + nl++; + remaining -= (size_t)(nl - p); + p = nl; + cur_line++; + } + + /* Find the end of the visible window */ + { + const char *end_p = p; + size_t end_remaining = remaining; + size_t lines_found = 0; + + while (lines_found < line_ticker->max_lines && end_remaining > 0) + { + const char *nl = (const char *)memchr(end_p, '\n', end_remaining); + if (!nl) + { + end_p = end_p + end_remaining; + break; + } + lines_found++; + if (lines_found >= line_ticker->max_lines) + { + end_p = nl; + break; + } + nl++; + end_remaining -= (size_t)(nl - end_p); + end_p = nl; + } + + /* Single memcpy for the whole block */ + { + size_t copy_len = (size_t)(end_p - p); + size_t max_copy = line_ticker->len - 1; + if (copy_len > max_copy) + copy_len = max_copy; + memcpy(line_ticker->s, p, copy_len); + line_ticker->s[copy_len] = '\0'; + } + } + } + + ret = true; + is_active = true; + p_anim->flags |= GFX_ANIM_FLAG_TICKER_IS_ACTIVE; + +end: + if (wrapped_str) + { + free(wrapped_str); + wrapped_str = NULL; + } + + if (!ret) + if (line_ticker->len > 0) + line_ticker->s[0] = '\0'; + + return is_active; +} + +static void gfx_animation_set_line_smooth_fade_parameters( + bool scroll_up, + size_t scroll_ticks, size_t line_phase, size_t line_height, + size_t num_lines, size_t num_display_lines, size_t line_offset, + float y_offset, + size_t *top_fade_line_offset, + float *top_fade_y_offset, float *top_fade_alpha, + size_t *bottom_fade_line_offset, + float *bottom_fade_y_offset, float *bottom_fade_alpha) +{ + /* When a line fades out, alpha transitions from + * 1 to 0 over the course of one half of the + * scrolling line height. When a line fades in, + * it's the other way around */ + float fade_out_alpha = ((float)scroll_ticks - ((float)line_phase * 2.0f)) / (float)scroll_ticks; + float fade_in_alpha = -1.0f * fade_out_alpha; + if (fade_out_alpha < 0.0f) + fade_out_alpha = 0.0f; + if (fade_in_alpha < 0.0f) + fade_in_alpha = 0.0f; + + *top_fade_line_offset = (line_offset > 0) ? line_offset - 1 : num_lines; + *top_fade_y_offset = y_offset - (float)line_height; + if (scroll_up) + { + *top_fade_alpha = fade_out_alpha; + *bottom_fade_alpha = fade_in_alpha; + } + else + { + *top_fade_alpha = fade_in_alpha; + *bottom_fade_alpha = fade_out_alpha; + } + *bottom_fade_line_offset = line_offset + num_display_lines; + *bottom_fade_y_offset = y_offset + (float)(line_height * num_display_lines); +} + +static void gfx_animation_set_line_smooth_fade_parameters_default( + size_t *top_fade_line_offset, float *top_fade_y_offset, float *top_fade_alpha, + size_t *bottom_fade_line_offset, float *bottom_fade_y_offset, float *bottom_fade_alpha) +{ + *top_fade_line_offset = 0; + *top_fade_y_offset = 0.0f; + *top_fade_alpha = 0.0f; + + *bottom_fade_line_offset = 0; + *bottom_fade_y_offset = 0.0f; + *bottom_fade_alpha = 0.0f; +} + + +static void gfx_animation_line_ticker_smooth_generic(uint64_t idx, + bool fade_enabled, size_t line_len, size_t line_height, + size_t max_display_lines, size_t num_lines, + size_t *num_display_lines, size_t *line_offset, + float *y_offset, + bool *fade_active, + size_t *top_fade_line_offset, float *top_fade_y_offset, + float *top_fade_alpha, + size_t *bottom_fade_line_offset, float *bottom_fade_y_offset, + float *bottom_fade_alpha) +{ + size_t scroll_ticks = TICKER_LINE_SMOOTH_SCROLL_TICKS(line_len); + /* Note: This function is only called if num_lines > max_display_lines */ + size_t excess_lines = num_lines - max_display_lines; + /* Ticker will pause for one line duration when the first + * or last line is reached */ + size_t ticker_period = ((excess_lines * 2) + 2) * scroll_ticks; + size_t phase = idx % ticker_period; + size_t line_phase = 0; + bool pause = false; + bool scroll_up = true; + + if (phase >= scroll_ticks) + phase -= scroll_ticks; + else + { + /* Pause on first line */ + pause = true; + phase = 0; + } + + /* Pause on last line and change direction */ + if (phase >= excess_lines * scroll_ticks) + { + scroll_up = false; + + if (phase < (excess_lines + 1) * scroll_ticks) + { + pause = true; + phase = 0; + } + else + phase -= (excess_lines + 1) * scroll_ticks; + } + + line_phase = phase % scroll_ticks; + + if (pause) + { + /* Static display of max_display_lines + * (no animation) */ + *num_display_lines = max_display_lines; + *y_offset = 0.0f; + *fade_active = false; + *line_offset = scroll_up ? 0 : excess_lines; + } + else if (line_phase == 0) + { + /* Static display of max_display_lines + * (no animation) */ + *num_display_lines = max_display_lines; + *y_offset = 0.0f; + *fade_active = false; + *line_offset = scroll_up ? (phase / scroll_ticks) : (excess_lines - (phase / scroll_ticks)); + } + else + { + /* Scroll animation is active */ + *num_display_lines = (max_display_lines > 1) ? max_display_lines - 1 : 1; + *fade_active = fade_enabled; + + if (scroll_up) + { + *line_offset = (phase / scroll_ticks) + 1; + *y_offset = (float)line_height * (float)(scroll_ticks - line_phase) / (float)scroll_ticks; + } + else + { + *line_offset = excess_lines - (phase / scroll_ticks); + *y_offset = (float)line_height * (1.0f - (float)(scroll_ticks - line_phase) / (float)scroll_ticks); + } + + /* Set fade parameters if fade animation is active */ + if (*fade_active) + gfx_animation_set_line_smooth_fade_parameters( + scroll_up, scroll_ticks, line_phase, line_height, + num_lines, *num_display_lines, *line_offset, *y_offset, + top_fade_line_offset, top_fade_y_offset, top_fade_alpha, + bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); + } + + /* Set 'default' fade parameters if fade animation + * is inactive */ + if (!*fade_active) + gfx_animation_set_line_smooth_fade_parameters_default( + top_fade_line_offset, top_fade_y_offset, top_fade_alpha, + bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); +} + + +static void gfx_animation_line_ticker_smooth_loop(uint64_t idx, + bool fade_enabled, size_t line_len, size_t line_height, + size_t max_display_lines, size_t num_lines, + size_t *num_display_lines, size_t *line_offset, float *y_offset, + bool *fade_active, + size_t *top_fade_line_offset, float *top_fade_y_offset, float *top_fade_alpha, + size_t *bottom_fade_line_offset, float *bottom_fade_y_offset, float *bottom_fade_alpha) +{ + size_t scroll_ticks = TICKER_LINE_SMOOTH_SCROLL_TICKS(line_len); + size_t ticker_period = (num_lines + 1) * scroll_ticks; + size_t phase = idx % ticker_period; + size_t line_phase = phase % scroll_ticks; + + *line_offset = phase / scroll_ticks; + + if (line_phase == (scroll_ticks - 1)) + { + /* Static display of max_display_lines + * (no animation) */ + *num_display_lines = max_display_lines; + *fade_active = false; + } + else + { + *num_display_lines = (max_display_lines > 1) ? max_display_lines - 1 : 1; + *fade_active = fade_enabled; + } + + *y_offset = (float)line_height * (float)(scroll_ticks - line_phase) / (float)scroll_ticks; + + /* Set fade parameters */ + if (*fade_active) + gfx_animation_set_line_smooth_fade_parameters( + true, scroll_ticks, line_phase, line_height, + num_lines, *num_display_lines, *line_offset, *y_offset, + top_fade_line_offset, top_fade_y_offset, top_fade_alpha, + bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); + else + gfx_animation_set_line_smooth_fade_parameters_default( + top_fade_line_offset, top_fade_y_offset, top_fade_alpha, + bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); +} + +bool gfx_animation_line_ticker_smooth(gfx_animation_t *p_anim, gfx_animation_ctx_line_ticker_smooth_t *line_ticker) +{ + char wrapped_str[PATH_MAX_LENGTH]; + const char *wideglyph_str = NULL; + size_t line_ticker_src_len = 0; + size_t wrapped_str_len = 0; + int glyph_width = 0; + int glyph_height = 0; + size_t line_len = 0; + size_t max_display_lines = 0; + size_t num_display_lines = 0; + size_t line_offset = 0; + size_t top_fade_line_offset = 0; + size_t bottom_fade_line_offset = 0; + bool fade_active = false; + int wideglyph_width = 100; + +#define GFX_LINE_TICKER_MAX_LINES 256 + const char *line_starts[GFX_LINE_TICKER_MAX_LINES]; + size_t line_lengths[GFX_LINE_TICKER_MAX_LINES]; + size_t num_lines = 0; + + size_t (*word_wrap_func)(char *dst, size_t dst_size, + const char *src, size_t src_len, + int line_width, int wideglyph_width, unsigned max_lines); + + /* Sanity check */ + if (!line_ticker) + return false; + + if ( !line_ticker->font + || (!line_ticker->src_str || !*line_ticker->src_str) + || (line_ticker->field_width < 1) + || (line_ticker->field_height < 1)) + { + if (line_ticker->dst_str_len > 0) + line_ticker->dst_str[0] = '\0'; + + if (line_ticker->fade_enabled) + { + if (line_ticker->top_fade_str_len > 0) + line_ticker->top_fade_str[0] = '\0'; + + if (line_ticker->bottom_fade_str_len > 0) + line_ticker->bottom_fade_str[0] = '\0'; + + *line_ticker->top_fade_alpha = 0.0f; + *line_ticker->bottom_fade_alpha = 0.0f; + } + + return false; + } + + /* Get font dimensions */ + if ((glyph_width = font_driver_get_message_width( + line_ticker->font, "a", 1, line_ticker->font_scale)) <= 0) + goto fail; + + if ((wideglyph_str = msg_hash_get_wideglyph_str())) + { + int new_glyph_width = font_driver_get_message_width( + line_ticker->font, wideglyph_str, strlen(wideglyph_str), + line_ticker->font_scale); + + if (new_glyph_width > 0) + wideglyph_width = new_glyph_width * 100 / glyph_width; + word_wrap_func = word_wrap_wideglyph; + } + else + word_wrap_func = word_wrap; + + /* > Height. Line metrics live on the font itself now (they are + * filled once at font init), scaled here to the ticker's draw + * scale. */ + if ((glyph_height = (int)roundf( + line_ticker->font->metrics.height * line_ticker->font_scale)) + <= 0) + goto fail; + + /* Determine line wrap parameters */ + line_len = (size_t)(line_ticker->field_width / glyph_width); + max_display_lines = (size_t)(line_ticker->field_height / glyph_height); + + if ((line_len < 1) || (max_display_lines < 1)) + goto fail; + + /* Line wrap input string */ + line_ticker_src_len = strlen(line_ticker->src_str); + wrapped_str_len = sizeof(wrapped_str); + + if (line_ticker_src_len + 11 > wrapped_str_len) + goto fail; + + wrapped_str[0] = '\0'; + + (word_wrap_func)( + wrapped_str, + wrapped_str_len, + line_ticker->src_str, + line_ticker_src_len, + (int)line_len, + wideglyph_width, 0); + + if (!*wrapped_str) + goto fail; + + /* Split into lines using memchr */ + { + const char *p = wrapped_str; + const char *end_ptr = wrapped_str + strlen(wrapped_str); + + while (p < end_ptr) + { + const char *nl = (const char*)memchr(p, '\n', end_ptr - p); + if (!nl) + nl = end_ptr; + + if (num_lines >= GFX_LINE_TICKER_MAX_LINES) + break; + + line_starts[num_lines] = p; + line_lengths[num_lines] = (size_t)(nl - p); + num_lines++; + + p = nl + 1; + } + } + + if (num_lines < 1) + goto fail; + + /* Check whether total number of lines fits within + * the set field limit */ + if (num_lines <= max_display_lines) + { + strlcpy(line_ticker->dst_str, wrapped_str, line_ticker->dst_str_len); + *line_ticker->y_offset = 0.0f; + + if (line_ticker->fade_enabled) + { + if (line_ticker->top_fade_str_len > 0) + line_ticker->top_fade_str[0] = '\0'; + + if (line_ticker->bottom_fade_str_len > 0) + line_ticker->bottom_fade_str[0] = '\0'; + + *line_ticker->top_fade_y_offset = 0.0f; + *line_ticker->bottom_fade_y_offset = 0.0f; + + *line_ticker->top_fade_alpha = 0.0f; + *line_ticker->bottom_fade_alpha = 0.0f; + } + + return true; + } + + /* Determine which lines should be shown, along with + * y axis draw offset */ + switch (line_ticker->type_enum) + { + case TICKER_TYPE_LOOP: + gfx_animation_line_ticker_smooth_loop( + line_ticker->idx, + line_ticker->fade_enabled, + line_len, (size_t)glyph_height, + max_display_lines, num_lines, + &num_display_lines, &line_offset, line_ticker->y_offset, + &fade_active, + &top_fade_line_offset, line_ticker->top_fade_y_offset, line_ticker->top_fade_alpha, + &bottom_fade_line_offset, line_ticker->bottom_fade_y_offset, line_ticker->bottom_fade_alpha); + break; + case TICKER_TYPE_BOUNCE: + default: + gfx_animation_line_ticker_smooth_generic( + line_ticker->idx, + line_ticker->fade_enabled, + line_len, (size_t)glyph_height, + max_display_lines, num_lines, + &num_display_lines, &line_offset, line_ticker->y_offset, + &fade_active, + &top_fade_line_offset, line_ticker->top_fade_y_offset, line_ticker->top_fade_alpha, + &bottom_fade_line_offset, line_ticker->bottom_fade_y_offset, line_ticker->bottom_fade_alpha); + break; + } + + if (!num_display_lines) + goto fail; + + /* Build output string from required lines */ + { + size_t i; + size_t dst_offset = 0; + bool is_loop = (line_ticker->type_enum == TICKER_TYPE_LOOP); + + for (i = 0; i < num_display_lines; i++) + { + size_t line_idx = line_offset + i; + size_t copy_len; + + /* Only wrap around for loop ticker mode; + * for bounce/generic mode, clamp to valid range */ + if (is_loop) + line_idx %= num_lines; + else if (line_idx >= num_lines) + break; + + copy_len = line_lengths[line_idx]; + + if (dst_offset + copy_len + 2 > line_ticker->dst_str_len) + break; + + if (i > 0) + line_ticker->dst_str[dst_offset++] = '\n'; + + memcpy(line_ticker->dst_str + dst_offset, + line_starts[line_idx], copy_len); + dst_offset += copy_len; + } + + if (dst_offset < line_ticker->dst_str_len) + line_ticker->dst_str[dst_offset] = '\0'; + else if (line_ticker->dst_str_len > 0) + line_ticker->dst_str[line_ticker->dst_str_len - 1] = '\0'; + } + + /* Extract top/bottom fade strings, if required */ + if (fade_active) + { + size_t top_fade_line_index = top_fade_line_offset; + size_t bottom_fade_line_index = bottom_fade_line_offset; + + if (line_ticker->type_enum == TICKER_TYPE_LOOP) + { + /* For the top fade, use (num_lines + 1) so that the + * sentinel value 'num_lines' (set when line_offset == 0) + * correctly maps to >= num_lines and suppresses the + * fade string during the loop gap */ + top_fade_line_index %= (num_lines + 1); + /* For the bottom fade, use num_lines so that the index + * wraps to the correct next line at the loop boundary + * instead of duplicating a line already in the main + * display */ + bottom_fade_line_index %= num_lines; + } + + if (top_fade_line_index < num_lines + && line_ticker->top_fade_str_len > 0) + { + size_t copy_len = line_lengths[top_fade_line_index]; + if (copy_len >= line_ticker->top_fade_str_len) + copy_len = line_ticker->top_fade_str_len - 1; + memcpy(line_ticker->top_fade_str, + line_starts[top_fade_line_index], copy_len); + line_ticker->top_fade_str[copy_len] = '\0'; + } + + if (bottom_fade_line_index < num_lines + && line_ticker->bottom_fade_str_len > 0) + { + size_t copy_len = line_lengths[bottom_fade_line_index]; + if (copy_len >= line_ticker->bottom_fade_str_len) + copy_len = line_ticker->bottom_fade_str_len - 1; + memcpy(line_ticker->bottom_fade_str, + line_starts[bottom_fade_line_index], copy_len); + line_ticker->bottom_fade_str[copy_len] = '\0'; + } + } + + p_anim->flags |= GFX_ANIM_FLAG_TICKER_IS_ACTIVE; + + return true; + +fail: + if (line_ticker->dst_str_len > 0) + line_ticker->dst_str[0] = '\0'; + + if (line_ticker->fade_enabled) + { + if (line_ticker->top_fade_str_len > 0) + line_ticker->top_fade_str[0] = '\0'; + + if (line_ticker->bottom_fade_str_len > 0) + line_ticker->bottom_fade_str[0] = '\0'; + + *line_ticker->top_fade_alpha = 0.0f; + *line_ticker->bottom_fade_alpha = 0.0f; + } + + return false; +} + bool gfx_animation_kill_by_tag(uintptr_t *tag) { unsigned i; diff --git a/gfx/gfx_animation.h b/gfx/gfx_animation.h index abcbb8b8b350..9dbb6a2be017 100644 --- a/gfx/gfx_animation.h +++ b/gfx/gfx_animation.h @@ -36,6 +36,22 @@ * */ #define TICKER_PIXEL_PERIOD (16.666666666666668f) +/* Mean human reading speed for all western languages, + * characters per minute */ +#define TICKER_CPM (1000.0f) + +/* Base time for which a line should be shown, in us */ +#define TICKER_LINE_DURATION_US(line_len) ((line_len * 60.0f * 1000.0f * 1000.0f) / TICKER_CPM) +/* Base time for which a line should be shown, in ms */ +#define TICKER_LINE_DURATION_MS(line_len) ((line_len * 60.0f * 1000.0f) / TICKER_CPM) +/* Ticker updates (nominally) once every TICKER_SPEED us + * > Base number of ticks for which line should be shown */ +#define TICKER_LINE_DISPLAY_TICKS(line_len) ((size_t)(TICKER_LINE_DURATION_US(line_len) / (float)TICKER_SPEED)) +/* Smooth ticker updates (nominally) once every TICKER_PIXEL_PERIOD ms + * > Base number of ticks for which text should scroll + * from one line to the next */ +#define TICKER_LINE_SMOOTH_SCROLL_TICKS(line_len) ((size_t)(TICKER_LINE_DURATION_MS(line_len) / TICKER_PIXEL_PERIOD)) + #define ANIM_IS_ACTIVE(_p) (((_p)->flags & (GFX_ANIM_FLAG_IS_ACTIVE)) || ((_p)->flags & GFX_ANIM_FLAG_TICKER_IS_ACTIVE)) #define GFX_ANIMATION_CLEAR_ACTIVE(anim) ((anim)->flags &= ~(GFX_ANIM_FLAG_IS_ACTIVE | GFX_ANIM_FLAG_TICKER_IS_ACTIVE)) @@ -264,6 +280,18 @@ bool gfx_animation_ticker(gfx_animation_ctx_ticker_t *ticker); bool gfx_animation_ticker_smooth(gfx_animation_ctx_ticker_smooth_t *ticker); +/* Vertical (line) tickers: wrap 'str'/'src_str' to the field width and + * scroll the wrapped block upwards, one line at a time for + * gfx_animation_line_ticker() and pixel-smoothly - with optional top / + * bottom fade lines - for gfx_animation_line_ticker_smooth(). + * Used by XMB's entry sublabels and metadata panel, and by Ozone's + * content metadata panel. */ +bool gfx_animation_line_ticker(gfx_animation_t *p_anim, + gfx_animation_ctx_line_ticker_t *line_ticker); + +bool gfx_animation_line_ticker_smooth(gfx_animation_t *p_anim, + gfx_animation_ctx_line_ticker_smooth_t *line_ticker); + bool gfx_animation_kill_by_tag(uintptr_t *tag); bool gfx_animation_push(gfx_animation_ctx_entry_t *entry); diff --git a/gfx/gfx_thumbnail.c b/gfx/gfx_thumbnail.c index 4abbb6753404..903048da3977 100644 --- a/gfx/gfx_thumbnail.c +++ b/gfx/gfx_thumbnail.c @@ -39,6 +39,9 @@ #include "gfx_thumbnail.h" #include "../frontend/frontend_driver.h" +#ifdef HAVE_NETWORKING +#include "../network/screenscraper.h" +#endif #if (defined(HAVE_RWEBM) || defined(HAVE_RMP4)) && \ defined(HAVE_AUDIOMIXER) && \ @@ -1912,6 +1915,18 @@ void gfx_thumbnail_request( /* Trigger thumbnail download * * Note: download will grab all 3 possible thumbnails, no matter * what left/right thumbnails are set at the moment */ +#if defined(HAVE_NETWORKING) && defined(HAVE_MENU) + /* While signed in to ScreenScraper, on-demand artwork + * also goes through the scraper (hash/name matching plus + * the extended media types), with the libretro server as + * the per-item fallback inside the task */ + if (screenscraper_signed_in()) + { + task_push_pl_entry_screenscraper(path_data->system, + playlist, (unsigned)idx); + } + else +#endif task_push_pl_entry_thumbnail_download(path_data->system, playlist, (unsigned)idx, false, true); } @@ -2825,6 +2840,14 @@ static const char *gfx_thumbnail_get_type( return "Named_Boxarts"; case 4: return "Named_Logos"; + case 5: + return "Named_Boxarts3D"; + case 6: + return "Named_Fanarts"; + case 7: + return "Named_Marquees"; + case 8: + return "Named_Videos"; case 0: default: break; diff --git a/griffin/griffin.c b/griffin/griffin.c index 240f3b413c32..714eae0dd441 100644 --- a/griffin/griffin.c +++ b/griffin/griffin.c @@ -1391,6 +1391,8 @@ NETPLAY #endif #include "../tasks/task_netplay_find_content.c" #include "../tasks/task_pl_thumbnail_download.c" +#include "../network/screenscraper.c" +#include "../tasks/task_screenscraper.c" #endif /*============================================================ @@ -1445,6 +1447,11 @@ PLAYLISTS ============================================================ */ #include "../playlist.c" +/*============================================================ +SCREENSCRAPER (display half - reads local sidecars, no network) +============================================================ */ +#include "../network/screenscraper_meta.c" + /*============================================================ MENU ============================================================ */ diff --git a/intl/msg_hash_lbl.h b/intl/msg_hash_lbl.h index 6aa1205c5008..976e203fd11a 100644 --- a/intl/msg_hash_lbl.h +++ b/intl/msg_hash_lbl.h @@ -754,6 +754,10 @@ MSG_HASH( MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST, MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST_STR ) +MSG_HASH( + MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST, + MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST_STR + ) MSG_HASH( MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_TWITCH_LIST, MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_TWITCH_LIST_STR @@ -1850,6 +1854,10 @@ MSG_HASH( MENU_ENUM_LABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST, MENU_ENUM_LABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST_STR ) +MSG_HASH( + MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER, + MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER_STR + ) MSG_HASH( MENU_ENUM_LABEL_PLAYLIST_MANAGER_REFRESH_PLAYLIST, MENU_ENUM_LABEL_PLAYLIST_MANAGER_REFRESH_PLAYLIST_STR diff --git a/intl/msg_hash_us.c b/intl/msg_hash_us.c index 1f8bb147410e..d391e999f13f 100644 --- a/intl/msg_hash_us.c +++ b/intl/msg_hash_us.c @@ -584,6 +584,8 @@ static const char *const msg_hash_us_lbl_strs[] = { #include "../settings/settings_def_widget_scale_fullscreen.h" #include "../settings/settings_def_widget_scale.h" #include "../settings/settings_def_cheevos_account.h" +#include "../settings/settings_def_screenscraper.h" +#include "../settings/settings_def_screenscraper_account.h" #include "../settings/settings_def_menu_show_restart.h" #include "../settings/settings_def_quit_restart.h" #include "../settings/settings_def_menu_throttle.h" @@ -960,6 +962,8 @@ static const uint32_t msg_hash_us_lbl_ids[] = { #include "../settings/settings_def_widget_scale_fullscreen.h" #include "../settings/settings_def_widget_scale.h" #include "../settings/settings_def_cheevos_account.h" +#include "../settings/settings_def_screenscraper.h" +#include "../settings/settings_def_screenscraper_account.h" #include "../settings/settings_def_menu_show_restart.h" #include "../settings/settings_def_quit_restart.h" #include "../settings/settings_def_menu_throttle.h" diff --git a/intl/msg_hash_us.h b/intl/msg_hash_us.h index afbe4b0c0acc..1c3a4f8060fb 100644 --- a/intl/msg_hash_us.h +++ b/intl/msg_hash_us.h @@ -22288,6 +22288,14 @@ MSG_HASH( MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST, "Validate core associations and remove invalid and duplicate entries." ) +MSG_HASH( + MENU_ENUM_LABEL_VALUE_PLAYLIST_MANAGER_SCREENSCRAPER, + "Scrape Using ScreenScraper" + ) +MSG_HASH( + MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_SCREENSCRAPER, + "Download media and metadata for every entry in this playlist from screenscraper.fr, using the ScreenScraper settings under Settings > Playlists." + ) MSG_HASH( MENU_ENUM_LABEL_VALUE_PLAYLIST_MANAGER_REFRESH_PLAYLIST, "Refresh Playlist" @@ -22900,6 +22908,8 @@ MSG_HASH(MENU_ENUM_SUBLABEL_##T, sub) MSG_HASH(MENU_ENUM_SUBLABEL_##T, sub) #define S_ACTION_LV_NS(T, TV, n, sd, ok, rp, c, us) #include "../settings/settings_def_cheevos_account.h" +#include "../settings/settings_def_screenscraper.h" +#include "../settings/settings_def_screenscraper_account.h" #undef S_BOOL #undef S_BOOL_NS #undef S_BOOL_H @@ -25691,11 +25701,11 @@ MSG_HASH( ) MSG_HASH( MENU_ENUM_LABEL_VALUE_MENU_TICKER_TYPE_BOUNCE, - "Bounce Left/Right" + "Bounce" ) MSG_HASH( MENU_ENUM_LABEL_VALUE_MENU_TICKER_TYPE_LOOP, - "Scroll Left" + "Scroll" ) MSG_HASH( MENU_ENUM_LABEL_VALUE_AI_SERVICE_IMAGE_MODE, @@ -26073,6 +26083,22 @@ MSG_HASH( MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_LOGOS, "Content Logo" ) +MSG_HASH( + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_BOXARTS_3D, + "3D Boxart (ScreenScraper)" + ) +MSG_HASH( + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_FANARTS, + "Fan Art (ScreenScraper)" + ) +MSG_HASH( + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_MARQUEES, + "Marquee (ScreenScraper)" + ) +MSG_HASH( + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_VIDEOS, + "Video Snap (ScreenScraper)" + ) MSG_HASH( MENU_ENUM_LABEL_VALUE_SCROLL_NORMAL, "Normal" @@ -32576,6 +32602,14 @@ MSG_HASH( MSG_PREEMPT_FAILED_TO_LOAD_STATE, "Failed to load state. Preemptive Frames has been disabled." ) +MSG_HASH( + MSG_SCREENSCRAPER_QUOTA_PAUSED, + "ScreenScraper daily requests used up. Scraping paused; it resumes automatically once the allowance refreshes." + ) +MSG_HASH( + MSG_SCREENSCRAPER_QUOTA_RESUMED, + "ScreenScraper allowance refreshed. Resuming the paused scrape." + ) MSG_HASH( MSG_SCANNING_OF_FILE_FINISHED, "Scanning of file finished." diff --git a/menu/cbs/menu_cbs_deferred_push.c b/menu/cbs/menu_cbs_deferred_push.c index 3efdf6bf95b0..d38eca751404 100644 --- a/menu/cbs/menu_cbs_deferred_push.c +++ b/menu/cbs/menu_cbs_deferred_push.c @@ -241,6 +241,7 @@ GENERIC_DEFERRED_PUSH(deferred_push_playlist_manager_settings, DISPLAYLIST_ GENERIC_DEFERRED_PUSH(deferred_push_input_retropad_binds_list, DISPLAYLIST_INPUT_RETROPAD_BINDS_LIST) GENERIC_DEFERRED_PUSH(deferred_push_input_hotkey_binds_list, DISPLAYLIST_INPUT_HOTKEY_BINDS_LIST) GENERIC_DEFERRED_PUSH(deferred_push_accounts_cheevos_list, DISPLAYLIST_ACCOUNTS_CHEEVOS_LIST) +GENERIC_DEFERRED_PUSH(deferred_push_accounts_screenscraper_list, DISPLAYLIST_ACCOUNTS_SCREENSCRAPER_LIST) GENERIC_DEFERRED_PUSH(deferred_push_accounts_twitch_list, DISPLAYLIST_ACCOUNTS_TWITCH_LIST) GENERIC_DEFERRED_PUSH(deferred_push_accounts_youtube_list, DISPLAYLIST_ACCOUNTS_YOUTUBE_LIST) GENERIC_DEFERRED_PUSH(deferred_push_accounts_facebook_list, DISPLAYLIST_ACCOUNTS_FACEBOOK_LIST) @@ -759,6 +760,7 @@ static int menu_cbs_init_bind_deferred_push_compare_label( {MENU_ENUM_LABEL_DEFERRED_DATABASE_MANAGER_LIST, deferred_push_database_manager_list_deferred}, {MENU_ENUM_LABEL_CONFIGURATIONS, deferred_push_configurations}, {MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST, deferred_push_accounts_cheevos_list}, + {MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST, deferred_push_accounts_screenscraper_list}, {MENU_ENUM_LABEL_DATABASE_MANAGER_LIST, deferred_push_database_manager_list}, {MENU_ENUM_LABEL_ACHIEVEMENT_PAUSE_MENU, deferred_push_achievement_pause_menu}, {MENU_ENUM_LABEL_ACHIEVEMENT_LIST, deferred_push_achievement_list}, @@ -850,6 +852,7 @@ static int menu_cbs_init_bind_deferred_push_compare_label( { MENU_ENUM_LABEL_DEFERRED_INPUT_RETROPAD_BINDS_LIST, deferred_push_input_retropad_binds_list }, { MENU_ENUM_LABEL_DEFERRED_INPUT_HOTKEY_BINDS_LIST, deferred_push_input_hotkey_binds_list }, { MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST, deferred_push_accounts_cheevos_list }, + { MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST, deferred_push_accounts_screenscraper_list }, { MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_YOUTUBE_LIST, deferred_push_accounts_youtube_list }, { MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_TWITCH_LIST, deferred_push_accounts_twitch_list }, { MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_FACEBOOK_LIST, deferred_push_accounts_facebook_list }, diff --git a/menu/cbs/menu_cbs_ok.c b/menu/cbs/menu_cbs_ok.c index 3a943d78a183..b21f5fd32c8d 100644 --- a/menu/cbs/menu_cbs_ok.c +++ b/menu/cbs/menu_cbs_ok.c @@ -75,6 +75,9 @@ #include "../../tasks/task_content.h" #include "../../tasks/task_file_transfer.h" #include "../../tasks/tasks_internal.h" +#ifdef HAVE_NETWORKING +#include "../../network/screenscraper.h" +#endif #include "../../input/input_remapping.h" #include "../../paths.h" #include "../../playlist.h" @@ -504,6 +507,8 @@ static enum msg_hash_enums action_ok_dl_to_enum(unsigned lbl) return MENU_ENUM_LABEL_DEFERRED_PLAYLIST_MANAGER_SETTINGS; case ACTION_OK_DL_ACCOUNTS_CHEEVOS_LIST: return MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST; + case ACTION_OK_DL_ACCOUNTS_SCREENSCRAPER_LIST: + return MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST; case ACTION_OK_DL_ACCOUNTS_TWITCH_LIST: return MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_TWITCH_LIST; case ACTION_OK_DL_ACCOUNTS_FACEBOOK_LIST: @@ -1853,6 +1858,7 @@ int generic_action_ok_displaylist_push( case ACTION_OK_DL_PLAYLIST_MANAGER_SETTINGS: case ACTION_OK_DL_ACCOUNTS_CHEEVOS_LIST: case ACTION_OK_DL_ACCOUNTS_YOUTUBE_LIST: + case ACTION_OK_DL_ACCOUNTS_SCREENSCRAPER_LIST: case ACTION_OK_DL_ACCOUNTS_TWITCH_LIST: case ACTION_OK_DL_ACCOUNTS_FACEBOOK_LIST: case ACTION_OK_DL_ACCOUNTS_KICK_LIST: @@ -6894,6 +6900,7 @@ STATIC_DEFAULT_ACTION_OK_FUNC(action_ok_push_smb_client_settings_list, ACTION_OK #endif STATIC_DEFAULT_ACTION_OK_FUNC(action_ok_push_user_binds_list, ACTION_OK_DL_USER_BINDS_LIST) STATIC_DEFAULT_ACTION_OK_FUNC(action_ok_push_accounts_cheevos_list, ACTION_OK_DL_ACCOUNTS_CHEEVOS_LIST) +STATIC_DEFAULT_ACTION_OK_FUNC(action_ok_push_accounts_screenscraper_list, ACTION_OK_DL_ACCOUNTS_SCREENSCRAPER_LIST) #ifdef HAVE_LAKKA STATIC_DEFAULT_ACTION_OK_FUNC(action_ok_push_eject_disc, ACTION_OK_DL_EJECT_DISC) #endif @@ -9051,8 +9058,15 @@ static int action_ok_pl_content_thumbnails(const char *path, sizeof(playlist_path)); playlist_config_set_path(&playlist_config, playlist_path); - task_push_pl_thumbnail_download(path, &playlist_config, - path_dir_thumbnails); + /* While signed in to ScreenScraper it acts as the primary + * scraper (with the libretro server as per-item fallback, + * ordered by the scraper-order setting) */ + if (screenscraper_signed_in()) + task_push_pl_screenscraper(path, &playlist_config, + path_dir_thumbnails); + else + task_push_pl_thumbnail_download(path, &playlist_config, + path_dir_thumbnails); return 0; } @@ -9104,6 +9118,28 @@ static int action_ok_playlist_clean(const char *path, return 0; } +#ifdef HAVE_NETWORKING +static int action_ok_playlist_screenscraper(const char *path, + const char *label, unsigned type, size_t idx, size_t entry_idx) +{ + char system[NAME_MAX_LENGTH]; + playlist_t *playlist = playlist_get_cached(); + playlist_config_t *playlist_config = NULL; + settings_t *settings = config_get_ptr(); + if (!playlist || !settings) + return -1; + playlist_config = playlist_get_config(playlist); + if (!playlist_config || !*playlist_config->path) + return -1; + /* System name: playlist file name without extension */ + fill_pathname(system, + path_basename(playlist_config->path), "", sizeof(system)); + task_push_pl_screenscraper(system, playlist_config, + settings->paths.directory_thumbnails); + return 0; +} +#endif + static int action_ok_playlist_refresh(const char *path, const char *label, unsigned type, size_t idx, size_t entry_idx) { @@ -9544,8 +9580,14 @@ static int menu_cbs_init_bind_ok_compare_label(menu_file_list_cbs_t *cbs, {MENU_ENUM_LABEL_PLAYLIST_MANAGER_SETTINGS, action_ok_push_playlist_manager_settings}, {MENU_ENUM_LABEL_PLAYLIST_MANAGER_RESET_CORES, action_ok_playlist_reset_cores}, {MENU_ENUM_LABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST, action_ok_playlist_clean}, +#ifdef HAVE_NETWORKING + {MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER, action_ok_playlist_screenscraper}, +#endif {MENU_ENUM_LABEL_PLAYLIST_MANAGER_REFRESH_PLAYLIST, action_ok_playlist_refresh}, {MENU_ENUM_LABEL_ACCOUNTS_RETRO_ACHIEVEMENTS, action_ok_push_accounts_cheevos_list}, +#ifdef HAVE_NETWORKING + {MENU_ENUM_LABEL_ACCOUNTS_SCREENSCRAPER, action_ok_push_accounts_screenscraper_list}, +#endif #ifdef HAVE_LAKKA {MENU_ENUM_LABEL_EJECT_DISC, action_ok_push_eject_disc}, #endif @@ -9678,6 +9720,9 @@ static int menu_cbs_init_bind_ok_compare_label(menu_file_list_cbs_t *cbs, {MENU_ENUM_LABEL_STREAM_CONFIG, action_ok_stream_configfile}, {MENU_ENUM_LABEL_RGUI_MENU_THEME_PRESET, action_ok_rgui_menu_theme_preset}, {MENU_ENUM_LABEL_ACCOUNTS_RETRO_ACHIEVEMENTS, action_ok_push_accounts_cheevos_list}, +#ifdef HAVE_NETWORKING + {MENU_ENUM_LABEL_ACCOUNTS_SCREENSCRAPER, action_ok_push_accounts_screenscraper_list}, +#endif {MENU_ENUM_LABEL_FAVORITES, action_ok_push_content_list}, {MENU_ENUM_LABEL_DOWNLOADED_FILE_DETECT_CORE_LIST, action_ok_push_downloads_dir}, {MENU_ENUM_LABEL_DETECT_CORE_LIST_OK, action_ok_file_load_detect_core}, diff --git a/menu/cbs/menu_cbs_sublabel.c b/menu/cbs/menu_cbs_sublabel.c index 550cfc2deb4a..221fab8ac059 100644 --- a/menu/cbs/menu_cbs_sublabel.c +++ b/menu/cbs/menu_cbs_sublabel.c @@ -25,6 +25,8 @@ #include #include "../menu_driver.h" +#include "../../network/screenscraper.h" +#include "../../gfx/gfx_thumbnail.h" #include "../menu_cbs.h" #include "../../input/input_remapping.h" @@ -1242,9 +1244,70 @@ static int action_bind_sublabel_playlist_entry( || string_is_equal(entry->core_path, "DETECT")) return 0; + _len = 0; + + /* Scraped facts come first, when this entry has a metadata sidecar. + * Drivers that draw the facts in a panel of their own are skipped, + * or the same text would appear twice: Ozone always does, XMB does + * when its metadata panel is switched on. Those panels also have + * room to word wrap and scroll the synopsis, which a one-line + * sublabel does not. */ + { + const char *ss_ident = menu_driver_ident(); + settings_t *ss_settings = config_get_ptr(); + bool ss_own_panel = + string_is_equal(ss_ident, "ozone") + || ( string_is_equal(ss_ident, "xmb") + && ss_settings + && ss_settings->bools.menu_xmb_show_metadata_panel); + + if ( !ss_own_panel + && ss_settings + && !string_is_empty(entry->db_name)) + { + char ss_db[NAME_MAX_LENGTH]; + char ss_img[NAME_MAX_LENGTH]; + screenscraper_meta_t meta; + + /* Playlist database names carry a ".lpl" suffix that the + * thumbnail tree does not use */ + strlcpy(ss_db, entry->db_name, sizeof(ss_db)); + path_remove_extension(ss_db); + + gfx_thumbnail_fill_content_img(ss_img, sizeof(ss_img), + !string_is_empty(entry->label) + ? entry->label : path_basename(entry->path), + false); + + if (screenscraper_metadata_load_entry( + ss_settings->paths.directory_thumbnails, + ss_db, ss_img, &meta)) + { + if (!string_is_empty(meta.rating)) + _len += snprintf(s + _len, len - _len, + "Rating: %s/20\n", meta.rating); + if (!string_is_empty(meta.publisher)) + _len += snprintf(s + _len, len - _len, + "Publisher: %s\n", meta.publisher); + if (!string_is_empty(meta.description)) + { + /* A list row cannot carry a whole synopsis; the full + * text remains on the information screen */ + char blurb[192]; + size_t b_len = strlcpy(blurb, meta.description, + sizeof(blurb)); + if (b_len >= sizeof(blurb) - 1) + strlcpy(blurb + sizeof(blurb) - 4, "...", 4); + _len += snprintf(s + _len, len - _len, "%s\n", blurb); + } + } + } + } + /* Add core name */ - _len = strlcpy(s, - msg_hash_to_str(MENU_ENUM_LABEL_VALUE_PLAYLIST_SUBLABEL_CORE), len); + _len += strlcpy(s + _len, + msg_hash_to_str(MENU_ENUM_LABEL_VALUE_PLAYLIST_SUBLABEL_CORE), + len - _len); s[ _len] = ' '; s[++_len] = '\0'; _len += strlcpy(s + _len, entry->core_name, len - _len); @@ -2453,6 +2516,7 @@ int menu_cbs_init_bind_sublabel(menu_file_list_cbs_t *cbs, { MENU_ENUM_LABEL_PLAYLIST_MANAGER_LABEL_DISPLAY_MODE, MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_LABEL_DISPLAY_MODE }, { MENU_ENUM_LABEL_PLAYLIST_MANAGER_SORT_MODE, MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_SORT_MODE }, { MENU_ENUM_LABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST, MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST }, + { MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER, MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_SCREENSCRAPER }, { MENU_ENUM_LABEL_PLAYLIST_MANAGER_REFRESH_PLAYLIST, MENU_ENUM_SUBLABEL_PLAYLIST_MANAGER_REFRESH_PLAYLIST }, { MENU_ENUM_LABEL_DELETE_PLAYLIST, MENU_ENUM_SUBLABEL_DELETE_PLAYLIST }, { MENU_ENUM_LABEL_AI_SERVICE_URL, MENU_ENUM_SUBLABEL_AI_SERVICE_URL }, diff --git a/menu/drivers/ozone.c b/menu/drivers/ozone.c index 7575318934a0..70e5d2e90695 100644 --- a/menu/drivers/ozone.c +++ b/menu/drivers/ozone.c @@ -39,6 +39,7 @@ #include "../menu_screensaver.h" #include "../../msg_hash_lbl_str.h" +#include "../../network/screenscraper.h" #include "../../gfx/gfx_animation.h" #include "../../gfx/gfx_display.h" #include "../../gfx/gfx_thumbnail.h" @@ -51,6 +52,9 @@ #include "../../file_path_special.h" #include "../../runtime_file.h" #include "../../input/input_osk.h" +#ifdef HAVE_NETWORKING +#include "../../tasks/tasks_internal.h" +#endif #ifdef HAVE_AUDIOMIXER #include "../../audio/audio_driver.h" @@ -683,6 +687,30 @@ struct ozone_handle char selection_core_name[NAME_MAX_LENGTH]; char selection_playtime[NAME_MAX_LENGTH]; char selection_lastplayed[NAME_MAX_LENGTH]; + /* Scraped facts for the selected entry, shown above the playlist + * metadata when a sidecar exists for it */ + char selection_ss_publisher[NAME_MAX_LENGTH]; + /* "|" of the entry the scraped facts belong to, + * so a stale set can be spotted and refreshed */ + char selection_ss_key[NAME_MAX_LENGTH]; + /* Synopsis as scraped, i.e. one long unbroken paragraph. The + * horizontal ticker and the vertical line ticker both want it this + * way - each does its own fitting. */ + char selection_ss_description[1024]; + /* The same text pre-wrapped to the metadata column, for the static + * (non-scrolling) presentation. Kept separate from the raw copy + * because word_wrap() may not read and write the same buffer, and + * because both presentations have to be available without waiting + * for the next selection change. */ + char selection_ss_description_wrapped[1024]; + /* Line count of selection_ss_description_wrapped */ + unsigned selection_ss_description_lines; + /* Rating is drawn as five stars; this is how many of them are + * filled in. Zero filled stars is a legitimate value, so a + * separate flag records whether a rating was scraped at all. */ + unsigned selection_ss_stars; + bool selection_ss_has_rating; + bool selection_ss_valid; char selection_entry_enumeration[NAME_MAX_LENGTH]; char fullscreen_thumbnail_label[NAME_MAX_LENGTH]; @@ -4544,6 +4572,113 @@ static void ozone_update_content_metadata(ozone_handle_t *ozone) core_label = menu_st->thumbnail_path_data->content_core_name; } + /* Scraped metadata for this entry, if it was ever scraped */ + { + settings_t *ss_settings = config_get_ptr(); + gfx_thumbnail_path_data_t *pdata = menu_st->thumbnail_path_data; + screenscraper_meta_t meta; + + ozone->selection_ss_valid = false; + ozone->selection_ss_has_rating = false; + ozone->selection_ss_stars = 0; + ozone->selection_ss_publisher[0] = '\0'; + ozone->selection_ss_description[0] = '\0'; + ozone->selection_ss_key[0] = '\0'; + + if (ss_settings && pdata) + { + const char *ss_db = *pdata->content_db_name + ? pdata->content_db_name : pdata->system; + const char *ss_img = *pdata->content_img + ? pdata->content_img : pdata->content_img_full; + + /* Remember which entry these facts describe. The thumbnail + * data they resolve from is not populated yet when a list + * is first entered, so ozone_render() compares this key + * against the live one and refreshes when it changes. */ + snprintf(ozone->selection_ss_key, + sizeof(ozone->selection_ss_key), "%s|%s", + ss_db ? ss_db : "", + ss_img ? ss_img : ""); + + if (screenscraper_metadata_load_entry( + ss_settings->paths.directory_thumbnails, + ss_db, ss_img, &meta)) + { + int ss_stars = screenscraper_rating_stars(meta.rating); + + ozone->selection_ss_valid = true; + + /* Negative means "no rating was scraped"; the star row + * is then skipped entirely rather than drawn empty. */ + if (ss_stars >= 0) + { + ozone->selection_ss_has_rating = true; + ozone->selection_ss_stars = (unsigned)ss_stars; + } + + if (!string_is_empty(meta.publisher)) + snprintf(ozone->selection_ss_publisher, + sizeof(ozone->selection_ss_publisher), + "Publisher: %s", meta.publisher); + if (!string_is_empty(meta.description)) + strlcpy(ozone->selection_ss_description, + meta.description, + sizeof(ozone->selection_ss_description)); + } +#if defined(HAVE_NETWORKING) + else if (ozone->flags & OZONE_FLAG_IS_PLAYLIST) + { + /* No sidecar for this entry. Entries whose artwork is + * already complete never fire the on-demand artwork + * path, so this is the only chance to fetch their + * metadata; the push declines by itself when signed + * out, when on-demand downloads are off, or when a + * not-found marker says the service has nothing. The + * facts appear the next time the entry is selected. */ + playlist_t *ss_pl = playlist_get_cached(); + if (ss_pl) + task_push_pl_entry_screenscraper_meta(pdata->system, + ss_db, ss_img, ss_pl, + (unsigned)pdata->playlist_index); + } +#endif + } + + /* Pre-wrap the synopsis to the metadata column. This runs + * whatever the scroll mode is, for two reasons: the static + * presentation needs the wrapped copy (a zero line count + * gives the row no height, so it never appears), and the + * vertical scroller needs the line count to decide how tall + * the scrolling block should be. Both metadata settings can + * then be flipped without waiting for a selection change. + * + * Note the destination is a distinct buffer: word_wrap() + * reads the source while writing the destination, so an + * in-place call leaves the string mangled. */ + ozone->selection_ss_description_wrapped[0] = '\0'; + ozone->selection_ss_description_lines = 0; + + if ( *ozone->selection_ss_description + && (ozone->fonts.footer.glyph_width > 0)) + { + unsigned metadata_len = + (ozone->dimensions.thumbnail_bar_width + - ((ozone->dimensions.sidebar_entry_icon_padding * 2) * 2)) + / ozone->fonts.footer.glyph_width; + + (ozone->word_wrap)(ozone->selection_ss_description_wrapped, + sizeof(ozone->selection_ss_description_wrapped), + ozone->selection_ss_description, + strlen(ozone->selection_ss_description), + metadata_len, + ozone->fonts.footer.wideglyph_width, 0); + + ozone->selection_ss_description_lines = + ozone_count_lines(ozone->selection_ss_description_wrapped); + } + } + _len = strlcpy(ozone->selection_core_name, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_PLAYLIST_SUBLABEL_CORE), sizeof(ozone->selection_core_name)); @@ -4563,6 +4698,8 @@ static void ozone_update_content_metadata(ozone_handle_t *ozone) (ozone->dimensions.thumbnail_bar_width - ((ozone->dimensions.sidebar_entry_icon_padding * 2) * 2)) / ozone->fonts.footer.glyph_width; + /* The scraped synopsis is wrapped further up, into its own + * buffer, whatever the scroll mode is. */ size_t _len = strlcpy(tmpstr, ozone->selection_core_name, sizeof(tmpstr)); (ozone->word_wrap)(ozone->selection_core_name, sizeof(ozone->selection_core_name), @@ -5660,6 +5797,367 @@ static void ozone_content_metadata_line( + (unsigned)((float)ozone->fonts.footer.line_height * 1.5f); } +#if defined(HAVE_OZONE) +/* Draws the pre-wrapped synopsis, clamped to the lines that actually + * fit above the footer. + * + * ozone_content_metadata_line() draws nothing at all when a block + * would overflow, which is right for the short rows it was written + * for but wrong here: a synopsis wrapped to this narrow sidebar runs + * to far more lines than the panel has room for, so the whole + * description silently disappeared. Showing as much as fits, ending + * in an ellipsis, is what a reader wants - the full text is still on + * the information screen. */ +static void ozone_draw_metadata_description_static( + unsigned video_width, + unsigned video_height, + ozone_handle_t *ozone, + unsigned *y, + unsigned column_x, + uint32_t color) +{ + char clamped[sizeof(ozone->selection_ss_description_wrapped)]; + const char *src = ozone->selection_ss_description_wrapped; + unsigned line_h = (unsigned)ozone->fonts.footer.line_height; + unsigned floor_y = video_height - ozone->dimensions.footer_height; + unsigned max_lines; + unsigned lines = ozone->selection_ss_description_lines; + size_t cut = 0; + unsigned seen = 0; + + if (string_is_empty(src) || line_h == 0 || *y >= floor_y) + return; + + max_lines = (floor_y - *y) / line_h; + + if (max_lines == 0) + return; + + /* Fits as-is */ + if (lines <= max_lines) + { + ozone_content_metadata_line(video_width, video_height, ozone, + y, column_x, src, color, (uint8_t)lines); + return; + } + + /* Cut at the newline that ends the last line that fits */ + while (src[cut]) + { + if (src[cut] == '\n') + { + seen++; + if (seen == max_lines) + break; + } + cut++; + } + + if (cut >= sizeof(clamped)) + cut = sizeof(clamped) - 1; + + memcpy(clamped, src, cut); + clamped[cut] = '\0'; + + /* Mark the truncation in place, so the width stays within the + * wrap the text was laid out for */ + if (cut >= 3) + memcpy(clamped + cut - 3, "...", 3); + + ozone_content_metadata_line(video_width, video_height, ozone, + y, column_x, clamped, color, (uint8_t)max_lines); +} +#endif + +/* Draws a scraped rating as a row of five stars: 'stars' of them at + * full opacity, the remainder dimmed. Occupies exactly the space of a + * one-line ozone_content_metadata_line() row - same clipping rule, + * same vertical advance - so the metadata rows underneath keep their + * spacing whichever way the rating happens to render. + * + * 'panel_width' is the usable width of the metadata column; the star + * size shrinks to fit if the thumbnail bar is narrow. */ +static void ozone_draw_metadata_stars( + gfx_display_t *p_disp, + void *userdata, + unsigned video_width, + unsigned video_height, + ozone_handle_t *ozone, + unsigned *y, + unsigned column_x, + unsigned panel_width, + unsigned stars, + float alpha, + math_matrix_4x4 *mymat) +{ + unsigned i; + int icon_y; + /* Stack-local scratch - avoids a data race with threaded video + * backends reading a previous frame's colour data */ + float star_filled[16] = { + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + }; + float star_empty[16] = { + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + }; + gfx_display_ctx_driver_t *dispctx = p_disp->dispctx; + uintptr_t texture = + ozone->icons_textures[OZONE_ENTRIES_ICONS_TEXTURE_FAVORITE]; + unsigned line_height = (unsigned)ozone->fonts.footer.line_height; + unsigned icon_size = (unsigned)ozone->dimensions.sidebar_entry_icon_size; + + if ( !dispctx + || !dispctx->draw + || !texture + || !line_height) + return; + + /* Same clipping rule as a one-line text row */ + if (*y + line_height > video_height - ozone->dimensions.footer_height) + return; + + /* One text line tall at most, so the row does not grow, and narrow + * enough that five icons plus four quarter-icon gaps (six icon + * widths in total) stay inside the metadata column */ + if (icon_size > line_height) + icon_size = line_height; + if (icon_size * 6 > panel_width) + icon_size = panel_width / 6; + if (!icon_size) + return; + + if (stars > 5) + stars = 5; + + icon_y = (int)*y + ((int)line_height - (int)icon_size) / 2; + + gfx_display_set_alpha(star_filled, alpha); + /* Unearned stars stay visible as a faint outline of the scale */ + gfx_display_set_alpha(star_empty, alpha * 0.25f); + + if (dispctx->blend_begin) + dispctx->blend_begin(userdata); + + for (i = 0; i < 5; i++) + ozone_draw_icon( + p_disp, + userdata, + video_width, + video_height, + icon_size, + icon_size, + texture, + (float)(column_x + i * (icon_size + (icon_size / 4))), + (float)icon_y, + video_width, + video_height, + 0.0f, + 1.0f, + (i < stars) ? star_filled : star_empty, + mymat); + + if (dispctx->blend_end) + dispctx->blend_end(userdata); + + *y += (unsigned)((float)ozone->fonts.footer.line_height * 1.5f); +} + +/* Room the vertically scrolling synopsis may occupy: everything from + * '*y' down to the top of the footer, less the height the metadata rows + * drawn underneath it will need. 'rows_lines' is the total number of + * text lines those rows occupy and 'rows_count' how many rows there + * are, because ozone_content_metadata_line() adds half a line of + * padding per row on top of the lines themselves. + * Returns 0 when nothing is left. */ +static unsigned ozone_metadata_description_avail_height( + ozone_handle_t *ozone, + unsigned video_height, + unsigned y, + unsigned rows_lines, + unsigned rows_count) +{ + int bottom = (int)video_height - (int)ozone->dimensions.footer_height; + int reserved = (int)((float)ozone->fonts.footer.line_height + * ((float)rows_lines + ((float)rows_count * 0.5f))); + int avail = bottom - (int)y - reserved; + + return (avail > 0) ? (unsigned)avail : 0; +} + +/* Draws the scraped synopsis as a vertically scrolling block, the way + * XMB's metadata panel presents it: the text is wrapped to the width of + * the metadata column, and when the wrapped block is taller than the + * room available it scrolls upwards in a loop, the lines entering and + * leaving the field fading in and out (smooth ticker only - the + * line-stepping ticker has no fade). + * + * 'avail_height' is the room the block may use; the caller has already + * subtracted whatever the rows drawn underneath need. '*y' is advanced + * exactly as ozone_content_metadata_line() advances it for a block of + * the same number of lines, so those rows keep their spacing. + * + * Returns false when there is not even one line of room, in which case + * nothing is drawn and '*y' is left alone. */ +static bool ozone_draw_metadata_description_vertical( + ozone_handle_t *ozone, + gfx_animation_t *p_anim, + settings_t *settings, + unsigned video_width, + unsigned video_height, + unsigned *y, + unsigned column_x, + unsigned field_width, + unsigned avail_height, + float metadata_alpha) +{ + char body[1024]; + char top_fade[1024]; + char bottom_fade[1024]; + unsigned max_lines; + unsigned draw_lines; + float ticker_y_offset = 0.0f; + float ticker_top_fade_y_offset = 0.0f; + float ticker_bot_fade_y_offset = 0.0f; + float ticker_top_fade_alpha = 0.0f; + float ticker_bot_fade_alpha = 0.0f; + unsigned line_height = (unsigned)ozone->fonts.footer.line_height; + float text_y = (float)*y + + (float)ozone->fonts.footer.line_ascender; + uint32_t text_color = COLOR_TEXT_ALPHA( + ozone->theme->text_rgba, (uint32_t)(metadata_alpha * 255.0f)); + bool use_smooth_ticker = settings->bools.menu_ticker_smooth; + enum gfx_animation_ticker_type menu_ticker_type = + (enum gfx_animation_ticker_type)settings->uints.menu_ticker_type; + + if ( !line_height + || (field_width < 1) + || (avail_height < line_height) + || (ozone->fonts.footer.glyph_width < 1)) + return false; + + /* Height of the scrolling field: as many whole lines as fit, but + * never more than the synopsis actually needs. A synopsis short + * enough to fit therefore occupies exactly its own height and does + * not scroll at all, matching the static presentation. */ + max_lines = avail_height / line_height; + draw_lines = ozone->selection_ss_description_lines; + + if ((draw_lines < 1) || (draw_lines > max_lines)) + draw_lines = max_lines; + + body[0] = '\0'; + top_fade[0] = '\0'; + bottom_fade[0] = '\0'; + + if (use_smooth_ticker) + { + gfx_animation_ctx_line_ticker_smooth_t line_ticker_smooth; + + line_ticker_smooth.idx = p_anim->ticker_pixel_line_idx; + line_ticker_smooth.font = ozone->fonts.footer.font; + line_ticker_smooth.font_scale = 1.0f; + line_ticker_smooth.type_enum = menu_ticker_type; + line_ticker_smooth.fade_enabled = true; + + line_ticker_smooth.field_width = field_width; + line_ticker_smooth.field_height = draw_lines * line_height; + + line_ticker_smooth.src_str = ozone->selection_ss_description; + line_ticker_smooth.dst_str = body; + line_ticker_smooth.dst_str_len = sizeof(body); + line_ticker_smooth.y_offset = &ticker_y_offset; + + line_ticker_smooth.top_fade_str = top_fade; + line_ticker_smooth.top_fade_str_len = sizeof(top_fade); + line_ticker_smooth.top_fade_y_offset = &ticker_top_fade_y_offset; + line_ticker_smooth.top_fade_alpha = &ticker_top_fade_alpha; + + line_ticker_smooth.bottom_fade_str = bottom_fade; + line_ticker_smooth.bottom_fade_str_len = sizeof(bottom_fade); + line_ticker_smooth.bottom_fade_y_offset = &ticker_bot_fade_y_offset; + line_ticker_smooth.bottom_fade_alpha = &ticker_bot_fade_alpha; + + gfx_animation_line_ticker_smooth(p_anim, &line_ticker_smooth); + } + else + { + gfx_animation_ctx_line_ticker_t line_ticker; + + line_ticker.idx = p_anim->ticker_idx; + line_ticker.type_enum = menu_ticker_type; + line_ticker.line_len = (size_t)(field_width + / ozone->fonts.footer.glyph_width); + line_ticker.max_lines = draw_lines; + line_ticker.s = body; + line_ticker.len = sizeof(body); + line_ticker.str = ozone->selection_ss_description; + + gfx_animation_line_ticker(p_anim, &line_ticker); + } + + if (*body) + gfx_display_draw_text( + ozone->fonts.footer.font, + body, + (float)column_x, + text_y + ticker_y_offset, + video_width, + video_height, + text_color, + TEXT_ALIGN_LEFT, + 1.0f, + false, + 1.0f, + true); + + /* Partial lines entering / leaving the field. Only the smooth + * ticker produces these; the line-stepping one leaves them empty. */ + if (use_smooth_ticker) + { + if (*top_fade && (ticker_top_fade_alpha > 0.0f)) + gfx_display_draw_text( + ozone->fonts.footer.font, + top_fade, + (float)column_x, + text_y + ticker_top_fade_y_offset, + video_width, + video_height, + COLOR_TEXT_ALPHA(ozone->theme->text_rgba, + (uint32_t)(metadata_alpha * ticker_top_fade_alpha * 255.0f)), + TEXT_ALIGN_LEFT, + 1.0f, + false, + 1.0f, + true); + + if (*bottom_fade && (ticker_bot_fade_alpha > 0.0f)) + gfx_display_draw_text( + ozone->fonts.footer.font, + bottom_fade, + (float)column_x, + text_y + ticker_bot_fade_y_offset, + video_width, + video_height, + COLOR_TEXT_ALPHA(ozone->theme->text_rgba, + (uint32_t)(metadata_alpha * ticker_bot_fade_alpha * 255.0f)), + TEXT_ALIGN_LEFT, + 1.0f, + false, + 1.0f, + true); + } + + *y += (unsigned)(line_height * (draw_lines - 1)) + + (unsigned)((float)line_height * 1.5f); + return true; +} + /* Compute new scroll position * If the center of the currently selected entry is not in the middle @@ -6933,10 +7431,17 @@ static void ozone_draw_thumbnail_bar( bool use_smooth_ticker = settings->bools.menu_ticker_smooth; enum gfx_animation_ticker_type menu_ticker_type = (enum gfx_animation_ticker_type)settings->uints.menu_ticker_type; - bool show_entry_idx = settings->bools.playlist_show_entry_idx; + /* Entry index and last played give way to the scraped facts, so + * the panel does not grow unboundedly - but only when there are + * scraped facts to show. Without them the panel keeps its full + * stock contents rather than losing rows for nothing. */ + bool have_ss_facts = ozone->selection_ss_valid; + bool show_entry_idx = settings->bools.playlist_show_entry_idx + && !have_ss_facts; bool show_entry_core = (!(ozone->flags & OZONE_FLAG_IS_DB_MANAGER_LIST)); bool show_entry_playtime = (!(ozone->flags & OZONE_FLAG_IS_DB_MANAGER_LIST)); - bool show_entry_last_played = (!(ozone->flags & OZONE_FLAG_IS_DB_MANAGER_LIST)); + bool show_entry_last_played = (!(ozone->flags & OZONE_FLAG_IS_DB_MANAGER_LIST)) + && !have_ss_facts; unsigned y = (unsigned)bottom_row_y_position; unsigned separator_padding = ozone->dimensions.sidebar_entry_icon_padding * 2; unsigned column_x = x_position + separator_padding; @@ -6947,6 +7452,15 @@ static void ozone_draw_thumbnail_bar( : 1.0f; uint32_t text_color = COLOR_TEXT_ALPHA( ozone->theme->text_rgba, (uint32_t)(metadata_alpha * 255.0f)); + /* These govern the scraped synopsis only; every other row keeps + * whatever ozone_scroll_content_metadata gives it. Neither being + * set is the Horizontal default, i.e. upstream behaviour. */ + unsigned metadata_scroll_style = + settings->uints.ozone_metadata_scroll_style; + bool metadata_scroll_vertical = + (metadata_scroll_style == OZONE_METADATA_SCROLL_VERTICAL); + bool metadata_scroll_off = + (metadata_scroll_style == OZONE_METADATA_SCROLL_OFF); if (scroll_content_metadata) { @@ -7002,6 +7516,101 @@ static void ozone_draw_thumbnail_bar( if (scroll_content_metadata) { + /* Scraped facts first. Each is run through the ticker, so a + * synopsis longer than the panel scrolls through in a loop + * instead of being cut off. */ + if (ozone->selection_ss_valid) + { + const char *ss_rows[2]; + unsigned ss_row_count = 0; + unsigned ss_i; + + /* Rating is a graphic, not text, so it is drawn on its own + * ahead of the ticker-texted rows */ + if (ozone->selection_ss_has_rating) + ozone_draw_metadata_stars( + p_disp, + userdata, + video_width, + video_height, + ozone, + &y, + column_x, + sidebar_width - separator_padding * 2, + ozone->selection_ss_stars, + metadata_alpha, + mymat); + + if (*ozone->selection_ss_publisher) + ss_rows[ss_row_count++] = ozone->selection_ss_publisher; + /* The synopsis is only a ticker row under Horizontal. + * Vertical draws it as a scrolling block below, and Off + * draws it statically wrapped. */ + if ( *ozone->selection_ss_description + && !metadata_scroll_vertical + && !metadata_scroll_off) + ss_rows[ss_row_count++] = ozone->selection_ss_description; + + for (ss_i = 0; ss_i < ss_row_count; ss_i++) + { + ticker_buf[0] = '\0'; + + if (use_smooth_ticker) + { + ticker_smooth.src_str = ss_rows[ss_i]; + gfx_animation_ticker_smooth(&ticker_smooth); + } + else + { + ticker.str = ss_rows[ss_i]; + gfx_animation_ticker(&ticker); + } + + ozone_content_metadata_line( + video_width, + video_height, + ozone, + &y, + ticker_x_offset + column_x, + ticker_buf, + text_color, + 1); + } + + if (*ozone->selection_ss_description && metadata_scroll_vertical) + { + /* Every remaining row is a single ticker line here */ + unsigned rows_below = + (show_entry_idx ? 1 : 0) + + (show_entry_core ? 1 : 0) + + (show_entry_playtime ? 1 : 0) + + (show_entry_last_played ? 1 : 0); + + ozone_draw_metadata_description_vertical( + ozone, + p_anim, + settings, + video_width, + video_height, + &y, + column_x, + sidebar_width - separator_padding * 2, + ozone_metadata_description_avail_height( + ozone, video_height, y, rows_below, rows_below), + metadata_alpha); + } + /* Off: the pre-wrapped copy, drawn as a static block, even + * though every other row on this panel is ticker text */ + else if (*ozone->selection_ss_description && metadata_scroll_off) + ozone_draw_metadata_description_static( + video_width, + video_height, + ozone, + &y, + column_x, + text_color); + } + /* Entry enumeration */ if (show_entry_idx) { @@ -7116,6 +7725,87 @@ static void ozone_draw_thumbnail_bar( } else { + /* Scraped facts first: rating, publisher, description */ + if (ozone->selection_ss_valid) + { + if (ozone->selection_ss_has_rating) + ozone_draw_metadata_stars( + p_disp, + userdata, + video_width, + video_height, + ozone, + &y, + column_x, + sidebar_width - separator_padding * 2, + ozone->selection_ss_stars, + metadata_alpha, + mymat); + + if (*ozone->selection_ss_publisher) + ozone_content_metadata_line( + video_width, video_height, ozone, &y, column_x, + ozone->selection_ss_publisher, text_color, + ozone_count_lines(ozone->selection_ss_publisher)); + + if (*ozone->selection_ss_description) + { + if (metadata_scroll_vertical) + { + /* Rows below are wrapped, so they can be several + * lines each; count them so the scrolling block does + * not eat their space */ + unsigned rows_below_lines = 0; + unsigned rows_below_count = 0; + + if (show_entry_idx) + { + rows_below_lines += ozone_count_lines( + ozone->selection_entry_enumeration); + rows_below_count++; + } + if (show_entry_core) + { + rows_below_lines += ozone->selection_core_name_lines; + rows_below_count++; + } + if (show_entry_playtime) + { + rows_below_lines += ozone_count_lines( + ozone->selection_playtime); + rows_below_count++; + } + if (show_entry_last_played) + { + rows_below_lines += ozone->selection_lastplayed_lines; + rows_below_count++; + } + + ozone_draw_metadata_description_vertical( + ozone, + p_anim, + settings, + video_width, + video_height, + &y, + column_x, + sidebar_width - separator_padding * 2, + ozone_metadata_description_avail_height( + ozone, video_height, y, + rows_below_lines, rows_below_count), + metadata_alpha); + } + /* Horizontal and Off coincide here: this panel mode has + * no ticker configured for any row, so there is nothing + * for Horizontal to scroll along and both draw the + * pre-wrapped copy statically. */ + else + ozone_draw_metadata_description_static( + video_width, video_height, ozone, &y, column_x, + text_color); + } + } + /* Entry enumeration */ if (show_entry_idx) ozone_content_metadata_line( @@ -10567,6 +11257,32 @@ static void ozone_render(void *data, if (!ozone) return; + /* The scraped facts are resolved from the selected entry's thumbnail + * data, which is still empty when a list is first entered - so the + * set gathered then belongs to no entry, or to the previous one. + * Compare the key against the live entry and refresh when they + * differ; unchanged selections cost one string compare and touch + * no files. */ + { + gfx_thumbnail_path_data_t *ss_pdata = menu_st->thumbnail_path_data; + + if (ss_pdata) + { + char ss_key[NAME_MAX_LENGTH]; + const char *ss_db = *ss_pdata->content_db_name + ? ss_pdata->content_db_name : ss_pdata->system; + const char *ss_img = *ss_pdata->content_img + ? ss_pdata->content_img : ss_pdata->content_img_full; + + snprintf(ss_key, sizeof(ss_key), "%s|%s", + ss_db ? ss_db : "", + ss_img ? ss_img : ""); + + if (!string_is_equal(ss_key, ozone->selection_ss_key)) + ozone_update_content_metadata(ozone); + } + } + /* Advance animated thumbnails (animated WebP) once per frame on the * main thread. No-op for still images. */ gfx_thumbnail_animate(&ozone->thumbnails.right); diff --git a/menu/drivers/rgui.c b/menu/drivers/rgui.c index 7618177e7d21..b382eb4bbd19 100644 --- a/menu/drivers/rgui.c +++ b/menu/drivers/rgui.c @@ -7818,7 +7818,7 @@ static void rgui_action_switch_thumbnail(rgui_t *rgui) cur_primary++; /* Wrap primary to first image type, and skip logo */ - if (cur_primary > PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 2) + if (cur_primary > PLAYLIST_THUMBNAIL_MODE_BOXARTS - PLAYLIST_THUMBNAIL_MODE_OFF) cur_primary = 1; /* Final dupe check */ @@ -7841,7 +7841,7 @@ static void rgui_action_switch_thumbnail(rgui_t *rgui) cur_secondary++; /* Wrap secondary to no image, and skip logo */ - if (cur_secondary > PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 2) + if (cur_secondary > PLAYLIST_THUMBNAIL_MODE_BOXARTS - PLAYLIST_THUMBNAIL_MODE_OFF) cur_secondary = (cur_primary) ? 0 : 1; configuration_set_uint(settings, settings->uints.menu_left_thumbnails, cur_secondary); diff --git a/menu/drivers/xmb.c b/menu/drivers/xmb.c index 615b682358c3..fdec38f51543 100644 --- a/menu/drivers/xmb.c +++ b/menu/drivers/xmb.c @@ -63,6 +63,8 @@ #include "../../cheevos/cheevos_menu.h" #endif +#include "../../network/screenscraper.h" + /* Force a helper out of line even though it has a single call site. * * Follows the RXML_NOINLINE precedent in @@ -122,21 +124,9 @@ * a fixed colour: HTML WhiteSmoke */ #define XMB_SCREENSAVER_TINT 0xF5F5F5 -/* Mean human reading speed for all western languages, - * characters per minute */ -#define TICKER_CPM (1000.0f) - -/* Base time for which a line should be shown, in us */ -#define TICKER_LINE_DURATION_US(line_len) ((line_len * 60.0f * 1000.0f * 1000.0f) / TICKER_CPM) -/* Base time for which a line should be shown, in ms */ -#define TICKER_LINE_DURATION_MS(line_len) ((line_len * 60.0f * 1000.0f) / TICKER_CPM) -/* Ticker updates (nominally) once every TICKER_SPEED us - * > Base number of ticks for which line should be shown */ -#define TICKER_LINE_DISPLAY_TICKS(line_len) ((size_t)(TICKER_LINE_DURATION_US(line_len) / (float)TICKER_SPEED)) -/* Smooth ticker updates (nominally) once every TICKER_PIXEL_PERIOD ms - * > Base number of ticks for which text should scroll - * from one line to the next */ -#define TICKER_LINE_SMOOTH_SCROLL_TICKS(line_len) ((size_t)(TICKER_LINE_DURATION_MS(line_len) / TICKER_PIXEL_PERIOD)) +/* Note: the TICKER_CPM / TICKER_LINE_* macros that used to live here + * moved to gfx/gfx_animation.h along with the line ticker itself, which + * Ozone's content metadata panel now shares. */ enum { @@ -363,6 +353,11 @@ typedef struct xmb_handle char *box_message; char *bg_file_path; + /* Animated background (video snap / WebM). Held as a thumbnail so + * the existing decode, streaming and frame-advance machinery does + * the work; its texture is then drawn as the wallpaper. */ + gfx_thumbnail_t bg_anim; + bool bg_is_anim; file_list_t horizontal_list; /* ptr alignment */ /* Maps console tabs to playlist database names */ @@ -507,6 +502,17 @@ typedef struct xmb_handle char savestate_thumbnail_file_path[PATH_MAX_LENGTH]; char fullscreen_thumbnail_label[NAME_MAX_LENGTH]; + /* Scraped facts for the selected entry, drawn in the metadata + * panel that stands in for the left thumbnail */ + char ss_publisher[NAME_MAX_LENGTH]; + char ss_description[1024]; + /* Rating is drawn as five stars; this is how many are filled in. + * Zero filled stars is a legitimate value, so a separate flag + * records whether a rating was scraped at all. */ + unsigned ss_stars; + bool ss_has_rating; + bool ss_valid; + char thumbnails_left_status_prev; char thumbnails_right_status_prev; @@ -526,6 +532,14 @@ typedef struct xmb_handle * traversal, without delaying the work behind any visible animation * — the instant input lets go, the populate runs. */ bool pending_dynamic_icons_repopulate; + /* Per-game wallpapers resolve from the selected entry's thumbnail + * data, which is not populated yet when a list is first entered and + * is skipped while a scroll is still moving - so the update is + * deferred to a frame where both are settled. */ + bool pending_dynamic_wallpaper; + /* Scraped facts resolve from the same thumbnail data as the + * per-game wallpaper, so the reload rides the same deferral */ + bool pending_ss_metadata; bool show_thumbnails; bool show_mouse; bool show_screensaver; @@ -1702,11 +1716,111 @@ static void xmb_path_dynamic_wallpaper(xmb_handle_t *xmb, char *s, size_t len) * then comes 'menu_wallpaper', and then iconset 'bg.png' */ if (menu_dynamic_wallpaper_enable) { - size_t _len = fill_pathname_join_special(s, - dir_dynamic_wallpapers, - xmb->title_name, - len); - strlcpy(s + _len, ".png", len - _len); + s[0] = '\0'; + + /* Per-game mode: dynamic_wallpapers//.png first, + * then the game's scraped fan art, then the per-system file */ + if (settings->uints.menu_dynamic_wallpaper_mode == 1) + { + struct menu_state *menu_st = menu_state_get_ptr(); + gfx_thumbnail_path_data_t *pdata = menu_st->thumbnail_path_data; + + if (pdata && (*pdata->content_img || *pdata->content_img_full)) + { + /* The scraper stores media in whatever format the service + * served (fan art is usually JPEG), so probe the loadable + * extensions instead of assuming the thumbnail ".png". + * In 'Videos' mode the animated container types come first + * and the stills act as the fallback. */ + static const char *wp_exts_img[] = { + ".png", ".jpg", ".jpeg", ".bmp" + }; + static const char *wp_exts_vid[] = { + ".mp4", ".webm", ".png", ".jpg", ".jpeg", ".bmp" + }; + bool wp_video = (settings->uints.menu_dynamic_wallpaper_type == 1); + const char **wp_exts = wp_video ? wp_exts_vid : wp_exts_img; + size_t wp_ext_count = wp_video + ? ARRAY_SIZE(wp_exts_vid) : ARRAY_SIZE(wp_exts_img); + char base[PATH_MAX_LENGTH]; + char stem[NAME_MAX_LENGTH]; + size_t e; + + /* content_img is the label-derived name and the one the + * thumbnail tree normally uses; content_img_full is only + * populated when the full file name differs from it. */ + strlcpy(stem, + *pdata->content_img + ? pdata->content_img : pdata->content_img_full, + sizeof(stem)); + path_remove_extension(stem); + + /* dynamic_wallpapers//. */ + fill_pathname_join_special(base, dir_dynamic_wallpapers, + xmb->title_name, sizeof(base)); + for (e = 0; e < wp_ext_count; e++) + { + char probe[NAME_MAX_LENGTH]; + size_t _l = strlcpy(probe, stem, sizeof(probe)); + strlcpy(probe + _l, wp_exts[e], sizeof(probe) - _l); + fill_pathname_join_special(s, base, probe, len); + if (path_is_valid(s)) + break; + s[0] = '\0'; + } + + if (!*s) + { + /* Scraped fan art fallback */ + const char *db_name = *pdata->content_db_name + ? pdata->content_db_name : pdata->system; + if ( !string_is_empty(db_name) + && !string_is_empty(settings->paths.directory_thumbnails)) + { + /* 'Videos' searches the video snaps first and uses + * fan art as the fallback; 'Images' only ever looks + * at fan art. */ + static const char *wp_dirs[] = { + "Named_Videos", "Named_Fanarts" + }; + char fan_dir[PATH_MAX_LENGTH]; + size_t d0 = wp_video ? 0 : 1; + size_t di; + + fill_pathname_join_special(base, + settings->paths.directory_thumbnails, db_name, + sizeof(base)); + + for (di = d0; di < ARRAY_SIZE(wp_dirs) && !*s; di++) + { + fill_pathname_join_special(fan_dir, base, + wp_dirs[di], sizeof(fan_dir)); + + for (e = 0; e < wp_ext_count; e++) + { + char probe[NAME_MAX_LENGTH]; + size_t _l = strlcpy(probe, stem, sizeof(probe)); + strlcpy(probe + _l, wp_exts[e], sizeof(probe) - _l); + fill_pathname_join_special(s, fan_dir, probe, len); + if (path_is_valid(s)) + break; + s[0] = '\0'; + } + } + } + } + } + } + + /* Per-system wallpaper (also the per-game fallback) */ + if (!*s) + { + size_t _len = fill_pathname_join_special(s, + dir_dynamic_wallpapers, + xmb->title_name, + len); + strlcpy(s + _len, ".png", len - _len); + } } if (s && *s && path_is_valid(s)) @@ -1731,6 +1845,77 @@ static void xmb_path_dynamic_wallpaper(xmb_handle_t *xmb, char *s, size_t len) } } +/* Reloads the scraped facts for the selected entry into the handle. + * Resolves from the same thumbnail path data as the per-game + * wallpaper, so it is called from the same settled frame - by which + * point that data describes the entry actually being looked at. + * Entries with no sidecar simply leave 'ss_valid' false, which the + * draw path reads as "no panel". */ +static void xmb_update_ss_metadata(xmb_handle_t *xmb) +{ + settings_t *settings = config_get_ptr(); + struct menu_state *menu_st = menu_state_get_ptr(); + gfx_thumbnail_path_data_t *pdata = menu_st->thumbnail_path_data; + screenscraper_meta_t meta; + const char *ss_db; + const char *ss_img; + int ss_stars; + + xmb->ss_valid = false; + xmb->ss_has_rating = false; + xmb->ss_stars = 0; + xmb->ss_publisher[0] = '\0'; + xmb->ss_description[0] = '\0'; + + if ( !settings + || !pdata + || !(xmb->is_playlist || xmb->is_explore_list)) + return; + + ss_db = *pdata->content_db_name + ? pdata->content_db_name : pdata->system; + ss_img = *pdata->content_img + ? pdata->content_img : pdata->content_img_full; + + if (!screenscraper_metadata_load_entry( + settings->paths.directory_thumbnails, + ss_db, ss_img, &meta)) + { +#if defined(HAVE_NETWORKING) + /* No sidecar for this entry. Entries whose artwork is already + * complete never fire the on-demand artwork path, so this is + * the only chance to fetch their metadata; the push declines + * by itself when signed out, when on-demand downloads are off, + * or when a not-found marker says the service has nothing. The + * facts appear the next time the entry is selected. */ + playlist_t *pl = playlist_get_cached(); + if (pl && xmb->is_playlist) + task_push_pl_entry_screenscraper_meta(pdata->system, + ss_db, ss_img, pl, (unsigned)pdata->playlist_index); +#endif + return; + } + + xmb->ss_valid = true; + + ss_stars = screenscraper_rating_stars(meta.rating); + + /* Negative means "no rating was scraped"; the star row is then + * skipped entirely rather than drawn empty. */ + if (ss_stars >= 0) + { + xmb->ss_has_rating = true; + xmb->ss_stars = (unsigned)ss_stars; + } + + if (!string_is_empty(meta.publisher)) + snprintf(xmb->ss_publisher, sizeof(xmb->ss_publisher), + "Publisher: %s", meta.publisher); + if (!string_is_empty(meta.description)) + strlcpy(xmb->ss_description, meta.description, + sizeof(xmb->ss_description)); +} + static void xmb_update_dynamic_wallpaper(xmb_handle_t *xmb, bool reset) { char path[PATH_MAX_LENGTH]; @@ -1743,6 +1928,28 @@ static void xmb_update_dynamic_wallpaper(xmb_handle_t *xmb, bool reset) { if (path_is_valid(path)) { + enum image_type_enum bg_type = image_texture_get_type(path); + bool is_anim = (bg_type == IMAGE_TYPE_MP4) + || (bg_type == IMAGE_TYPE_WEBM); + + /* A video background is decoded by the animated thumbnail + * pipeline; the still path below only handles images. */ + if (is_anim) + { + if (reset) + xmb_context_bg_destroy(xmb); + gfx_thumbnail_reset(&xmb->bg_anim); + gfx_thumbnail_request_file(path, &xmb->bg_anim, 0); + xmb->bg_is_anim = true; + + free(xmb->bg_file_path); + xmb->bg_file_path = strdup(path); + return; + } + + gfx_thumbnail_reset(&xmb->bg_anim); + xmb->bg_is_anim = false; + if (reset) { xmb_context_bg_destroy(xmb); @@ -1768,6 +1975,9 @@ static void xmb_update_dynamic_wallpaper(xmb_handle_t *xmb, bool reset) } else { + gfx_thumbnail_reset(&xmb->bg_anim); + xmb->bg_is_anim = false; + xmb_load_image(xmb, NULL, MENU_IMAGE_NONE); free(xmb->bg_file_path); @@ -2536,6 +2746,14 @@ static void xmb_selection_pointer_changed( gfx_animation_push(&anim_entry); } } + + /* Per-game dynamic wallpapers follow the selection. The work is + * deferred to xmb_render(): each update pushes an asynchronous + * load, so firing one per entry while scrolling swamps the + * wallpaper path, and on the first entry of a freshly entered list + * the thumbnail data it resolves from is not populated yet. */ + xmb->pending_dynamic_wallpaper = true; + xmb->pending_ss_metadata = true; } static void xmb_list_open_new(xmb_handle_t *xmb, @@ -4002,7 +4220,17 @@ static void xmb_populate_entries(void *data, xmb_unload_icon_thumbnail_textures(xmb); if (xmb->allow_dynamic_wallpaper) + { xmb_update_dynamic_wallpaper(xmb, false); + /* Entering a list: the entry that ends up selected has no + * thumbnail data yet, so schedule a second pass for a settled + * frame - that is the one that resolves its per-game media. */ + xmb->pending_dynamic_wallpaper = true; + } + + /* Same story for the scraped facts panel: the newly selected entry + * has no thumbnail data to resolve a sidecar from yet */ + xmb->pending_ss_metadata = true; /* Determine whether to show entry index */ xmb->entry_index_str[0] = '\0'; @@ -4908,684 +5136,6 @@ static uintptr_t xmb_icon_get_id(xmb_handle_t *xmb, return xmb->textures.list[XMB_TEXTURE_SUBSETTING]; } -static size_t xmb_animation_line_ticker_loop(uint64_t idx, - size_t line_len, size_t num_lines) -{ - size_t line_ticks = TICKER_LINE_DISPLAY_TICKS(line_len); - size_t ticker_period = num_lines + 1; - size_t phase = (idx / line_ticks) % ticker_period; - /* In this case, line_offset is simply equal to the phase */ - return phase; -} - -static size_t xmb_animation_line_ticker_generic(uint64_t idx, - size_t line_len, size_t max_lines, size_t num_lines) -{ - size_t line_ticks = TICKER_LINE_DISPLAY_TICKS(line_len); - /* Note: This function is only called if num_lines > max_lines */ - size_t excess_lines = num_lines - max_lines; - /* Ticker will pause for one line duration when the first - * or last line is reached (this is mostly required for the - * case where num_lines == (max_lines + 1), since otherwise - * the text flicks rapidly up and down in disconcerting - * fashion...) */ - size_t ticker_period = (excess_lines * 2) + 2; - size_t phase = (idx / line_ticks) % ticker_period; - - /* Pause on first line */ - if (phase > 0) - phase--; - /* Pause on last line */ - if (phase > excess_lines) - phase--; - - /* Lines scrolling upwards */ - if (phase <= excess_lines) - return phase; - /* Lines scrolling downwards */ - return (excess_lines * 2) - phase; -} - -XMB_NOINLINE static bool xmb_animation_line_ticker(gfx_animation_t *p_anim, gfx_animation_ctx_line_ticker_t *line_ticker) -{ - char *wrapped_str = NULL; - size_t wrapped_str_len = 0; - size_t line_ticker_str_len = 0; - size_t line_offset = 0; - size_t total_lines = 0; - bool ret = false; - bool is_active = false; - - /* Sanity check */ - if (!line_ticker) - return false; - if ( (!line_ticker->str || !*line_ticker->str) - || (line_ticker->line_len < 1) - || (line_ticker->max_lines < 1) - || (line_ticker->len < 1)) - goto end; - - /* Line wrap input string */ - line_ticker_str_len = strlen(line_ticker->str); - wrapped_str_len = line_ticker_str_len + 1 + 10; /* 10 bytes use for inserting '\n' */ - if (!(wrapped_str = (char*)malloc(wrapped_str_len))) - goto end; - - wrapped_str[0] = '\0'; - - word_wrap( - wrapped_str, - wrapped_str_len, - line_ticker->str, - line_ticker_str_len, - (int)line_ticker->line_len, - 100, 0); - - if (!wrapped_str || !*wrapped_str) - goto end; - - /* Count total number of lines */ - { - size_t slen = strlen(wrapped_str); - const char *p = wrapped_str; - size_t remaining = slen; - total_lines = 1; - - while (remaining > 0) - { - const char *nl = (const char *)memchr(p, '\n', remaining); - if (!nl) - break; - total_lines++; - nl++; - remaining -= (size_t)(nl - p); - p = nl; - } - } - - /* Check whether total number of lines fits within - * the set limit */ - if (total_lines <= line_ticker->max_lines) - { - strlcpy(line_ticker->s, wrapped_str, line_ticker->len); - ret = true; - goto end; - } - - /* Determine offset of first line in wrapped string */ - switch (line_ticker->type_enum) - { - case TICKER_TYPE_LOOP: - line_offset = xmb_animation_line_ticker_loop( - line_ticker->idx, - line_ticker->line_len, - total_lines); - break; - case TICKER_TYPE_BOUNCE: - default: - line_offset = xmb_animation_line_ticker_generic( - line_ticker->idx, - line_ticker->line_len, - line_ticker->max_lines, - total_lines); - break; - } - - /* Build output string from required lines */ - { - const char *p = wrapped_str; - size_t remaining = strlen(wrapped_str); - size_t cur_line = 0; - - /* Skip to the starting line */ - while (cur_line < line_offset && remaining > 0) - { - const char *nl = (const char *)memchr(p, '\n', remaining); - if (!nl) - break; - nl++; - remaining -= (size_t)(nl - p); - p = nl; - cur_line++; - } - - /* Find the end of the visible window */ - { - const char *end_p = p; - size_t end_remaining = remaining; - size_t lines_found = 0; - - while (lines_found < line_ticker->max_lines && end_remaining > 0) - { - const char *nl = (const char *)memchr(end_p, '\n', end_remaining); - if (!nl) - { - end_p = end_p + end_remaining; - break; - } - lines_found++; - if (lines_found >= line_ticker->max_lines) - { - end_p = nl; - break; - } - nl++; - end_remaining -= (size_t)(nl - end_p); - end_p = nl; - } - - /* Single memcpy for the whole block */ - { - size_t copy_len = (size_t)(end_p - p); - size_t max_copy = line_ticker->len - 1; - if (copy_len > max_copy) - copy_len = max_copy; - memcpy(line_ticker->s, p, copy_len); - line_ticker->s[copy_len] = '\0'; - } - } - } - - ret = true; - is_active = true; - p_anim->flags |= GFX_ANIM_FLAG_TICKER_IS_ACTIVE; - -end: - if (wrapped_str) - { - free(wrapped_str); - wrapped_str = NULL; - } - - if (!ret) - if (line_ticker->len > 0) - line_ticker->s[0] = '\0'; - - return is_active; -} - -static void xmb_animation_set_line_smooth_fade_parameters( - bool scroll_up, - size_t scroll_ticks, size_t line_phase, size_t line_height, - size_t num_lines, size_t num_display_lines, size_t line_offset, - float y_offset, - size_t *top_fade_line_offset, - float *top_fade_y_offset, float *top_fade_alpha, - size_t *bottom_fade_line_offset, - float *bottom_fade_y_offset, float *bottom_fade_alpha) -{ - /* When a line fades out, alpha transitions from - * 1 to 0 over the course of one half of the - * scrolling line height. When a line fades in, - * it's the other way around */ - float fade_out_alpha = ((float)scroll_ticks - ((float)line_phase * 2.0f)) / (float)scroll_ticks; - float fade_in_alpha = -1.0f * fade_out_alpha; - if (fade_out_alpha < 0.0f) - fade_out_alpha = 0.0f; - if (fade_in_alpha < 0.0f) - fade_in_alpha = 0.0f; - - *top_fade_line_offset = (line_offset > 0) ? line_offset - 1 : num_lines; - *top_fade_y_offset = y_offset - (float)line_height; - if (scroll_up) - { - *top_fade_alpha = fade_out_alpha; - *bottom_fade_alpha = fade_in_alpha; - } - else - { - *top_fade_alpha = fade_in_alpha; - *bottom_fade_alpha = fade_out_alpha; - } - *bottom_fade_line_offset = line_offset + num_display_lines; - *bottom_fade_y_offset = y_offset + (float)(line_height * num_display_lines); -} - -static void xmb_animation_set_line_smooth_fade_parameters_default( - size_t *top_fade_line_offset, float *top_fade_y_offset, float *top_fade_alpha, - size_t *bottom_fade_line_offset, float *bottom_fade_y_offset, float *bottom_fade_alpha) -{ - *top_fade_line_offset = 0; - *top_fade_y_offset = 0.0f; - *top_fade_alpha = 0.0f; - - *bottom_fade_line_offset = 0; - *bottom_fade_y_offset = 0.0f; - *bottom_fade_alpha = 0.0f; -} - - -static void xmb_animation_line_ticker_smooth_generic(uint64_t idx, - bool fade_enabled, size_t line_len, size_t line_height, - size_t max_display_lines, size_t num_lines, - size_t *num_display_lines, size_t *line_offset, - float *y_offset, - bool *fade_active, - size_t *top_fade_line_offset, float *top_fade_y_offset, - float *top_fade_alpha, - size_t *bottom_fade_line_offset, float *bottom_fade_y_offset, - float *bottom_fade_alpha) -{ - size_t scroll_ticks = TICKER_LINE_SMOOTH_SCROLL_TICKS(line_len); - /* Note: This function is only called if num_lines > max_display_lines */ - size_t excess_lines = num_lines - max_display_lines; - /* Ticker will pause for one line duration when the first - * or last line is reached */ - size_t ticker_period = ((excess_lines * 2) + 2) * scroll_ticks; - size_t phase = idx % ticker_period; - size_t line_phase = 0; - bool pause = false; - bool scroll_up = true; - - if (phase >= scroll_ticks) - phase -= scroll_ticks; - else - { - /* Pause on first line */ - pause = true; - phase = 0; - } - - /* Pause on last line and change direction */ - if (phase >= excess_lines * scroll_ticks) - { - scroll_up = false; - - if (phase < (excess_lines + 1) * scroll_ticks) - { - pause = true; - phase = 0; - } - else - phase -= (excess_lines + 1) * scroll_ticks; - } - - line_phase = phase % scroll_ticks; - - if (pause) - { - /* Static display of max_display_lines - * (no animation) */ - *num_display_lines = max_display_lines; - *y_offset = 0.0f; - *fade_active = false; - *line_offset = scroll_up ? 0 : excess_lines; - } - else if (line_phase == 0) - { - /* Static display of max_display_lines - * (no animation) */ - *num_display_lines = max_display_lines; - *y_offset = 0.0f; - *fade_active = false; - *line_offset = scroll_up ? (phase / scroll_ticks) : (excess_lines - (phase / scroll_ticks)); - } - else - { - /* Scroll animation is active */ - *num_display_lines = (max_display_lines > 1) ? max_display_lines - 1 : 1; - *fade_active = fade_enabled; - - if (scroll_up) - { - *line_offset = (phase / scroll_ticks) + 1; - *y_offset = (float)line_height * (float)(scroll_ticks - line_phase) / (float)scroll_ticks; - } - else - { - *line_offset = excess_lines - (phase / scroll_ticks); - *y_offset = (float)line_height * (1.0f - (float)(scroll_ticks - line_phase) / (float)scroll_ticks); - } - - /* Set fade parameters if fade animation is active */ - if (*fade_active) - xmb_animation_set_line_smooth_fade_parameters( - scroll_up, scroll_ticks, line_phase, line_height, - num_lines, *num_display_lines, *line_offset, *y_offset, - top_fade_line_offset, top_fade_y_offset, top_fade_alpha, - bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); - } - - /* Set 'default' fade parameters if fade animation - * is inactive */ - if (!*fade_active) - xmb_animation_set_line_smooth_fade_parameters_default( - top_fade_line_offset, top_fade_y_offset, top_fade_alpha, - bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); -} - - -static void xmb_animation_line_ticker_smooth_loop(uint64_t idx, - bool fade_enabled, size_t line_len, size_t line_height, - size_t max_display_lines, size_t num_lines, - size_t *num_display_lines, size_t *line_offset, float *y_offset, - bool *fade_active, - size_t *top_fade_line_offset, float *top_fade_y_offset, float *top_fade_alpha, - size_t *bottom_fade_line_offset, float *bottom_fade_y_offset, float *bottom_fade_alpha) -{ - size_t scroll_ticks = TICKER_LINE_SMOOTH_SCROLL_TICKS(line_len); - size_t ticker_period = (num_lines + 1) * scroll_ticks; - size_t phase = idx % ticker_period; - size_t line_phase = phase % scroll_ticks; - - *line_offset = phase / scroll_ticks; - - if (line_phase == (scroll_ticks - 1)) - { - /* Static display of max_display_lines - * (no animation) */ - *num_display_lines = max_display_lines; - *fade_active = false; - } - else - { - *num_display_lines = (max_display_lines > 1) ? max_display_lines - 1 : 1; - *fade_active = fade_enabled; - } - - *y_offset = (float)line_height * (float)(scroll_ticks - line_phase) / (float)scroll_ticks; - - /* Set fade parameters */ - if (*fade_active) - xmb_animation_set_line_smooth_fade_parameters( - true, scroll_ticks, line_phase, line_height, - num_lines, *num_display_lines, *line_offset, *y_offset, - top_fade_line_offset, top_fade_y_offset, top_fade_alpha, - bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); - else - xmb_animation_set_line_smooth_fade_parameters_default( - top_fade_line_offset, top_fade_y_offset, top_fade_alpha, - bottom_fade_line_offset, bottom_fade_y_offset, bottom_fade_alpha); -} - -XMB_NOINLINE static bool xmb_animation_line_ticker_smooth(gfx_animation_t *p_anim, gfx_animation_ctx_line_ticker_smooth_t *line_ticker) -{ - char wrapped_str[PATH_MAX_LENGTH]; - const char *wideglyph_str = NULL; - size_t line_ticker_src_len = 0; - size_t wrapped_str_len = 0; - int glyph_width = 0; - int glyph_height = 0; - size_t line_len = 0; - size_t max_display_lines = 0; - size_t num_display_lines = 0; - size_t line_offset = 0; - size_t top_fade_line_offset = 0; - size_t bottom_fade_line_offset = 0; - bool fade_active = false; - int wideglyph_width = 100; - -#define XMB_LINE_TICKER_MAX_LINES 256 - const char *line_starts[XMB_LINE_TICKER_MAX_LINES]; - size_t line_lengths[XMB_LINE_TICKER_MAX_LINES]; - size_t num_lines = 0; - - size_t (*word_wrap_func)(char *dst, size_t dst_size, - const char *src, size_t src_len, - int line_width, int wideglyph_width, unsigned max_lines); - - /* Sanity check */ - if (!line_ticker) - return false; - - if ( !line_ticker->font - || (!line_ticker->src_str || !*line_ticker->src_str) - || (line_ticker->field_width < 1) - || (line_ticker->field_height < 1)) - { - if (line_ticker->dst_str_len > 0) - line_ticker->dst_str[0] = '\0'; - - if (line_ticker->fade_enabled) - { - if (line_ticker->top_fade_str_len > 0) - line_ticker->top_fade_str[0] = '\0'; - - if (line_ticker->bottom_fade_str_len > 0) - line_ticker->bottom_fade_str[0] = '\0'; - - *line_ticker->top_fade_alpha = 0.0f; - *line_ticker->bottom_fade_alpha = 0.0f; - } - - return false; - } - - /* Get font dimensions */ - if ((glyph_width = font_driver_get_message_width( - line_ticker->font, "a", 1, line_ticker->font_scale)) <= 0) - goto fail; - - if ((wideglyph_str = msg_hash_get_wideglyph_str())) - { - int new_glyph_width = font_driver_get_message_width( - line_ticker->font, wideglyph_str, strlen(wideglyph_str), - line_ticker->font_scale); - - if (new_glyph_width > 0) - wideglyph_width = new_glyph_width * 100 / glyph_width; - word_wrap_func = word_wrap_wideglyph; - } - else - word_wrap_func = word_wrap; - - /* > Height */ - if ((glyph_height = (int)roundf(line_ticker->font->metrics.height - * line_ticker->font_scale)) <= 0) - goto fail; - - /* Determine line wrap parameters */ - line_len = (size_t)(line_ticker->field_width / glyph_width); - max_display_lines = (size_t)(line_ticker->field_height / glyph_height); - - if ((line_len < 1) || (max_display_lines < 1)) - goto fail; - - /* Line wrap input string */ - line_ticker_src_len = strlen(line_ticker->src_str); - wrapped_str_len = sizeof(wrapped_str); - - if (line_ticker_src_len + 11 > wrapped_str_len) - goto fail; - - wrapped_str[0] = '\0'; - - (word_wrap_func)( - wrapped_str, - wrapped_str_len, - line_ticker->src_str, - line_ticker_src_len, - (int)line_len, - wideglyph_width, 0); - - if (!*wrapped_str) - goto fail; - - /* Split into lines using memchr */ - { - const char *p = wrapped_str; - const char *end_ptr = wrapped_str + strlen(wrapped_str); - - while (p < end_ptr) - { - const char *nl = (const char*)memchr(p, '\n', end_ptr - p); - if (!nl) - nl = end_ptr; - - if (num_lines >= XMB_LINE_TICKER_MAX_LINES) - break; - - line_starts[num_lines] = p; - line_lengths[num_lines] = (size_t)(nl - p); - num_lines++; - - p = nl + 1; - } - } - - if (num_lines < 1) - goto fail; - - /* Check whether total number of lines fits within - * the set field limit */ - if (num_lines <= max_display_lines) - { - strlcpy(line_ticker->dst_str, wrapped_str, line_ticker->dst_str_len); - *line_ticker->y_offset = 0.0f; - - if (line_ticker->fade_enabled) - { - if (line_ticker->top_fade_str_len > 0) - line_ticker->top_fade_str[0] = '\0'; - - if (line_ticker->bottom_fade_str_len > 0) - line_ticker->bottom_fade_str[0] = '\0'; - - *line_ticker->top_fade_y_offset = 0.0f; - *line_ticker->bottom_fade_y_offset = 0.0f; - - *line_ticker->top_fade_alpha = 0.0f; - *line_ticker->bottom_fade_alpha = 0.0f; - } - - return true; - } - - /* Determine which lines should be shown, along with - * y axis draw offset */ - switch (line_ticker->type_enum) - { - case TICKER_TYPE_LOOP: - xmb_animation_line_ticker_smooth_loop( - line_ticker->idx, - line_ticker->fade_enabled, - line_len, (size_t)glyph_height, - max_display_lines, num_lines, - &num_display_lines, &line_offset, line_ticker->y_offset, - &fade_active, - &top_fade_line_offset, line_ticker->top_fade_y_offset, line_ticker->top_fade_alpha, - &bottom_fade_line_offset, line_ticker->bottom_fade_y_offset, line_ticker->bottom_fade_alpha); - break; - case TICKER_TYPE_BOUNCE: - default: - xmb_animation_line_ticker_smooth_generic( - line_ticker->idx, - line_ticker->fade_enabled, - line_len, (size_t)glyph_height, - max_display_lines, num_lines, - &num_display_lines, &line_offset, line_ticker->y_offset, - &fade_active, - &top_fade_line_offset, line_ticker->top_fade_y_offset, line_ticker->top_fade_alpha, - &bottom_fade_line_offset, line_ticker->bottom_fade_y_offset, line_ticker->bottom_fade_alpha); - break; - } - - if (!num_display_lines) - goto fail; - - /* Build output string from required lines */ - { - size_t i; - size_t dst_offset = 0; - bool is_loop = (line_ticker->type_enum == TICKER_TYPE_LOOP); - - for (i = 0; i < num_display_lines; i++) - { - size_t line_idx = line_offset + i; - size_t copy_len; - - /* Only wrap around for loop ticker mode; - * for bounce/generic mode, clamp to valid range */ - if (is_loop) - line_idx %= num_lines; - else if (line_idx >= num_lines) - break; - - copy_len = line_lengths[line_idx]; - - if (dst_offset + copy_len + 2 > line_ticker->dst_str_len) - break; - - if (i > 0) - line_ticker->dst_str[dst_offset++] = '\n'; - - memcpy(line_ticker->dst_str + dst_offset, - line_starts[line_idx], copy_len); - dst_offset += copy_len; - } - - if (dst_offset < line_ticker->dst_str_len) - line_ticker->dst_str[dst_offset] = '\0'; - else if (line_ticker->dst_str_len > 0) - line_ticker->dst_str[line_ticker->dst_str_len - 1] = '\0'; - } - - /* Extract top/bottom fade strings, if required */ - if (fade_active) - { - size_t top_fade_line_index = top_fade_line_offset; - size_t bottom_fade_line_index = bottom_fade_line_offset; - - if (line_ticker->type_enum == TICKER_TYPE_LOOP) - { - /* For the top fade, use (num_lines + 1) so that the - * sentinel value 'num_lines' (set when line_offset == 0) - * correctly maps to >= num_lines and suppresses the - * fade string during the loop gap */ - top_fade_line_index %= (num_lines + 1); - /* For the bottom fade, use num_lines so that the index - * wraps to the correct next line at the loop boundary - * instead of duplicating a line already in the main - * display */ - bottom_fade_line_index %= num_lines; - } - - if (top_fade_line_index < num_lines - && line_ticker->top_fade_str_len > 0) - { - size_t copy_len = line_lengths[top_fade_line_index]; - if (copy_len >= line_ticker->top_fade_str_len) - copy_len = line_ticker->top_fade_str_len - 1; - memcpy(line_ticker->top_fade_str, - line_starts[top_fade_line_index], copy_len); - line_ticker->top_fade_str[copy_len] = '\0'; - } - - if (bottom_fade_line_index < num_lines - && line_ticker->bottom_fade_str_len > 0) - { - size_t copy_len = line_lengths[bottom_fade_line_index]; - if (copy_len >= line_ticker->bottom_fade_str_len) - copy_len = line_ticker->bottom_fade_str_len - 1; - memcpy(line_ticker->bottom_fade_str, - line_starts[bottom_fade_line_index], copy_len); - line_ticker->bottom_fade_str[copy_len] = '\0'; - } - } - - p_anim->flags |= GFX_ANIM_FLAG_TICKER_IS_ACTIVE; - - return true; - -fail: - if (line_ticker->dst_str_len > 0) - line_ticker->dst_str[0] = '\0'; - - if (line_ticker->fade_enabled) - { - if (line_ticker->top_fade_str_len > 0) - line_ticker->top_fade_str[0] = '\0'; - - if (line_ticker->bottom_fade_str_len > 0) - line_ticker->bottom_fade_str[0] = '\0'; - - *line_ticker->top_fade_alpha = 0.0f; - *line_ticker->bottom_fade_alpha = 0.0f; - } - - return false; -} - /* Loop-invariant state for xmb_draw_item(). * * xmb_draw_item() used to take eighteen parameters, sixteen of which @@ -5692,7 +5242,7 @@ XMB_NOINLINE static void xmb_draw_item_sublabel( line_ticker_smooth.bottom_fade_y_offset = &ticker_bottom_fade_y_offset; line_ticker_smooth.bottom_fade_alpha = &ticker_bottom_fade_alpha; - xmb_animation_line_ticker_smooth(ctx->p_anim, &line_ticker_smooth); + gfx_animation_line_ticker_smooth(ctx->p_anim, &line_ticker_smooth); } else { @@ -5710,7 +5260,7 @@ XMB_NOINLINE static void xmb_draw_item_sublabel( line_ticker.len = sizeof(entry_sublabel); line_ticker.str = sublabel; - xmb_animation_line_ticker(ctx->p_anim, &line_ticker); + gfx_animation_line_ticker(ctx->p_anim, &line_ticker); } /* Draw sublabel */ @@ -7722,6 +7272,11 @@ static void xmb_context_reset_internal(xmb_handle_t *xmb, * corrected it. xmb_populate_entries already uses the correct * set_title -> update_dynamic_wallpaper order. */ xmb_update_dynamic_wallpaper(xmb, true); + /* The reset above restores the wallpaper for the current title; a + * deferred pass then re-resolves the selected entry's per-game + * media once its thumbnail data is back in place. */ + xmb->pending_dynamic_wallpaper = true; + xmb->pending_ss_metadata = true; menu_screensaver_context_destroy(xmb->screensaver); @@ -7744,7 +7299,9 @@ static void xmb_context_reset_internal(xmb_handle_t *xmb, { /* Synchronous populate — clear any pending deferred flag so * xmb_render does not redundantly repeat this work once input - * settles. */ + * settles. The wallpaper flag is deliberately left alone: + * the reset above requested it, and the per-game media can + * only be resolved on a later, settled frame. */ xmb->pending_dynamic_icons_repopulate = false; xmb_populate_dynamic_icons(xmb); } @@ -7825,6 +7382,38 @@ static void xmb_render(void *data, * *current* state. If the final list is not a playlist, the * pending-icons unload branch from populate_entries runs here * instead. */ + /* Deferred per-game wallpaper update: runs on a settled frame, by + * which point the selected entry's thumbnail data is in place. */ + if ( xmb->pending_dynamic_wallpaper + && (menu_st->scroll.acceleration == 0)) + { + xmb->pending_dynamic_wallpaper = false; + + if ( xmb->allow_dynamic_wallpaper + && settings->bools.menu_dynamic_wallpaper_enable + && settings->uints.menu_dynamic_wallpaper_mode == 1) + { + /* A video background whose texture has gone (context reset, + * decoder released) is reloaded even though the resolved + * path has not changed - otherwise the path check would + * treat it as already loaded and leave the screen bare. */ + bool anim_lost = xmb->bg_is_anim && !xmb->bg_anim.texture; + xmb_update_dynamic_wallpaper(xmb, anim_lost); + } + } + + /* Deferred scraped-facts reload, on the same settled frame. Kept + * separate from the wallpaper flag above because it is gated on a + * different setting. */ + if ( xmb->pending_ss_metadata + && (menu_st->scroll.acceleration == 0)) + { + xmb->pending_ss_metadata = false; + + if (settings->bools.menu_xmb_show_metadata_panel) + xmb_update_ss_metadata(xmb); + } + if ( xmb->pending_dynamic_icons_repopulate && (menu_st->scroll.acceleration == 0)) { @@ -8970,6 +8559,253 @@ XMB_NOINLINE static void xmb_draw_fullscreen_thumbnails( xmb_hide_fullscreen_thumbnails(xmb, false); } +/* Draws the scraped facts for the selected entry into the rectangle + * the left thumbnail would otherwise occupy: a five-star rating, the + * publisher, then the synopsis. + * + * Nothing is drawn behind them - no quad, no frame - so the panel is + * transparent and whatever sits behind it shows through. + * + * The synopsis goes through the line ticker, the same machinery the + * entry sublabels use, so a synopsis taller than the panel scrolls + * through it in a loop instead of being cut off. */ +XMB_NOINLINE static void xmb_draw_metadata_panel( + xmb_handle_t *xmb, + gfx_display_t *p_disp, + gfx_animation_t *p_anim, + gfx_display_ctx_driver_t *dispctx, + settings_t *settings, + void *userdata, + unsigned video_width, + unsigned video_height, + bool shadows_enable, + uintptr_t star_texture, + float x, + float y, + float width, + float height, + math_matrix_4x4 *mymat) +{ + float line_height; + float row_y = y; + float bottom = y + height; + int glyph_w = 0; + + if ( (width < 1.0f) + || (height < 1.0f) + || !xmb->font2) + return; + + if ((line_height = xmb->font2->metrics.height) < 1.0f) + line_height = xmb->font2_size; + + if ((glyph_w = font_driver_get_message_width(xmb->font2, "a", 1, 1.0f)) < 1) + glyph_w = (int)(xmb->font2_size * 0.5f); + + /* Rating, as five stars: the earned ones solid, the rest faint so + * the scale itself stays readable */ + if (xmb->ss_has_rating && star_texture) + { + /* Stack-local scratch - avoids a data race with threaded video + * backends reading a previous frame's colour data */ + float star_filled[16] = { + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + }; + float star_empty[16] = { + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00, + }; + unsigned stars = (xmb->ss_stars > 5) ? 5 : xmb->ss_stars; + float icon_size = line_height; + unsigned i; + + /* Five icons plus four quarter-icon gaps is six icon widths; + * shrink them if the panel is too narrow for that */ + if ((icon_size * 6.0f) > width) + icon_size = width / 6.0f; + + if (icon_size >= 1.0f && (row_y + icon_size) <= bottom) + { + float spacing = icon_size / 4.0f; + + gfx_display_set_alpha(star_filled, xmb->alpha); + gfx_display_set_alpha(star_empty, xmb->alpha * 0.25f); + + if (dispctx && dispctx->blend_begin) + dispctx->blend_begin(userdata); + + for (i = 0; i < 5; i++) + xmb_draw_icon( + userdata, + p_disp, + dispctx, + video_width, + video_height, + shadows_enable, + (int)icon_size, + (int)icon_size, + star_texture, + x + ((float)i * (icon_size + spacing)), + /* xmb_draw_icon() takes the icon's bottom edge */ + row_y + icon_size, + video_width, + video_height, + xmb->alpha, + 0.0f, + 1.0f, + (i < stars) ? star_filled : star_empty, + xmb->shadow_offset, + mymat); + + if (dispctx && dispctx->blend_end) + dispctx->blend_end(userdata); + + row_y += icon_size + (line_height * 0.5f); + } + } + + /* Publisher */ + if (*xmb->ss_publisher && ((row_y + line_height) <= bottom)) + { + char wrapped[NAME_MAX_LENGTH]; + size_t w_len = (xmb->word_wrap)( + wrapped, sizeof(wrapped), + xmb->ss_publisher, strlen(xmb->ss_publisher), + (int)(width / (float)glyph_w), + xmb->wideglyph_width, 0); + + if (w_len > 0) + { + size_t c; + unsigned lines = 1; + + for (c = 0; c < w_len; c++) + if (wrapped[c] == '\n') + lines++; + + xmb_draw_text(shadows_enable, xmb, settings, + wrapped, + x, + row_y + line_height, + 1.0f, + xmb->alpha, + TEXT_ALIGN_LEFT, + video_width, video_height, xmb->font2); + + row_y += ((float)lines * line_height) + (line_height * 0.5f); + } + } + + /* Synopsis, scrolled through whatever height is left */ + if (*xmb->ss_description && ((row_y + line_height) <= bottom)) + { + char body[1024]; + char top_fade[1024]; + char bottom_fade[1024]; + float ticker_y_offset = 0.0f; + float ticker_top_fade_y_offset = 0.0f; + float ticker_bot_fade_y_offset = 0.0f; + float ticker_top_fade_alpha = 0.0f; + float ticker_bot_fade_alpha = 0.0f; + float field_height = bottom - row_y; + bool use_smooth_ticker = settings->bools.menu_ticker_smooth; + enum gfx_animation_ticker_type menu_ticker_type = + (enum gfx_animation_ticker_type)settings->uints.menu_ticker_type; + + body[0] = '\0'; + top_fade[0] = '\0'; + bottom_fade[0] = '\0'; + + if (use_smooth_ticker) + { + gfx_animation_ctx_line_ticker_smooth_t line_ticker_smooth; + + line_ticker_smooth.fade_enabled = true; + line_ticker_smooth.type_enum = menu_ticker_type; + line_ticker_smooth.idx = p_anim->ticker_pixel_line_idx; + + line_ticker_smooth.font = xmb->font2; + line_ticker_smooth.font_scale = 1.0f; + + line_ticker_smooth.field_width = (unsigned)width; + line_ticker_smooth.field_height = (unsigned)field_height; + + line_ticker_smooth.src_str = xmb->ss_description; + line_ticker_smooth.dst_str = body; + line_ticker_smooth.dst_str_len = sizeof(body); + line_ticker_smooth.y_offset = &ticker_y_offset; + + line_ticker_smooth.top_fade_str = top_fade; + line_ticker_smooth.top_fade_str_len = sizeof(top_fade); + line_ticker_smooth.top_fade_y_offset = &ticker_top_fade_y_offset; + line_ticker_smooth.top_fade_alpha = &ticker_top_fade_alpha; + + line_ticker_smooth.bottom_fade_str = bottom_fade; + line_ticker_smooth.bottom_fade_str_len = sizeof(bottom_fade); + line_ticker_smooth.bottom_fade_y_offset = &ticker_bot_fade_y_offset; + line_ticker_smooth.bottom_fade_alpha = &ticker_bot_fade_alpha; + + gfx_animation_line_ticker_smooth(p_anim, &line_ticker_smooth); + } + else + { + gfx_animation_ctx_line_ticker_t line_ticker; + unsigned max_lines = (unsigned)(field_height / line_height); + + if (max_lines < 1) + max_lines = 1; + + line_ticker.type_enum = menu_ticker_type; + line_ticker.idx = p_anim->ticker_idx; + line_ticker.line_len = (size_t)(width / (float)glyph_w); + line_ticker.max_lines = max_lines; + line_ticker.s = body; + line_ticker.len = sizeof(body); + line_ticker.str = xmb->ss_description; + + gfx_animation_line_ticker(p_anim, &line_ticker); + } + + if (*body) + xmb_draw_text(shadows_enable, xmb, settings, + body, + x, + ticker_y_offset + row_y + line_height, + 1.0f, + xmb->alpha, + TEXT_ALIGN_LEFT, + video_width, video_height, xmb->font2); + + if (use_smooth_ticker) + { + if (*top_fade && (ticker_top_fade_alpha > 0.0f)) + xmb_draw_text(shadows_enable, xmb, settings, + top_fade, + x, + ticker_top_fade_y_offset + row_y + line_height, + 1.0f, + ticker_top_fade_alpha * xmb->alpha, + TEXT_ALIGN_LEFT, + video_width, video_height, xmb->font2); + + if (*bottom_fade && (ticker_bot_fade_alpha > 0.0f)) + xmb_draw_text(shadows_enable, xmb, settings, + bottom_fade, + x, + ticker_bot_fade_y_offset + row_y + line_height, + 1.0f, + ticker_bot_fade_alpha * xmb->alpha, + TEXT_ALIGN_LEFT, + video_width, video_height, xmb->font2); + } + } +} + static void xmb_frame(void *data, video_frame_info_t *video_info) { math_matrix_4x4 mymat; @@ -9058,6 +8894,15 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) { uintptr_t tex_list[XMB_TEXTURE_LAST]; uintptr_t tex_bg = xmb->textures.bg; + + /* Animated background: step it once for this frame and draw its + * current frame in place of the still wallpaper texture. */ + if (xmb->bg_is_anim) + { + gfx_thumbnail_animate(&xmb->bg_anim); + if (xmb->bg_anim.texture) + tex_bg = xmb->bg_anim.texture; + } memcpy(tex_list, xmb->textures.list, sizeof(tex_list)); if (tex_list[XMB_TEXTURE_MAIN_MENU]) @@ -9182,7 +9027,13 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) dispctx, video_width, video_height, - menu_shader_pipeline, + /* The shader pipeline is drawn over the wallpaper, which + * would sit on top of a playing video background - so a + * video background suppresses it for as long as it is the + * active wallpaper, whatever the pipeline setting says. */ + xmb->bg_is_anim + ? XMB_SHADER_PIPELINE_WALLPAPER + : menu_shader_pipeline, color_theme, MIN(xmb->alpha, menu_wallpaper_opacity), libretro_running, @@ -9601,6 +9452,19 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) || ( xmb->thumbnails.left.status < GFX_THUMBNAIL_STATUS_AVAILABLE && xmb->thumbnails_left_status_prev <= GFX_THUMBNAIL_STATUS_AVAILABLE && xmb->thumbnails_left_status_prev != GFX_THUMBNAIL_STATUS_UNKNOWN)); + /* Scraped facts panel: it stands in for the left thumbnail, so + * the layout below is asked to make room for a left thumbnail + * whether or not one actually exists, and every left thumbnail + * draw becomes a panel draw instead. */ + bool show_ss_panel = + settings->bools.menu_xmb_show_metadata_panel + && xmb->ss_valid + && ( xmb->ss_has_rating + || *xmb->ss_publisher + || *xmb->ss_description); + + if (show_ss_panel) + show_left_thumbnail = true; /* Check if we are using the proper PS3 layout, * or the aborted PSP layout */ @@ -9646,19 +9510,22 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) background_color, NULL); - gfx_display_draw_quad( - p_disp, - userdata, - video_width, - video_height, - thumb_x, - left_thumb_y, - scaled_thumb_width, - scaled_thumb_height, - video_width, - video_height, - background_color, - NULL); + /* The metadata panel is transparent by design, so + * it never gets the darkening quad */ + if (!show_ss_panel) + gfx_display_draw_quad( + p_disp, + userdata, + video_width, + video_height, + thumb_x, + left_thumb_y, + scaled_thumb_width, + scaled_thumb_height, + video_width, + video_height, + background_color, + NULL); } gfx_thumbnail_draw( @@ -9673,6 +9540,15 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) GFX_THUMBNAIL_ALIGN_CENTRE, 1.0f, 1.0f, &thumbnail_shadow); + if (show_ss_panel) + xmb_draw_metadata_panel( + xmb, p_disp, p_anim, dispctx, settings, userdata, + video_width, video_height, shadows_enable, + tex_list[XMB_TEXTURE_FAVORITE], + thumb_x, left_thumb_y, + scaled_thumb_width, scaled_thumb_height, + &mymat); + else gfx_thumbnail_draw( userdata, video_width, @@ -9696,7 +9572,7 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) + ((thumb_width - scaled_thumb_width) / 2.0f); float thumb_y = xmb->margins_screen_top + (xmb->icon_size / 1.5f); - if (thumbnail_background) + if (thumbnail_background && (show_right_thumbnail || !show_ss_panel)) gfx_display_draw_quad( p_disp, userdata, @@ -9711,6 +9587,18 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) background_color, NULL); + /* Only when the right thumbnail is absent does this + * slot belong to the left one, and therefore to the + * panel that replaces it */ + if (show_ss_panel && !show_right_thumbnail) + xmb_draw_metadata_panel( + xmb, p_disp, p_anim, dispctx, settings, userdata, + video_width, video_height, shadows_enable, + tex_list[XMB_TEXTURE_FAVORITE], + thumb_x, thumb_y, + scaled_thumb_width, scaled_thumb_height, + &mymat); + else gfx_thumbnail_draw( userdata, video_width, @@ -9785,7 +9673,7 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) if (thumb_height > xmb->icon_size) { - if (thumbnail_background) + if (thumbnail_background && !show_ss_panel) gfx_display_draw_quad( p_disp, userdata, @@ -9800,6 +9688,15 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) background_color, NULL); + if (show_ss_panel) + xmb_draw_metadata_panel( + xmb, p_disp, p_anim, dispctx, settings, userdata, + video_width, video_height, shadows_enable, + tex_list[XMB_TEXTURE_FAVORITE], + thumb_x, thumb_y, + scaled_thumb_width, scaled_thumb_height, + &mymat); + else gfx_thumbnail_draw( userdata, video_width, @@ -9840,7 +9737,7 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) * > Impose a minimum size limit */ if (thumb_height > xmb->icon_size) { - if (thumbnail_background) + if (thumbnail_background && !show_ss_panel) gfx_display_draw_quad( p_disp, userdata, @@ -9855,6 +9752,17 @@ static void xmb_frame(void *data, video_frame_info_t *video_info) background_color, NULL); + /* The single slot of this layout is the left one, so the + * panel takes it whenever it is active */ + if (show_ss_panel) + xmb_draw_metadata_panel( + xmb, p_disp, p_anim, dispctx, settings, userdata, + video_width, video_height, shadows_enable, + tex_list[XMB_TEXTURE_FAVORITE], + thumb_x, thumb_y, + scaled_thumb_width, scaled_thumb_height, + &mymat); + else gfx_thumbnail_draw( userdata, video_width, @@ -10403,6 +10311,10 @@ static void xmb_free(void *data) if (xmb) { + /* Release the animated background decoder before the rest + * of the handle goes away */ + gfx_thumbnail_reset(&xmb->bg_anim); + /* Invalidate any in-flight async icon loads before freeing * the nodes they would write into */ xmb_icon_load_gen++; @@ -10433,6 +10345,21 @@ static void xmb_context_bg_destroy(xmb_handle_t *xmb) if (!xmb) return; + /* The animated background owns a decoder and a texture of its own; + * both belong to the graphics context being torn down, so they go + * with it. Leaving them behind hands the render path a stale + * texture handle after a resize or driver reinit. */ + gfx_thumbnail_reset(&xmb->bg_anim); + xmb->bg_is_anim = false; + + /* Forget which file is loaded, too. xmb_update_dynamic_wallpaper + * skips its work whenever the freshly resolved path still equals + * this one, so leaving it set after the texture has been destroyed + * means the background is never re-requested - it only comes back + * once the user moves to an entry resolving to a different path. */ + free(xmb->bg_file_path); + xmb->bg_file_path = NULL; + video_driver_texture_unload(&xmb->textures.bg); gfx_display_deinit_white_texture(); } diff --git a/menu/menu_cbs.h b/menu/menu_cbs.h index 34e8fd964f4a..e882930f921a 100644 --- a/menu/menu_cbs.h +++ b/menu/menu_cbs.h @@ -147,6 +147,7 @@ enum ACTION_OK_DL_PLAYLIST_MANAGER_SETTINGS, ACTION_OK_DL_ACCOUNTS_LIST, ACTION_OK_DL_ACCOUNTS_CHEEVOS_LIST, + ACTION_OK_DL_ACCOUNTS_SCREENSCRAPER_LIST, ACTION_OK_DL_ACCOUNTS_YOUTUBE_LIST, ACTION_OK_DL_ACCOUNTS_TWITCH_LIST, ACTION_OK_DL_ACCOUNTS_FACEBOOK_LIST, diff --git a/menu/menu_defines.h b/menu/menu_defines.h index 888eea5312e1..c8b2cb2a6bb6 100644 --- a/menu/menu_defines.h +++ b/menu/menu_defines.h @@ -487,6 +487,28 @@ enum ozone_header_separator OZONE_HEADER_SEPARATOR_LAST }; +/* Whether, and which way, a scraped synopsis too long for the content + * metadata panel animates. + * HORIZONTAL is the upstream behaviour: the synopsis scrolls sideways + * on one line when ozone_scroll_content_metadata puts the panel in + * ticker mode, and sits statically wrapped when it does not. + * VERTICAL scrolls the wrapped synopsis upwards as a block, the way + * XMB's metadata panel does, in either panel mode. + * OFF never animates the synopsis, in either panel mode. + * + * The numbering is deliberately Horizontal-first: it predates the OFF + * value, so appending OFF at the end keeps every existing + * 'ozone_metadata_scroll_style' in a user's config meaning what it + * meant before. Menu order follows the numbering, so the drop-down + * reads Horizontal / Vertical / Off. */ +enum ozone_metadata_scroll_style +{ + OZONE_METADATA_SCROLL_HORIZONTAL = 0, + OZONE_METADATA_SCROLL_VERTICAL, + OZONE_METADATA_SCROLL_OFF, + OZONE_METADATA_SCROLL_LAST +}; + enum ozone_font_scale { OZONE_FONT_SCALE_NONE = 0, diff --git a/menu/menu_displaylist.c b/menu/menu_displaylist.c index 155a2539659b..ca9b1b2c77cc 100644 --- a/menu/menu_displaylist.c +++ b/menu/menu_displaylist.c @@ -131,6 +131,7 @@ #endif #include "../tasks/task_content.h" #include "../tasks/tasks_internal.h" +#include "../network/screenscraper.h" #include "../dynamic.h" #include "../runtime_file.h" #include "../manual_content_scan.h" @@ -2863,6 +2864,10 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, unsigned i, j, k; char query[256]; char path_base[NAME_MAX_LENGTH]; + /* Thumbnail-tree directory name, i.e. what a scrape filed this + * system's metadata sidecars under. Reading them is a local-file + * operation, so this is not conditional on networking. */ + char ss_system[NAME_MAX_LENGTH]; playlist_config_t playlist_config; playlist_t *playlist = NULL; database_info_list_t *db_info = NULL; @@ -2910,6 +2915,11 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, gfx_thumbnail_set_system(menu_st->thumbnail_path_data, path_base, playlist_get_cached()); + /* The bare .rdb basename is also the thumbnail-tree directory + * name, i.e. what the scraper filed this system's sidecars under. + * Kept before the ".lpl" below overwrites it. */ + strlcpy(ss_system, path_base, sizeof(ss_system)); + strlcpy(path_base + _len, ".lpl", sizeof(path_base) - _len); fill_pathname_join_special(menu->db_playlist_file, @@ -2926,6 +2936,13 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, char crc_str[20]; database_info_t *db_info_entry = &db_info->list[i]; size_t tmp_len; + /* Scraped facts for this title, when it has a sidecar. They + * take precedence over the offline database below; see the + * RDB_ENTRY_STR_SS() comment. */ + const struct playlist_entry *ss_pl_entry = NULL; + screenscraper_meta_t ss; + + ss.valid = false; snprintf(crc_str, sizeof(crc_str), "%08lX", (unsigned long)db_info_entry->crc32); @@ -2987,10 +3004,52 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, continue; menu->scratchpad.unsigned_var = j; + ss_pl_entry = entry; break; } } + /* The scraper keys its sidecars off the playlist label, so the + * matched playlist entry is the reliable way in; the database's + * own name only stands in when this title is not on a playlist + * (in which case a sidecar is unlikely to exist anyway). */ + { + settings_t *ss_settings = config_get_ptr(); + const char *ss_label = NULL; + + if (ss_pl_entry) + ss_label = !string_is_empty(ss_pl_entry->label) + ? ss_pl_entry->label + : path_basename(ss_pl_entry->path); + else if (!string_is_empty(db_info_entry->name)) + ss_label = db_info_entry->name; + + if (ss_settings && ss_label && *ss_system) + { + char ss_img[NAME_MAX_LENGTH]; + + gfx_thumbnail_fill_content_img(ss_img, sizeof(ss_img), + ss_label, false); + + screenscraper_metadata_load_entry( + ss_settings->paths.directory_thumbnails, + ss_system, ss_img, &ss); + } + } + + if (ss.valid && !string_is_empty(ss.name)) + { + tmp_len = strlcpy(tmp, + msg_hash_to_str(MENU_ENUM_LABEL_VALUE_RDB_ENTRY_NAME), + sizeof(tmp)); + tmp_len += strlcpy(tmp + tmp_len, ": ", sizeof(tmp) - tmp_len); + strlcpy(tmp + tmp_len, ss.name, sizeof(tmp) - tmp_len); + menu_entries_append(info->list, tmp, + MENU_ENUM_LABEL_RDB_ENTRY_NAME_STR, + MENU_ENUM_LABEL_RDB_ENTRY_NAME, + 0, 0, 0, NULL); + } + else if (db_info_entry->name) { tmp_len = strlcpy(tmp, @@ -3004,6 +3063,19 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, 0, 0, 0, NULL); } + if (ss.valid && !string_is_empty(ss.description)) + { + tmp_len = strlcpy(tmp, + msg_hash_to_str(MENU_ENUM_LABEL_VALUE_RDB_ENTRY_DESCRIPTION), + sizeof(tmp)); + tmp_len += strlcpy(tmp + tmp_len, ": ", sizeof(tmp) - tmp_len); + strlcpy(tmp + tmp_len, ss.description, sizeof(tmp) - tmp_len); + menu_entries_append(info->list, tmp, + MENU_ENUM_LABEL_RDB_ENTRY_DESCRIPTION_STR, + MENU_ENUM_LABEL_RDB_ENTRY_DESCRIPTION, + 0, 0, 0, NULL); + } + else if (db_info_entry->description) { tmp_len = strlcpy(tmp, @@ -3029,6 +3101,26 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, goto error; \ } +/* Same row, but sourced from the ScreenScraper sidecar when it has + * something to say and from the offline database otherwise. The + * scrape wins because it covers content the shipped .rdb has never + * heard of, and where the two overlap it is the fresher of the two; + * the .rdb then fills in whatever the scrape left blank. */ +#define RDB_ENTRY_STR_SS(ss_field, field, enum_label, enum_val_label) \ + { \ + const char *_ss_val = (ss.valid && !string_is_empty(ss.ss_field)) \ + ? ss.ss_field : db_info_entry->field; \ + if (!string_is_empty(_ss_val)) \ + { \ + if (create_string_list_rdb_entry_string( \ + enum_label, \ + msg_hash_to_str(enum_val_label), \ + msg_hash_to_str(enum_label), \ + _ss_val, info->path, info->list) == -1) \ + goto error; \ + } \ + } + #define RDB_ENTRY_INT(field, enum_label, enum_val_label) \ if (db_info_entry->field) \ { \ @@ -3052,9 +3144,11 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, goto error; \ } - RDB_ENTRY_STR(genre, MENU_ENUM_LABEL_RDB_ENTRY_GENRE, + RDB_ENTRY_STR_SS(genre, genre, + MENU_ENUM_LABEL_RDB_ENTRY_GENRE, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_GENRE) - RDB_ENTRY_STR(publisher, MENU_ENUM_LABEL_RDB_ENTRY_PUBLISHER, + RDB_ENTRY_STR_SS(publisher, publisher, + MENU_ENUM_LABEL_RDB_ENTRY_PUBLISHER, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_PUBLISHER) RDB_ENTRY_STR(category, MENU_ENUM_LABEL_RDB_ENTRY_CATEGORY, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_CATEGORY) @@ -3085,6 +3179,19 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, RDB_ENTRY_STR(vehicular, MENU_ENUM_LABEL_RDB_ENTRY_VEHICULAR, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_VEHICULAR) + /* The .rdb can list several developers, the scrape only ever + * one - so the scraped name replaces the whole list rather than + * being merged into it */ + if (ss.valid && !string_is_empty(ss.developer)) + { + if (create_string_list_rdb_entry_string( + MENU_ENUM_LABEL_RDB_ENTRY_DEVELOPER, + msg_hash_to_str(MENU_ENUM_LABEL_VALUE_RDB_ENTRY_DEVELOPER), + MENU_ENUM_LABEL_RDB_ENTRY_DEVELOPER_STR, + ss.developer, info->path, info->list) == -1) + goto error; + } + else if (db_info_entry->developer) { const char *val_rdb_entry_dev = @@ -3132,11 +3239,13 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, RDB_ENTRY_STR(bbfc_rating, MENU_ENUM_LABEL_RDB_ENTRY_BBFC_RATING, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_BBFC_RATING) - RDB_ENTRY_STR(esrb_rating, MENU_ENUM_LABEL_RDB_ENTRY_ESRB_RATING, + RDB_ENTRY_STR_SS(esrb, esrb_rating, + MENU_ENUM_LABEL_RDB_ENTRY_ESRB_RATING, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_ESRB_RATING) RDB_ENTRY_STR(elspa_rating, MENU_ENUM_LABEL_RDB_ENTRY_ELSPA_RATING, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_ELSPA_RATING) - RDB_ENTRY_STR(pegi_rating, MENU_ENUM_LABEL_RDB_ENTRY_PEGI_RATING, + RDB_ENTRY_STR_SS(pegi, pegi_rating, + MENU_ENUM_LABEL_RDB_ENTRY_PEGI_RATING, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_PEGI_RATING) RDB_ENTRY_STR(enhancement_hw, MENU_ENUM_LABEL_RDB_ENTRY_ENHANCEMENT_HW, MENU_ENUM_LABEL_VALUE_RDB_ENTRY_ENHANCEMENT_HW) @@ -3178,6 +3287,7 @@ static int menu_displaylist_parse_database_entry(menu_handle_t *menu, } #undef RDB_ENTRY_STR +#undef RDB_ENTRY_STR_SS #undef RDB_ENTRY_INT #undef RDB_ENTRY_BOOL @@ -4999,6 +5109,16 @@ static bool menu_displaylist_parse_playlist_manager_settings( MENU_ENUM_LABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST, MENU_SETTING_ACTION_PLAYLIST_MANAGER_CLEAN_PLAYLIST, 0, 0, NULL); +#ifdef HAVE_NETWORKING + /* Scrape using ScreenScraper */ + if (screenscraper_signed_in()) + menu_entries_append(list, + msg_hash_to_str(MENU_ENUM_LABEL_VALUE_PLAYLIST_MANAGER_SCREENSCRAPER), + MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER_STR, + MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER, + MENU_SETTING_ACTION, 0, 0, NULL); +#endif + /* Delete playlist */ menu_entries_append(list, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_DELETE_PLAYLIST), @@ -6576,8 +6696,14 @@ static unsigned populate_playlist_thumbnail_mode_dropdown_list( ? MENU_SETTING_DROPDOWN_ITEM_PLAYLIST_RIGHT_THUMBNAIL_MODE : MENU_SETTING_DROPDOWN_ITEM_PLAYLIST_LEFT_THUMBNAIL_MODE; - /* Loop over all thumbnail modes */ - for (i = 0; i <= (unsigned)PLAYLIST_THUMBNAIL_MODE_LOGOS; i++) + /* Loop over all thumbnail modes; the ScreenScraper-sourced + * extended types are offered when an account is configured or + * when such media is already on disk */ + { + unsigned max_mode = (unsigned)PLAYLIST_THUMBNAIL_MODE_LOGOS; + if (screenscraper_extended_media_allowed()) + max_mode = (unsigned)PLAYLIST_THUMBNAIL_MODE_VIDEOS; + for (i = 0; i <= max_mode; i++) { enum msg_hash_enums label_value; enum playlist_thumbnail_mode thumbnail_mode = @@ -6601,6 +6727,18 @@ static unsigned populate_playlist_thumbnail_mode_dropdown_list( case PLAYLIST_THUMBNAIL_MODE_LOGOS: label_value = MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_LOGOS; break; + case PLAYLIST_THUMBNAIL_MODE_BOXARTS_3D: + label_value = MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_BOXARTS_3D; + break; + case PLAYLIST_THUMBNAIL_MODE_FANARTS: + label_value = MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_FANARTS; + break; + case PLAYLIST_THUMBNAIL_MODE_MARQUEES: + label_value = MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_MARQUEES; + break; + case PLAYLIST_THUMBNAIL_MODE_VIDEOS: + label_value = MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_VIDEOS; + break; default: /* PLAYLIST_THUMBNAIL_MODE_DEFAULT */ label_value = MENU_ENUM_LABEL_VALUE_PLAYLIST_MANAGER_THUMBNAIL_MODE_DEFAULT; @@ -6625,6 +6763,7 @@ static unsigned populate_playlist_thumbnail_mode_dropdown_list( menu_st->selection_ptr = i; } } + } } return count; @@ -7666,10 +7805,30 @@ unsigned menu_displaylist_build_list( bool playlist_show_sublabels = settings->bools.playlist_show_sublabels; bool history_list_enable = settings->bools.history_list_enable; bool truncate_playlist = settings->bools.ozone_truncate_playlist_name; +#ifdef HAVE_NETWORKING + bool screenscraper_on = screenscraper_signed_in(); +#endif static menu_displaylist_build_info_selective_t build_list[] = { #ifdef HAVE_NETWORKING {MENU_ENUM_LABEL_NETWORK_ON_DEMAND_THUMBNAILS, PARSE_ONLY_BOOL, true}, + {MENU_ENUM_LABEL_SCREENSCRAPER_PRIMARY_SCRAPER, PARSE_ONLY_UINT, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_QUOTA_ACTION, PARSE_ONLY_UINT, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_REGION, PARSE_ONLY_UINT, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_LANGUAGE, PARSE_ONLY_UINT, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BOXARTS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_SNAPS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_TITLES, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_LOGOS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BOXARTS3D, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_FANARTS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_MARQUEES, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_VIDEOS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_MANUALS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BEZELS, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_METADATA, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_OVERWRITE, PARSE_ONLY_BOOL, false}, + {MENU_ENUM_LABEL_SCREENSCRAPER_USE_CRC, PARSE_ONLY_BOOL, false}, #endif {MENU_ENUM_LABEL_PLAYLIST_USE_FILENAME, PARSE_ONLY_BOOL, true}, {MENU_ENUM_LABEL_PLAYLIST_ALLOW_NON_PNG, PARSE_ONLY_BOOL, true}, @@ -7709,6 +7868,27 @@ unsigned menu_displaylist_build_list( case MENU_ENUM_LABEL_CONTENT_HISTORY_SIZE: build_list[i].checked = history_list_enable; break; +#ifdef HAVE_NETWORKING + case MENU_ENUM_LABEL_SCREENSCRAPER_PRIMARY_SCRAPER: + case MENU_ENUM_LABEL_SCREENSCRAPER_QUOTA_ACTION: + case MENU_ENUM_LABEL_SCREENSCRAPER_REGION: + case MENU_ENUM_LABEL_SCREENSCRAPER_LANGUAGE: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BOXARTS: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_SNAPS: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_TITLES: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_LOGOS: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BOXARTS3D: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_FANARTS: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_MARQUEES: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_VIDEOS: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_MANUALS: + case MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BEZELS: + case MENU_ENUM_LABEL_SCREENSCRAPER_METADATA: + case MENU_ENUM_LABEL_SCREENSCRAPER_OVERWRITE: + case MENU_ENUM_LABEL_SCREENSCRAPER_USE_CRC: + build_list[i].checked = screenscraper_on; + break; +#endif case MENU_ENUM_LABEL_OZONE_SORT_AFTER_TRUNCATE_PLAYLIST_NAME: build_list[i].checked = truncate_playlist; break; @@ -10821,6 +11001,9 @@ unsigned menu_displaylist_build_list( { static const menu_displaylist_build_info_t build_list[] = { {MENU_ENUM_LABEL_ACCOUNTS_RETRO_ACHIEVEMENTS, PARSE_ACTION}, +#ifdef HAVE_NETWORKING + {MENU_ENUM_LABEL_ACCOUNTS_SCREENSCRAPER, PARSE_ACTION}, +#endif {MENU_ENUM_LABEL_ACCOUNTS_YOUTUBE, PARSE_ACTION}, {MENU_ENUM_LABEL_ACCOUNTS_TWITCH, PARSE_ACTION}, {MENU_ENUM_LABEL_ACCOUNTS_FACEBOOK, PARSE_ACTION}, @@ -10836,6 +11019,24 @@ unsigned menu_displaylist_build_list( } } break; + case DISPLAYLIST_ACCOUNTS_SCREENSCRAPER_LIST: + { + static const menu_displaylist_build_info_t build_list[] = { + {MENU_ENUM_LABEL_SCREENSCRAPER_USERNAME, PARSE_ONLY_STRING}, + {MENU_ENUM_LABEL_SCREENSCRAPER_PASSWORD, PARSE_ONLY_STRING}, + {MENU_ENUM_LABEL_SCREENSCRAPER_DEVID, PARSE_ONLY_STRING}, + {MENU_ENUM_LABEL_SCREENSCRAPER_DEVPASSWORD, PARSE_ONLY_STRING}, + }; + + for (i = 0; i < ARRAY_SIZE(build_list); i++) + { + if (MENU_DISPLAYLIST_PARSE_SETTINGS_ENUM(list, + build_list[i].enum_idx, build_list[i].parse_type, + false) == 0) + count++; + } + } + break; case DISPLAYLIST_ACCOUNTS_CHEEVOS_LIST: { static const menu_displaylist_build_info_t build_list[] = { @@ -12294,6 +12495,8 @@ unsigned menu_displaylist_build_list( {MENU_ENUM_LABEL_XMB_ALPHA_FACTOR, PARSE_ONLY_UINT, true}, {MENU_ENUM_LABEL_MENU_WALLPAPER, PARSE_ONLY_PATH , true}, {MENU_ENUM_LABEL_DYNAMIC_WALLPAPER, PARSE_ONLY_BOOL , true}, + {MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_MODE, PARSE_ONLY_UINT , false}, + {MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_TYPE, PARSE_ONLY_UINT , false}, {MENU_ENUM_LABEL_MENU_WALLPAPER_OPACITY, PARSE_ONLY_FLOAT, true}, {MENU_ENUM_LABEL_MENU_TEXTURE_MIPMAPPING, PARSE_ONLY_BOOL, true}, {MENU_ENUM_LABEL_MENU_USE_PREFERRED_SYSTEM_COLOR_THEME, PARSE_ONLY_BOOL, true}, @@ -12340,6 +12543,7 @@ unsigned menu_displaylist_build_list( {MENU_ENUM_LABEL_LEFT_THUMBNAILS, PARSE_ONLY_UINT, true}, {MENU_ENUM_LABEL_ICON_THUMBNAILS, PARSE_ONLY_UINT, true}, {MENU_ENUM_LABEL_XMB_VERTICAL_THUMBNAILS, PARSE_ONLY_BOOL, true}, + {MENU_ENUM_LABEL_XMB_SHOW_METADATA_PANEL, PARSE_ONLY_BOOL, false}, {MENU_ENUM_LABEL_MENU_XMB_THUMBNAIL_SCALE_FACTOR, PARSE_ONLY_UINT, true}, {MENU_ENUM_LABEL_OZONE_THUMBNAIL_SCALE_FACTOR, PARSE_ONLY_FLOAT, true}, {MENU_ENUM_LABEL_MENU_THUMBNAIL_UPSCALE_THRESHOLD, PARSE_ONLY_UINT, true}, @@ -12371,6 +12575,7 @@ unsigned menu_displaylist_build_list( {MENU_ENUM_LABEL_MENU_TICKER_TYPE, PARSE_ONLY_UINT, true}, {MENU_ENUM_LABEL_MENU_TICKER_SPEED, PARSE_ONLY_FLOAT, true}, {MENU_ENUM_LABEL_MENU_TICKER_SMOOTH, PARSE_ONLY_BOOL, true}, + {MENU_ENUM_LABEL_OZONE_METADATA_SCROLL_STYLE, PARSE_ONLY_UINT, false}, {MENU_ENUM_LABEL_OZONE_SCROLL_CONTENT_METADATA, PARSE_ONLY_BOOL, true}, {MENU_ENUM_LABEL_MENU_RGUI_EXTENDED_ASCII, PARSE_ONLY_BOOL, true}, {MENU_ENUM_LABEL_MATERIALUI_MENU_TRANSITION_ANIMATION, PARSE_ONLY_UINT, true}, @@ -12432,6 +12637,24 @@ unsigned menu_displaylist_build_list( case MENU_ENUM_LABEL_MENU_HDR_BRIGHTNESS_NITS: build_list[i].checked = (settings->uints.video_hdr_mode > 0); break; + case MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_MODE: + case MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_TYPE: + build_list[i].checked = settings->bools.menu_dynamic_wallpaper_enable; + break; + case MENU_ENUM_LABEL_XMB_SHOW_METADATA_PANEL: + /* The panel is drawn by XMB only. Its content is a + * local sidecar, so this stays available on a + * target that cannot scrape but has had a + * thumbnails tree copied onto it. */ + build_list[i].checked = string_is_equal( + settings->arrays.menu_driver, "xmb"); + break; + case MENU_ENUM_LABEL_OZONE_METADATA_SCROLL_STYLE: + /* Likewise, Ozone only - and likewise independent + * of whether this build can scrape */ + build_list[i].checked = string_is_equal( + settings->arrays.menu_driver, "ozone"); + break; default: break; } @@ -15117,6 +15340,7 @@ bool menu_displaylist_ctl(enum menu_displaylist_ctl_state type, case DISPLAYLIST_OVERLAY_MOUSE_SETTINGS_LIST: #endif case DISPLAYLIST_ACCOUNTS_CHEEVOS_LIST: + case DISPLAYLIST_ACCOUNTS_SCREENSCRAPER_LIST: case DISPLAYLIST_ACCOUNTS_LIST: case DISPLAYLIST_MENU_FILE_BROWSER_SETTINGS_LIST: case DISPLAYLIST_MENU_VIEWS_SETTINGS_LIST: diff --git a/menu/menu_displaylist.h b/menu/menu_displaylist.h index cb6ffe1edd8b..2d02580e189b 100644 --- a/menu/menu_displaylist.h +++ b/menu/menu_displaylist.h @@ -261,6 +261,7 @@ enum menu_displaylist_ctl_state DISPLAYLIST_ADD_TO_PLAYLIST_QUICKMENU, DISPLAYLIST_PLAYLIST_MANAGER_SETTINGS, DISPLAYLIST_ACCOUNTS_CHEEVOS_LIST, + DISPLAYLIST_ACCOUNTS_SCREENSCRAPER_LIST, DISPLAYLIST_ACCOUNTS_YOUTUBE_LIST, DISPLAYLIST_ACCOUNTS_TWITCH_LIST, DISPLAYLIST_ACCOUNTS_FACEBOOK_LIST, diff --git a/menu/menu_driver.c b/menu/menu_driver.c index e772ce8c2431..f3a06941d602 100644 --- a/menu/menu_driver.c +++ b/menu/menu_driver.c @@ -7278,7 +7278,7 @@ int action_cycle_thumbnail(unsigned mode) cur_primary++; /* Wrap primary to first image type, and skip logo */ - if (cur_primary > PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 2) + if (cur_primary > PLAYLIST_THUMBNAIL_MODE_BOXARTS - PLAYLIST_THUMBNAIL_MODE_OFF) cur_primary = 1; /* Final dupe check */ @@ -7300,7 +7300,7 @@ int action_cycle_thumbnail(unsigned mode) /* Wrap secondary to no image, and skip logo. * If primary disabled, wrap to first image. */ - if (cur_secondary > PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 2) + if (cur_secondary > PLAYLIST_THUMBNAIL_MODE_BOXARTS - PLAYLIST_THUMBNAIL_MODE_OFF) cur_secondary = (cur_primary) ? 0 : 1; configuration_set_uint(settings, settings->uints.menu_left_thumbnails, cur_secondary); diff --git a/menu/menu_setting.c b/menu/menu_setting.c index 2e454e799981..262acad43d4f 100644 --- a/menu/menu_setting.c +++ b/menu/menu_setting.c @@ -85,6 +85,7 @@ #include "../core.h" #include "../configuration.h" +#include "../network/screenscraper.h" #include "../msg_hash.h" #include "../defaults.h" #include "../driver.h" @@ -3380,17 +3381,19 @@ static size_t setting_get_string_representation_max_users( return 0; } -#if defined(HAVE_CHEEVOS) || defined(HAVE_CLOUDSYNC) +#if defined(HAVE_CHEEVOS) || defined(HAVE_CLOUDSYNC) || defined(HAVE_NETWORKING) static size_t setting_get_string_representation_password( rarch_setting_t *setting, char *s, size_t len) { - if (setting) + if (setting && setting->value.target.string) { - if ( setting->value.target.string - && setting->value.target.string[0] != '\0') + if (setting->value.target.string[0] != '\0') return strlcpy(s, "********", len); - if (config_get_ptr()->arrays.cheevos_token[0]) +#ifdef HAVE_CHEEVOS + if ( setting->enum_idx == MENU_ENUM_LABEL_CHEEVOS_PASSWORD + && config_get_ptr()->arrays.cheevos_token[0]) return strlcpy(s, "********", len); +#endif *setting->value.target.string = '\0'; } return 0; @@ -3685,6 +3688,76 @@ static size_t setting_get_string_representation_uint_accessibility_narrator_engi } #endif +static size_t setting_get_string_representation_uint_dynamic_wallpaper_type( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + if (*setting->value.target.unsigned_integer == 1) + return strlcpy(s, "Videos", len); + return strlcpy(s, "Images", len); +} + +static size_t setting_get_string_representation_uint_dynamic_wallpaper_mode( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + if (*setting->value.target.unsigned_integer == 1) + return strlcpy(s, "Per Game", len); + return strlcpy(s, "Per System", len); +} + +/* Extended thumbnail modes (3D boxarts, fan art, marquees, video + * snaps) are ScreenScraper-sourced; expose them when an account is + * configured or when such media is already on disk. */ +static unsigned menu_setting_thumbnail_mode_max(void) +{ + if (screenscraper_extended_media_allowed()) + return PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 1; + return PLAYLIST_THUMBNAIL_MODE_LOGOS - PLAYLIST_THUMBNAIL_MODE_OFF; +} + +#ifdef HAVE_NETWORKING +static size_t setting_get_string_representation_uint_screenscraper_primary( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + if (*setting->value.target.unsigned_integer == 1) + return strlcpy(s, "libretro", len); + return strlcpy(s, "ScreenScraper", len); +} + +static size_t setting_get_string_representation_uint_screenscraper_quota( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + if (*setting->value.target.unsigned_integer == 1) + return strlcpy(s, "Continue With libretro", len); + return strlcpy(s, "Pause And Resume Later", len); +} + +static size_t setting_get_string_representation_uint_screenscraper_region( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + return strlcpy(s, screenscraper_region_name( + *setting->value.target.unsigned_integer), len); +} + +static size_t setting_get_string_representation_uint_screenscraper_language( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + return strlcpy(s, screenscraper_language_name( + *setting->value.target.unsigned_integer), len); +} +#endif + static size_t setting_get_string_representation_uint_menu_thumbnails( rarch_setting_t *setting, char *s, size_t len) { @@ -3709,6 +3782,18 @@ static size_t setting_get_string_representation_uint_menu_thumbnails( case 4: return strlcpy(s, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_LOGOS), len); break; + case 5: + return strlcpy(s, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_BOXARTS_3D), len); + break; + case 6: + return strlcpy(s, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_FANARTS), len); + break; + case 7: + return strlcpy(s, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_MARQUEES), len); + break; + case 8: + return strlcpy(s, msg_hash_to_str(MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_VIDEOS), len); + break; } } @@ -5094,6 +5179,26 @@ static size_t setting_get_string_representation_uint_ozone_header_icon( return 0; } +/* Values are literal rather than msg_hash entries: this is a local + * addition, so there is nothing translated to point at. */ +static size_t setting_get_string_representation_uint_ozone_metadata_scroll_style( + rarch_setting_t *setting, char *s, size_t len) +{ + if (!setting) + return 0; + switch (*setting->value.target.unsigned_integer) + { + case OZONE_METADATA_SCROLL_VERTICAL: + return strlcpy(s, "Vertical", len); + case OZONE_METADATA_SCROLL_OFF: + return strlcpy(s, "Off", len); + case OZONE_METADATA_SCROLL_HORIZONTAL: + default: + break; + } + return strlcpy(s, "Horizontal", len); +} + static size_t setting_get_string_representation_uint_ozone_header_separator( rarch_setting_t *setting, char *s, size_t len) { @@ -12769,6 +12874,20 @@ static const setting_desc_t cheevos_acct_desc[] = { }; #endif +#ifdef HAVE_NETWORKING +static const setting_desc_t screenscraper_desc[] = { +/* GENERATED: rows come from settings_def_screenscraper.h in order. */ +#include "../settings/settings_def_screenscraper.h" +}; +#endif + +#ifdef HAVE_NETWORKING +static const setting_desc_t screenscraper_acct_desc[] = { +/* GENERATED: rows come from settings_def_screenscraper_account.h in order. */ +#include "../settings/settings_def_screenscraper_account.h" +}; +#endif + static const setting_desc_t dir_desc_0[] = { /* GENERATED: rows come from settings_def_dir_core.h in order. */ #include "../settings/settings_def_dir_core.h" @@ -15623,7 +15742,7 @@ static void settings_build_menu( general_read_handler); SETTINGS_ACTION_SET(ok, &(*list)[list_info->index - 1], &setting_action_ok_uint) SETTINGS_ACTION_SET(repr, &(*list)[list_info->index - 1], &setting_get_string_representation_uint_menu_thumbnails) - menu_settings_list_current_add_range(list, list_info, 0, PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 1, 1, true, true); + menu_settings_list_current_add_range(list, list_info, 0, menu_setting_thumbnail_mode_max(), 1, true, true); (*list)[list_info->index - 1].ui_type = ST_UI_TYPE_UINT_RADIO_BUTTONS; CONFIG_UINT( @@ -15639,7 +15758,7 @@ static void settings_build_menu( general_read_handler); SETTINGS_ACTION_SET(ok, &(*list)[list_info->index - 1], &setting_action_ok_uint) SETTINGS_ACTION_SET(repr, &(*list)[list_info->index - 1], &setting_get_string_representation_uint_menu_thumbnails) - menu_settings_list_current_add_range(list, list_info, 0, PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 1, 1, true, true); + menu_settings_list_current_add_range(list, list_info, 0, menu_setting_thumbnail_mode_max(), 1, true, true); (*list)[list_info->index - 1].ui_type = ST_UI_TYPE_UINT_RADIO_BUTTONS; } @@ -15971,6 +16090,15 @@ static void settings_build_playlist( { ADD_DESC(pl_desc_4); } +#endif + + END_SUB_GROUP(list, list_info, parent_group); + +#ifdef HAVE_NETWORKING + START_SUB_GROUP(list, list_info, "ScreenScraper", &group_info, &subgroup_info, parent_group); + + ADD_DESC(screenscraper_desc); + #endif GROUP_END(); @@ -16747,6 +16875,9 @@ static void settings_build_user_accounts( #if !IOS ADD_DESC(user_accounts_desc_0_s1); #endif +#endif +#ifdef HAVE_NETWORKING + ADD_DESC(screenscraper_acct_desc); #endif GROUP_END(); } diff --git a/msg_hash.h b/msg_hash.h index e0a6a37a95c8..cef3169c58fc 100644 --- a/msg_hash.h +++ b/msg_hash.h @@ -585,6 +585,8 @@ enum msg_hash_enums MSG_SCANNING, MSG_SCANNING_OF_DIRECTORY_FINISHED, MSG_SCANNING_OF_FILE_FINISHED, + MSG_SCREENSCRAPER_QUOTA_PAUSED, + MSG_SCREENSCRAPER_QUOTA_RESUMED, MSG_SCANNING_NO_DATABASE, MSG_LOADED_STATE_FROM_SLOT, MSG_LOADED_STATE_FROM_SLOT_AUTO, @@ -18140,6 +18142,8 @@ enum msg_hash_enums #define S_ACTION_EX_H(T, n, sd, ok, rp, c, us, sub) MENU_LBL_H(T), #define S_ACTION_EX_NS_H(T, n, sd, ok, rp, c, us) MENU_LBL_H(T), #include "settings/settings_def_cheevos_account.h" +#include "settings/settings_def_screenscraper.h" +#include "settings/settings_def_screenscraper_account.h" #undef S_BOOL #undef S_BOOL_NS #undef S_UINT @@ -19217,6 +19221,7 @@ enum msg_hash_enums MENU_ENUM_LABEL_DEFERRED_CORE_OPTION_OVERRIDE_LIST, MENU_ENUM_LABEL_DEFERRED_USER_BINDS_LIST, MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST, + MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST, MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_TWITCH_LIST, MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_YOUTUBE_LIST, MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_FACEBOOK_LIST, @@ -29414,6 +29419,7 @@ enum msg_hash_enums MENU_ENUM_LABEL_VALUE_PLAYLIST_MANAGER_SORT_MODE_OFF, MENU_LABEL(PLAYLIST_MANAGER_CLEAN_PLAYLIST), + MENU_LABEL(PLAYLIST_MANAGER_SCREENSCRAPER), MSG_PLAYLIST_MANAGER_CLEANING_PLAYLIST, MSG_PLAYLIST_MANAGER_PLAYLIST_CLEANED, @@ -31362,6 +31368,10 @@ enum msg_hash_enums MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_SCREENSHOTS, MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_TITLE_SCREENS, MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_LOGOS, + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_BOXARTS_3D, + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_FANARTS, + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_MARQUEES, + MENU_ENUM_LABEL_VALUE_THUMBNAIL_MODE_VIDEOS, MENU_ENUM_LABEL_VALUE_RGUI_THUMB_SCALE_POINT, MENU_ENUM_LABEL_VALUE_RGUI_THUMB_SCALE_BILINEAR, diff --git a/msg_hash_lbl_str.h b/msg_hash_lbl_str.h index 250db5eeb8f2..74c6d55fc4b5 100644 --- a/msg_hash_lbl_str.h +++ b/msg_hash_lbl_str.h @@ -195,6 +195,7 @@ #define MENU_ENUM_LABEL_DEFERRED_BROWSE_URL_START_STR "deferred_browse_url_start" #define MENU_ENUM_LABEL_DEFERRED_BROWSE_URL_LIST_STR "deferred_browse_url_list" #define MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_CHEEVOS_LIST_STR "deferred_accounts_cheevos_list" +#define MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_SCREENSCRAPER_LIST_STR "deferred_accounts_screenscraper_list" #define MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_TWITCH_LIST_STR "deferred_accounts_twitch_list" #define MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_YOUTUBE_LIST_STR "deferred_accounts_youtube_list" #define MENU_ENUM_LABEL_DEFERRED_ACCOUNTS_FACEBOOK_LIST_STR "deferred_accounts_facebook_list" @@ -467,6 +468,7 @@ #define MENU_ENUM_LABEL_PLAYLIST_MANAGER_LEFT_THUMBNAIL_MODE_STR "playlist_manager_left_thumbnail_mode" #define MENU_ENUM_LABEL_PLAYLIST_MANAGER_SORT_MODE_STR "playlist_manager_sort_mode" #define MENU_ENUM_LABEL_PLAYLIST_MANAGER_CLEAN_PLAYLIST_STR "playlist_manager_clean_playlist" +#define MENU_ENUM_LABEL_PLAYLIST_MANAGER_SCREENSCRAPER_STR "playlist_manager_screenscraper" #define MENU_ENUM_LABEL_PLAYLIST_MANAGER_REFRESH_PLAYLIST_STR "playlist_manager_refresh_playlist" #define MENU_ENUM_LABEL_PLAYLIST_SETTINGS_BEGIN_STR "playlist_settings_begin" #define MENU_ENUM_LABEL_RDB_ENTRY_STR "rdb_entry" @@ -937,6 +939,7 @@ #define MENU_ENUM_LABEL_ACCOUNTS_KICK_STR "kick" #define MENU_ENUM_LABEL_ACCOUNTS_LIST_STR "accounts_list" #define MENU_ENUM_LABEL_ACCOUNTS_RETRO_ACHIEVEMENTS_STR "retro_achievements" +#define MENU_ENUM_LABEL_ACCOUNTS_SCREENSCRAPER_STR "accounts_screenscraper" #define MENU_ENUM_LABEL_ACCOUNTS_TWITCH_STR "twitch" #define MENU_ENUM_LABEL_ACCOUNTS_YOUTUBE_STR "youtube" #define MENU_ENUM_LABEL_ADD_CONTENT_LIST_STR "add_content" @@ -1085,6 +1088,8 @@ #define MENU_ENUM_LABEL_DUMP_DISC_STR "dump_disc" #define MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_STR "menu_dynamic_wallpaper_enable" #define MENU_ENUM_LABEL_DYNAMIC_WALLPAPERS_DIRECTORY_STR "dynamic_wallpapers_directory" +#define MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_MODE_STR "menu_dynamic_wallpaper_mode" +#define MENU_ENUM_LABEL_DYNAMIC_WALLPAPER_TYPE_STR "menu_dynamic_wallpaper_type" #define MENU_ENUM_LABEL_EJECT_DISC_STR "eject_disc" #define MENU_ENUM_LABEL_ENABLE_DEVICE_VIBRATION_STR "enable_device_vibration" #define MENU_ENUM_LABEL_FASTFORWARD_FRAMESKIP_STR "fastforward_frameskip" @@ -1395,6 +1400,7 @@ #define MENU_ENUM_LABEL_OZONE_HEADER_ICON_STR "ozone_header_icon" #define MENU_ENUM_LABEL_OZONE_HEADER_SEPARATOR_STR "ozone_header_separator" #define MENU_ENUM_LABEL_OZONE_MENU_COLOR_THEME_STR "ozone_menu_color_theme" +#define MENU_ENUM_LABEL_OZONE_METADATA_SCROLL_STYLE_STR "ozone_metadata_scroll_style" #define MENU_ENUM_LABEL_OZONE_PADDING_FACTOR_STR "ozone_padding_factor" #define MENU_ENUM_LABEL_OZONE_SCROLL_CONTENT_METADATA_STR "ozone_scroll_content_metadata" #define MENU_ENUM_LABEL_OZONE_SHOW_SIDEBAR_STR "ozone_show_sidebar" @@ -1490,6 +1496,27 @@ #define MENU_ENUM_LABEL_SAVING_SETTINGS_STR "saving_settings" #define MENU_ENUM_LABEL_SCAN_SERIAL_AND_CRC_STR "scan_serial_and_crc" #define MENU_ENUM_LABEL_SCAN_WITHOUT_CORE_MATCH_STR "scan_without_core_match" +#define MENU_ENUM_LABEL_SCREENSCRAPER_DEVID_STR "screenscraper_devid" +#define MENU_ENUM_LABEL_SCREENSCRAPER_DEVPASSWORD_STR "screenscraper_devpassword" +#define MENU_ENUM_LABEL_SCREENSCRAPER_LANGUAGE_STR "screenscraper_language" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BEZELS_STR "screenscraper_media_bezels" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BOXARTS_STR "screenscraper_media_boxarts" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_BOXARTS3D_STR "screenscraper_media_boxarts3d" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_FANARTS_STR "screenscraper_media_fanarts" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_LOGOS_STR "screenscraper_media_logos" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_MANUALS_STR "screenscraper_media_manuals" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_MARQUEES_STR "screenscraper_media_marquees" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_SNAPS_STR "screenscraper_media_snaps" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_TITLES_STR "screenscraper_media_titles" +#define MENU_ENUM_LABEL_SCREENSCRAPER_MEDIA_VIDEOS_STR "screenscraper_media_videos" +#define MENU_ENUM_LABEL_SCREENSCRAPER_METADATA_STR "screenscraper_metadata" +#define MENU_ENUM_LABEL_SCREENSCRAPER_OVERWRITE_STR "screenscraper_overwrite" +#define MENU_ENUM_LABEL_SCREENSCRAPER_PASSWORD_STR "screenscraper_password" +#define MENU_ENUM_LABEL_SCREENSCRAPER_PRIMARY_SCRAPER_STR "screenscraper_primary_scraper" +#define MENU_ENUM_LABEL_SCREENSCRAPER_QUOTA_ACTION_STR "screenscraper_quota_action" +#define MENU_ENUM_LABEL_SCREENSCRAPER_REGION_STR "screenscraper_region" +#define MENU_ENUM_LABEL_SCREENSCRAPER_USERNAME_STR "screenscraper_username" +#define MENU_ENUM_LABEL_SCREENSCRAPER_USE_CRC_STR "screenscraper_use_crc" #define MENU_ENUM_LABEL_SCREENSHOT_DIRECTORY_STR "screenshot_directory" #define MENU_ENUM_LABEL_SCREEN_ORIENTATION_STR "screen_orientation" #define MENU_ENUM_LABEL_SCREEN_RESOLUTION_STR "screen_resolution" @@ -1678,6 +1705,7 @@ #define MENU_ENUM_LABEL_XMB_MENU_COLOR_THEME_STR "xmb_menu_color_theme" #define MENU_ENUM_LABEL_XMB_RIBBON_ENABLE_STR "xmb_ribbon_enable" #define MENU_ENUM_LABEL_XMB_SHADOWS_ENABLE_STR "xmb_shadows_enable" +#define MENU_ENUM_LABEL_XMB_SHOW_METADATA_PANEL_STR "xmb_show_metadata_panel" #define MENU_ENUM_LABEL_XMB_SWITCH_ICONS_STR "xmb_switch_icons" #define MENU_ENUM_LABEL_XMB_THEME_STR "xmb_theme" #define MENU_ENUM_LABEL_XMB_VERTICAL_THUMBNAILS_STR "xmb_vertical_thumbnails" diff --git a/network/screenscraper.c b/network/screenscraper.c new file mode 100644 index 000000000000..72d1a1c09fec --- /dev/null +++ b/network/screenscraper.c @@ -0,0 +1,1106 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2026 - RetroArch + * + * RetroArch is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with RetroArch. If not, see . + */ + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "screenscraper.h" + +#include "../configuration.h" + +/* ---- Request allowance tracking ---- */ + +static size_t ss_append_param(char *s, size_t len, size_t _len, + const char *key, const char *value); + +static bool ss_quota_exhausted = false; + +bool screenscraper_quota_exhausted(void) +{ + return ss_quota_exhausted; +} + +void screenscraper_set_quota_exhausted(bool exhausted) +{ + ss_quota_exhausted = exhausted; +} + +bool screenscraper_status_is_quota(int status, + const char *data, size_t len) +{ + /* 429 is the documented "too many requests" status; the service + * also answers 430/431 for the per-day and per-minute ceilings, and + * some error paths return the explanation with a 400. */ + if (status == 429 || status == 430 || status == 431) + return true; + + if (data && len > 0) + { + char buf[512]; + size_t i; + size_t _len = (len < sizeof(buf) - 1) ? len : sizeof(buf) - 1; + memcpy(buf, data, _len); + buf[_len] = '\0'; + for (i = 0; i < _len; i++) + buf[i] = (char)tolower((unsigned char)buf[i]); + + if ( strstr(buf, "quota") + || strstr(buf, "maximum threads") + || strstr(buf, "closed for non-registered") + || strstr(buf, "requests per day") + || strstr(buf, "non membre")) + return true; + } + + return false; +} + +size_t screenscraper_build_user_info_url(char *s, size_t len, + const screenscraper_creds_t *creds) +{ + size_t _len; + + if ( !creds + || string_is_empty(creds->devid) + || string_is_empty(creds->devpassword)) + return 0; + + _len = strlcpy(s, SCREENSCRAPER_API_URL "/ssuserInfos.php?output=json", + len); + _len = ss_append_param(s, len, _len, "devid", creds->devid); + _len = ss_append_param(s, len, _len, "devpassword", creds->devpassword); + _len = ss_append_param(s, len, _len, "softname", SCREENSCRAPER_SOFTNAME); + _len = ss_append_param(s, len, _len, "ssid", creds->username); + _len = ss_append_param(s, len, _len, "sspassword", creds->password); + + return _len; +} + +/* ---- Region/language preference tables ---- */ + +typedef struct +{ + const char *code; + const char *name; +} ss_pref_entry_t; + +/* Keep in sync with SCREENSCRAPER_REGION_LAST in + * settings/settings_def_screenscraper.h */ +static const ss_pref_entry_t ss_regions[] = { + { "wor", "World" }, + { "us", "USA" }, + { "eu", "Europe" }, + { "jp", "Japan" }, + { "asi", "Asia" }, + { "au", "Australia" }, + { "br", "Brazil" }, + { "ca", "Canada" }, + { "cn", "China" }, + { "de", "Germany" }, + { "es", "Spain" }, + { "fr", "France" }, + { "it", "Italy" }, + { "kr", "Korea" }, + { "nl", "Netherlands" }, + { "pt", "Portugal" }, + { "ru", "Russia" }, + { "uk", "United Kingdom" }, +}; + +/* Keep in sync with SCREENSCRAPER_LANGUAGE_LAST in + * settings/settings_def_screenscraper.h */ +static const ss_pref_entry_t ss_languages[] = { + { "en", "English" }, + { "fr", "French" }, + { "de", "German" }, + { "es", "Spanish" }, + { "it", "Italian" }, + { "pt", "Portuguese" }, + { "nl", "Dutch" }, + { "ja", "Japanese" }, + { "ko", "Korean" }, + { "ru", "Russian" }, + { "zh", "Chinese" }, + { "pl", "Polish" }, + { "sv", "Swedish" }, + { "no", "Norwegian" }, + { "da", "Danish" }, + { "fi", "Finnish" }, +}; + +const char *screenscraper_region_code(unsigned idx) +{ + if (idx < ARRAY_SIZE(ss_regions)) + return ss_regions[idx].code; + return ss_regions[0].code; +} + +const char *screenscraper_region_name(unsigned idx) +{ + if (idx < ARRAY_SIZE(ss_regions)) + return ss_regions[idx].name; + return ss_regions[0].name; +} + +const char *screenscraper_language_code(unsigned idx) +{ + if (idx < ARRAY_SIZE(ss_languages)) + return ss_languages[idx].code; + return ss_languages[0].code; +} + +const char *screenscraper_language_name(unsigned idx) +{ + if (idx < ARRAY_SIZE(ss_languages)) + return ss_languages[idx].name; + return ss_languages[0].name; +} + +/* ---- libretro database name -> ScreenScraper systemeid ---- */ + +typedef struct +{ + const char *db_name; + unsigned id; +} ss_system_map_t; + +/* Well-known ScreenScraper platform identifiers, keyed by libretro + * database (playlist) names. Extend freely; order is irrelevant. */ +static const ss_system_map_t ss_system_map[] = { + { "Nintendo - Nintendo Entertainment System", 3 }, + { "Nintendo - Family Computer Disk System", 106 }, + { "Nintendo - Super Nintendo Entertainment System", 4 }, + { "Nintendo - Nintendo 64", 14 }, + { "Nintendo - GameCube", 13 }, + { "Nintendo - Wii", 16 }, + { "Nintendo - Wii U", 18 }, + { "Nintendo - Game Boy", 9 }, + { "Nintendo - Game Boy Color", 10 }, + { "Nintendo - Game Boy Advance", 12 }, + { "Nintendo - Nintendo DS", 15 }, + { "Nintendo - Nintendo 3DS", 17 }, + { "Nintendo - Virtual Boy", 11 }, + { "Nintendo - Pokemon Mini", 211 }, + { "Sega - SG-1000", 109 }, + { "Sega - Master System - Mark III", 2 }, + { "Sega - Mega Drive - Genesis", 1 }, + { "Sega - 32X", 19 }, + { "Sega - Mega-CD - Sega CD", 20 }, + { "Sega - Game Gear", 21 }, + { "Sega - Saturn", 22 }, + { "Sega - Dreamcast", 23 }, + { "Sony - PlayStation", 57 }, + { "Sony - PlayStation 2", 58 }, + { "Sony - PlayStation 3", 59 }, + { "Sony - PlayStation Portable", 61 }, + { "Sony - PlayStation Vita", 62 }, + { "NEC - PC Engine - TurboGrafx 16", 31 }, + { "NEC - PC Engine SuperGrafx", 105 }, + { "NEC - PC Engine CD - TurboGrafx-CD", 114 }, + { "NEC - PC-FX", 72 }, + { "SNK - Neo Geo", 142 }, + { "SNK - Neo Geo CD", 70 }, + { "SNK - Neo Geo Pocket", 25 }, + { "SNK - Neo Geo Pocket Color", 82 }, + { "Atari - 2600", 26 }, + { "Atari - 5200", 40 }, + { "Atari - 7800", 41 }, + { "Atari - Jaguar", 27 }, + { "Atari - Lynx", 28 }, + { "Atari - ST", 42 }, + { "Bandai - WonderSwan", 45 }, + { "Bandai - WonderSwan Color", 46 }, + { "Coleco - ColecoVision", 48 }, + { "Mattel - Intellivision", 115 }, + { "Magnavox - Odyssey2", 104 }, + { "Microsoft - MSX", 113 }, + { "Microsoft - MSX2", 116 }, + { "Commodore - 64", 66 }, + { "Commodore - Amiga", 64 }, + { "Amstrad - CPC", 65 }, + { "Sinclair - ZX Spectrum", 76 }, + { "GCE - Vectrex", 102 }, + { "The 3DO Company - 3DO", 29 }, + { "MAME", 75 }, + { "FBNeo - Arcade Games", 75 }, + { "DOS", 135 }, + { "ScummVM", 123 }, +}; + +unsigned screenscraper_system_id(const char *db_name) +{ + char name[NAME_MAX_LENGTH]; + size_t i; + char *ext = NULL; + + if (string_is_empty(db_name)) + return 0; + + /* Strip a trailing ".lpl", if present */ + strlcpy(name, db_name, sizeof(name)); + if ((ext = strrchr(name, '.')) && string_is_equal(ext, ".lpl")) + *ext = '\0'; + + for (i = 0; i < ARRAY_SIZE(ss_system_map); i++) + { + if (string_is_equal(name, ss_system_map[i].db_name)) + return ss_system_map[i].id; + } + + return 0; +} + +/* ---- Request building ---- */ + +static size_t ss_append_param(char *s, size_t len, size_t _len, + const char *key, const char *value) +{ + char *encoded = NULL; + + if (string_is_empty(value)) + return _len; + + net_http_urlencode(&encoded, value); + if (!encoded) + return _len; + + _len += snprintf(s + _len, len - _len, "&%s=%s", key, encoded); + free(encoded); + return _len; +} + +size_t screenscraper_build_game_info_url(char *s, size_t len, + const screenscraper_creds_t *creds, + unsigned system_id, const char *crc32, + const char *rom_name, int64_t rom_size) +{ + size_t _len; + + if ( !creds + || string_is_empty(creds->devid) + || string_is_empty(creds->devpassword)) + return 0; + + _len = strlcpy(s, SCREENSCRAPER_API_URL "/jeuInfos.php?output=json", + len); + _len = ss_append_param(s, len, _len, "devid", creds->devid); + _len = ss_append_param(s, len, _len, "devpassword", creds->devpassword); + _len = ss_append_param(s, len, _len, "softname", SCREENSCRAPER_SOFTNAME); + _len = ss_append_param(s, len, _len, "ssid", creds->username); + _len = ss_append_param(s, len, _len, "sspassword", creds->password); + + if (system_id > 0) + _len += snprintf(s + _len, len - _len, "&systemeid=%u", system_id); + + _len += snprintf(s + _len, len - _len, "&romtype=rom"); + _len = ss_append_param(s, len, _len, "crc", crc32); + _len = ss_append_param(s, len, _len, "romnom", rom_name); + + if (rom_size > 0) + _len += snprintf(s + _len, len - _len, "&romtaille=%lld", + (long long)rom_size); + + return _len; +} + +/* ---- Response parsing ---- */ + +/* Region preference chain used when the configured region misses */ +static const char *ss_region_fallback[] = { + "wor", "us", "eu", "jp", "ss", "uk", "fr" +}; + +static int ss_region_score(const char *region, const char *preferred) +{ + size_t i; + if (string_is_empty(region)) + return (int)ARRAY_SIZE(ss_region_fallback) + 2; + if (!string_is_empty(preferred) && string_is_equal(region, preferred)) + return 0; + for (i = 0; i < ARRAY_SIZE(ss_region_fallback); i++) + { + if (string_is_equal(region, ss_region_fallback[i])) + return (int)i + 1; + } + return (int)ARRAY_SIZE(ss_region_fallback) + 1; +} + +static int ss_language_score(const char *lang, const char *preferred) +{ + if (string_is_empty(lang)) + return 3; + if (!string_is_empty(preferred) && string_is_equal(lang, preferred)) + return 0; + if (string_is_equal(lang, "en")) + return 1; + return 2; +} + +#define SS_MAX_DEPTH 12 +#define SS_WORST_SCORE 0x7fffffff + +typedef struct +{ + rjson_t *json; + const screenscraper_creds_t *creds; + screenscraper_game_t *game; + + /* Names of the containers enclosing the current position; + * stack[0] is the outermost object/array. */ + char stack[SS_MAX_DEPTH][20]; + unsigned depth; + /* Member name seen last at the current level; becomes the name of + * the next container that opens. */ + char pending_key[20]; + + /* Scores of the currently selected regional/language variants */ + int name_score; + int synopsis_score; + int date_score; + + /* Accumulator for the current array item (nom/date/media/...) */ + char item_region[8]; + char item_lang[8]; + char item_type[32]; + char item_parent[16]; + char *item_text; + char *item_url; + char *item_format; + + /* Genre handling: per-genre best language variant */ + int genre_lang_score; + char *genre_best; +} ss_parse_ctx_t; + +static void ss_set_str(char **dst, const char *src, size_t len) +{ + if (*dst) + free(*dst); + if (!(*dst = (char*)malloc(len + 1))) + return; + memcpy(*dst, src, len); + (*dst)[len] = '\0'; +} + +static void ss_replace_str(char **dst, char **src) +{ + if (*dst) + free(*dst); + *dst = *src; + *src = NULL; +} + +static void ss_reset_item(ss_parse_ctx_t *ctx) +{ + ctx->item_region[0] = '\0'; + ctx->item_lang[0] = '\0'; + ctx->item_type[0] = '\0'; + ctx->item_parent[0] = '\0'; + if (ctx->item_text) + { + free(ctx->item_text); + ctx->item_text = NULL; + } + if (ctx->item_url) + { + free(ctx->item_url); + ctx->item_url = NULL; + } + if (ctx->item_format) + { + free(ctx->item_format); + ctx->item_format = NULL; + } +} + +/* True when the enclosing containers, innermost last, match the + * argument list (a NULL matches any name, e.g. array items). */ +static bool ss_in(ss_parse_ctx_t *ctx, unsigned n, ...) +{ + va_list ap; + unsigned i; + bool match = true; + + if (ctx->depth < n) + return false; + + va_start(ap, n); + for (i = 0; i < n; i++) + { + const char *want = va_arg(ap, const char*); + const char *have = ctx->stack[ctx->depth - n + i]; + if (want && !string_is_equal(want, have)) + { + match = false; + break; + } + } + va_end(ap); + return match; +} + +static bool ss_handle_member(void *context, const char *s, size_t len) +{ + ss_parse_ctx_t *ctx = (ss_parse_ctx_t*)context; + size_t _len = (len < sizeof(ctx->pending_key) - 1) + ? len : sizeof(ctx->pending_key) - 1; + memcpy(ctx->pending_key, s, _len); + ctx->pending_key[_len] = '\0'; + return true; +} + +static bool ss_handle_start(void *context) +{ + ss_parse_ctx_t *ctx = (ss_parse_ctx_t*)context; + + if (ctx->depth < SS_MAX_DEPTH) + strlcpy(ctx->stack[ctx->depth], ctx->pending_key, + sizeof(ctx->stack[0])); + ctx->pending_key[0] = '\0'; + ctx->depth++; + + /* Array items inherit the array's name, so paths read + * "jeu > noms > noms" for each item of the noms array. */ + if ( ctx->depth >= 2 && ctx->depth <= SS_MAX_DEPTH + && ctx->stack[ctx->depth - 1][0] == '\0') + memmove(ctx->stack[ctx->depth - 1], ctx->stack[ctx->depth - 2], + sizeof(ctx->stack[0])); + + /* A fresh item of one of the collected arrays begins */ + if ( ss_in(ctx, 2, "noms", "noms") + || ss_in(ctx, 2, "synopsis", "synopsis") + || ss_in(ctx, 2, "dates", "dates") + || ss_in(ctx, 2, "medias", "medias") + || ss_in(ctx, 2, "classifications", "classifications")) + ss_reset_item(ctx); + else if (ss_in(ctx, 2, "genres", "genres")) + { + ctx->genre_lang_score = SS_WORST_SCORE; + if (ctx->genre_best) + { + free(ctx->genre_best); + ctx->genre_best = NULL; + } + } + + return true; +} + +static void ss_commit_item(ss_parse_ctx_t *ctx) +{ + screenscraper_game_t *game = ctx->game; + + /* jeu > noms item: pick the best-region name */ + if (ss_in(ctx, 3, "jeu", "noms", "noms")) + { + int score = ss_region_score(ctx->item_region, ctx->creds->region); + if (ctx->item_text && score < ctx->name_score) + { + ctx->name_score = score; + ss_replace_str(&game->name, &ctx->item_text); + } + } + /* jeu > synopsis item: pick the best-language synopsis */ + else if (ss_in(ctx, 3, "jeu", "synopsis", "synopsis")) + { + int score = ss_language_score(ctx->item_lang, ctx->creds->language); + if (ctx->item_text && score < ctx->synopsis_score) + { + ctx->synopsis_score = score; + ss_replace_str(&game->synopsis, &ctx->item_text); + } + } + /* jeu > dates item: pick the best-region release date */ + else if (ss_in(ctx, 3, "jeu", "dates", "dates")) + { + int score = ss_region_score(ctx->item_region, ctx->creds->region); + if (ctx->item_text && score < ctx->date_score) + { + ctx->date_score = score; + ss_replace_str(&game->release_date, &ctx->item_text); + } + } + /* jeu > classifications item */ + else if (ss_in(ctx, 3, "jeu", "classifications", "classifications")) + { + if (ctx->item_text) + { + if (string_starts_with(ctx->item_type, "ESRB")) + ss_replace_str(&game->esrb, &ctx->item_text); + else if (string_starts_with(ctx->item_type, "PEGI")) + ss_replace_str(&game->pegi, &ctx->item_text); + } + } + /* jeu > medias item */ + else if (ss_in(ctx, 3, "jeu", "medias", "medias")) + { + /* Only game-level media; skip system/company artwork */ + if ( ctx->item_url && ctx->item_type[0] + && ( ctx->item_parent[0] == '\0' + || string_is_equal(ctx->item_parent, "jeu"))) + { + screenscraper_media_t *tmp = (screenscraper_media_t*)realloc( + game->media, + (game->media_count + 1) * sizeof(screenscraper_media_t)); + if (tmp) + { + screenscraper_media_t *m = &tmp[game->media_count]; + game->media = tmp; + m->url = ctx->item_url; + m->format = ctx->item_format; + ctx->item_url = NULL; + ctx->item_format = NULL; + strlcpy(m->type, ctx->item_type, sizeof(m->type)); + strlcpy(m->region, ctx->item_region, sizeof(m->region)); + game->media_count++; + } + } + } + /* jeu > genres item: append the best-language genre name */ + else if (ss_in(ctx, 3, "jeu", "genres", "genres")) + { + if (ctx->genre_best) + { + if (!game->genre) + { + game->genre = ctx->genre_best; + ctx->genre_best = NULL; + } + else + { + size_t old_len = strlen(game->genre); + size_t add_len = strlen(ctx->genre_best); + char *joined = (char*)malloc(old_len + 2 + add_len + 1); + if (joined) + { + memcpy(joined, game->genre, old_len); + memcpy(joined + old_len, ", ", 2); + memcpy(joined + old_len + 2, ctx->genre_best, add_len + 1); + free(game->genre); + game->genre = joined; + } + free(ctx->genre_best); + ctx->genre_best = NULL; + } + } + } +} + +static bool ss_handle_end(void *context) +{ + ss_parse_ctx_t *ctx = (ss_parse_ctx_t*)context; + + ss_commit_item(ctx); + + if (ctx->depth > 0) + { + ctx->depth--; + if (ctx->depth < SS_MAX_DEPTH) + ctx->stack[ctx->depth][0] = '\0'; + } + ctx->pending_key[0] = '\0'; + return true; +} + +static bool ss_handle_string(void *context, const char *s, size_t len) +{ + ss_parse_ctx_t *ctx = (ss_parse_ctx_t*)context; + const char *key = ctx->pending_key; + screenscraper_game_t *game = ctx->game; + + /* Regional/language array items (noms, synopsis, dates, + * classifications, medias) */ + if ( ss_in(ctx, 2, "noms", "noms") + || ss_in(ctx, 2, "synopsis", "synopsis") + || ss_in(ctx, 2, "dates", "dates") + || ss_in(ctx, 2, "classifications", "classifications") + || ss_in(ctx, 2, "medias", "medias")) + { + if (string_is_equal(key, "region")) + { + size_t _len = (len < sizeof(ctx->item_region) - 1) + ? len : sizeof(ctx->item_region) - 1; + memcpy(ctx->item_region, s, _len); + ctx->item_region[_len] = '\0'; + } + else if (string_is_equal(key, "langue")) + { + size_t _len = (len < sizeof(ctx->item_lang) - 1) + ? len : sizeof(ctx->item_lang) - 1; + memcpy(ctx->item_lang, s, _len); + ctx->item_lang[_len] = '\0'; + } + else if (string_is_equal(key, "type")) + { + size_t _len = (len < sizeof(ctx->item_type) - 1) + ? len : sizeof(ctx->item_type) - 1; + memcpy(ctx->item_type, s, _len); + ctx->item_type[_len] = '\0'; + } + else if (string_is_equal(key, "parent")) + { + size_t _len = (len < sizeof(ctx->item_parent) - 1) + ? len : sizeof(ctx->item_parent) - 1; + memcpy(ctx->item_parent, s, _len); + ctx->item_parent[_len] = '\0'; + } + else if (string_is_equal(key, "text")) + ss_set_str(&ctx->item_text, s, len); + else if (string_is_equal(key, "url")) + ss_set_str(&ctx->item_url, s, len); + else if (string_is_equal(key, "format")) + ss_set_str(&ctx->item_format, s, len); + } + /* jeu > genres > item > noms > item */ + else if (ss_in(ctx, 2, "genres", "noms")) + { + if (string_is_equal(key, "langue")) + { + size_t _len = (len < sizeof(ctx->item_lang) - 1) + ? len : sizeof(ctx->item_lang) - 1; + memcpy(ctx->item_lang, s, _len); + ctx->item_lang[_len] = '\0'; + } + else if (string_is_equal(key, "text")) + { + int score = ss_language_score(ctx->item_lang, + ctx->creds->language); + if (score < ctx->genre_lang_score) + { + ctx->genre_lang_score = score; + ss_set_str(&ctx->genre_best, s, len); + } + } + } + /* Scalar members of jeu sub-objects */ + else if (ss_in(ctx, 2, "jeu", "editeur")) + { + if (string_is_equal(key, "text")) + ss_set_str(&game->publisher, s, len); + } + else if (ss_in(ctx, 2, "jeu", "developpeur")) + { + if (string_is_equal(key, "text")) + ss_set_str(&game->developer, s, len); + } + else if (ss_in(ctx, 2, "jeu", "joueurs")) + { + if (string_is_equal(key, "text")) + ss_set_str(&game->players, s, len); + } + else if (ss_in(ctx, 2, "jeu", "note")) + { + if (string_is_equal(key, "text")) + ss_set_str(&game->rating, s, len); + } + + ctx->pending_key[0] = '\0'; + return true; +} + +static bool ss_handle_scalar(void *context) +{ + ss_parse_ctx_t *ctx = (ss_parse_ctx_t*)context; + ctx->pending_key[0] = '\0'; + return true; +} + +static bool ss_handle_number(void *context, const char *s, size_t len) +{ + return ss_handle_scalar(context); +} + +static bool ss_handle_bool(void *context, bool value) +{ + return ss_handle_scalar(context); +} + +static bool ss_handle_null(void *context) +{ + return ss_handle_scalar(context); +} + +void screenscraper_game_free(screenscraper_game_t *game) +{ + size_t i; + + if (!game) + return; + + free(game->name); + free(game->synopsis); + free(game->genre); + free(game->developer); + free(game->publisher); + free(game->players); + free(game->rating); + free(game->release_date); + free(game->esrb); + free(game->pegi); + + for (i = 0; i < game->media_count; i++) + { + free(game->media[i].url); + free(game->media[i].format); + } + free(game->media); + free(game); +} + +screenscraper_game_t *screenscraper_parse_game_info( + const char *data, size_t len, + const screenscraper_creds_t *creds) +{ + ss_parse_ctx_t ctx; + enum rjson_type end; + rjson_t *json = NULL; + screenscraper_game_t *game = NULL; + + if (!data || len == 0 || !creds) + return NULL; + + if (!(json = rjson_open_buffer(data, len))) + return NULL; + + if (!(game = (screenscraper_game_t*)calloc(1, sizeof(*game)))) + { + rjson_free(json); + return NULL; + } + + memset(&ctx, 0, sizeof(ctx)); + ctx.json = json; + ctx.creds = creds; + ctx.game = game; + ctx.name_score = SS_WORST_SCORE; + ctx.synopsis_score = SS_WORST_SCORE; + ctx.date_score = SS_WORST_SCORE; + + rjson_set_options(json, + RJSON_OPTION_ALLOW_UTF8BOM + | RJSON_OPTION_ALLOW_TRAILING_DATA + | RJSON_OPTION_REPLACE_INVALID_ENCODING); + + end = rjson_parse(json, &ctx, + ss_handle_member, + ss_handle_string, + ss_handle_number, + ss_handle_start, /* object start */ + ss_handle_end, /* object end */ + ss_handle_start, /* array start */ + ss_handle_end, /* array end */ + ss_handle_bool, + ss_handle_null); + + rjson_free(json); + ss_reset_item(&ctx); + if (ctx.genre_best) + free(ctx.genre_best); + + if (end != RJSON_DONE) + { + screenscraper_game_free(game); + return NULL; + } + + /* An entry without any usable content means the lookup missed */ + if (!game->name && game->media_count == 0) + { + screenscraper_game_free(game); + return NULL; + } + + return game; +} + +/* ---- Account allowance parsing ---- */ + +typedef struct +{ + char key[32]; + screenscraper_quota_t *quota; + unsigned depth; + bool in_user; +} ss_user_ctx_t; + +static bool ss_user_member(void *context, const char *s, size_t len) +{ + ss_user_ctx_t *ctx = (ss_user_ctx_t*)context; + size_t _len = (len < sizeof(ctx->key) - 1) ? len : sizeof(ctx->key) - 1; + memcpy(ctx->key, s, _len); + ctx->key[_len] = '\0'; + /* The account block is the object introduced by this member */ + if (string_is_equal(ctx->key, "ssuser")) + ctx->in_user = true; + return true; +} + +static bool ss_user_value(void *context, const char *s, size_t len) +{ + ss_user_ctx_t *ctx = (ss_user_ctx_t*)context; + + if (ctx->in_user) + { + char val[32]; + size_t _len = (len < sizeof(val) - 1) ? len : sizeof(val) - 1; + memcpy(val, s, _len); + val[_len] = '\0'; + + if (string_is_equal(ctx->key, "requeststoday")) + { + ctx->quota->used = (unsigned)strtoul(val, NULL, 10); + ctx->quota->valid = true; + } + else if (string_is_equal(ctx->key, "maxrequestsperday")) + { + ctx->quota->max = (unsigned)strtoul(val, NULL, 10); + ctx->quota->valid = true; + } + } + + ctx->key[0] = '\0'; + return true; +} + +static bool ss_user_start(void *context) +{ + ss_user_ctx_t *ctx = (ss_user_ctx_t*)context; + ctx->depth++; + return true; +} + +static bool ss_user_end(void *context) +{ + ss_user_ctx_t *ctx = (ss_user_ctx_t*)context; + if (ctx->depth > 0) + ctx->depth--; + /* Leaving the account object */ + if (ctx->in_user && ctx->depth <= 2) + ctx->in_user = false; + return true; +} + +static bool ss_user_bool(void *context, bool value) +{ + ((ss_user_ctx_t*)context)->key[0] = '\0'; + return true; +} + +static bool ss_user_null(void *context) +{ + ((ss_user_ctx_t*)context)->key[0] = '\0'; + return true; +} + +bool screenscraper_parse_user_info(const char *data, size_t len, + screenscraper_quota_t *quota) +{ + ss_user_ctx_t ctx; + rjson_t *json = NULL; + enum rjson_type end; + + if (!data || len == 0 || !quota) + return false; + + memset(&ctx, 0, sizeof(ctx)); + memset(quota, 0, sizeof(*quota)); + ctx.quota = quota; + + if (!(json = rjson_open_buffer(data, len))) + return false; + + rjson_set_options(json, + RJSON_OPTION_ALLOW_UTF8BOM + | RJSON_OPTION_ALLOW_TRAILING_DATA + | RJSON_OPTION_REPLACE_INVALID_ENCODING); + + end = rjson_parse(json, &ctx, + ss_user_member, + ss_user_value, + ss_user_value, /* numbers arrive as text too */ + ss_user_start, ss_user_end, + ss_user_start, ss_user_end, + ss_user_bool, ss_user_null); + + rjson_free(json); + + return (end == RJSON_DONE) && quota->valid; +} + +/* ---- Media selection ---- */ + +typedef struct +{ + const char *primary; + const char *secondary; +} ss_media_names_t; + +static const ss_media_names_t ss_media_names[SS_MEDIA_LAST] = { + { "box-2D", NULL }, /* SS_MEDIA_BOXART */ + { "ss", NULL }, /* SS_MEDIA_SNAP */ + { "sstitle", NULL }, /* SS_MEDIA_TITLE */ + { "wheel", "wheel-hd" }, /* SS_MEDIA_LOGO */ + { "box-3D", NULL }, /* SS_MEDIA_BOXART_3D */ + { "fanart", NULL }, /* SS_MEDIA_FANART */ + { "screenmarquee", "marquee" }, /* SS_MEDIA_MARQUEE */ + { "video-normalized", "video" }, /* SS_MEDIA_VIDEO */ + { "manuel", NULL }, /* SS_MEDIA_MANUAL */ + { "bezel-16-9", NULL }, /* SS_MEDIA_BEZEL */ +}; + +static const char *ss_media_sub_dirs[SS_MEDIA_LAST] = { + "Named_Boxarts", + "Named_Snaps", + "Named_Titles", + "Named_Logos", + "Named_Boxarts3D", + "Named_Fanarts", + "Named_Marquees", + "Named_Videos", + "Named_Manuals", + "Named_Bezels", +}; + +static const char *ss_media_default_exts[SS_MEDIA_LAST] = { + "png", "png", "png", "png", "png", "png", "png", "mp4", "pdf", "png" +}; + +const char *screenscraper_media_sub_dir(enum screenscraper_media_kind kind) +{ + if (kind < SS_MEDIA_LAST) + return ss_media_sub_dirs[kind]; + return NULL; +} + +const char *screenscraper_media_default_ext( + enum screenscraper_media_kind kind) +{ + if (kind < SS_MEDIA_LAST) + return ss_media_default_exts[kind]; + return "png"; +} + +const screenscraper_media_t *screenscraper_select_media( + const screenscraper_game_t *game, + enum screenscraper_media_kind kind, + const char *preferred_region) +{ + size_t i; + const screenscraper_media_t *best = NULL; + int best_score = SS_WORST_SCORE; + + if (!game || kind >= SS_MEDIA_LAST) + return NULL; + + for (i = 0; i < game->media_count; i++) + { + const screenscraper_media_t *m = &game->media[i]; + int score; + int type_penalty = 0; + + if (string_is_equal(m->type, ss_media_names[kind].primary)) + type_penalty = 0; + else if ( ss_media_names[kind].secondary + && string_is_equal(m->type, + ss_media_names[kind].secondary)) + type_penalty = 100; + else + continue; + + score = type_penalty + ss_region_score(m->region, preferred_region); + if (score < best_score) + { + best_score = score; + best = m; + } + } + + return best; +} + +/* ---- Metadata sidecar ---- */ + +static void ss_json_pair(rjsonwriter_t *writer, + const char *key, const char *value) +{ + if (string_is_empty(value)) + return; + rjsonwriter_add_string(writer, key); + rjsonwriter_raw(writer, ":", 1); + rjsonwriter_add_string(writer, value); + rjsonwriter_raw(writer, ",", 1); + rjsonwriter_raw(writer, "\n", 1); +} + +char *screenscraper_metadata_to_json(const screenscraper_game_t *game) +{ + rjsonwriter_t *writer = NULL; + const char *buf = NULL; + char *result = NULL; + int len = 0; + + if (!game) + return NULL; + + if (!(writer = rjsonwriter_open_memory())) + return NULL; + + rjsonwriter_raw(writer, "{", 1); + rjsonwriter_raw(writer, "\n", 1); + ss_json_pair(writer, "name", game->name); + ss_json_pair(writer, "description", game->synopsis); + ss_json_pair(writer, "genre", game->genre); + ss_json_pair(writer, "developer", game->developer); + ss_json_pair(writer, "publisher", game->publisher); + ss_json_pair(writer, "players", game->players); + ss_json_pair(writer, "rating", game->rating); + ss_json_pair(writer, "releasedate", game->release_date); + ss_json_pair(writer, "esrb", game->esrb); + ss_json_pair(writer, "pegi", game->pegi); + /* Source marker doubles as the always-present final member, + * keeping the object free of trailing commas. */ + rjsonwriter_add_string(writer, "source"); + rjsonwriter_raw(writer, ":", 1); + rjsonwriter_add_string(writer, "screenscraper"); + rjsonwriter_raw(writer, "\n", 1); + rjsonwriter_raw(writer, "}", 1); + rjsonwriter_raw(writer, "\n", 1); + + buf = rjsonwriter_get_memory_buffer(writer, &len); + if (buf && len > 0) + { + if ((result = (char*)malloc((size_t)len + 1))) + { + memcpy(result, buf, (size_t)len); + result[len] = '\0'; + } + } + + rjsonwriter_free(writer); + return result; +} diff --git a/network/screenscraper.h b/network/screenscraper.h new file mode 100644 index 000000000000..b92bff817383 --- /dev/null +++ b/network/screenscraper.h @@ -0,0 +1,240 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2026 - RetroArch + * + * RetroArch is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with RetroArch. If not, see . + */ + +#ifndef __RARCH_SCREENSCRAPER_H +#define __RARCH_SCREENSCRAPER_H + +#include +#include +#include +#include + +RETRO_BEGIN_DECLS + +/* This header is safe to include unconditionally, but the declarations + * below come from two translation units with different availability: + * + * network/screenscraper_meta.c - always compiled. The metadata + * sidecar reader (screenscraper_meta_t, screenscraper_metadata_*, + * screenscraper_rating_stars) plus screenscraper_signed_in(). + * Local files only, so menus can show scraped facts on a target + * built without networking. + * + * network/screenscraper.c - HAVE_NETWORKING only. Request + * building, jeuInfos/ssuserInfos parsing, media selection and the + * daily allowance. Everything here needs the network. + * + * Calling anything from the second group outside #ifdef HAVE_NETWORKING + * will link-fail on a no-networking build. The groups are marked below. + */ + +#define SCREENSCRAPER_API_URL "https://api.screenscraper.fr/api2" +#define SCREENSCRAPER_SOFTNAME "RetroArch" + +/* Media kinds RetroArch knows how to place into the thumbnails tree. + * The first four map onto existing thumbnail types; the rest are new + * asset directories populated exclusively by the scraper. */ +enum screenscraper_media_kind +{ + SS_MEDIA_BOXART = 0, /* box-2D -> Named_Boxarts */ + SS_MEDIA_SNAP, /* ss -> Named_Snaps */ + SS_MEDIA_TITLE, /* sstitle -> Named_Titles */ + SS_MEDIA_LOGO, /* wheel -> Named_Logos */ + SS_MEDIA_BOXART_3D, /* box-3D -> Named_Boxarts3D */ + SS_MEDIA_FANART, /* fanart -> Named_Fanarts */ + SS_MEDIA_MARQUEE, /* screenmarquee -> Named_Marquees */ + SS_MEDIA_VIDEO, /* video-normalized -> Named_Videos */ + SS_MEDIA_MANUAL, /* manuel -> Named_Manuals */ + SS_MEDIA_BEZEL, /* bezel-16-9 -> Named_Bezels */ + SS_MEDIA_LAST +}; + +/* One downloadable media entry parsed from a jeuInfos response */ +typedef struct +{ + char *url; + char *format; /* "png", "mp4", "pdf", ... */ + char type[32]; /* raw ScreenScraper media type name */ + char region[8]; +} screenscraper_media_t; + +/* Game description parsed from a jeuInfos response. Regional/language + * variants are already resolved against the caller's preferences. */ +typedef struct +{ + char *name; + char *synopsis; + char *genre; + char *developer; + char *publisher; + char *players; + char *rating; /* ScreenScraper note, "x/20" scale */ + char *release_date; + char *esrb; + char *pegi; + screenscraper_media_t *media; + size_t media_count; +} screenscraper_game_t; + +/* Credentials/preferences for building API requests */ +typedef struct +{ + const char *devid; + const char *devpassword; + const char *username; /* ssid; optional */ + const char *password; /* sspassword; optional */ + const char *region; /* preferred media region, e.g. "us" */ + const char *language; /* preferred text language, e.g. "en"*/ +} screenscraper_creds_t; + +/* True when a complete set of ScreenScraper credentials is configured + * (account username/password and developer id/password). While signed + * in, ScreenScraper acts as the primary playlist scraper. */ +bool screenscraper_signed_in(void); + +/* Metadata sidecar written next to the scraped media. Read back by + * the menus so scraped facts can stand in front of the offline + * database, which has no entries at all for a lot of content. */ +typedef struct +{ + char name[NAME_MAX_LENGTH]; + char description[4096]; + char genre[NAME_MAX_LENGTH]; + char developer[NAME_MAX_LENGTH]; + char publisher[NAME_MAX_LENGTH]; + char players[32]; + char rating[32]; + char releasedate[32]; + char esrb[64]; + char pegi[64]; + bool valid; +} screenscraper_meta_t; + +/* Builds the sidecar path for a playlist entry: + * //Metadata/.json */ +size_t screenscraper_metadata_path(char *s, size_t len, + const char *dir_thumbnails, const char *db_name, + const char *img_name); + +/* Loads and parses a metadata sidecar. Returns false when absent or + * unreadable; 'meta' is always zeroed first. */ +bool screenscraper_metadata_load(const char *path, + screenscraper_meta_t *meta); + +/* Convenience: path + load in one step. */ +bool screenscraper_metadata_load_entry(const char *dir_thumbnails, + const char *db_name, const char *img_name, + screenscraper_meta_t *meta); + +/* True when the menus should offer the ScreenScraper-only thumbnail + * types (3D boxart, fan art, marquee, video snap): either an account + * is configured, or the thumbnail tree already holds media of those + * kinds. Scraped media stays usable after signing out, and on a tree + * copied over from another machine - including on a build with no + * networking at all, where nobody can ever be "signed in". + * Walks the thumbnail directory, so call it when building a menu + * list, not per frame. */ +bool screenscraper_extended_media_allowed(void); + +/* Converts a scraped rating (ScreenScraper's native 0-20 scale, held + * as a string) into a count of filled stars out of five, rounded to + * the nearest star with halves rounding up. Returns -1 when the entry + * carries no usable rating, so callers can tell "unrated" apart from + * a genuine zero-star rating and draw no star row at all. */ +int screenscraper_rating_stars(const char *rating); + +/* Daily request allowance reported by the service alongside every + * lookup. 'valid' is false when the response carried no user block. */ +typedef struct +{ + unsigned used; + unsigned max; + bool valid; +} screenscraper_quota_t; + +/* Process-wide "the daily allowance is spent" latch. While set, the + * scraper stops issuing lookups; it clears on the next calendar day or + * when a fresh account query reports headroom again. */ +bool screenscraper_quota_exhausted(void); +void screenscraper_set_quota_exhausted(bool exhausted); + +/* True when the HTTP status/response body denotes a spent allowance + * rather than an ordinary lookup failure. */ +bool screenscraper_status_is_quota(int status, + const char *data, size_t len); + +/* Builds a urlencoded ssuserInfos.php request (account status and + * remaining allowance). Returns length, 0 on failure. */ +size_t screenscraper_build_user_info_url(char *s, size_t len, + const screenscraper_creds_t *creds); + +/* Parses an ssuserInfos.php response into 'quota'. */ +bool screenscraper_parse_user_info(const char *data, size_t len, + screenscraper_quota_t *quota); + +/* Region/language preference tables. Settings store an index; these + * return the ScreenScraper wire code ("wor", "en", ...) and a human + * readable name for menu display. Out-of-range indices return the + * defaults (world / English). Counts must match the def-file range + * constants SCREENSCRAPER_REGION_LAST / SCREENSCRAPER_LANGUAGE_LAST. */ +const char *screenscraper_region_code(unsigned idx); +const char *screenscraper_region_name(unsigned idx); +const char *screenscraper_language_code(unsigned idx); +const char *screenscraper_language_name(unsigned idx); + +/* Maps a libretro database name (playlist db_name, with or without + * the ".lpl" suffix) to a ScreenScraper systemeid. + * Returns 0 when the system is unknown. */ +unsigned screenscraper_system_id(const char *db_name); + +/* Builds a urlencoded jeuInfos.php request. Any of crc32/rom_name/ + * rom_size may be empty/0 and is then omitted. Returns length, 0 on + * failure (missing dev credentials). */ +size_t screenscraper_build_game_info_url(char *s, size_t len, + const screenscraper_creds_t *creds, + unsigned system_id, const char *crc32, + const char *rom_name, int64_t rom_size); + +/* Parses a jeuInfos JSON response. Region/language selection uses the + * preferences in 'creds'. Returns NULL on parse/API failure. */ +screenscraper_game_t *screenscraper_parse_game_info( + const char *data, size_t len, + const screenscraper_creds_t *creds); + +/* Picks the best media entry of the given kind from a parsed game, + * honouring the preferred region. Returns NULL if absent. */ +const screenscraper_media_t *screenscraper_select_media( + const screenscraper_game_t *game, + enum screenscraper_media_kind kind, + const char *preferred_region); + +/* Thumbnail-tree subdirectory a media kind is stored under */ +const char *screenscraper_media_sub_dir(enum screenscraper_media_kind kind); + +/* File extension (without dot) to use when the media entry carries no + * usable format field */ +const char *screenscraper_media_default_ext(enum screenscraper_media_kind kind); + +/* Serialises the metadata portion of 'game' as a JSON object. + * Returns a heap string owned by the caller. */ +char *screenscraper_metadata_to_json(const screenscraper_game_t *game); + +void screenscraper_game_free(screenscraper_game_t *game); + +RETRO_END_DECLS + +#endif diff --git a/network/screenscraper_meta.c b/network/screenscraper_meta.c new file mode 100644 index 000000000000..8345707f1148 --- /dev/null +++ b/network/screenscraper_meta.c @@ -0,0 +1,333 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2025 - The RetroArch team + * + * RetroArch is free software: you can redistribute it and/or modify it under the terms + * of the GNU General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with RetroArch. + * If not, see . + */ + +/* ScreenScraper, display half. + * + * Reading a metadata sidecar is a local-file operation: the JSON has + * already been fetched and written to disk by an earlier scrape, quite + * possibly on a different machine. Menus that show scraped facts - the + * Ozone and XMB metadata panels, playlist sublabels, the database info + * list - therefore need nothing more than this file, so it is compiled + * unconditionally. + * + * The other half (network/screenscraper.c and tasks/task_screenscraper.c) + * builds the API requests, parses jeuInfos/ssuserInfos and tracks the + * daily allowance. That genuinely needs networking and stays behind + * HAVE_NETWORKING, which is why the two live in separate translation + * units rather than one file full of #ifdefs. + * + * Consequence worth remembering: a target built without networking can + * still display a thumbnails/ tree copied across from a PC, it just + * cannot produce one. + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "screenscraper.h" + +#include "../configuration.h" + +/* Whether a ScreenScraper account is configured. Display code calls + * this to decide whether to offer the scraper-only media types, so it + * has to resolve in every build - without the client there is nothing + * to be signed in to, and the answer is a definite no. */ +bool screenscraper_signed_in(void) +{ +#ifdef HAVE_NETWORKING + settings_t *settings = config_get_ptr(); + if (!settings) + return false; + return !string_is_empty(settings->arrays.screenscraper_username) + && !string_is_empty(settings->arrays.screenscraper_password) + && !string_is_empty(settings->arrays.screenscraper_devid) + && !string_is_empty(settings->arrays.screenscraper_devpassword); +#else + return false; +#endif +} + +/* ---- Metadata sidecar ---- */ + +size_t screenscraper_metadata_path(char *s, size_t len, + const char *dir_thumbnails, const char *db_name, + const char *img_name) +{ + char base[PATH_MAX_LENGTH]; + char dir[PATH_MAX_LENGTH]; + char file_name[NAME_MAX_LENGTH]; + + if ( string_is_empty(dir_thumbnails) + || string_is_empty(db_name) + || string_is_empty(img_name)) + return 0; + + strlcpy(file_name, img_name, sizeof(file_name)); + path_remove_extension(file_name); + strlcat(file_name, ".json", sizeof(file_name)); + + fill_pathname_join_special(base, dir_thumbnails, db_name, sizeof(base)); + fill_pathname_join_special(dir, base, "Metadata", sizeof(dir)); + return fill_pathname_join_special(s, dir, file_name, len); +} + +typedef struct +{ + char key[32]; + screenscraper_meta_t *meta; +} ss_meta_ctx_t; + +static bool ss_meta_member(void *context, const char *s, size_t len) +{ + ss_meta_ctx_t *ctx = (ss_meta_ctx_t*)context; + size_t _len = (len < sizeof(ctx->key) - 1) ? len : sizeof(ctx->key) - 1; + memcpy(ctx->key, s, _len); + ctx->key[_len] = '\0'; + return true; +} + +static bool ss_meta_value(void *context, const char *s, size_t len) +{ + ss_meta_ctx_t *ctx = (ss_meta_ctx_t*)context; + screenscraper_meta_t *m = ctx->meta; + char *dst = NULL; + size_t dst_size = 0; + + if (string_is_equal(ctx->key, "name")) + { dst = m->name; dst_size = sizeof(m->name); } + else if (string_is_equal(ctx->key, "description")) + { dst = m->description; dst_size = sizeof(m->description); } + else if (string_is_equal(ctx->key, "genre")) + { dst = m->genre; dst_size = sizeof(m->genre); } + else if (string_is_equal(ctx->key, "developer")) + { dst = m->developer; dst_size = sizeof(m->developer); } + else if (string_is_equal(ctx->key, "publisher")) + { dst = m->publisher; dst_size = sizeof(m->publisher); } + else if (string_is_equal(ctx->key, "players")) + { dst = m->players; dst_size = sizeof(m->players); } + else if (string_is_equal(ctx->key, "rating")) + { dst = m->rating; dst_size = sizeof(m->rating); } + else if (string_is_equal(ctx->key, "releasedate")) + { dst = m->releasedate; dst_size = sizeof(m->releasedate); } + else if (string_is_equal(ctx->key, "esrb")) + { dst = m->esrb; dst_size = sizeof(m->esrb); } + else if (string_is_equal(ctx->key, "pegi")) + { dst = m->pegi; dst_size = sizeof(m->pegi); } + + if (dst && dst_size > 0) + { + size_t i; + size_t out = 0; + size_t _len = (len < dst_size - 1) ? len : dst_size - 1; + + /* Synopses arrive with the service's own paragraph breaks still + * in them. The menus draw these as single runs of text, where a + * control character has no glyph and comes out as an empty box, + * so fold every control code down to a space and collapse the + * runs that produces. Bytes >= 0x80 are left alone: they are + * UTF-8 continuation bytes, not control codes. */ + for (i = 0; i < _len; i++) + { + unsigned char c = (unsigned char)s[i]; + + if (c < 0x20 || c == 0x7F) + c = ' '; + + if (c == ' ' && (out == 0 || dst[out - 1] == ' ')) + continue; + + dst[out++] = (char)c; + } + + /* Drop a trailing space left by the collapse above */ + while (out > 0 && dst[out - 1] == ' ') + out--; + + dst[out] = '\0'; + m->valid = true; + } + + ctx->key[0] = '\0'; + return true; +} + +static bool ss_meta_noop(void *context) +{ + ((ss_meta_ctx_t*)context)->key[0] = '\0'; + return true; +} + +static bool ss_meta_bool(void *context, bool value) +{ + return ss_meta_noop(context); +} + +bool screenscraper_metadata_load(const char *path, + screenscraper_meta_t *meta) +{ + ss_meta_ctx_t ctx; + rjson_t *json = NULL; + char *buf = NULL; + int64_t buf_len = 0; + enum rjson_type end; + + if (!meta) + return false; + + memset(meta, 0, sizeof(*meta)); + + if (string_is_empty(path) || !path_is_valid(path)) + return false; + if (!filestream_read_file(path, (void**)&buf, &buf_len) || !buf) + return false; + + if (!(json = rjson_open_buffer(buf, (size_t)buf_len))) + { + free(buf); + return false; + } + + memset(&ctx, 0, sizeof(ctx)); + ctx.meta = meta; + + rjson_set_options(json, + RJSON_OPTION_ALLOW_UTF8BOM + | RJSON_OPTION_ALLOW_TRAILING_DATA + | RJSON_OPTION_REPLACE_INVALID_ENCODING); + + end = rjson_parse(json, &ctx, + ss_meta_member, + ss_meta_value, + ss_meta_value, + ss_meta_noop, ss_meta_noop, + ss_meta_noop, ss_meta_noop, + ss_meta_bool, ss_meta_noop); + + rjson_free(json); + free(buf); + + return (end == RJSON_DONE) && meta->valid; +} + +bool screenscraper_metadata_load_entry(const char *dir_thumbnails, + const char *db_name, const char *img_name, + screenscraper_meta_t *meta) +{ + char path[PATH_MAX_LENGTH]; + + if (!screenscraper_metadata_path(path, sizeof(path), + dir_thumbnails, db_name, img_name)) + { + if (meta) + memset(meta, 0, sizeof(*meta)); + return false; + } + + return screenscraper_metadata_load(path, meta); +} + +int screenscraper_rating_stars(const char *rating) +{ + int value; + + /* Absent, or something that is not a plain number: "unrated" */ + if (string_is_empty(rating) || !ISDIGIT((unsigned char)*rating)) + return -1; + + value = atoi(rating); + + if (value < 0) + return -1; + if (value > 20) + value = 20; + + /* ScreenScraper rates on a 0-20 scale, so a star is worth four + * points. Integer (value + 2) / 4 is value/4 rounded to the + * nearest whole star with halves rounding up: + * 0 -> 0, 1 -> 0, 2 -> 1, 17 -> 4, 18 -> 5, 20 -> 5. */ + return (value + 2) / 4; +} + +bool screenscraper_extended_media_allowed(void) +{ + /* The media types no other source provides, so their presence is + * proof a scrape reached this thumbnail tree at some point. */ + static const char *ss_dirs[] = { + "Named_Boxarts3D", + "Named_Fanarts", + "Named_Marquees", + "Named_Videos" + }; + settings_t *settings = config_get_ptr(); + struct string_list *systems = NULL; + const char *dir_thumbnails = NULL; + bool found = false; + size_t i; + + /* Signed in: offer the types whether or not anything has been + * scraped yet, since the next scrape can fill them. */ + if (screenscraper_signed_in()) + return true; + + /* Not signed in - but a tree copied in from elsewhere, or left + * behind after signing out, is still perfectly usable, so go by + * what is actually on disk. */ + if (!settings) + return false; + + dir_thumbnails = settings->paths.directory_thumbnails; + + if ( string_is_empty(dir_thumbnails) + || !path_is_directory(dir_thumbnails)) + return false; + + if (!(systems = dir_list_new(dir_thumbnails, NULL, true, false, + false, false))) + return false; + + for (i = 0; i < systems->size && !found; i++) + { + size_t j; + const char *system_dir = systems->elems[i].data; + + if (string_is_empty(system_dir) || !path_is_directory(system_dir)) + continue; + + for (j = 0; j < ARRAY_SIZE(ss_dirs); j++) + { + char probe[PATH_MAX_LENGTH]; + fill_pathname_join_special(probe, system_dir, ss_dirs[j], + sizeof(probe)); + if (path_is_directory(probe)) + { + found = true; + break; + } + } + } + + string_list_free(systems); + + return found; +} diff --git a/playlist.h b/playlist.h index b014bc34547c..8277d1be4565 100644 --- a/playlist.h +++ b/playlist.h @@ -65,6 +65,10 @@ enum playlist_thumbnail_mode PLAYLIST_THUMBNAIL_MODE_BOXARTS, PLAYLIST_THUMBNAIL_MODE_LOGOS, + PLAYLIST_THUMBNAIL_MODE_BOXARTS_3D, + PLAYLIST_THUMBNAIL_MODE_FANARTS, + PLAYLIST_THUMBNAIL_MODE_MARQUEES, + PLAYLIST_THUMBNAIL_MODE_VIDEOS, PLAYLIST_THUMBNAIL_MODE_LAST }; diff --git a/retroarch.c b/retroarch.c index c5cff4f2c950..7b42ab1bd523 100644 --- a/retroarch.c +++ b/retroarch.c @@ -6553,6 +6553,11 @@ int rarch_main(int argc, char *argv[], void *data) if (settings->uints.cloud_sync_sync_mode == CLOUD_SYNC_MODE_AUTOMATIC) task_push_cloud_sync(); #endif +#if defined(HAVE_NETWORKING) && defined(HAVE_MENU) + /* A scrape halted by the ScreenScraper request allowance resumes + * here, once the service confirms the allowance has refreshed */ + task_push_screenscraper_resume_check(); +#endif #ifdef HAVE_LAKKA sd_notify(0, "READY=1"); #endif diff --git a/samples/gfx/gfx_animation_sanitize/stubs_retroarch.c b/samples/gfx/gfx_animation_sanitize/stubs_retroarch.c index c35323fa8c7c..1ce530451749 100644 --- a/samples/gfx/gfx_animation_sanitize/stubs_retroarch.c +++ b/samples/gfx/gfx_animation_sanitize/stubs_retroarch.c @@ -20,7 +20,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -/* The only two frontend symbols gfx/gfx_animation.c reaches for. +/* The frontend symbols gfx/gfx_animation.c reaches for. * * The smooth ticker takes either a font_data_t or, when that is NULL, * a fixed glyph_width; the test passes NULL so the width path is the @@ -44,3 +44,8 @@ unsigned font_driver_get_generation(void *font) (void)font; return 0; } + +const char *msg_hash_get_wideglyph_str(void) +{ + return NULL; +} diff --git a/settings/settings_def_menu_startup.h b/settings/settings_def_menu_startup.h index 45de57eb12b9..0e5954d3e591 100644 --- a/settings/settings_def_menu_startup.h +++ b/settings/settings_def_menu_startup.h @@ -31,3 +31,16 @@ S_BOOL(menu_ticker_smooth, MENU_TICKER_SMOOTH, "Smooth Ticker Text", "Use smooth scrolling animation when displaying long menu text. Has a small performance impact.") #endif +/* Descriptor and configuration rows are #ifdef HAVE_OZONE; the string + * tables always carry this row via the strings pass. */ +#if defined(HAVE_OZONE) || defined(SETTINGS_DEF_STRINGS_PASS) +/* The configuration row lives under defined(HAVE_MENU); other passes are + * unaffected. */ +#if !defined(SETTINGS_DEF_CONFIG_PASS) || (defined(HAVE_MENU)) +S_UINT_EX(ozone_metadata_scroll_style, OZONE_METADATA_SCROLL_STYLE, + "ozone_metadata_scroll_style", + DEFAULT_OZONE_METADATA_SCROLL_STYLE, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, OZONE_METADATA_SCROLL_LAST-1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_ozone_metadata_scroll_style, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "Metadata Text Animation", + "Whether a scraped synopsis too long for the content metadata panel animates, and which way it moves. 'Horizontal' moves it sideways along a single line. 'Vertical' wraps it to the width of the sidebar and moves the whole block upwards, the way the XMB metadata panel does. 'Off' leaves it static, wrapped over as many lines as the panel has room for. Speed, smoothness, and whether the movement scrolls or bounces, all come from the ticker text settings alongside this one.") +#endif +#endif diff --git a/settings/settings_def_menu_thumbnails.h b/settings/settings_def_menu_thumbnails.h index 2d3fd048201c..792b598bbff4 100644 --- a/settings/settings_def_menu_thumbnails.h +++ b/settings/settings_def_menu_thumbnails.h @@ -9,7 +9,7 @@ #ifndef SETTINGS_DEF_CONFIG_PASS S_UINT_EX(menu_icon_thumbnails, ICON_THUMBNAILS, "icon_thumbnails", - DEFAULT_MENU_ICON_THUMBNAILS_DEFAULT, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, PLAYLIST_THUMBNAIL_MODE_LAST - PLAYLIST_THUMBNAIL_MODE_OFF - 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_menu_thumbnails, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + DEFAULT_MENU_ICON_THUMBNAILS_DEFAULT, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, PLAYLIST_THUMBNAIL_MODE_LOGOS - PLAYLIST_THUMBNAIL_MODE_OFF, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_menu_thumbnails, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, "Icon Thumbnail", "Type of playlist icon thumbnail to display.") #endif @@ -18,3 +18,18 @@ S_BOOL(menu_xmb_vertical_thumbnails, XMB_VERTICAL_THUMBNAILS, DEFAULT_XMB_VERTICAL_THUMBNAILS, SD_FLAG_NONE, 0, 0, "Thumbnail Vertical Disposition", "Display the left thumbnail under the right one, on the right side of the screen.") +S_BOOL(menu_xmb_show_metadata_panel, XMB_SHOW_METADATA_PANEL, + "xmb_show_metadata_panel", + false, SD_FLAG_NONE, 0, 0, + "Scraped Metadata Panel", + "Replace the left thumbnail with the scraped facts for the selected entry: rating, publisher and synopsis. Only applies to entries that have been scraped.") +S_UINT_EX(menu_dynamic_wallpaper_type, DYNAMIC_WALLPAPER_TYPE, + "menu_dynamic_wallpaper_type", + 0, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_dynamic_wallpaper_type, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "Dynamic Background Type", + "Media used for dynamic backgrounds. 'Images' uses still artwork (fan art); 'Videos' prefers a video snap for the selected entry and falls back to still artwork when none exists.") +S_UINT_EX(menu_dynamic_wallpaper_mode, DYNAMIC_WALLPAPER_MODE, + "menu_dynamic_wallpaper_mode", + 1, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_dynamic_wallpaper_mode, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "Dynamic Background Mode", + "'Per Game' loads a background for the selected entry: dynamic_wallpapers//.png, falling back to the game's scraped fan art and then the per-system wallpaper. 'Per System' always uses dynamic_wallpapers/.png.") diff --git a/settings/settings_def_ozone_typography.h b/settings/settings_def_ozone_typography.h index e9d2adb7a87b..4dddd7b2ea51 100644 --- a/settings/settings_def_ozone_typography.h +++ b/settings/settings_def_ozone_typography.h @@ -121,6 +121,10 @@ S_BOOL(ozone_scroll_content_metadata, OZONE_SCROLL_CONTENT_METADATA, "When enabled, each item of content metadata shown on the right sidebar of playlists (associated core, play time) will occupy a single line; strings exceeding the width of the sidebar will be displayed as scrolling ticker text. When disabled, each item of content metadata will be displayed statically, wrapped to occupy as many lines as required.") #endif #endif +/* Note: ozone_metadata_scroll_style used to sit here, next to the + * ticker toggle it pairs with. It lives in settings_def_menu_startup.h + * now, alongside the ticker text animation / speed / smoothness rows + * that supply everything about the scroll except its direction. */ /* Descriptor and configuration rows are #ifdef HAVE_OZONE; the string * tables always carry this row via the strings pass. */ #if defined(HAVE_OZONE) || defined(SETTINGS_DEF_STRINGS_PASS) diff --git a/settings/settings_def_screenscraper.h b/settings/settings_def_screenscraper.h new file mode 100644 index 000000000000..68470d114d63 --- /dev/null +++ b/settings/settings_def_screenscraper.h @@ -0,0 +1,136 @@ +/* Single-source definitions: ScreenScraper scraper preferences group. + * Account credentials live in settings_def_screenscraper_account.h. + * Grammar identical to settings_def_video_sync.h plus S_FLOAT and + * the _NS no-sublabel variants; the descriptor argument span + * matches SDESC__ROW; row order is menu display order; + * h2json.py parses these rows for the Crowdin source upload. */ + +/* Descriptor and configuration rows are #if defined(HAVE_NETWORKING); the string + * tables always carry this row via the strings pass. */ +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_UINT_EX(screenscraper_primary_scraper, SCREENSCRAPER_PRIMARY_SCRAPER, + "screenscraper_primary_scraper", + 0, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_screenscraper_primary, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "Primary Scraper", + "Which scraper provides boxarts, screenshots and title screens first; the other one is used as the per-item fallback. ScreenScraper is the primary while signed in unless changed here.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_UINT_EX(screenscraper_quota_action, SCREENSCRAPER_QUOTA_ACTION, + "screenscraper_quota_action", + 0, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_screenscraper_quota, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "When Daily Requests Run Out", + "ScreenScraper allows a limited number of requests per day. 'Pause And Resume Later' stops the scrape and picks it up automatically once the allowance refreshes; 'Continue With libretro' finishes the run using the libretro thumbnail server alone.") +#endif +/* Range constants for the region/language selection settings; must + * match the ss_regions/ss_languages tables in network/screenscraper.c */ +#ifndef SCREENSCRAPER_REGION_LAST +#define SCREENSCRAPER_REGION_LAST 18 +#endif +#ifndef SCREENSCRAPER_LANGUAGE_LAST +#define SCREENSCRAPER_LANGUAGE_LAST 16 +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_UINT_EX(screenscraper_region, SCREENSCRAPER_REGION, + "screenscraper_region", + 0, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, SCREENSCRAPER_REGION_LAST - 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_screenscraper_region, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "Preferred Media Region", + "Region whose artwork and release dates are preferred when a game has several regional variants.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_UINT_EX(screenscraper_language, SCREENSCRAPER_LANGUAGE, + "screenscraper_language", + 0, SD_FLAG_NONE, SDESC_RANGE_MINMAX, 0, 0, SCREENSCRAPER_LANGUAGE_LAST - 1, 1, 0, setting_action_ok_uint, setting_get_string_representation_uint_screenscraper_language, NULL, NULL, NULL, NULL, ST_UI_TYPE_UINT_RADIO_BUTTONS, + "Preferred Text Language", + "Language preferred for the synopsis and genre text stored in the metadata sidecar.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_boxarts, SCREENSCRAPER_MEDIA_BOXARTS, + "screenscraper_media_boxarts", + DEFAULT_SCREENSCRAPER_MEDIA_ON, SD_FLAG_NONE, 0, 0, + "Scrape Boxarts", + "Download 2D box art into 'Named_Boxarts'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_snaps, SCREENSCRAPER_MEDIA_SNAPS, + "screenscraper_media_snaps", + DEFAULT_SCREENSCRAPER_MEDIA_ON, SD_FLAG_NONE, 0, 0, + "Scrape Screenshots", + "Download in-game screenshots into 'Named_Snaps'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_titles, SCREENSCRAPER_MEDIA_TITLES, + "screenscraper_media_titles", + DEFAULT_SCREENSCRAPER_MEDIA_ON, SD_FLAG_NONE, 0, 0, + "Scrape Title Screens", + "Download title screens into 'Named_Titles'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_logos, SCREENSCRAPER_MEDIA_LOGOS, + "screenscraper_media_logos", + DEFAULT_SCREENSCRAPER_MEDIA_ON, SD_FLAG_NONE, 0, 0, + "Scrape Logos (Wheels)", + "Download game logo art into 'Named_Logos'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_boxarts3d, SCREENSCRAPER_MEDIA_BOXARTS3D, + "screenscraper_media_boxarts3d", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Scrape 3D Boxarts", + "Download 3D box art into 'Named_Boxarts3D'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_fanarts, SCREENSCRAPER_MEDIA_FANARTS, + "screenscraper_media_fanarts", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Scrape Fan Art", + "Download fan art backgrounds into 'Named_Fanarts'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_marquees, SCREENSCRAPER_MEDIA_MARQUEES, + "screenscraper_media_marquees", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Scrape Marquees", + "Download marquee art into 'Named_Marquees'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_videos, SCREENSCRAPER_MEDIA_VIDEOS, + "screenscraper_media_videos", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Scrape Videos", + "Download video snaps into 'Named_Videos'. Significantly increases scraping time and disk usage.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_manuals, SCREENSCRAPER_MEDIA_MANUALS, + "screenscraper_media_manuals", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Scrape Manuals", + "Download game manuals (PDF) into 'Named_Manuals'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_media_bezels, SCREENSCRAPER_MEDIA_BEZELS, + "screenscraper_media_bezels", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Scrape Bezels", + "Download 16:9 bezel art into 'Named_Bezels'.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_metadata, SCREENSCRAPER_METADATA, + "screenscraper_metadata", + DEFAULT_SCREENSCRAPER_MEDIA_ON, SD_FLAG_NONE, 0, 0, + "Scrape Metadata", + "Save synopsis, genre, developer, publisher, players, rating and release date to a per-game JSON file in the 'Metadata' thumbnail directory.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_overwrite, SCREENSCRAPER_OVERWRITE, + "screenscraper_overwrite", + DEFAULT_SCREENSCRAPER_MEDIA_OFF, SD_FLAG_NONE, 0, 0, + "Overwrite Existing Media", + "Redownload media and metadata that already exist locally.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_BOOL(screenscraper_use_crc, SCREENSCRAPER_USE_CRC, + "screenscraper_use_crc", + DEFAULT_SCREENSCRAPER_MEDIA_ON, SD_FLAG_NONE, 0, 0, + "Match Using CRC Hashes", + "Identify games by their CRC checksum in addition to the file name. More accurate; disable only if lookups fail.") +#endif diff --git a/settings/settings_def_screenscraper_account.h b/settings/settings_def_screenscraper_account.h new file mode 100644 index 000000000000..914720b21894 --- /dev/null +++ b/settings/settings_def_screenscraper_account.h @@ -0,0 +1,57 @@ +/* Single-source definitions: ScreenScraper account group. + * Grammar identical to settings_def_video_sync.h plus S_FLOAT and + * the _NS no-sublabel variants; the descriptor argument span + * matches SDESC__ROW; row order is menu display order; + * h2json.py parses these rows for the Crowdin source upload. */ + +/* Rows marked _H reserve a MENU_ENUM_LABEL_HELP_ enum member; + * outside the enum pass they behave exactly like the base row. */ +#ifndef SETTINGS_DEF_ENUM_PASS +#ifndef S_ACTION_H +#define S_ACTION_H S_ACTION +#endif +#endif +/* Descriptor and configuration rows are #ifdef HAVE_NETWORKING; the string + * tables always carry this row via the strings pass. */ +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +S_ACTION_H(ACCOUNTS_SCREENSCRAPER, + "accounts_screenscraper", + "ScreenScraper", + "Sign in to screenscraper.fr. While signed in, ScreenScraper becomes the primary source for playlist thumbnails and metadata.") +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +#ifndef SETTINGS_DEF_CONFIG_PASS +S_STRING(screenscraper_username, SCREENSCRAPER_USERNAME, + "screenscraper_username", + "", SD_FLAG_ALLOW_INPUT, 0, NULL, NULL, setting_generic_action_start_default, NULL, NULL, NULL, ST_UI_TYPE_STRING_LINE_EDIT, + "Username", + "Input your screenscraper.fr account username. A registered account raises the daily scraping quota.") +#endif +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +#ifndef SETTINGS_DEF_CONFIG_PASS +S_STRING(screenscraper_password, SCREENSCRAPER_PASSWORD, + "screenscraper_password", + "", SD_FLAG_ALLOW_INPUT, 0, NULL, setting_get_string_representation_password, setting_generic_action_start_default, NULL, NULL, NULL, ST_UI_TYPE_PASSWORD_LINE_EDIT, + "Password", + "Input the password of your screenscraper.fr account.") +#endif +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +#ifndef SETTINGS_DEF_CONFIG_PASS +S_STRING(screenscraper_devid, SCREENSCRAPER_DEVID, + "screenscraper_devid", + "", SD_FLAG_ALLOW_INPUT, 0, NULL, NULL, setting_generic_action_start_default, NULL, NULL, NULL, ST_UI_TYPE_STRING_LINE_EDIT, + "Developer ID", + "Developer credentials for the ScreenScraper API. Required; request them on the screenscraper.fr forum.") +#endif +#endif +#if defined(HAVE_NETWORKING) || defined(SETTINGS_DEF_STRINGS_PASS) +#ifndef SETTINGS_DEF_CONFIG_PASS +S_STRING(screenscraper_devpassword, SCREENSCRAPER_DEVPASSWORD, + "screenscraper_devpassword", + "", SD_FLAG_ALLOW_INPUT, 0, NULL, setting_get_string_representation_password, setting_generic_action_start_default, NULL, NULL, NULL, ST_UI_TYPE_PASSWORD_LINE_EDIT, + "Developer Password", + "Developer password for the ScreenScraper API.") +#endif +#endif diff --git a/tasks/task_screenscraper.c b/tasks/task_screenscraper.c new file mode 100644 index 000000000000..e233a770564c --- /dev/null +++ b/tasks/task_screenscraper.c @@ -0,0 +1,1343 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2026 - RetroArch + * + * RetroArch is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with RetroArch. If not, see . + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "task_file_transfer.h" +#include "tasks_internal.h" + +#include "../configuration.h" +#include "../file_path_special.h" +#include "../gfx/gfx_thumbnail.h" +#include "../msg_hash.h" +#include "../network/screenscraper.h" +#include "../playlist.h" +#include "../verbosity.h" + +enum ss_scrape_status +{ + SS_SCRAPE_BEGIN = 0, + SS_SCRAPE_ITERATE_ENTRY, + SS_SCRAPE_WAIT_INFO, + SS_SCRAPE_ITERATE_MEDIA, + SS_SCRAPE_WAIT_MEDIA, + SS_SCRAPE_END +}; + +enum ss_scrape_flags +{ + SS_SCRAPE_FLAG_HTTP_COMPLETE = (1 << 0), + SS_SCRAPE_FLAG_INFO_VALID = (1 << 1), + /* The service answered and does not know this game - a definitive + * miss, as opposed to a network/parameter failure */ + SS_SCRAPE_FLAG_INFO_MISS = (1 << 2) +}; + +typedef struct ss_scrape_handle +{ + char *system; + char *playlist_path; + char *dir_thumbnails; + playlist_t *playlist; + gfx_thumbnail_path_data_t *thumbnail_path_data; + retro_task_t *http_task; + screenscraper_game_t *game; + + /* Copies of the relevant settings, so mid-scrape changes + * cannot corrupt the run */ + char username[64]; + char password[64]; + char devid[64]; + char devpassword[64]; + char region[8]; + char language[8]; + + playlist_config_t playlist_config; /* size_t alignment */ + + size_t list_size; + size_t list_index; + unsigned media_kind; /* enum screenscraper_media_kind */ + /* Source attempt for the current media kind: indexes the ordered + * source chain (ScreenScraper / libretro thumbnail server) */ + unsigned attempt; + + enum ss_scrape_status status; + + bool media_enabled[SS_MEDIA_LAST]; + bool metadata_enabled; + bool overwrite; + bool use_crc; + /* Scraper order: when set, the libretro thumbnail server is tried + * first and ScreenScraper acts as the fallback */ + bool prefer_libretro; + /* On-demand mode: scrape a single playlist entry and stop */ + bool single_entry; + /* What to do when the daily allowance runs out: pause and save a + * resume point, or carry on with the libretro server alone */ + bool quota_continue_libretro; + /* Set once the allowance ran out during this run */ + bool quota_hit; + + uint8_t flags; +} ss_scrape_handle_t; + +/*********************/ +/* Utility Functions */ +/*********************/ + +/* Builds the local destination path for the given media kind of the + * current playlist entry: + * ///. */ +static bool ss_scrape_get_media_path(ss_scrape_handle_t *ss, + const screenscraper_media_t *media, + enum screenscraper_media_kind kind, + char *path, size_t path_size) +{ + char tmp_buf[PATH_MAX_LENGTH]; + char file_name[NAME_MAX_LENGTH]; + const char *system_name = NULL; + const char *img_name = NULL; + const char *sub_dir = screenscraper_media_sub_dir(kind); + const char *ext = NULL; + + if (!ss->thumbnail_path_data || !sub_dir) + return false; + if (string_is_empty(ss->dir_thumbnails)) + return false; + + /* Playlist database name has priority over system name */ + if (*ss->thumbnail_path_data->content_db_name) + system_name = ss->thumbnail_path_data->content_db_name; + else if (*ss->thumbnail_path_data->system) + system_name = ss->thumbnail_path_data->system; + else + return false; + + /* content_img is the label-derived name every menu driver looks for + * first; content_img_full is only populated when the full file name + * differs from it, so it cannot be used on its own. */ + if (*ss->thumbnail_path_data->content_img) + img_name = ss->thumbnail_path_data->content_img; + else if (*ss->thumbnail_path_data->content_img_full) + img_name = ss->thumbnail_path_data->content_img_full; + else + return false; + + /* The classic thumbnail types (and logos) must keep the ".png" + * name menu drivers expect; extended media adopt the extension the + * service reports (mp4, pdf, ...) */ + strlcpy(file_name, img_name, sizeof(file_name)); + if (kind > SS_MEDIA_LOGO) + { + if (media && !string_is_empty(media->format)) + ext = media->format; + else + ext = screenscraper_media_default_ext(kind); + + if (!string_is_equal(ext, "png")) + { + path_remove_extension(file_name); + strlcat(file_name, ".", sizeof(file_name)); + strlcat(file_name, ext, sizeof(file_name)); + } + } + + fill_pathname_join_special(tmp_buf, ss->dir_thumbnails, + system_name, sizeof(tmp_buf)); + fill_pathname_join_special(path, tmp_buf, sub_dir, path_size); + fill_pathname_join_special(tmp_buf, path, file_name, sizeof(tmp_buf)); + strlcpy(path, tmp_buf, path_size); + + return !string_is_empty(path); +} + +/* Builds the metadata sidecar path: + * //Metadata/.json */ +static bool ss_scrape_get_metadata_path(ss_scrape_handle_t *ss, + char *path, size_t path_size) +{ + char tmp_buf[PATH_MAX_LENGTH]; + char file_name[NAME_MAX_LENGTH]; + const char *system_name = NULL; + + if (!ss->thumbnail_path_data) + return false; + if (string_is_empty(ss->dir_thumbnails)) + return false; + + if (*ss->thumbnail_path_data->content_db_name) + system_name = ss->thumbnail_path_data->content_db_name; + else if (*ss->thumbnail_path_data->system) + system_name = ss->thumbnail_path_data->system; + else + return false; + + if (*ss->thumbnail_path_data->content_img) + strlcpy(file_name, ss->thumbnail_path_data->content_img, + sizeof(file_name)); + else if (*ss->thumbnail_path_data->content_img_full) + strlcpy(file_name, ss->thumbnail_path_data->content_img_full, + sizeof(file_name)); + else + return false; + + path_remove_extension(file_name); + strlcat(file_name, ".json", sizeof(file_name)); + + fill_pathname_join_special(tmp_buf, ss->dir_thumbnails, + system_name, sizeof(tmp_buf)); + fill_pathname_join_special(path, tmp_buf, "Metadata", path_size); + fill_pathname_join_special(tmp_buf, path, file_name, sizeof(tmp_buf)); + strlcpy(path, tmp_buf, path_size); + + return !string_is_empty(path); +} + +static void ss_scrape_write_metadata(ss_scrape_handle_t *ss) +{ + char path[PATH_MAX_LENGTH]; + char output_dir[PATH_MAX_LENGTH]; + char *json = NULL; + + if (!ss->metadata_enabled || !ss->game) + return; + if (!ss_scrape_get_metadata_path(ss, path, sizeof(path))) + return; + if (path_is_valid(path) && !ss->overwrite) + { + /* A not-found marker is not data: real facts replace it even + * with overwriting off. Anything that parses as actual + * metadata is left alone. */ + screenscraper_meta_t existing; + if (screenscraper_metadata_load(path, &existing)) + return; + } + if (!(json = screenscraper_metadata_to_json(ss->game))) + return; + + strlcpy(output_dir, path, sizeof(output_dir)); + path_basedir_wrapper(output_dir); + + if (!path_mkdir(output_dir)) + RARCH_ERR("[ScreenScraper] Failed to create \"%s\".\n", output_dir); + else if (!filestream_write_file(path, json, strlen(json))) + RARCH_ERR("[ScreenScraper] Failed to write \"%s\".\n", path); + else + RARCH_LOG("[ScreenScraper] Metadata \"%s\".\n", path); + + free(json); +} + +/* Records a definitive "the service does not know this game" as a + * stub sidecar. The reader does not recognise its key, so it never + * shows up as scraped facts - but its presence stops the metadata + * backfill from spending a lookup on the same answer every time the + * entry is browsed. A later successful lookup replaces it (the + * metadata writer treats markers as absent), and Overwrite Existing + * Media forces a retry. */ +static void ss_scrape_write_miss_marker(ss_scrape_handle_t *ss) +{ + char path[PATH_MAX_LENGTH]; + char output_dir[PATH_MAX_LENGTH]; + static const char *marker = "{\"screenscraper\":\"not-found\"}"; + + if (!ss->metadata_enabled) + return; + if (!ss_scrape_get_metadata_path(ss, path, sizeof(path))) + return; + if (path_is_valid(path)) + return; + + strlcpy(output_dir, path, sizeof(output_dir)); + path_basedir_wrapper(output_dir); + + if (path_mkdir(output_dir)) + filestream_write_file(path, marker, strlen(marker)); +} + +/* Resume point for a scrape interrupted by the request allowance. + * Stored beside the thumbnails so it travels with the media tree. */ +static void ss_scrape_resume_path(const char *dir_thumbnails, + char *s, size_t len) +{ + fill_pathname_join_special(s, dir_thumbnails, + "screenscraper_resume.txt", len); +} + +static void ss_scrape_resume_save(ss_scrape_handle_t *ss) +{ + char path[PATH_MAX_LENGTH]; + char buf[PATH_MAX_LENGTH + 256]; + size_t _len; + + if (ss->single_entry || string_is_empty(ss->dir_thumbnails)) + return; + + ss_scrape_resume_path(ss->dir_thumbnails, path, sizeof(path)); + + /* system, entry index and playlist path, one per line */ + _len = strlcpy(buf, ss->system ? ss->system : "", sizeof(buf)); + _len += snprintf(buf + _len, sizeof(buf) - _len, "\n%u\n", + (unsigned)ss->list_index); + strlcpy(buf + _len, ss->playlist_config.path, sizeof(buf) - _len); + + if (filestream_write_file(path, buf, strlen(buf))) + RARCH_LOG("[ScreenScraper] Paused at entry %u of \"%s\".\n", + (unsigned)ss->list_index, ss->system ? ss->system : ""); +} + +static void ss_scrape_resume_clear(const char *dir_thumbnails) +{ + char path[PATH_MAX_LENGTH]; + + if (string_is_empty(dir_thumbnails)) + return; + + ss_scrape_resume_path(dir_thumbnails, path, sizeof(path)); + if (path_is_valid(path)) + filestream_delete(path); +} + +/* jeuInfos response callback: parse the game description */ +static void ss_scrape_info_cb(retro_task_t *task, void *task_data, + void *user_data, const char *err) +{ + http_transfer_data_t *data = (http_transfer_data_t*)task_data; + ss_scrape_handle_t *ss = (ss_scrape_handle_t*)user_data; + + if (!ss) + return; + + ss->flags |= SS_SCRAPE_FLAG_HTTP_COMPLETE; + + if (!data || !data->data) + { + RARCH_WARN("[ScreenScraper] Game lookup returned no data.\n"); + return; + } + + if (screenscraper_status_is_quota(data->status, data->data, data->len)) + { + screenscraper_set_quota_exhausted(true); + ss->quota_hit = true; + RARCH_WARN("[ScreenScraper] Daily request allowance reached.\n"); + return; + } + + if (data->status == 404) + { + /* Definitive answer: the service knows the system and does not + * know this game. Recorded so the entry is not asked about + * again on every browse. */ + ss->flags |= SS_SCRAPE_FLAG_INFO_MISS; + RARCH_LOG("[ScreenScraper] Game not found.\n"); + return; + } + + if (data->status != 200) + { + /* The service explains parameter/quota problems in the response + * body; surfacing a snippet turns an opaque status code into an + * actionable message. */ + char reason[160]; + size_t _len = (data->len < sizeof(reason) - 1) + ? data->len : sizeof(reason) - 1; + size_t i; + + memcpy(reason, data->data, _len); + reason[_len] = '\0'; + for (i = 0; i < _len; i++) + if (reason[i] == '\n' || reason[i] == '\r') + reason[i] = ' '; + + RARCH_WARN("[ScreenScraper] Game lookup failed (HTTP %d): %s\n", + data->status, reason); + return; + } + + { + screenscraper_creds_t creds; + creds.devid = ss->devid; + creds.devpassword = ss->devpassword; + creds.username = ss->username; + creds.password = ss->password; + creds.region = ss->region; + creds.language = ss->language; + + if (ss->game) + screenscraper_game_free(ss->game); + if ((ss->game = screenscraper_parse_game_info( + data->data, data->len, &creds))) + ss->flags |= SS_SCRAPE_FLAG_INFO_VALID; + else + /* A 200 whose body does not parse as a game description is + * the API's other way of saying "not found" */ + ss->flags |= SS_SCRAPE_FLAG_INFO_MISS; + } +} + +/* Media download callback: write the file to disk */ +static void ss_scrape_media_cb(retro_task_t *task, void *task_data, + void *user_data, const char *err) +{ + char output_dir[DIR_MAX_LENGTH]; + http_transfer_data_t *data = (http_transfer_data_t*)task_data; + file_transfer_t *transf = (file_transfer_t*)user_data; + ss_scrape_handle_t *ss = NULL; + + if (!transf) + goto finish; + + if (!(ss = (ss_scrape_handle_t*)transf->user_data)) + goto finish; + + ss->flags |= SS_SCRAPE_FLAG_HTTP_COMPLETE; + + if (!data || !data->data || !*transf->path) + goto finish; + + if (data->status != 200) + { + err = "File not found."; + goto finish; + } + + strlcpy(output_dir, transf->path, sizeof(output_dir)); + path_basedir_wrapper(output_dir); + + if (!path_mkdir(output_dir)) + { + err = msg_hash_to_str(MSG_FAILED_TO_CREATE_THE_DIRECTORY); + goto finish; + } + + if (!filestream_write_file(transf->path, data->data, data->len)) + { + err = "Write failed."; + goto finish; + } + +finish: + if (err && *err) + RARCH_ERR("[ScreenScraper] Download \"%s\" failed: %s\n", + (transf ? transf->path : "unknown"), err); + else + RARCH_LOG("[ScreenScraper] Downloaded \"%s\".\n", + (transf ? transf->path : "unknown")); + + if (transf) + free(transf); +} + +/* Requests jeuInfos for the current playlist entry. + * Returns false when the entry cannot be queried at all + * (missing credentials or unusable entry). */ +static bool ss_scrape_request_info(ss_scrape_handle_t *ss) +{ + char url[2048]; + char rom_name[NAME_MAX_LENGTH]; + const struct playlist_entry *entry = NULL; + const char *content_path = NULL; + char crc32[16]; + screenscraper_creds_t creds; + unsigned system_id; + int64_t rom_size = 0; + + crc32[0] = '\0'; + rom_name[0] = '\0'; + + playlist_get_index(ss->playlist, ss->list_index, &entry); + if (!entry) + return false; + + /* ROM name: base filename of the content (strip any archive + * sub-path: "archive.zip#rom.bin" -> "archive.zip") */ + content_path = entry->path; + if (!string_is_empty(content_path)) + { + char tmp[PATH_MAX_LENGTH]; + const char *archive_delim = NULL; + + strlcpy(tmp, content_path, sizeof(tmp)); + if ((archive_delim = path_get_archive_delim(tmp))) + tmp[archive_delim - tmp] = '\0'; + + strlcpy(rom_name, path_basename(tmp), sizeof(rom_name)); + rom_size = path_get_size(tmp); + } + + /* The playlist 'crc32' field is dual purpose: it holds either + * "XXXXXXXX|crc" or "|serial" (disc based systems store the + * serial there). Only a genuine 8 digit hex CRC may be sent as the + * 'crc' parameter - a serial makes the API reject the whole request + * with HTTP 400, so in that case identification falls back to file + * name plus size. */ + if (ss->use_crc && !string_is_empty(entry->crc32)) + { + char tmp[32]; + char *delim = NULL; + + strlcpy(tmp, entry->crc32, sizeof(tmp)); + + if ((delim = strchr(tmp, '|'))) + { + *delim = '\0'; + if (string_is_equal(delim + 1, "crc")) + { + size_t i; + size_t _len = strlen(tmp); + bool valid = (_len == 8); + bool all_zero = true; + + for (i = 0; valid && i < _len; i++) + { + if (!isxdigit((unsigned char)tmp[i])) + valid = false; + else if (tmp[i] != '0') + all_zero = false; + } + + if (valid && !all_zero) + strlcpy(crc32, tmp, sizeof(crc32)); + } + } + } + + creds.devid = ss->devid; + creds.devpassword = ss->devpassword; + creds.username = ss->username; + creds.password = ss->password; + creds.region = ss->region; + creds.language = ss->language; + + system_id = screenscraper_system_id(entry->db_name + ? entry->db_name : ss->system); + + if (!screenscraper_build_game_info_url(url, sizeof(url), &creds, + system_id, crc32, rom_name, rom_size)) + return false; + + ss->flags &= ~(SS_SCRAPE_FLAG_HTTP_COMPLETE + | SS_SCRAPE_FLAG_INFO_VALID + | SS_SCRAPE_FLAG_INFO_MISS); + + if (!(ss->http_task = (retro_task_t*)task_push_http_transfer( + url, true, NULL, ss_scrape_info_cb, ss))) + ss->flags |= SS_SCRAPE_FLAG_HTTP_COMPLETE; + + return true; +} + +/* Builds the libretro thumbnail-server URL for the current entry and + * classic media kind. Returns false when the kind has no equivalent on + * the server (logos and all extended media). */ +static bool ss_scrape_get_libretro_url(ss_scrape_handle_t *ss, + enum screenscraper_media_kind kind, char *s, size_t len) +{ + char raw_url[PATH_MAX_LENGTH + 512]; + const char *system_name = NULL; + const char *sub_dir = NULL; + const char *img_name = NULL; + + switch (kind) + { + case SS_MEDIA_SNAP: + sub_dir = "Named_Snaps"; + break; + case SS_MEDIA_TITLE: + sub_dir = "Named_Titles"; + break; + case SS_MEDIA_BOXART: + sub_dir = "Named_Boxarts"; + break; + default: + /* The libretro server hosts no logos or extended media */ + return false; + } + + if (!ss->thumbnail_path_data) + return false; + if (*ss->thumbnail_path_data->content_db_name) + system_name = ss->thumbnail_path_data->content_db_name; + else if (*ss->thumbnail_path_data->system) + system_name = ss->thumbnail_path_data->system; + else + return false; + if (*ss->thumbnail_path_data->content_img) + img_name = ss->thumbnail_path_data->content_img; + else if (*ss->thumbnail_path_data->content_img_full) + img_name = ss->thumbnail_path_data->content_img_full; + else + return false; + + snprintf(raw_url, sizeof(raw_url), "%s/%s/%s/%s", + FILE_PATH_CORE_THUMBNAILS_URL, system_name, sub_dir, img_name); + net_http_urlencode_full(s, raw_url, len); + + return !string_is_empty(s); +} + +/* Computes the destination path of the current media kind (source + * independent: both scrapers write the same file). */ +static bool ss_scrape_current_dest(ss_scrape_handle_t *ss, + char *path, size_t path_size) +{ + enum screenscraper_media_kind kind = + (enum screenscraper_media_kind)ss->media_kind; + const screenscraper_media_t *media = ss->game + ? screenscraper_select_media(ss->game, kind, ss->region) + : NULL; + return ss_scrape_get_media_path(ss, media, kind, path, path_size); +} + +/* Enqueues the download of the current media kind from the source + * selected by 'attempt' (the ordered ScreenScraper/libretro-server + * chain). Returns true if a transfer was started. */ +static bool ss_scrape_request_media(ss_scrape_handle_t *ss) +{ + char path[PATH_MAX_LENGTH]; + char lr_url[2048]; + const char *urls[2]; + unsigned n_urls = 0; + const screenscraper_media_t *media = NULL; + file_transfer_t *transf = NULL; + enum screenscraper_media_kind kind = + (enum screenscraper_media_kind)ss->media_kind; + + if (!ss->media_enabled[kind]) + return false; + + if (ss->game) + media = screenscraper_select_media(ss->game, kind, ss->region); + + if (!ss_scrape_get_media_path(ss, media, kind, path, sizeof(path))) + return false; + + /* Only download missing media unless overwriting */ + if (path_is_valid(path) && !ss->overwrite) + return false; + + /* Assemble the ordered source chain */ + lr_url[0] = '\0'; + if (!ss_scrape_get_libretro_url(ss, kind, lr_url, sizeof(lr_url))) + lr_url[0] = '\0'; + + if (ss->prefer_libretro) + { + if (*lr_url) + urls[n_urls++] = lr_url; + if (media) + urls[n_urls++] = media->url; + } + else + { + if (media) + urls[n_urls++] = media->url; + if (*lr_url) + urls[n_urls++] = lr_url; + } + + if (ss->attempt >= n_urls) + return false; + + if (!(transf = (file_transfer_t*)malloc(sizeof(file_transfer_t)))) + return false; + + ss->flags &= ~SS_SCRAPE_FLAG_HTTP_COMPLETE; + + transf->enum_idx = MSG_UNKNOWN; + transf->path[0] = '\0'; + transf->user_data = (void*)ss; + strlcpy(transf->path, path, sizeof(transf->path)); + + if (!(ss->http_task = (retro_task_t*)task_push_http_transfer_file( + urls[ss->attempt], true, NULL, ss_scrape_media_cb, transf))) + { + ss->flags |= SS_SCRAPE_FLAG_HTTP_COMPLETE; + return false; + } + + return true; +} + +static void ss_scrape_free_handle(ss_scrape_handle_t *ss) +{ + if (!ss) + return; + + if (ss->system) + free(ss->system); + if (ss->playlist_path) + free(ss->playlist_path); + if (ss->dir_thumbnails) + free(ss->dir_thumbnails); + if (ss->playlist) + playlist_free(ss->playlist); + if (ss->thumbnail_path_data) + free(ss->thumbnail_path_data); + if (ss->game) + screenscraper_game_free(ss->game); + + free(ss); +} + +/*************************/ +/* Task Handler */ +/*************************/ + +static void task_screenscraper_handler(retro_task_t *task) +{ + uint8_t flg; + ss_scrape_handle_t *ss = NULL; + + if (!task) + goto task_finished; + + if (!(ss = (ss_scrape_handle_t*)task->state)) + goto task_finished; + + flg = task_get_flags(task); + + if ((flg & RETRO_TASK_FLG_CANCELLED) > 0) + { + /* Drain any in-flight transfer before freeing the handle: the + * http callbacks dereference it and would otherwise write into + * freed memory (heap corruption on cancel/exit). */ + if ( (ss->status == SS_SCRAPE_WAIT_INFO + || ss->status == SS_SCRAPE_WAIT_MEDIA) + && ss->http_task + && !(ss->flags & SS_SCRAPE_FLAG_HTTP_COMPLETE)) + return; + goto task_finished; + } + + switch (ss->status) + { + case SS_SCRAPE_BEGIN: + if (!path_is_valid(ss->playlist_config.path)) + goto task_finished; + + if (!(ss->playlist = playlist_init(&ss->playlist_config))) + goto task_finished; + + ss->list_size = playlist_size(ss->playlist); + if (ss->list_size < 1) + goto task_finished; + + if (!(ss->thumbnail_path_data = gfx_thumbnail_path_init())) + goto task_finished; + + if (!gfx_thumbnail_set_system( + ss->thumbnail_path_data, ss->system, ss->playlist)) + goto task_finished; + + ss->status = SS_SCRAPE_ITERATE_ENTRY; + break; + + case SS_SCRAPE_ITERATE_ENTRY: + if (ss->list_index >= ss->list_size) + { + ss->status = SS_SCRAPE_END; + break; + } + + if (gfx_thumbnail_set_content_playlist( + ss->thumbnail_path_data, ss->playlist, ss->list_index)) + { + task_free_title(task); + if (*ss->thumbnail_path_data->content_label) + task_set_title(task, + strdup(ss->thumbnail_path_data->content_label)); + else + task_set_title(task, strdup("")); + task_set_progress(task, + (ss->list_index * 100) / ss->list_size); + + /* Allowance spent: either stop here and remember where to + * pick up, or keep going with the libretro server alone */ + if (screenscraper_quota_exhausted()) + { + if (!ss->quota_continue_libretro) + { + ss->status = SS_SCRAPE_END; + break; + } + /* Libretro-only pass: no lookup, media step still runs + * and falls back to the thumbnail server */ + if (ss->game) + { + screenscraper_game_free(ss->game); + ss->game = NULL; + } + ss->media_kind = 0; + ss->attempt = 0; + ss->status = SS_SCRAPE_ITERATE_MEDIA; + break; + } + + if (ss_scrape_request_info(ss)) + ss->status = SS_SCRAPE_WAIT_INFO; + else + { + /* Cannot query this entry (or credentials are + * missing) - skip ahead */ + ss->list_index++; + } + } + else + ss->list_index++; + break; + + case SS_SCRAPE_WAIT_INFO: + if (!ss->http_task) + ss->flags |= SS_SCRAPE_FLAG_HTTP_COMPLETE; + + if (!(ss->flags & SS_SCRAPE_FLAG_HTTP_COMPLETE)) + break; + + ss->http_task = NULL; + + if (ss->flags & SS_SCRAPE_FLAG_INFO_VALID) + ss_scrape_write_metadata(ss); + else if (ss->flags & SS_SCRAPE_FLAG_INFO_MISS) + ss_scrape_write_miss_marker(ss); + + /* Iterate media even when the ScreenScraper lookup missed: + * the libretro thumbnail server can still provide the classic + * thumbnail types as a fallback source */ + ss->media_kind = 0; + ss->attempt = 0; + ss->status = SS_SCRAPE_ITERATE_MEDIA; + break; + + case SS_SCRAPE_ITERATE_MEDIA: + if (ss->media_kind >= SS_MEDIA_LAST) + { + /* Entry complete */ + if (ss->game) + { + screenscraper_game_free(ss->game); + ss->game = NULL; + } + ss->list_index++; + ss->status = ss->single_entry + ? SS_SCRAPE_END : SS_SCRAPE_ITERATE_ENTRY; + break; + } + + if (ss_scrape_request_media(ss)) + ss->status = SS_SCRAPE_WAIT_MEDIA; + else + { + ss->media_kind++; + ss->attempt = 0; + } + break; + + case SS_SCRAPE_WAIT_MEDIA: + if (!ss->http_task) + ss->flags |= SS_SCRAPE_FLAG_HTTP_COMPLETE; + + if (!(ss->flags & SS_SCRAPE_FLAG_HTTP_COMPLETE)) + break; + + ss->http_task = NULL; + + { + char dest[PATH_MAX_LENGTH]; + /* When the download failed, move on to the next source of + * the chain; the request logic advances the kind once the + * chain is exhausted */ + if ( ss_scrape_current_dest(ss, dest, sizeof(dest)) + && !path_is_valid(dest)) + ss->attempt++; + else + { + ss->media_kind++; + ss->attempt = 0; + } + } + ss->status = SS_SCRAPE_ITERATE_MEDIA; + break; + + case SS_SCRAPE_END: + default: + task_set_progress(task, 100); + goto task_finished; + } + + return; + +task_finished: + if (task) + task_set_flags(task, RETRO_TASK_FLG_FINISHED, true); + + if (ss && !ss->single_entry) + { + if ( screenscraper_quota_exhausted() + && !ss->quota_continue_libretro + && ss->list_index < ss->list_size) + { + /* Paused part way: remember the spot and say so, once */ + char msg[128]; + size_t _len = strlcpy(msg, + msg_hash_to_str(MSG_SCREENSCRAPER_QUOTA_PAUSED), + sizeof(msg)); + ss_scrape_resume_save(ss); + runloop_msg_queue_push(msg, _len, 2, 300, true, NULL, + MESSAGE_QUEUE_ICON_DEFAULT, MESSAGE_QUEUE_CATEGORY_INFO); + } + else if (ss->list_index >= ss->list_size) + ss_scrape_resume_clear(ss->dir_thumbnails); + } + + ss_scrape_free_handle(ss); + if (task) + task->state = NULL; +} + +static bool task_screenscraper_finder(retro_task_t *task, void *user_data) +{ + ss_scrape_handle_t *ss = NULL; + + if (!task || !user_data) + return false; + if (task->handler != task_screenscraper_handler) + return false; + if (!(ss = (ss_scrape_handle_t*)task->state)) + return false; + + return string_is_equal( + (const char*)user_data, ss->playlist_config.path); +} + +/*************************/ +/* Task Push */ +/*************************/ + +static void ss_scrape_copy_settings(ss_scrape_handle_t *ss, + settings_t *settings); + +bool task_push_pl_screenscraper( + const char *system, + const playlist_config_t *playlist_config, + const char *dir_thumbnails) +{ + return task_push_pl_screenscraper_at(system, playlist_config, + dir_thumbnails, 0); +} + +/* Same, but begins at 'start_index' - used to resume a scrape that the + * request allowance interrupted. */ +bool task_push_pl_screenscraper_at( + const char *system, + const playlist_config_t *playlist_config, + const char *dir_thumbnails, + size_t start_index) +{ + task_finder_data_t find_data; + settings_t *settings = config_get_ptr(); + retro_task_t *task = NULL; + ss_scrape_handle_t *ss = NULL; + + if ( !settings + || !playlist_config + || string_is_empty(system) + || string_is_empty(playlist_config->path) + || string_is_empty(dir_thumbnails)) + return false; + + /* Concurrent scrapes of the same playlist make no sense */ + find_data.func = task_screenscraper_finder; + find_data.userdata = (void*)playlist_config->path; + if (task_queue_find(&find_data)) + return false; + + if (!(task = task_init())) + return false; + + if (!(ss = (ss_scrape_handle_t*)calloc(1, sizeof(*ss)))) + { + free(task); + return false; + } + + ss->system = strdup(system); + ss->playlist_path = strdup(playlist_config->path); + ss->dir_thumbnails = strdup(dir_thumbnails); + ss->status = SS_SCRAPE_BEGIN; + ss->list_index = start_index; + + if (!playlist_config_copy(playlist_config, &ss->playlist_config)) + { + ss_scrape_free_handle(ss); + free(task); + return false; + } + + ss_scrape_copy_settings(ss, settings); + + task->handler = task_screenscraper_handler; + task->state = ss; + task->title = strdup(system); + task->progress = 0; + task->progress_cb = task_window_progress_cb; + task->flags |= RETRO_TASK_FLG_ALTERNATIVE_LOOK; + + task_queue_push(task); + + return true; +} + +/* Copies the scraper-relevant settings into the handle, so mid-scrape + * changes cannot corrupt the run */ +static void ss_scrape_copy_settings(ss_scrape_handle_t *ss, + settings_t *settings) +{ + strlcpy(ss->username, settings->arrays.screenscraper_username, + sizeof(ss->username)); + strlcpy(ss->password, settings->arrays.screenscraper_password, + sizeof(ss->password)); + strlcpy(ss->devid, settings->arrays.screenscraper_devid, + sizeof(ss->devid)); + strlcpy(ss->devpassword, settings->arrays.screenscraper_devpassword, + sizeof(ss->devpassword)); + strlcpy(ss->region, + screenscraper_region_code(settings->uints.screenscraper_region), + sizeof(ss->region)); + strlcpy(ss->language, + screenscraper_language_code(settings->uints.screenscraper_language), + sizeof(ss->language)); + + ss->media_enabled[SS_MEDIA_BOXART] = + settings->bools.screenscraper_media_boxarts; + ss->media_enabled[SS_MEDIA_SNAP] = + settings->bools.screenscraper_media_snaps; + ss->media_enabled[SS_MEDIA_TITLE] = + settings->bools.screenscraper_media_titles; + ss->media_enabled[SS_MEDIA_LOGO] = + settings->bools.screenscraper_media_logos; + ss->media_enabled[SS_MEDIA_BOXART_3D] = + settings->bools.screenscraper_media_boxarts3d; + ss->media_enabled[SS_MEDIA_FANART] = + settings->bools.screenscraper_media_fanarts; + ss->media_enabled[SS_MEDIA_MARQUEE] = + settings->bools.screenscraper_media_marquees; + ss->media_enabled[SS_MEDIA_VIDEO] = + settings->bools.screenscraper_media_videos; + ss->media_enabled[SS_MEDIA_MANUAL] = + settings->bools.screenscraper_media_manuals; + ss->media_enabled[SS_MEDIA_BEZEL] = + settings->bools.screenscraper_media_bezels; + ss->metadata_enabled = + settings->bools.screenscraper_metadata; + ss->overwrite = + settings->bools.screenscraper_overwrite; + ss->use_crc = + settings->bools.screenscraper_use_crc; + ss->prefer_libretro = + (settings->uints.screenscraper_primary_scraper == 1); + ss->quota_continue_libretro = + (settings->uints.screenscraper_quota_action == 1); +} + +/*************************/ +/* Resume after a pause */ +/*************************/ + +typedef struct +{ + char system[NAME_MAX_LENGTH]; + char playlist_path[PATH_MAX_LENGTH]; + char dir_thumbnails[PATH_MAX_LENGTH]; + size_t index; +} ss_resume_t; + +/* ssuserInfos.php callback: resume the saved scrape when the account + * has requests left again. */ +static void ss_resume_user_info_cb(retro_task_t *task, void *task_data, + void *user_data, const char *err) +{ + http_transfer_data_t *data = (http_transfer_data_t*)task_data; + ss_resume_t *resume = (ss_resume_t*)user_data; + screenscraper_quota_t quota; + settings_t *settings = config_get_ptr(); + + if (!resume) + return; + + if ( !data || !data->data || data->status != 200 + || !screenscraper_parse_user_info(data->data, data->len, "a)) + { + RARCH_WARN("[ScreenScraper] Could not read account status; " + "the paused scrape stays paused.\n"); + goto finish; + } + + RARCH_LOG("[ScreenScraper] Account allowance: %u/%u requests used.\n", + quota.used, quota.max); + + if (quota.max > 0 && quota.used >= quota.max) + { + screenscraper_set_quota_exhausted(true); + RARCH_LOG("[ScreenScraper] Allowance still spent; " + "scrape of \"%s\" remains paused at entry %u.\n", + resume->system, (unsigned)resume->index); + goto finish; + } + + /* Headroom again: pick the scrape up where it stopped */ + screenscraper_set_quota_exhausted(false); + + if (settings) + { + playlist_config_t cfg; + cfg.capacity = COLLECTION_SIZE; + cfg.old_format = settings->bools.playlist_use_old_format; + cfg.compress = settings->bools.playlist_compression; + cfg.fuzzy_archive_match = settings->bools.playlist_fuzzy_archive_match; + cfg.autofix_paths = false; + cfg.path[0] = '\0'; + cfg.base_content_directory[0] = '\0'; + playlist_config_set_path(&cfg, resume->playlist_path); + + if (task_push_pl_screenscraper_at(resume->system, &cfg, + resume->dir_thumbnails, resume->index)) + { + char msg[128]; + size_t _len = strlcpy(msg, + msg_hash_to_str(MSG_SCREENSCRAPER_QUOTA_RESUMED), + sizeof(msg)); + RARCH_LOG("[ScreenScraper] Resuming \"%s\" at entry %u.\n", + resume->system, (unsigned)resume->index); + runloop_msg_queue_push(msg, _len, 2, 300, true, NULL, + MESSAGE_QUEUE_ICON_DEFAULT, MESSAGE_QUEUE_CATEGORY_INFO); + } + } + +finish: + free(resume); +} + +/* Called once at startup: if a scrape was paused by the request + * allowance, ask the service whether there is headroom again and, if + * so, carry on from the saved entry. */ +bool task_push_screenscraper_resume_check(void) +{ + char path[PATH_MAX_LENGTH]; + char url[2048]; + char *file_buf = NULL; + int64_t file_len = 0; + settings_t *settings = config_get_ptr(); + ss_resume_t *resume = NULL; + screenscraper_creds_t creds; + struct string_list *lines = NULL; + + if (!settings || !screenscraper_signed_in()) + return false; + if (string_is_empty(settings->paths.directory_thumbnails)) + return false; + + ss_scrape_resume_path(settings->paths.directory_thumbnails, + path, sizeof(path)); + if (!path_is_valid(path)) + return false; + + if (!filestream_read_file(path, (void**)&file_buf, &file_len) + || !file_buf) + return false; + + lines = string_split(file_buf, "\n"); + free(file_buf); + + if (!lines || lines->size < 3) + { + if (lines) + string_list_free(lines); + return false; + } + + if (!(resume = (ss_resume_t*)calloc(1, sizeof(*resume)))) + { + string_list_free(lines); + return false; + } + + strlcpy(resume->system, lines->elems[0].data, sizeof(resume->system)); + resume->index = (size_t)strtoul(lines->elems[1].data, NULL, 10); + strlcpy(resume->playlist_path, lines->elems[2].data, + sizeof(resume->playlist_path)); + strlcpy(resume->dir_thumbnails, settings->paths.directory_thumbnails, + sizeof(resume->dir_thumbnails)); + string_list_free(lines); + + if ( string_is_empty(resume->system) + || string_is_empty(resume->playlist_path) + || !path_is_valid(resume->playlist_path)) + { + free(resume); + return false; + } + + creds.devid = settings->arrays.screenscraper_devid; + creds.devpassword = settings->arrays.screenscraper_devpassword; + creds.username = settings->arrays.screenscraper_username; + creds.password = settings->arrays.screenscraper_password; + creds.region = screenscraper_region_code( + settings->uints.screenscraper_region); + creds.language = screenscraper_language_code( + settings->uints.screenscraper_language); + + if (!screenscraper_build_user_info_url(url, sizeof(url), &creds)) + { + free(resume); + return false; + } + + RARCH_LOG("[ScreenScraper] Paused scrape found; checking allowance.\n"); + + if (!task_push_http_transfer(url, true, NULL, + ss_resume_user_info_cb, resume)) + { + free(resume); + return false; + } + + return true; +} + +/* On-demand variant: scrapes a single playlist entry. Lets artwork + * appear while browsing, using ScreenScraper's hash/name matching + * rather than the libretro server's exact-label lookup. */ +/* Metadata backfill: an entry whose thumbnails are all present never + * fires the on-demand artwork path again, so a missing sidecar would + * stay missing forever. Menu drivers call this when a sidecar load + * fails; it pushes a single-entry scrape only when the file is + * genuinely absent - a not-found marker counts as present, so entries + * the service does not know are not re-queried on every browse. */ +bool task_push_pl_entry_screenscraper_meta( + const char *system, + const char *db_name, + const char *img_name, + playlist_t *playlist, + unsigned idx) +{ + char path[PATH_MAX_LENGTH]; + settings_t *settings = config_get_ptr(); + + if (!settings || !playlist) + return false; + if (!settings->bools.network_on_demand_thumbnails) + return false; + if (!settings->bools.screenscraper_metadata) + return false; + if (!screenscraper_signed_in()) + return false; + if (!screenscraper_metadata_path(path, sizeof(path), + settings->paths.directory_thumbnails, db_name, img_name)) + return false; + if (path_is_valid(path)) + return false; + + return task_push_pl_entry_screenscraper(system, playlist, idx); +} + +bool task_push_pl_entry_screenscraper( + const char *system, + playlist_t *playlist, + unsigned idx) +{ + settings_t *settings = config_get_ptr(); + const char *conf_path = NULL; + retro_task_t *task = NULL; + ss_scrape_handle_t *ss = NULL; + playlist_config_t cfg; + + if ( !settings || !playlist + || string_is_empty(system) + || !screenscraper_signed_in()) + return false; + if (string_is_empty(settings->paths.directory_thumbnails)) + return false; + if (!(conf_path = playlist_get_conf_path(playlist)) + || string_is_empty(conf_path)) + return false; + + cfg.capacity = COLLECTION_SIZE; + cfg.old_format = settings->bools.playlist_use_old_format; + cfg.compress = settings->bools.playlist_compression; + cfg.fuzzy_archive_match = settings->bools.playlist_fuzzy_archive_match; + cfg.autofix_paths = false; + cfg.path[0] = '\0'; + cfg.base_content_directory[0] = '\0'; + playlist_config_set_path(&cfg, conf_path); + + /* One in-flight scrape per playlist is plenty; while a scrape of + * this playlist is already running, further on-demand requests are + * simply dropped */ + { + task_finder_data_t find_data; + find_data.func = task_screenscraper_finder; + find_data.userdata = (void*)cfg.path; + if (task_queue_find(&find_data)) + return false; + } + + if (!(task = task_init())) + return false; + + if (!(ss = (ss_scrape_handle_t*)calloc(1, sizeof(*ss)))) + { + free(task); + return false; + } + + ss->system = strdup(system); + ss->playlist_path = strdup(conf_path); + ss->dir_thumbnails = strdup(settings->paths.directory_thumbnails); + ss->status = SS_SCRAPE_BEGIN; + ss->single_entry = true; + ss->list_index = idx; + + if (!playlist_config_copy(&cfg, &ss->playlist_config)) + { + ss_scrape_free_handle(ss); + free(task); + return false; + } + + ss_scrape_copy_settings(ss, settings); + + task->handler = task_screenscraper_handler; + task->state = ss; + task->title = strdup(system); + task->progress = 0; + task->flags |= RETRO_TASK_FLG_MUTE; + + task_queue_push(task); + return true; +} diff --git a/tasks/tasks_internal.h b/tasks/tasks_internal.h index 3b5af70e6db5..a6941c9296ad 100644 --- a/tasks/tasks_internal.h +++ b/tasks/tasks_internal.h @@ -170,6 +170,38 @@ bool task_push_pl_thumbnail_download( const char *dir_thumbnails); #endif +/* ScreenScraper playlist scrape (metadata + media) */ +bool task_push_pl_screenscraper( + const char *system, + const playlist_config_t *playlist_config, + const char *dir_thumbnails); + +/* As above, resuming at a given playlist entry */ +bool task_push_pl_screenscraper_at( + const char *system, + const playlist_config_t *playlist_config, + const char *dir_thumbnails, + size_t start_index); + +/* ScreenScraper on-demand scrape of a single playlist entry */ +bool task_push_pl_entry_screenscraper( + const char *system, + playlist_t *playlist, + unsigned idx); + +/* As above, but only when the entry's metadata sidecar is genuinely + * absent - lets menu drivers backfill metadata for entries whose + * artwork already exists (which never fire the artwork path again) */ +bool task_push_pl_entry_screenscraper_meta( + const char *system, + const char *db_name, + const char *img_name, + playlist_t *playlist, + unsigned idx); + +/* Startup: resume a scrape that the request allowance interrupted */ +bool task_push_screenscraper_resume_check(void); + #endif /* Core backup/restore tasks */ diff --git a/tools/settings_table.reference b/tools/settings_table.reference index c7bc259704a6..7dbacae889c1 100644 --- a/tools/settings_table.reference +++ b/tools/settings_table.reference @@ -67,6 +67,19 @@ bool video_vsync 1 1 bool video_scan_subframes 1 0 bool video_waitable_swapchains 1 1 bool video_hard_sync 1 0 +bool screenscraper_media_boxarts 1 1 +bool screenscraper_media_snaps 1 1 +bool screenscraper_media_titles 1 1 +bool screenscraper_media_logos 1 1 +bool screenscraper_media_boxarts3d 1 0 +bool screenscraper_media_fanarts 1 0 +bool screenscraper_media_marquees 1 0 +bool screenscraper_media_videos 1 0 +bool screenscraper_media_manuals 1 0 +bool screenscraper_media_bezels 1 0 +bool screenscraper_metadata 1 1 +bool screenscraper_overwrite 1 0 +bool screenscraper_use_crc 1 1 bool menu_throttle_framerate 1 1 bool video_ctx_scaling 1 0 bool netplay_fade_chat 1 1 @@ -214,6 +227,7 @@ bool rewind_enable 1 0 bool playlist_show_entry_idx 1 1 bool menu_linear_filter 1 0 bool xmb_vertical_thumbnails 1 0 +bool xmb_show_metadata_panel 1 0 bool pause_on_disconnect 1 0 bool video_frame_delay_auto 1 0 bool audio_fastpath_s16 1 0 @@ -431,6 +445,10 @@ uint video_shader_subframes 1 1 uint video_max_swapchain_images 1 3 uint video_hard_sync_frames 1 0 uint aspect_ratio_index 1 ASPECT_RATIO_CORE +uint screenscraper_primary_scraper 1 0 +uint screenscraper_quota_action 1 0 +uint screenscraper_region 1 0 +uint screenscraper_language 1 0 uint input_bind_timeout 1 3 uint input_bind_hold 1 1 uint netplay_chat_color_name 1 0x008000 @@ -484,6 +502,7 @@ uint video_scale_integer_axis 1 0 uint video_scale_integer_scaling 1 0 uint microphone_resampler_quality 1 RESAMPLER_QUALITY_LOWER uint menu_ticker_type 1 (TICKER_TYPE_LOOP) +uint ozone_metadata_scroll_style 1 0 uint accessibility_narrator_speech_speed 1 5 uint accessibility_narrator_engine 1 ACCESSIBILITY_NARRATOR_ENGINE_ESPEAK uint menu_screensaver_animation 1 MENU_SCREENSAVER_BLANK @@ -521,6 +540,8 @@ uint video_hdr_subpixel_layout 1 0 uint rewind_granularity 1 1 uint playlist_show_history_icons 1 PLAYLIST_SHOW_HISTORY_ICONS_CONTENT uint rgui_internal_upscale_level 1 RGUI_UPSCALE_NONE +uint menu_dynamic_wallpaper_type 1 0 +uint menu_dynamic_wallpaper_mode 1 1 uint input_auto_game_focus 1 AUTO_GAME_FOCUS_OFF uint video_frame_delay 1 0 uint audio_resampler_quality 1 RESAMPLER_QUALITY_LOWER @@ -711,6 +732,19 @@ bool video_vsync 1 1 bool video_scan_subframes 1 0 bool video_waitable_swapchains 1 1 bool video_hard_sync 1 0 +bool screenscraper_media_boxarts 1 1 +bool screenscraper_media_snaps 1 1 +bool screenscraper_media_titles 1 1 +bool screenscraper_media_logos 1 1 +bool screenscraper_media_boxarts3d 1 0 +bool screenscraper_media_fanarts 1 0 +bool screenscraper_media_marquees 1 0 +bool screenscraper_media_videos 1 0 +bool screenscraper_media_manuals 1 0 +bool screenscraper_media_bezels 1 0 +bool screenscraper_metadata 1 1 +bool screenscraper_overwrite 1 0 +bool screenscraper_use_crc 1 1 bool menu_throttle_framerate 1 1 bool video_ctx_scaling 1 0 bool netplay_fade_chat 1 1 @@ -858,6 +892,7 @@ bool rewind_enable 1 0 bool playlist_show_entry_idx 1 1 bool menu_linear_filter 1 0 bool xmb_vertical_thumbnails 1 0 +bool xmb_show_metadata_panel 1 0 bool pause_on_disconnect 1 0 bool video_frame_delay_auto 1 0 bool audio_fastpath_s16 1 0 @@ -1073,6 +1108,10 @@ uint video_shader_subframes 1 1 uint video_max_swapchain_images 1 3 uint video_hard_sync_frames 1 0 uint aspect_ratio_index 1 ASPECT_RATIO_4_3 +uint screenscraper_primary_scraper 1 0 +uint screenscraper_quota_action 1 0 +uint screenscraper_region 1 0 +uint screenscraper_language 1 0 uint input_bind_timeout 1 3 uint input_bind_hold 1 0 uint netplay_chat_color_name 1 0x008000 @@ -1126,6 +1165,7 @@ uint video_scale_integer_axis 1 0 uint video_scale_integer_scaling 1 0 uint microphone_resampler_quality 1 RESAMPLER_QUALITY_NORMAL uint menu_ticker_type 1 (TICKER_TYPE_LOOP) +uint ozone_metadata_scroll_style 1 0 uint accessibility_narrator_speech_speed 1 5 uint accessibility_narrator_engine 1 ACCESSIBILITY_NARRATOR_ENGINE_ESPEAK uint menu_screensaver_animation 1 MENU_SCREENSAVER_BLANK @@ -1163,6 +1203,8 @@ uint video_hdr_subpixel_layout 1 0 uint rewind_granularity 1 1 uint playlist_show_history_icons 1 PLAYLIST_SHOW_HISTORY_ICONS_CONTENT uint rgui_internal_upscale_level 1 RGUI_UPSCALE_NONE +uint menu_dynamic_wallpaper_type 1 0 +uint menu_dynamic_wallpaper_mode 1 1 uint input_auto_game_focus 1 AUTO_GAME_FOCUS_OFF uint video_frame_delay 1 0 uint audio_resampler_quality 1 RESAMPLER_QUALITY_NORMAL @@ -1350,6 +1392,19 @@ bool video_vsync 1 1 bool video_scan_subframes 1 0 bool video_waitable_swapchains 1 1 bool video_hard_sync 1 0 +bool screenscraper_media_boxarts 1 1 +bool screenscraper_media_snaps 1 1 +bool screenscraper_media_titles 1 1 +bool screenscraper_media_logos 1 1 +bool screenscraper_media_boxarts3d 1 0 +bool screenscraper_media_fanarts 1 0 +bool screenscraper_media_marquees 1 0 +bool screenscraper_media_videos 1 0 +bool screenscraper_media_manuals 1 0 +bool screenscraper_media_bezels 1 0 +bool screenscraper_metadata 1 1 +bool screenscraper_overwrite 1 0 +bool screenscraper_use_crc 1 1 bool menu_throttle_framerate 1 1 bool video_ctx_scaling 1 0 bool netplay_fade_chat 1 1 @@ -1500,6 +1555,7 @@ bool rewind_enable 1 0 bool playlist_show_entry_idx 1 1 bool menu_linear_filter 1 0 bool xmb_vertical_thumbnails 1 0 +bool xmb_show_metadata_panel 1 0 bool pause_on_disconnect 1 0 bool video_frame_delay_auto 1 0 bool audio_fastpath_s16 1 0 @@ -1717,6 +1773,10 @@ uint video_shader_subframes 1 1 uint video_max_swapchain_images 1 3 uint video_hard_sync_frames 1 0 uint aspect_ratio_index 1 ASPECT_RATIO_CORE +uint screenscraper_primary_scraper 1 0 +uint screenscraper_quota_action 1 0 +uint screenscraper_region 1 0 +uint screenscraper_language 1 0 uint input_bind_timeout 1 3 uint input_bind_hold 1 0 uint netplay_chat_color_name 1 0x008000 @@ -1770,6 +1830,7 @@ uint video_scale_integer_axis 1 0 uint video_scale_integer_scaling 1 0 uint microphone_resampler_quality 1 RESAMPLER_QUALITY_NORMAL uint menu_ticker_type 1 (TICKER_TYPE_LOOP) +uint ozone_metadata_scroll_style 1 0 uint accessibility_narrator_speech_speed 1 5 uint accessibility_narrator_engine 1 ACCESSIBILITY_NARRATOR_ENGINE_ESPEAK uint menu_screensaver_animation 1 MENU_SCREENSAVER_BLANK @@ -1807,6 +1868,8 @@ uint video_hdr_subpixel_layout 1 0 uint rewind_granularity 1 1 uint playlist_show_history_icons 1 PLAYLIST_SHOW_HISTORY_ICONS_CONTENT uint rgui_internal_upscale_level 1 RGUI_UPSCALE_NONE +uint menu_dynamic_wallpaper_type 1 0 +uint menu_dynamic_wallpaper_mode 1 1 uint input_auto_game_focus 1 AUTO_GAME_FOCUS_OFF uint video_frame_delay 1 0 uint audio_resampler_quality 1 RESAMPLER_QUALITY_NORMAL