diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 24555fd1..dfdbd479 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -1,11 +1,23 @@ +appid +apikey +apiname +appdetails +appids +appinfo deno gpgarmor github githubassets https +IPlayer +ISteam leetcode +Nie npx +personaname pgn +playerstats +rtime scm shas splatoon @@ -13,5 +25,13 @@ Splatnet ssh statink STATINK +steamcommunity +steamid +steamids +steampowered +timecreated ubuntu +unlocktime +userid yargsparser +webtoken diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 51b24af1..7b180c74 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -55,3 +55,4 @@ ignore$ ^\Qsource/templates/terminal/partials/screenshot.ejs\E$ ^\Qtests/mocks/api/github/rest/emojis/get.mjs\E$ ^\Qtests/mocks/api/axios/get/lichess.mjs\E$ +^\Qtests/mocks/api/axios/get/steam.mjs\E$ \ No newline at end of file diff --git a/.github/readme/imgs/plugin_steam_userid.png b/.github/readme/imgs/plugin_steam_userid.png new file mode 100644 index 00000000..30ede968 Binary files /dev/null and b/.github/readme/imgs/plugin_steam_userid.png differ diff --git a/.github/readme/imgs/plugin_steam_webtoken.png b/.github/readme/imgs/plugin_steam_webtoken.png new file mode 100644 index 00000000..63e83717 Binary files /dev/null and b/.github/readme/imgs/plugin_steam_webtoken.png differ diff --git a/source/app/web/statics/embed/app.placeholder.js b/source/app/web/statics/embed/app.placeholder.js index f671abaa..da229afd 100644 --- a/source/app/web/statics/embed/app.placeholder.js +++ b/source/app/web/statics/embed/app.placeholder.js @@ -1035,6 +1035,104 @@ }, }) : null), + //Steam + ...(set.plugins.enabled.steam + ? ({ + steam: { + sections: options["anilist.sections"].split(",").map(x => x.trim()).filter(x => x), + player: { + level: faker.datatype.number(100), + avatar: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==", + created: 1366386002, + name: faker.internet.userName(), + }, + games: { + count: 2, + playtime: 89.23333333333333, + achievements: 0, + "most-played": [ + { + id: 524220, + name: "NieR:Automata™", + icon: + "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABAAEADASIAAhEBAxEB/8QAHAAAAgMBAQEBAAAAAAAAAAAABgcFCAkEAwEA/8QAMBAAAQMDAwEGBQQDAAAAAAAAAQIDBAAFEQYHITESExQiQVEjMkJhcQhSobEVgcH/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8ArPebk9cpIW+U8D0FcJUTj39q+Kxnjp6VN6N0zcdV3xi12lpTjzqgCoDIQPc0EKlZ6YJI6Yrvt1oudwIRAhPPqV+1NXb252F01pmHGfuDHjLoPMtazlGfbsmmoxZbXHwI9uhN4/aykH+qDNa5afvNsBE+3vsgj6k1EqKgeRhXTmtQXbTb3jh6BEcT7LZSo/yKXevNktKaqbkOGGIk5acNuMnsJSfwKCgXbUlWfqrpts12DLS8zgqB5yKJNydDXLQl9ct9xQe7J+G5jhQ9KEU/MMUH5XUZOR9qu1+l7Q8fS+jDfLk0lufLyVLWPlb6giqdaYgpueorfDI8rzoSRWhGpbZKe20MK15D6YiEoSnqSAKCMTuzZv8AMeFUy6Ifb7vx2fhAjqKYTD7chpDzCgttYCkqHQikM3fbEnbB6yqhpevThU34NKfi9vI82cUz9EKfs+lILd+eS24oAISRykY4BoC5RwCSQPzS9uu6tmgXZcRCHJEdo4elIPkb980R3iWLvZp8ayS21TO78uOopC27VtqsGjLzpm/2l1m8rU4UhxIKpBUrjBxQMHfPSkDX+3L8yEEPSmGy9GcQOSeOKoU60piQ404PM2opUPYjitDNl7XPg6Ebau6VBTqlLS2vqlB6CqQbsWtFn3AusZsdlJcLmPyTQRGkZibfqq2ynD5Wngo1ou5Pks6et8yE33iCyhS04z5SkVmecpWCnqORV7/0266Z1ZoliHJfQq5RPI40evYHAoCKdqvR0BwyViCLljPd9gBeag7rutF75pk2N98k+TBHP4rx3F2YiX68qvlqfVGuSQMIUSUEj7UAzdtNwo0pl+LLjPOtqzgNelAwY+58W3zA2rT8lhx35jkDipuJrLRV5mI8aICbik5Sh5sKXn84pSx9ttwrhOW7cJUdBUAAe66Ue6G2WhWq6ovF+f8AG3JGCgtkpQD9x60DMtEp+WHHFoCI2MN4GOKz73lnouW410eRyAstjHuCauzvNrGLonQ81/vEty3GyiMgcEqrPSXJXMmyJLxy68srJ+5OaDxX81EOhNWXHR1/YudsdUlTagVIB4UPYioi4wZEJ/upCCleM1yhJ59KC/8AtzvRpnV8NhLstuFcSACw6rlSvXFM5JT84UCFDg54rLZpx1hxLsdS2lJ6KScEf7ootm4OqbatCmLvJV2eneOFQ/ug0jzzyefzQDr3dfTOjWHRMnNPTUDiM2rzVR27bjaruR7Ui7SEk8ZbWU/9oXlyZEt0uS3nH3T9S1FR/mgLd0df3LXl9cmTVqEYKIaazwkenFBaeo9K+dk56V7w4zsqQGWkFS1dAKD/2Q==", + playtime: 44.88333333333333, + played: 1582407120, + description: "NieR: Automata tells the story of androids 2B, 9S and A2 and their battle to reclaim the machine-driven dystopia overrun by powerful machines.", + genres: [ + "Action", + "RPG", + ], + achievements: [ + { + icon: + "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAAUGBwP/xAAmEAABAgQDCQAAAAAAAAAAAAABAAIDBAUGBxIUFiMxNVNhc5Gx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAME/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A6XVcM5QqlppTK+G4GJvSXEEngOyT7d1TpwPRRiVz5niH0qTWNVWbd1TpwPRTi1bhnK7UtNN5WQ2gRN0S0kg8D2Wdqsw158/xH6EBiVz5niH0qTWiXVb05XalqZTKyG0GHvQWkkHiOyT7CVTqQPZQSarMNefP8R+hGwlU6kD2U4tW3pyhVLUzeV8NwEPdAuIJPE9kH//Z", + achieved: true, + unlocked: 1565976624, + name: "Transcendent Being", + description: "", + id: "ACH_BAD_END", + }, + { + icon: + "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGAABAQEBAQAAAAAAAAAAAAAABQQDAAL/xAAtEAABAwMCBAQGAwAAAAAAAAABAgMEAAURBiISITFBExQjMlFhcYGRsRXB0f/EABcBAQEBAQAAAAAAAAAAAAAAAAABAwT/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwC3UF7lWeeY8bc2rLnqKJIJPT6VhE1BdpCgAhtORy2qJ/ArxqprxtRIB7IH7pixMJZfZWOQPEB+DXG1FSL/AHhj3soB64IINU6evMm8TvLytqEYX6aikkg9D8vlSmomEPcK8hWEYIznuaE0uz4N/cx3R/dQdqdt3+cDkfgUtLZ2LOAr4DPaqbOiDeZrcW4w5cd2Mkq3HiQVBW4Ajl7eX3OOlb3W2yLjMMiPxJSklO9JByDjP0q+2tzIiAlyO27j4k1QfdotvsD7zjEOa9Hkoy64yBhrh6de2P1U2mXmpN2W8wh5Dakjh8UYKh3OKcuKZUpspbitNkjBOT/tHWu3yLfN8xJClpWQnYkqIyeX2oP/2Q==", + achieved: true, + unlocked: 1565976316, + name: "A Round by the Pond", + description: "20 different kinds of fish caught.", + id: "ACH_FISHING", + }, + ], + rate: { + total: 47, + achieved: 47, + }, + }, + ], + "recently-played": [ + { + id: 1113560, + name: "NieR Replicant ver.1.22474487139...", + icon: + "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABAAEADASIAAhEBAxEB/8QAHAAAAgIDAQEAAAAAAAAAAAAABgcDBQEECAIA/8QAMBAAAQMDAgUCBgEFAQAAAAAAAQIDBAAFERIhBhMxQVEHYRQiIzJCcRUIJFJigcH/xAAYAQADAQEAAAAAAAAAAAAAAAABAgMABP/EAB8RAAICAwACAwAAAAAAAAAAAAABAhEDEiEEQSIxYf/aAAwDAQACEQMRAD8A6m228CgT1PmN/DtMDd1s6z+jRfebg3abVJnSN2mEa1fqubeKOOBxNcBYoTwTcpaylxfX6Z+0DwaWT9FsMbezNluY5e3Jdpt0hoMqRh94jIx4HvV9ZbVHs0NMa3r5YTuvmb6vcV9Bsjdkt6GorJ0gfMvuVd81aWpUfWEqBKldSqpHV+lhHZCmNTbqSfyFYdjoQgKdSS0fzHQVOuGIriXUk8rqfet55DcVkTYg5jB+9pW9YWynjKkR5BcjFL7H+o3o1tPEbbiEtyxy1dM0K3S2amhMthUjI1AZ2J8YrUtt6DTyBcGg2UnAKhso0U6BKCmi09dLkm3cEvLdUUtuZSrfqKQ/oPbbchM6+SAVzHCUoKznAB2xTj/qVZMn08faQkqdVnTjzQF6dQmWOD4fKQOcjdxI/VNJ0LiXxDNy5FlvXo5jRPzHskeTUciNFmR0vwZCQ71Tg7ZoUeW6J5nWtZkxSdDzIOdGKEo/FlxmcQqZiwHmilWFJ7Y84qd2X1G1br4/8O7BeSEuDrqGc/qpLZc+RJMaUfpObJBPehlm6KU+0uc1yXBsVEdRUvEENu5RNKlrQtr6jC0Kxk1vsDjQfEKjRgw25g5K0at+teNUG5RXTIbCHEjC09/2KHrRc0XaAwlSyHUfTO++1ef5Q2+4FqajY7ax0Ioi6tBd6o2r+QjxtassqUQR2G1IK4sXPh65MXC3zELjh0pcjAbhI710h6gsvOWlHJISkElRPakTxE6yxqclIPwh+Vah296M+MGDsFZZw1CU4LjDQYsh0AOxldFjyBWxHgMc9UlLaQ6vZeBUMN2JCjMhb6dKkhSD3welbLMqDJePwMlLjgGVpT2qTtnSlqZlRfiHmFLwW0KyoeR4qJUpLbHORlbTSjgf+VsPvKDiUspKlDqmhuXJ+HYfLSgVJJOj/I+KMX6Fkm+klhuTFr4mXHQomPJwUgncKO5ovvqorrzSJK0pc/An8vakxeHC1Li3SM5qTHXrUQe/cVf8cX1f8FAfOdThyhwHocU9E2unQ/EUkXO3PvNqKYUdOvmdnD4pK8ZRFOfGW6WOWmUyChRGwB3FP3iRtL1qctkZCUuSRpAA2T7mgj1UtammLNISxzQkhp9SR9oCetPJeyGGdcOd/TG4G7zXbBc1KE2EsqjLUdl74A99qawZgWNouTHWoh/LVjKjSC9WZbNt4nactWuNNbOoqScZHagGVcrlOK3JEyS7k5IU4SKDx7O0Os+q1as6Ru/FYkkpgILLWcKWo/cPY0u+LuNmYrzTMJfMcZOsqB2UT2pdyOILm+xyVu4RpCfl2OKqMkryrJPcmtHHTthyeTaqKDnh3idtiW9FkkqgSDqyo50LO5/5RFd7wzcOCp0DX/cxAXU7/iemKUmQVbZx4qQOuDOHFZIwd+o8GncVdkFmdas//9k=", + playtime: 44.35, + played: 1625611102, + description: "The upgraded prequel of NieR:Automata. A kind young man sets out with Grimoire Weiss, a strange talking book, to search for the "Sealed verses" in order to save his sister Yonah, who fell terminally ill to the Black Scrawl.", + genres: [ + "Action", + "Adventure", + "RPG", + ], + achievements: [ + { + icon: + "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAwUGBAL/xAAsEAACAgEDAwMDAwUAAAAAAAABAgMEBQAGERIhMSJBUQdhkRMUoRVDUnGz/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAYEQEBAQEBAAAAAAAAAAAAAAABACERMf/aAAwDAQACEQMRAD8AsL+R3Jkd+5LEYnIrSr1Yo2QGFGDcqpJLEE88t4+2rbA08hXQDJ5N70jEDkxIgX/XSBzoWMxpr5vK3XPa08ZUD2CoF/PY6lZs7uaDaW9LmTr16NnHGT9nJGvVwvSCOxPq4DDhu3f21fcJ5a89nb8eKwVqKZ68mTtCoqxlAFLu3Qx6lbn0qPHzrNHlM5S3thcdcumercezDIpVCrGOIOGUhVIIblSD8ffSjIY3LZPZGzYzXS3LjpoLd4GRY1SJULBT1Hu3Qy/Y9/GqLH4dpc7jrP8ASWoVcfLI9UGRWLCVGMh4UkKvUV4HnudXCk+x+XisZ3IYz+9VCNz8hlB/jn+dSP1ZuGrs/dNeM8SW3pRIB5P6pCH/AJnXGWo7mxu/psvhKAvU7CKsimRF6V4UMOGYEN6fPfzphujZ1rPSLPDk7UD8wSiKZ+tVkicsvt7Bm4+CffUOCMYONyd/IboyG344QsVXJdVtyhCrVijjWNAf8pGX8Bu3fVVdz9SvuOhhnk5u3EeQIO5CgE8n8HSXBbZtbeny08Vy1dSy5siL9YiWWXjv1sex5+dI8Bh9yZL6hRbg3Jj4aaVYmihCOpBUhgPDE8+s8nto8Zt//9k=", + achieved: true, + unlocked: 1625610706, + name: "e8 a8 98 e6 86 b6 e3 82 b5 e3 83 bc e3 83 90 e3 83 bc", + description: "", + id: "ACHIEVEMENT_0230", + }, + { + icon: + "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGAABAAMBAAAAAAAAAAAAAAAABgQFBwP/xAAsEAACAQQBAwIFBAMAAAAAAAABAgMEBQYRABIhMSJBBxMUUaEVIzJhUnGC/8QAFgEBAQEAAAAAAAAAAAAAAAAAAQAC/8QAGhEAAwEBAQEAAAAAAAAAAAAAAAERIUExMv/aAAwDAQACEQMRAD8AXZJe7vT5DVJLe1sVvR1hgeeCN4JGIH8pvV0MdghXC/0TxvY6S4UsJ/Urm9fIwBBMSIF/10gb5Gp7LG9wvclZFHPTXAorQyKHR0EYUgg+ffgDNbZk2BWSeuw27O1hh0Xt9SonajUt5iZgT0bIBU9wD7+z9Yi8HGaV60ENKTkMVmknf5MRljV1lc6+4Ovbv4G+/CtFeMnt3xHtljvVfFU09XHI5VIkA0EYqdgAhtr48a4dseE5ZltTb8lya4W2qi6FqKairY2eKRWXYUqmgi999tkHR0eaHHZK2qyS23m809ujq6cMiCjeR+gFWB6mYAMNHQAUd/f244sDWW1vu8VRfbhbN/vUoRt/cMoP43+eTLxQpdLPX2+TulVTyQn/AKUgfnXM9yCgyy3ZrV3fG6GOtiqIgvTI6BV9KgjTMp36B3/vifFbvks88aX/AB40miCZ4qiNl2D/AIhiRzM6hp0+Gc/1Hw7xqQb39BErb9mUdJ/IPJtZfqSmyKgszODW1aNIqDyFAJ2fsPSeVCxX+wYvFQ2e3U1wrIzN8tvniNEBlZkBDaJPSwHkDY88O4xaMpuWd02QZVb4KN6WBoUETp0nYYDQDMd+o8YnWV4f/9k=", + achieved: true, + unlocked: 1625607419, + name: "Daredevil", + description: "", + id: "ACHIEVEMENT_0460", + }, + ], + rate: { + total: 47, + achieved: 44, + }, + }, + ], + }, + }, + }) + : null), //LeetCode ...(set.plugins.enabled.leetcode ? ({ diff --git a/source/plugins/steam/README.md b/source/plugins/steam/README.md new file mode 100644 index 00000000..8d720a2a --- /dev/null +++ b/source/plugins/steam/README.md @@ -0,0 +1,22 @@ + + + +## ➡️ Available options + + + + +## 🗝️ Obtaining a *Steam Web API* token + +Go to [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) to obtain a Steam Web API token: + +![Token](/.github/readme/imgs/plugin_steam_webtoken.png) + +To retrieve your Steam ID, access your user account on [store.steampowered.com/account](https://store.steampowered.com/account) and copy the identifier located behind the header: + +![User ID](/.github/readme/imgs/plugin_steam_userid.png) + +## ℹ️ Examples workflows + + + diff --git a/source/plugins/steam/examples.yml b/source/plugins/steam/examples.yml new file mode 100644 index 00000000..8a125ba7 --- /dev/null +++ b/source/plugins/steam/examples.yml @@ -0,0 +1,31 @@ +- name: Recently played games + uses: lowlighter/metrics@latest + with: + filename: metrics.plugin.steam.svg + token: NOT_NEEDED + base: "" + plugin_steam_token: ${{ secrets.STEAM_TOKEN }} + plugin_steam: yes + plugin_steam_user: 0 + plugin_steam_sections: recently-played + plugin_steam_achievements_limit: 0 + prod: + # ⚠️ Using mocked data for privacy reasons + with: + plugin_steam_token: MOCKED_TOKEN + use_mocked_data: yes + +- name: Profile and detailed game history + uses: lowlighter/metrics@latest + with: + filename: metrics.plugin.steam.full.svg + token: NOT_NEEDED + base: "" + plugin_steam_token: ${{ secrets.STEAM_TOKEN }} + plugin_steam: yes + plugin_steam_user: 0 + prod: + # ⚠️ Using mocked data for privacy reasons + with: + plugin_steam_token: MOCKED_TOKEN + use_mocked_data: yes \ No newline at end of file diff --git a/source/plugins/steam/index.mjs b/source/plugins/steam/index.mjs new file mode 100644 index 00000000..209eeef2 --- /dev/null +++ b/source/plugins/steam/index.mjs @@ -0,0 +1,104 @@ +//Setup +export default async function({login, q, imports, data, account}, {token, enabled = false, extras = false} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!q.steam) || (!imports.metadata.plugins.steam.enabled(enabled, {extras}))) + return null + + //Load inputs + let {user, sections, "games.ignored": _games_ignored, "games.limit": _games_limit, "recent.games.limit": _recent_games_limit, "achievements.limit": _achievements_limit, "playtime.threshold": _playtime_threshold} = imports.metadata.plugins.steam.inputs({data, account, q}) + + const urls = { + games: { + owned: `https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${token}&steamid=${user}&format=json&include_appinfo=1`, + schema: `https://api.steampowered.com/ISteamUserStats/GetSchemaForGame/v0002/?key=${token}&format=json`, + details: "https://store.steampowered.com/api/appdetails?", + }, + player: { + summary: `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${token}&steamids=${user}&format=json`, + level: `https://api.steampowered.com/IPlayerService/GetSteamLevel/v1/?key=${token}&steamid=${user}&format=json`, + achievement: `https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?key=${token}&steamid=${user}&format=json&l=en`, + }, + } + const result = {sections, player: null, games: {count: 0, playtime: 0, achievements: 0}} + + //Fetch owned games + console.debug(`metrics/compute/${login}/plugins > steam > fetching owned games`) + let {data: {response: {game_count: count, games}}} = await imports.axios.get(urls.games.owned) + result.games.count = count + result.games.playtime = games.reduce((total, {playtime_forever: playtime}) => (total += playtime), 0) / 60 + + //Fetch game achievements and order games by section + for (const section of ["most-played", "recently-played"]) { + if (!sections.includes(section)) + continue + result.games[section] = await Promise.all( + games + .map(({appid: id, name, img_icon_url: icon, playtime_forever: playtime, rtime_last_played: played}) => ({id, name, icon: `http://media.steampowered.com/steamcommunity/public/images/apps/${id}/${icon}.jpg`, playtime: playtime / 60, played})) + .filter(({playtime}) => (playtime >= _playtime_threshold)) + .filter(({id}) => (!_games_ignored.includes(`${id}`))) + .sort((a, b) => ({"most-played": (b.playtime - a.playtime), "recently-played": (b.played - a.played)}[section])) + .slice(0, ({"most-played": _games_limit, "recently-played": _recent_games_limit}[section]) || Infinity) + .map(async game => { + const schema = {} + try { + console.debug(`metrics/compute/${login}/plugins > steam > fetching schema for "${game.name}" (${game.id})`) + const {data: {game: {availableGameStats: {achievements = []} = {}}}} = await imports.axios.get(`${urls.games.schema}&appid=${game.id}`) + Object.assign(schema, Object.fromEntries(achievements.map(({name, icon}) => [name, {icon}]))) + } + catch (error) { + console.debug(`metrics/compute/${login}/plugins > steam > failed to get schema for "${game.name}" (${game.id}) > ${error}`) + } + const about = {} + try { + console.debug(`metrics/compute/${login}/plugins > steam > fetching details for "${game.name}" (${game.id})`) + const {data: {[game.id]: {data}}} = await imports.axios.get(`${urls.games.details}&appids=${game.id}`) + about.description = data.short_description ?? "" + about.genres = data.genres?.map(({description}) => description) ?? [] + } + catch (error) { + console.debug(`metrics/compute/${login}/plugins > steam > failed to get details for "${game.name}" (${game.id}) > ${error}`) + } + + let achievements = [] + const rate = {total: Object.keys(schema).length, achieved: 0} + try { + console.debug(`metrics/compute/${login}/plugins > steam > fetching player achievements "${game.name}" (${game.id})`) + let {data: {playerstats: {achievements: list = []}}} = await imports.axios.get(`${urls.player.achievement}&appid=${game.id}`) + achievements = await Promise.all(list.map(async ({apiname: id, achieved, unlocktime: unlocked, name, description}) => ({icon: await imports.imgb64(schema[id]?.icon ?? null, {width: 32, height: 32}), achieved: !!achieved, unlocked, name, description, id}))) + achievements = achievements.sort((a, b) => (b.unlocked - a.unlocked)) + rate.achieved = achievements.filter(({achieved}) => achieved).length + achievements = achievements.slice(0, _achievements_limit) + } + catch (error) { + console.debug(`metrics/compute/${login}/plugins > steam > failed to get player achievements for "${game.name}" (${game.id}) > ${error}`) + } + return {...game, ...about, icon: await imports.imgb64(game.icon, {width: 64, height: 64}), achievements, rate} + }), + ) + } + + //Fetch player info + if (sections.includes("player")) { + console.debug(`metrics/compute/${login}/plugins > steam > fetching profile info`) + let {data: {response: {players: [info]}}} = await imports.axios.get(urls.player.summary) + console.debug(`metrics/compute/${login}/plugins > steam > fetching profile level`) + const {data: {response: {player_level: level}}} = await imports.axios.get(urls.player.level) + result.player = { + level, + avatar: await imports.imgb64(info.avatar, {width: 64, height: 64}), + created: info.timecreated, + name: info.personaname, + } + } + + //Results + console.log(JSON.stringify(result)) + return result + } + //Handle errors + catch (error) { + throw imports.format.error(error) + } +} diff --git a/source/plugins/steam/metadata.yml b/source/plugins/steam/metadata.yml new file mode 100644 index 00000000..d0b51951 --- /dev/null +++ b/source/plugins/steam/metadata.yml @@ -0,0 +1,93 @@ +name: 🕹️ Steam +category: social +description: | + This plugin can display your player profile and played games from your Steam account. +disclaimer: | + This plugin is not affiliated, associated, authorized, endorsed by, or in any way officially connected with [Steam](https://store.steampowered.com). + All product and company names are trademarks™ or registered® trademarks of their respective holders. +examples: + +Recently played games: https://github.com/lowlighter/metrics/blob/examples/metrics.plugin.steam.svg + Profile and detailed game history: https://github.com/lowlighter/metrics/blob/examples/metrics.plugin.steam.full.svg +supports: + - user + - organization +scopes: [] +inputs: + + plugin_steam: + description: | + Enable steam plugin + type: boolean + default: no + + plugin_steam_token: + description: | + Steam token + type: token + default: "" + extras: + - metrics.api.steam + + plugin_steam_sections: + description: | + Displayed sections + + - `player`: display profile + - `most-played`: display most played games + - `recently-played`: display recently played games + type: array + format: comma-separated + default: player, most-played, recently-played + options: + - player + - most-played + - recently-played + + plugin_steam_user: + description: | + Steam user id + + This can be found on your Steam user account details + type: string + preset: no + + plugin_steam_games_ignored: + description: | + Ignored games + + Use App id as they are referenced in Steam catalog + type: array + format: comma-separated + default: "" + example: 400, 620 + + plugin_steam_games_limit: + description: | + Display limit (Most played games) + type: number + min: 0 + zero: disable + default: 1 + + plugin_steam_recent_games_limit: + description: | + Display limit (Recently played games) + type: number + min: 0 + zero: disable + default: 1 + + plugin_steam_achievements_limit: + description: | + Display limit (Games achievements) + type: number + min: 0 + default: 2 + + plugin_steam_playtime_threshold: + description: | + Display threshold (Game playtime in hours) + type: number + min: 0 + default: 2 + diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index e8523ae5..a7886e43 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -42,5 +42,6 @@ "sponsorships", "poopmap", "fortune", - "splatoon" + "splatoon", + "steam" ] diff --git a/source/templates/classic/partials/steam.ejs b/source/templates/classic/partials/steam.ejs new file mode 100644 index 00000000..9d1345e3 --- /dev/null +++ b/source/templates/classic/partials/steam.ejs @@ -0,0 +1,108 @@ +<% if (plugins.steam) { %> +
+

