Files
Modrinth-Enhanced/patches/0023-Browse-LittleSkin-s-skin-library.patch
T

618 lines
22 KiB
Diff

From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Thu, 17 Sep 2026 15:52:25 +0200
Subject: [PATCH] Browse LittleSkin's skin library
LittleSkin is an account server, and also one of the largest skin
libraries around. The Browse tab gets it next to Ely.by: its own search
by name, sorting by likes or date and a filter for wide or slim arms,
with the likes on every tile and more pages as the list is scrolled.
Picking a skin previews it, and adding it works for any account.
The list is the JSON LittleSkin's library page loads for itself, and its
robots.txt excludes none of it. Textures come through Rust, as Ely.by's
do: the site sends no CORS headers.
---
.../src/components/ui/skin/SkinBrowser.vue | 165 +++++++++++++++---
apps/app-frontend/src/helpers/skin-browser.ts | 42 ++++-
apps/app/build.rs | 2 +
apps/app/src/api/skin_browser.rs | 20 ++-
packages/app-lib/src/api/skin_browser.rs | 118 ++++++++++++-
5 files changed, 318 insertions(+), 29 deletions(-)
diff --git a/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
index 3d92c68..26646a4 100644
--- a/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
+++ b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
@@ -1,7 +1,7 @@
<!--
Finding skins elsewhere, next to the account's own.
- Ely.by's catalogue is browsed right here. NameMC, laby.net and crafty.gg are
+ Ely.by's catalogue and LittleSkin's library are browsed right here. NameMC, laby.net and crafty.gg are
not: their lists are bot-protected or not meant for other programs, so they
open in a window to browse as usual, and the skin page open there is what
gets previewed. Picking a skin only previews it; the skin page adds it.
@@ -10,10 +10,57 @@
<div class="flex flex-col gap-4">
<Chips v-model="source" :items="SOURCES" :format-label="sourceLabel" size="small" />
- <template v-if="source === 'ely'">
- <Chips v-model="elyMode" :items="ELY_MODES" :format-label="elyModeLabel" size="small" />
+ <template v-if="source === 'ely' || source === 'littleskin'">
+ <div v-if="source === 'littleskin'" class="flex flex-wrap items-center gap-2">
+ <Combobox
+ v-model="littleSkinSort"
+ class="w-max"
+ :options="littleSkinSortOptions"
+ :show-icon-in-selected="false"
+ dropdown-min-width="160px"
+ >
+ <template #prefix>
+ <ArrowUpDownIcon class="size-5 text-primary" />
+ </template>
+ <template #selected="{ label }">
+ <span>{{ label }}</span>
+ </template>
+ </Combobox>
+ <Combobox
+ v-model="littleSkinFilter"
+ class="w-max"
+ :options="littleSkinFilterOptions"
+ :show-icon-in-selected="false"
+ dropdown-min-width="160px"
+ >
+ <template #prefix>
+ <UserIcon class="size-5 text-primary" />
+ </template>
+ <template #selected="{ label }">
+ <span>{{ label }}</span>
+ </template>
+ </Combobox>
+ <Input
+ v-model="littleSkinKeyword"
+ type="search"
+ :icon="SearchIcon"
+ :placeholder="formatMessage(messages.namePlaceholder)"
+ wrapper-class="w-60"
+ />
+ </div>
- <div v-if="elyMode === 'catalogue'" class="flex flex-wrap items-center gap-2">
+ <Chips
+ v-if="source === 'ely'"
+ v-model="elyMode"
+ :items="ELY_MODES"
+ :format-label="elyModeLabel"
+ size="small"
+ />
+
+ <div
+ v-if="source === 'ely' && elyMode === 'catalogue'"
+ class="flex flex-wrap items-center gap-2"
+ >
<Combobox
v-model="sort"
class="w-max"
@@ -87,7 +134,7 @@
/>
</div>
<Input
- v-else
+ v-else-if="source === 'ely'"
v-model="playerTerm"
type="search"
:icon="SearchIcon"
@@ -117,10 +164,18 @@
<span v-tooltip="formatMessage(messages.likes)" class="flex items-center gap-1">
<HeartIcon class="size-4" />{{ compact(tile.stats.likes) }}
</span>
- <span v-tooltip="formatMessage(messages.wearers)" class="flex items-center gap-1">
+ <span
+ v-if="tile.stats.wearers !== undefined"
+ v-tooltip="formatMessage(messages.wearers)"
+ class="flex items-center gap-1"
+ >
<UsersIcon class="size-4" />{{ compact(tile.stats.wearers) }}
</span>
- <span v-tooltip="formatMessage(messages.views)" class="flex items-center gap-1">
+ <span
+ v-if="tile.stats.views !== undefined"
+ v-tooltip="formatMessage(messages.views)"
+ class="flex items-center gap-1"
+ >
<EyeIcon class="size-4" />{{ compact(tile.stats.views) }}
</span>
</template>
@@ -131,7 +186,7 @@
<p v-else-if="!loading" class="m-0 text-secondary">
{{
formatMessage(
- elyMode === 'players' && playerTerm.trim().length < 3
+ source === 'ely' && elyMode === 'players' && playerTerm.trim().length < 3
? messages.playerHint
: messages.nothingFound,
)
@@ -140,7 +195,7 @@
<div class="flex justify-center">
<SpinnerIcon v-if="loading" class="size-6 animate-spin" />
- <Button v-else-if="elyMode === 'catalogue' && hasMore" @click="loadMore">
+ <Button v-else-if="!showsPlayers && hasMore" @click="loadMore">
{{ formatMessage(messages.loadMore) }}
</Button>
</div>
@@ -223,7 +278,11 @@ import {
type ElyModel,
type ElySort,
getElyCatalogue,
+ getLittleSkinLibrary,
+ getLittleSkinTexture,
getSiteSkin,
+ type LittleSkinFilter,
+ type LittleSkinSort,
onSkinSitePage,
openSkinSite,
searchElyPlayers,
@@ -240,19 +299,20 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
-type Source = 'ely' | SkinSite
+type Source = 'ely' | 'littleskin' | SkinSite
type ElyMode = 'catalogue' | 'players'
type Order = 'site' | 'likes' | 'wearers' | 'views'
interface Stats {
likes: number
- wearers: number
- views: number
+ wearers?: number
+ views?: number
}
interface Entry {
key: string
title: string
+ /** Ely.by's texture address, or LittleSkin's texture id. */
url: string
variant: SkinModel
stats?: Stats
@@ -262,10 +322,11 @@ interface Tile extends Entry {
skin?: Skin
}
-const SOURCES: Source[] = ['ely', 'namemc', 'laby', 'crafty']
+const SOURCES: Source[] = ['ely', 'littleskin', 'namemc', 'laby', 'crafty']
const ELY_MODES: ElyMode[] = ['catalogue', 'players']
const SITE_NAMES: Record<Source, string> = {
ely: 'Ely.by',
+ littleskin: 'LittleSkin',
namemc: 'NameMC',
laby: 'laby.net',
crafty: 'crafty.gg',
@@ -284,6 +345,8 @@ const messages = defineMessages({
modelOld: { id: 'app.skins.browse.model.old', defaultMessage: 'Old format' },
modelNew: { id: 'app.skins.browse.model.new', defaultMessage: 'New format' },
modelSlim: { id: 'app.skins.browse.model.slim', defaultMessage: 'Slim' },
+ modelWide: { id: 'app.skins.browse.model.wide', defaultMessage: 'Wide' },
+ namePlaceholder: { id: 'app.skins.browse.name-placeholder', defaultMessage: 'Search by name' },
allCategories: { id: 'app.skins.browse.kind.all', defaultMessage: 'All categories' },
tagsPlaceholder: {
id: 'app.skins.browse.tags-placeholder',
@@ -341,6 +404,9 @@ const kind = ref('')
const tagsText = ref('')
const uploader = ref('')
const playerTerm = ref('')
+const littleSkinSort = ref<LittleSkinSort>('likes')
+const littleSkinFilter = ref<LittleSkinFilter>('skin')
+const littleSkinKeyword = ref('')
const sortOptions = computed<ComboboxOption<ElySort>[]>(() => [
{ value: 'best', label: formatMessage(messages.sortBest) },
@@ -362,6 +428,17 @@ const modelOptions = computed<ComboboxOption<ElyModel>[]>(() => [
{ value: 'slim', label: formatMessage(messages.modelSlim) },
])
+const littleSkinSortOptions = computed<ComboboxOption<LittleSkinSort>[]>(() => [
+ { value: 'likes', label: formatMessage(messages.orderLikes) },
+ { value: 'time', label: formatMessage(messages.sortNew) },
+])
+
+const littleSkinFilterOptions = computed<ComboboxOption<LittleSkinFilter>[]>(() => [
+ { value: 'skin', label: formatMessage(messages.modelAny) },
+ { value: 'steve', label: formatMessage(messages.modelWide) },
+ { value: 'alex', label: formatMessage(messages.modelSlim) },
+])
+
const kindOptions = computed<ComboboxOption<string>[]>(() => [
{ value: '', label: formatMessage(messages.allCategories) },
...ELY_KINDS.map((value) => ({ value, label: value })),
@@ -376,6 +453,7 @@ const pickedKey = ref<string | null>(null)
let generation = 0
const hasMore = computed(() => page.value < lastPage.value)
+const showsPlayers = computed(() => source.value === 'ely' && elyMode.value === 'players')
function skinOf(entry: Entry): Skin | undefined {
const texture = textures.value[entry.key]
@@ -391,10 +469,12 @@ function skinOf(entry: Entry): Skin | undefined {
const tiles = computed<Tile[]>(() => {
const ordered =
- order.value === 'site' || elyMode.value === 'players'
+ order.value === 'site' || source.value !== 'ely' || showsPlayers.value
? entries.value
: [...entries.value].sort(
- (a, b) => (b.stats?.[order.value as keyof Stats] ?? 0) - (a.stats?.[order.value as keyof Stats] ?? 0),
+ (a, b) =>
+ (b.stats?.[order.value as keyof Stats] ?? 0) -
+ (a.stats?.[order.value as keyof Stats] ?? 0),
)
return ordered.map((entry) => ({ ...entry, skin: skinOf(entry) }))
})
@@ -407,11 +487,14 @@ function tags() {
.slice(0, 10)
}
-/** Textures come through Rust: ely.by sends no CORS headers. They land one by one. */
+/** Textures come through Rust: neither site sends CORS headers. They land one by one. */
function loadTextures(list: Entry[], forGeneration: number) {
for (const entry of list) {
if (textures.value[entry.key]) continue
- getElyTexture(entry.url)
+ const request = entry.key.startsWith('browse-littleskin-')
+ ? getLittleSkinTexture(Number(entry.url))
+ : getElyTexture(entry.url)
+ request
.then((texture) => {
if (forGeneration !== generation) return
textures.value = { ...textures.value, [entry.key]: texture }
@@ -455,6 +538,39 @@ async function loadCatalogue(pageNumber: number, forGeneration: number) {
}
}
+async function loadLittleSkin(pageNumber: number, forGeneration: number) {
+ loading.value = true
+ try {
+ const result = await getLittleSkinLibrary({
+ filter: littleSkinFilter.value,
+ sort: littleSkinSort.value,
+ keyword: littleSkinKeyword.value.trim(),
+ page: pageNumber,
+ })
+ if (forGeneration !== generation) return
+
+ const known = new Set(entries.value.map((entry) => entry.key))
+ const added: Entry[] = result.items
+ .map((skin) => ({
+ key: `browse-littleskin-${skin.id}`,
+ title: skin.uploader ? `${skin.name} · ${skin.uploader}` : skin.name,
+ url: String(skin.id),
+ variant: (skin.is_slim ? 'SLIM' : 'CLASSIC') as SkinModel,
+ stats: { likes: skin.likes },
+ }))
+ .filter((entry) => !known.has(entry.key))
+
+ entries.value = [...entries.value, ...added]
+ page.value = result.current || pageNumber
+ lastPage.value = result.has_more ? page.value + 1 : page.value
+ loadTextures(added, forGeneration)
+ } catch (error) {
+ if (forGeneration === generation) handleError(error as Error)
+ } finally {
+ if (forGeneration === generation) loading.value = false
+ }
+}
+
async function loadPlayers(forGeneration: number) {
loading.value = true
try {
@@ -486,6 +602,11 @@ function reload() {
lastPage.value = 1
loading.value = false
+ if (source.value === 'littleskin') {
+ void loadLittleSkin(1, forGeneration)
+ return
+ }
+ if (source.value !== 'ely') return
if (elyMode.value === 'players') {
if (playerTerm.value.trim().length >= 3) void loadPlayers(forGeneration)
return
@@ -494,13 +615,15 @@ function reload() {
}
function loadMore() {
- if (!loading.value && hasMore.value) void loadCatalogue(page.value + 1, generation)
+ if (loading.value || !hasMore.value) return
+ if (source.value === 'littleskin') void loadLittleSkin(page.value + 1, generation)
+ else void loadCatalogue(page.value + 1, generation)
}
-watch([sort, model, kind, elyMode], reload)
+watch([source, sort, model, kind, elyMode, littleSkinSort, littleSkinFilter], reload)
let typingTimeout: ReturnType<typeof setTimeout> | undefined
-watch([tagsText, uploader, playerTerm], () => {
+watch([tagsText, uploader, playerTerm, littleSkinKeyword], () => {
clearTimeout(typingTimeout)
typingTimeout = setTimeout(reload, 500)
})
@@ -538,7 +661,7 @@ let unlistenSite: UnlistenFn | undefined
let unmounted = false
function openSite() {
- if (source.value === 'ely') return
+ if (source.value === 'ely' || source.value === 'littleskin') return
openSkinSite(source.value).catch((error) => handleError(error as Error))
}
diff --git a/apps/app-frontend/src/helpers/skin-browser.ts b/apps/app-frontend/src/helpers/skin-browser.ts
index ea86e86..e3c4b21 100644
--- a/apps/app-frontend/src/helpers/skin-browser.ts
+++ b/apps/app-frontend/src/helpers/skin-browser.ts
@@ -1,9 +1,11 @@
/**
* Finding skins on other sites, for the skin page.
*
- * Ely.by's catalogue is read directly. NameMC, laby.net and crafty.gg open in a
- * window the player browses; the launcher only learns which skin page it is on.
+ * Ely.by's catalogue and LittleSkin's library are read directly. NameMC, laby.net
+ * and crafty.gg open in a window the player browses; the launcher only learns
+ * which skin page it is on.
*/
+import { arrayBufferToBase64 } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
@@ -39,6 +41,30 @@ export interface ElyCataloguePage {
total: number
}
+export type LittleSkinFilter = 'skin' | 'steve' | 'alex'
+export type LittleSkinSort = 'likes' | 'time'
+
+export interface LittleSkinQuery {
+ filter: LittleSkinFilter
+ sort: LittleSkinSort
+ keyword: string
+ page: number
+}
+
+export interface LittleSkinSkin {
+ id: number
+ name: string
+ uploader: string
+ is_slim: boolean
+ likes: number
+}
+
+export interface LittleSkinPage {
+ items: LittleSkinSkin[]
+ current: number
+ has_more: boolean
+}
+
export interface ElyPlayer {
nickname: string
skin_url: string | null
@@ -86,6 +112,18 @@ export async function searchElyPlayers(term: string): Promise<ElyPlayer[]> {
return await invoke('plugin:skin-browser|skin_browser_ely_players', { term })
}
+export async function getLittleSkinLibrary(query: LittleSkinQuery): Promise<LittleSkinPage> {
+ return await invoke('plugin:skin-browser|skin_browser_littleskin_library', { query })
+}
+
+/** A LittleSkin texture as a data URL. It comes through Rust, as Ely.by's do. */
+export async function getLittleSkinTexture(id: number): Promise<string> {
+ const bytes = await invoke<number[]>('plugin:skin-browser|skin_browser_littleskin_texture', {
+ id,
+ })
+ return `data:image/png;base64,${arrayBufferToBase64(new Uint8Array(bytes))}`
+}
+
export async function getSiteSkin(site: SkinSite, id: string): Promise<SiteSkin> {
return await invoke('plugin:skin-browser|skin_browser_site_skin', { site, id })
}
diff --git a/apps/app/build.rs b/apps/app/build.rs
index e33a52d..ac124d9 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -54,6 +54,8 @@ fn main() {
.commands(&[
"skin_browser_ely_catalogue",
"skin_browser_ely_players",
+ "skin_browser_littleskin_library",
+ "skin_browser_littleskin_texture",
"skin_browser_site_skin",
"skin_browser_open_site",
])
diff --git a/apps/app/src/api/skin_browser.rs b/apps/app/src/api/skin_browser.rs
index cfd0d8c..e4451cf 100644
--- a/apps/app/src/api/skin_browser.rs
+++ b/apps/app/src/api/skin_browser.rs
@@ -1,6 +1,7 @@
//! Browsing skins from the skin page.
//!
-//! Ely.by's catalogue is read directly (see `theseus::skin_browser`). NameMC,
+//! Ely.by's catalogue and LittleSkin's library are read directly (see
+//! `theseus::skin_browser`). NameMC,
//! laby.net and crafty.gg open in a window the player browses like any browser;
//! the launcher only watches which page that window is on, so that the skin
//! page the player has open can be previewed and added.
@@ -13,7 +14,8 @@ use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder,
};
use theseus::skin_browser::{
- self, ElyCatalogueQuery, ElyCataloguePage, ElyPlayer, SiteSkin, SkinSite,
+ self, ElyCatalogueQuery, ElyCataloguePage, ElyPlayer, LittleSkinPage,
+ LittleSkinQuery, SiteSkin, SkinSite,
};
use crate::api::{Result, TheseusSerializableError};
@@ -23,6 +25,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
skin_browser_ely_catalogue,
skin_browser_ely_players,
+ skin_browser_littleskin_library,
+ skin_browser_littleskin_texture,
skin_browser_site_skin,
skin_browser_open_site,
])
@@ -53,6 +57,18 @@ pub async fn skin_browser_ely_players(term: String) -> Result<Vec<ElyPlayer>> {
Ok(skin_browser::ely_players(&term).await?)
}
+#[tauri::command]
+pub async fn skin_browser_littleskin_library(
+ query: LittleSkinQuery,
+) -> Result<LittleSkinPage> {
+ Ok(skin_browser::littleskin_library(&query).await?)
+}
+
+#[tauri::command]
+pub async fn skin_browser_littleskin_texture(id: u64) -> Result<Vec<u8>> {
+ Ok(skin_browser::littleskin_texture(id).await?)
+}
+
#[tauri::command]
pub async fn skin_browser_site_skin(
site: SkinSite,
diff --git a/packages/app-lib/src/api/skin_browser.rs b/packages/app-lib/src/api/skin_browser.rs
index fba085b..9038989 100644
--- a/packages/app-lib/src/api/skin_browser.rs
+++ b/packages/app-lib/src/api/skin_browser.rs
@@ -1,9 +1,10 @@
//! Browsing skins from other sites.
//!
-//! Only Ely.by's catalogue is read from here: its robots.txt invites it. NameMC,
-//! laby.net and crafty.gg are not - their lists are bot-protected or not meant
-//! for other programs - so the player browses them in a window of the desktop
-//! shell, and all that is read from them is the one skin whose page is open.
+//! Ely.by's catalogue and LittleSkin's skin library are read from here: Ely.by's
+//! robots.txt invites it, and LittleSkin's excludes nothing. NameMC, laby.net and
+//! crafty.gg are not - their lists are bot-protected or not meant for other
+//! programs - so the player browses them in a window of the desktop shell, and
+//! all that is read from them is the one skin whose page is open.
use base64::Engine;
use serde::{Deserialize, Serialize};
@@ -194,6 +195,115 @@ pub async fn ely_players(term: &str) -> crate::Result<Vec<ElyPlayer>> {
.map_err(|error| failed("read the Ely.by search", error))
}
+/// A search of LittleSkin's skin library, as the library page sends it.
+#[derive(Deserialize, Debug)]
+pub struct LittleSkinQuery {
+ /// `skin` for either model, `steve` or `alex`.
+ pub filter: String,
+ /// `likes` or `time`.
+ pub sort: String,
+ pub keyword: String,
+ pub page: u32,
+}
+
+#[derive(Serialize, Debug)]
+pub struct LittleSkinSkin {
+ pub id: u64,
+ pub name: String,
+ pub uploader: String,
+ pub is_slim: bool,
+ pub likes: u64,
+}
+
+#[derive(Serialize, Debug)]
+pub struct LittleSkinPage {
+ pub items: Vec<LittleSkinSkin>,
+ pub current: u32,
+ pub has_more: bool,
+}
+
+/// One page of LittleSkin's skin library, a Blessing Skin one.
+pub async fn littleskin_library(
+ query: &LittleSkinQuery,
+) -> crate::Result<LittleSkinPage> {
+ #[derive(Deserialize)]
+ struct RawPage {
+ #[serde(default)]
+ current_page: u32,
+ next_page_url: Option<String>,
+ data: Vec<RawTexture>,
+ }
+
+ #[derive(Deserialize)]
+ struct RawTexture {
+ tid: u64,
+ #[serde(default)]
+ name: String,
+ #[serde(rename = "type")]
+ kind: String,
+ #[serde(default)]
+ likes: u64,
+ #[serde(default)]
+ nickname: String,
+ }
+
+ if !matches!(query.filter.as_str(), "skin" | "steve" | "alex") {
+ return Err(invalid("skin model"));
+ }
+ if !matches!(query.sort.as_str(), "likes" | "time") {
+ return Err(invalid("sort"));
+ }
+ if !plain(&query.keyword, 64) {
+ return Err(invalid("search"));
+ }
+
+ let page = query.page.max(1).to_string();
+ let url = url::Url::parse_with_params(
+ "https://littleskin.cn/skinlib/list",
+ &[
+ ("filter", query.filter.as_str()),
+ ("sort", query.sort.as_str()),
+ ("keyword", query.keyword.trim()),
+ ("page", page.as_str()),
+ ],
+ )
+ .map_err(|error| failed("build the library address", error))?;
+
+ let page: RawPage = REQWEST_CLIENT
+ .get(url)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .and_then(|response| response.error_for_status())
+ .map_err(|error| failed("load the LittleSkin library", error))?
+ .json()
+ .await
+ .map_err(|error| failed("read the LittleSkin library", error))?;
+
+ Ok(LittleSkinPage {
+ items: page
+ .data
+ .into_iter()
+ // Capes share the library with skins.
+ .filter(|texture| matches!(texture.kind.as_str(), "steve" | "alex"))
+ .map(|texture| LittleSkinSkin {
+ id: texture.tid,
+ name: texture.name,
+ uploader: texture.nickname,
+ is_slim: texture.kind == "alex",
+ likes: texture.likes,
+ })
+ .collect(),
+ current: page.current_page,
+ has_more: page.next_page_url.is_some(),
+ })
+}
+
+/// A skin from LittleSkin's library.
+pub async fn littleskin_texture(id: u64) -> crate::Result<Vec<u8>> {
+ png(&format!("https://littleskin.cn/raw/{id}"), "LittleSkin").await
+}
+
/// A site that is browsed in the launcher's window.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]