+ + Steam +

+ <% if (plugins.steam.error) { %> +
+
+
+ + <%= plugins.steam.error.message %> +
+
+
+ <% } else { %> + <% if ((plugins.steam.sections.includes("player"))&&(plugins.steam.player)) { %> +
+
+
+ + <%= plugins.steam.player.name %> +
+
+ + <%= plugins.steam.games.count %> game<%= s(plugins.steam.games.count) %> +
+
+
+
+ + Steam level <%= plugins.steam.player.level %> +
+
+ + <%= f(parseInt(plugins.steam.games.playtime)) %> hour<%= s(plugins.steam.games.playtime) %> played +
+
+
+ <% } %> + <% for (const section of ["most-played", "recently-played"]) { if (plugins.steam.sections.includes(section)) { %> +
+

+ + <%= {"most-played":"Most played", "recently-played":"Recently played"}[section] %> +

+
+ <% for (const {name, icon, playtime, played, achievements, rate, genres, description} of plugins.steam.games[section]) { %> +
+ +
+
<%= name %>
+
+
+ + <%= genres.join(", ") %> +
+
+
+
+ + <%= f(parseInt(playtime)) %> hour<%= s(playtime) %> played +
+
+
+
+ + Last played on <%= f.date(played*1000, {date:true, timeZone:config.timezone?.name}) %> +
+
+
+
+
+ + <%= rate.achieved %> / <%= rate.total %> achievement<%= s(rate.total) %> unlocked +
+
+ <% { const achieved = achievements.filter(({achieved}) => achieved) %> + <% for (const {icon, name, description, unlocked} of achieved) { %> +
+ +
+
+
<%= name %>
+
<%= f.date(unlocked*1000, {date:true, timeZone:config.timezone?.name}) %>
+
+
<%= description %>
+
+
+ <% } %> + <% if ((achieved.length)&&(rate.achieved-achieved.length > 0)) { %> +
+
+
+<%= rate.achieved-achieved.length %> other<%= s(rate.achieved-achieved.length) %>...
+
+
+ <% } %> + <% } %> +
+
+
+ <% } %> +
+
+ <% } } %> + <% } %> +
+<% } %> diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index a9abb963..2215d1ed 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -1049,6 +1049,88 @@ border-radius: 7px; } +/* Steam */ + .steam .games { + margin-left: 28px; + } + .steam .media { + display: flex; + margin-bottom: 4px; + width: 450px; + } + .steam .media img { + margin: 0 10px; + border-radius: 7px; + } + .steam .media > img { + height: 32px; + width: 32px; + } + .steam .media .about { + flex-grow: 1; + } + .steam .media .name { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + line-height: 14px; + color: #58a6ff; + } + .steam .media .infos { + font-size: 12px; + color: #666666; + display: flex; + align-items: center; + justify-content: space-between; + } + .steam .media .infos > div { + display: inline-flex; + align-items: center; + margin-right: 16px; + } + .steam .media .infos svg { + fill: currentColor; + height: 12px; + width: 12px; + margin: 0; + margin-right: 4px; + } + .steam .media .achievement img { + height: 22px; + width: 22px; + margin-right: 6px; + } + .steam .media .achievement .name { + display: flex; + justify-content: space-between; + } + .steam .media .achievement .name > div:first-child { + max-width: 280px; + } + .steam .media .achievement .description { + overflow: hidden; + text-overflow: ellipsis; + display: block; + width: 344px; + max-height: 38px; + font-size: 12px; + white-space: normal; + /* May not work in all browsers */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .steam .media .achievement .unlocked { + font-size: 12px; + color: #666666; + font-style: italic; + flex-shrink: 0; + } + .steam .media .achievement .ellipsis { + margin-left: 18px; + } + /* Licenses */ .licenses { display: flex; diff --git a/tests/mocks/api/axios/get/steam.mjs b/tests/mocks/api/axios/get/steam.mjs new file mode 100644 index 00000000..3ebb23e6 --- /dev/null +++ b/tests/mocks/api/axios/get/steam.mjs @@ -0,0 +1,2263 @@ +/**Mocked data */ +export default function({faker, url, options, login = faker.internet.userName()}) { + // App details + if (/^https:..store.steampowered.com.api.appdetails*$/.test(url)) { + console.debug(`metrics/compute/mocks > mocking steam api result > ${url}`) + return ({ + status: 200, + data: { + "524220": { + "success": true, + "data": { + "type": "game", + "name": "NieR:Automata™", + "steam_appid": 524220, + "required_age": 0, + "is_free": false, + "dlc": [ + 580600, + ], + "detailed_description": + '

NieR:Automata™ Game of the YoRHa Edition<\/h1>


The NieR:Automata™ Game of the YoRHa Edition includes the game itself and comes packed with DLC and bonus content for the full experience of the award-winning post-apocalyptic action RPG, including: